Understanding Nested Object Change Detection in ORMs and ODMs

Aditya Yadav
5 min readDec 4, 2024

--

In modern application development, frameworks like Sequelize (for SQL databases) and Mongoose (for MongoDB) simplify our interaction with databases. However, when it comes to nested objects within schemas, these libraries can sometimes trip us up, especially with change detection.

In this guide, we’ll explore why nested object changes may go unnoticed, how these issues arise in SQL and NoSQL databases, and how to handle them effectively with easy-to-understand examples.

The Problem: Change Detection in Nested Objects

Most ORMs (Object-Relational Mappers) and ODMs (Object-Document Mappers) rely on change detection mechanisms. They track which fields in a document or record have been modified, so only the changed fields are updated in the database.

However, nested object changes can sometimes slip through the cracks. Consider the following example:

Scenario 1: Mutation Before Cloning

Here’s what happens when you modify a nested object directly before cloning:

// Fetching the initial object from Sequelize
const currentRecord = await SettingsModel.findOne({
where: { gameId },
});

// Assuming the 'settings' field contains the object we want to modify
let initialObject = currentRecord.settings; // settings is a nested object

// Sequelize caches the original object reference internally
// Original Sequelize snapshot (cached):
// initialObject = { mini: { value: 100 }, mega: { value: 200 } }

// Mutating the object directly
initialObject.mini.value += 10; // Changes value to 110
initialObject.mega.value += 20; // Changes value to 220

// Create a deep clone of the mutated object to ensure it's
// treated as a new reference
const updatedObject = _.cloneDeep(initialObject);

// Assign the updated cloned object to the relevant field
// of the current record
currentRecord.settings = updatedObject;

// Saving the model with the updated settings object
await currentRecord.save();

Why This Fails

  • Sequelize’s Internal Cache: When Sequelize fetched the object, it cached initialObject as { mini: { value: 100 }, mega: { value: 200 } }.
  • Direct Mutation: Mutating initialObject doesn't update Sequelize's cache, as the mutations happen on the nested properties (mini.value and mega.value) rather than replacing the settings field entirely.
  • Cloning at the End: When you reassign currentRecord.settings, the reference changes, but since Sequelize compares the new value (_.cloneDeep(initialObject)) to the already-mutated object (initialObject), it sees no difference between the "current" and "original" values.

Scenario 2: Cloning Before Mutating

Now, let’s see what happens when you clone the object at the start:

// Fetching the initial object from Sequelize
const currentRecord = await SettingsModel.findOne({
where: { gameId },
});

// Assuming the 'settings' field contains the object we want to modify
let initialObject = currentRecord.settings; // settings is a nested object

// Clone the object immediately
const updatedObject = _.cloneDeep(initialObject);

// Mutating the cloned object
updatedObject.mini.value += 10; // Changes value to 110
updatedObject.mega.value += 20; // Changes value to 220

// Reassigning the modified object
currentRecord.settings = updatedObject;

// Saving the model
await currentRecord.save();

Why This Works

  • No Direct Mutation of Original: The original initialObject fetched by Sequelize remains untouched, so its cached state doesn't interfere.
  • Reassignment with a New Object: When you reassign currentRecord.settings, Sequelize detects that the field value has changed entirely (it's a new reference compared to its cached value).
  • Proper Save Behavior: Sequelize recognizes that the settings field is different from its original state and marks it as "dirty," triggering an update when save is called.

Key Comparison

Another Example

Let’s make it clearer with an analogy:

Imagine Sequelize’s cache is like a photograph of your initialObject when it was first fetched. Any direct mutations you make to the original object are like drawing on the photo—the system doesn't know the photo has changed because it doesn't actively monitor the object for changes.

If you create a new photo (clone) before modifying anything, the system can compare this new photo with the original photo and clearly see the differences.

Does This Happen with MongoDB?

Yes! Similar issues can occur with MongoDB if you’re using an ODM like Mongoose. Mongoose tracks changes to documents using dirty checking, but it doesn’t automatically track modifications to nested objects.

Example: Nested Object Mutation in Mongoose

const doc = await MyModel.findById(someId);

// Modify a nested property
doc.settings.mini.value += 10;
// Save the document
await doc.save();

Just like Sequelize, this won’t work because Mongoose doesn’t detect the change to mini.value unless the settings field itself is reassigned.

Solution 1: Mark the Field as Modified

You can explicitly tell Mongoose that the field has been modified:

doc.settings.mini.value += 10;
doc.markModified('settings'); // Mark the entire settings field as modified
await doc.save();

This forces Mongoose to include the settings field in the update.

Solution 2: Reassign the Field

Another approach is to clone and reassign the settings object:

const clonedSettings = JSON.parse(JSON.stringify(doc.settings)); // Deep clone
clonedSettings.mini.value += 10;
doc.settings = clonedSettings; // Replace the reference
await doc.save();

By reassigning doc.settings, Mongoose detects the change and updates the database correctly.

Comparing SQL and NoSQL Behavior

Aspect Sequelize (SQL) Mongoose (MongoDB) Change Tracking Relies on internal cache, may miss nested changes. Tracks changes but requires explicit marking. Workarounds Clone and reassign or replace the entire object. Use markModified or reassign nested objects. Ease of Use Higher risk of unnoticed changes in nested objects. Similar risk with nested properties.

Key Takeaways

  1. Sequelize and Mongoose both have issues with detecting nested object changes unless the field is reassigned or explicitly marked as modified.
  2. Use _.cloneDeep or similar cloning strategies to ensure you’re working with new object references.
  3. If you’re using native MongoDB drivers, update statements like $set avoid these issues by directly specifying the update path.

By understanding how your ORM/ODM tracks changes and implementing these solutions, you can avoid frustrating bugs and ensure your database updates always reflect your application’s intent.

Happy coding! 🎉

Let me know if you need additional refinements or further examples!

--

--

Aditya Yadav
Aditya Yadav

Written by Aditya Yadav

Software Engineer who talks about tech concepts in web development

No responses yet