What is Change Tracking?
Change Tracking is a system that automatically monitors all modifications made to an Aggregate and its nested entities. Instead of manually tracking what changed, the library uses JavaScript Proxies to intercept property assignments and maintain a complete history of changes.
const order = new Order ({
id: Id . from ( "order-123" ),
status: "draft" ,
items: [
new OrderItem ({ id: Id . from ( "item-1" ), quantity: 2 , unitPrice: 29.99 }),
],
});
// Make changes naturally
order . confirm ();
order . items [ 0 ]. updateQuantity ( 5 );
order . addItem ( "prod-456" , 1 , 49.99 );
// Get all changes with a single call
const changes = order . getChanges ();
console . log ( changes . hasUpdates ()); // true - order status and item quantity changed
console . log ( changes . hasCreates ()); // true - new item added
Why Change Tracking Matters
Efficient Persistence Only persist what actually changed, not the entire aggregate
No Boilerplate Changes are tracked automatically via Proxies - no manual tracking code
Correct Operation Order Operations are automatically ordered to respect foreign key constraints
Batch Operations Group changes by entity type for optimized database operations
How It Works
1. Proxy-Based Interception
When you access props on an Entity or Aggregate, you receive a Proxy that intercepts all property changes:
class Order extends Aggregate < OrderProps > {
confirm () {
// This assignment is intercepted by the Proxy
this . props . status = "confirmed" ;
}
}
The Proxy:
Captures the previous value
Records the change in history
Validates the new value (if validation is enabled)
Applies the change
2. Nested Tracking
The tracking system handles complex nested structures automatically:
// All these changes are tracked
order . props . status = "confirmed" ; // Root property
order . items [ 0 ]. props . quantity = 5 ; // Nested entity property
order . items . push ( new OrderItem ({ ... })); // Array addition
order . items . splice ( 0 , 1 ); // Array removal
order . shippingAddress = new Address ({ ... }); // Entity replacement
3. Depth-Based Ordering
Changes are tagged with their depth in the aggregate tree:
Order (depth: 0)
├── OrderItem (depth: 1)
│ └── ItemDiscount (depth: 2)
└── ShippingAddress (depth: 1)
This enables correct operation ordering:
Deletes : Leaf → Root (depth DESC) - delete children before parents
Creates : Root → Leaf (depth ASC) - create parents before children
Updates : Any order - no FK dependencies
Basic Usage
Getting Changes
const changes = aggregate . getChanges ();
// Check what types of changes exist
changes . hasCreates (); // boolean
changes . hasUpdates (); // boolean
changes . hasDeletes (); // boolean
changes . hasChanges (); // boolean - any changes at all
changes . isEmpty (); // boolean - no changes
// Get operation counts
changes . count ; // total number of operations
Iterating Over Changes
const changes = order . getChanges ();
// Get arrays of operations
const creates = changes . creates (); // CreateOperation[]
const updates = changes . updates (); // UpdateOperation[]
const deletes = changes . deletes (); // DeleteOperation[]
// Iterate in correct execution order
for ( const op of changes . operations ()) {
console . log ( op . type , op . entity , op . depth );
}
// Convert to array
const allOps = changes . toArray ();
Filtering by Entity
const changes = order . getChanges ();
// Get changes for a specific entity type
const itemChanges = changes . for ( "OrderItem" );
itemChanges . creates ; // OrderItem[] - created items
itemChanges . updates ; // Array<{ entity: OrderItem, changed: Record<string, any> }>
itemChanges . deletes ; // OrderItem[] - deleted items
// Check for changes
itemChanges . hasCreates ();
itemChanges . hasUpdates ();
itemChanges . hasDeletes ();
Batch Operations
const changes = order . getChanges ();
const batch = changes . toBatchOperations ();
// Deletes grouped by entity, ordered by depth DESC
for ( const del of batch . deletes ) {
console . log ( `Delete ${ del . ids . length } ${ del . entity } (s)` );
// Delete 3 OrderItem(s)
}
// Creates grouped by entity, ordered by depth ASC
for ( const create of batch . creates ) {
console . log ( `Create ${ create . items . length } ${ create . entity } (s)` );
// Create 2 OrderItem(s)
}
// Updates grouped by entity
for ( const update of batch . updates ) {
console . log ( `Update ${ update . items . length } ${ update . entity } (s)` );
// Update 1 Order(s)
}
Clearing Changes
After persisting changes, mark the aggregate as clean:
// Persist changes
await repository . save ( order );
// Clear change history - resets tracking state
order . markAsClean ();
// Now changes are empty
const changes = order . getChanges ();
console . log ( changes . isEmpty ()); // true
Always call markAsClean() after successfully persisting changes. Otherwise, the same changes will be detected again on the next getChanges() call.
Type-Safe Change Tracking
Define an entity map for type-safe filtering:
// Define your entity types
type OrderEntities = {
Order : Order ;
OrderItem : OrderItem ;
ItemDiscount : ItemDiscount ;
ShippingAddress : Address ;
};
// Get typed changes
const changes = order . getChanges < OrderEntities >();
// Now 'for' has autocomplete and type inference
const itemChanges = changes . for ( "OrderItem" );
// ^? "Order" | "OrderItem" | "ItemDiscount" | "ShippingAddress"
itemChanges . creates . forEach (( item ) => {
console . log ( item . quantity ); // item is typed as OrderItem
});
You can also create a helper method on your aggregate:
class Order extends Aggregate < OrderProps > {
getTypedChanges () {
return this . getChanges < OrderEntities >();
}
}
// Usage
const changes = order . getTypedChanges ();
const items = changes . for ( "OrderItem" ); // Fully typed
Change History
Access the raw change history for debugging:
const history = order . getHistory ();
history . forEach (( entry ) => {
console . log ({
path: entry . path , // "status" or "items[0].quantity"
previousValue: entry . previousValue ,
currentValue: entry . currentValue ,
timestamp: entry . timestamp ,
});
});
Real-World Example
// Load order from database
const order = await orderRepository . findById ( "order-123" );
// Business operations
order . confirm ();
order . addItem ( "prod-new" , 2 , 39.99 );
order . items [ 0 ]. applyDiscount ( 10 );
order . removeItem ( Id . from ( "item-to-remove" ));
// Get all changes
const changes = order . getTypedChanges ();
// Persist using batch operations
await db . $transaction ( async ( tx ) => {
const batch = changes . toBatchOperations ();
// 1. Deletes (leaf → root)
for ( const del of batch . deletes ) {
await tx [ del . entity ]. deleteMany ({
where: { id: { in: del . ids } },
});
}
// 2. Creates (root → leaf)
for ( const create of batch . creates ) {
await tx [ create . entity ]. createMany ({
data: create . items . map (( item ) => ({
... mapper . toPersistence ( item . data ),
parentId: item . parentId ,
})),
});
}
// 3. Updates
for ( const update of batch . updates ) {
for ( const item of update . items ) {
await tx [ update . entity ]. update ({
where: { id: item . id },
data: item . changedFields ,
});
}
}
});
// Clear history after successful persistence
order . markAsClean ();
Next Steps