What is Change Tracking?
Change Tracking automatically monitors all modifications made to an Aggregate and its nested entities. Instead of manually tracking changes, the library uses JavaScript Proxies to intercept property assignments and maintain a complete history.
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
Efficient Persistence Only persist what actually changed, not the entire aggregate
Zero Boilerplate Changes tracked automatically - no manual code needed
Correct Ordering Operations automatically ordered to respect FK constraints
Batch Operations Group changes by entity type for optimized database operations
Basic Usage
Getting Changes
const changes = order . getChanges ();
// Check what changed
changes . hasCreates (); // Any new entities?
changes . hasUpdates (); // Any modified entities?
changes . hasDeletes (); // Any deleted entities?
changes . hasChanges (); // Any changes at all?
changes . isEmpty (); // No changes?
// Get count
changes . count ; // Total number of operations
Accessing Operations
// Get all creates (ordered root → leaf)
const creates = changes . creates ();
// CreateOperation[]
// Get all updates (no specific order)
const updates = changes . updates ();
// UpdateOperation[]
// Get all deletes (ordered leaf → root)
const deletes = changes . deletes ();
// DeleteOperation[]
// Iterate over all operations
for ( const op of changes . operations ()) {
console . log ( op . type , op . entity , op . depth );
}
Filtering by Entity
Get changes for a specific entity type:
const itemChanges = changes . for ( "OrderItem" );
// Access entities directly
itemChanges . creates ; // OrderItem[] - new items
itemChanges . deletes ; // OrderItem[] - deleted items
itemChanges . updates ; // Array<{ entity: OrderItem, changed: {...} }>
// Get IDs
itemChanges . createIds ; // string[]
itemChanges . updateIds ; // string[]
itemChanges . deleteIds ; // string[]
// Check for changes
itemChanges . hasCreates ();
itemChanges . hasUpdates ();
itemChanges . hasDeletes ();
// Count
itemChanges . count ;
Clearing Changes
After persisting, mark the aggregate as clean:
await repository . save ( order );
// Clear change history
order . markAsClean ();
// Now tracking starts fresh
const changes = order . getChanges ();
console . log ( changes . isEmpty ()); // true
Always call markAsClean() after successfully persisting. Otherwise, the same changes will be detected again.
Batch Operations
For efficient database persistence, use toBatchOperations() to group changes by entity:
Prisma ORM is being used in the example due to our high compatibility.
const batch = changes . toBatchOperations ();
// Deletes (leaf → root, respects FK constraints)
for ( const del of batch . deletes ) {
console . log ( `Delete ${ del . ids . length } ${ del . entity } (s)` );
await db [ del . entity ]. deleteMany ({
where: { id: { in: del . ids } },
});
}
// Creates (root → leaf, respects FK constraints)
for ( const create of batch . creates ) {
console . log ( `Create ${ create . items . length } ${ create . entity } (s)` );
await db [ create . entity ]. createMany ({
data: create . items . map (( item ) => ({
... mapToPersistence ( item . data ),
parentId: item . parentId , // FK to parent
})),
});
}
// Updates (any order, no FK dependencies)
for ( const update of batch . updates ) {
console . log ( `Update ${ update . items . length } ${ update . entity } (s)` );
for ( const item of update . items ) {
await db [ update . entity ]. update ({
where: { id: item . id },
data: item . changedFields , // Only changed fields
});
}
}
Operations are automatically ordered to prevent foreign key violations:
Deletes : Children first, then parents
Creates : Parents first, then children
Updates : Any order (no FK impact)
Operation Ordering
The library automatically orders operations to respect foreign key constraints:
Deletes: Leaf → Root
Children must be deleted before parents to avoid FK violations:
// Given this structure:
// Order
// └── OrderItem
// └── ItemDiscount
const deletes = changes . deletes ();
// Returns in order:
// 1. ItemDiscount (depth: 2)
// 2. OrderItem (depth: 1)
// 3. Order (depth: 0)
Creates: Root → Leaf
Parents must be created before children so FKs can reference them:
const creates = changes . creates ();
// Returns in order:
// 1. Order (depth: 0)
// 2. OrderItem (depth: 1)
// 3. ItemDiscount (depth: 2)
Updates: Any Order
Updates typically don’t affect relationships, so order doesn’t matter:
const updates = changes . updates ();
// Order not guaranteed - apply in any order
Working with Collections
Adding Items
const order = new Order ({
id: Id . from ( "order-123" ),
items: [],
});
order . addItem ( "prod-1" , 2 , 29.99 );
order . addItem ( "prod-2" , 1 , 49.99 );
const itemChanges = order . getChanges (). for ( "OrderItem" );
console . log ( itemChanges . creates . length ); // 2
Removing Items
const order = new Order ({
id: Id . from ( "order-123" ),
items: [
new OrderItem ({ id: Id . from ( "item-1" ), ... }),
new OrderItem ({ id: Id . from ( "item-2" ), ... }),
],
});
order . removeItem ( Id . from ( "item-1" ));
const itemChanges = order . getChanges (). for ( "OrderItem" );
console . log ( itemChanges . deleteIds ); // ["item-1"]
Updating Items
const order = new Order ({
id: Id . from ( "order-123" ),
items: [
new OrderItem ({ id: Id . from ( "item-1" ), quantity: 2 , ... }),
],
});
order . items [ 0 ]. updateQuantity ( 5 );
const itemChanges = order . getChanges (). for ( "OrderItem" );
console . log ( itemChanges . updates [ 0 ]. changed ); // { quantity: 5 }
Mixed Operations
// Create, update, and delete in the same transaction
order . addItem ( "prod-new" , 1 , 39.99 ); // Create
order . items [ 0 ]. updateQuantity ( 10 ); // Update
order . removeItem ( Id . from ( "item-old" )); // Delete
const itemChanges = order . getChanges (). for ( "OrderItem" );
console . log ( itemChanges . creates . length ); // 1
console . log ( itemChanges . updates . length ); // 1
console . log ( itemChanges . deletes . length ); // 1
Working with Single Entities (1:1)
Setting an Entity
const user = new User ({
id: Id . from ( "user-1" ),
address: null ,
});
user . setAddress ( new Address ({
street: "123 Main St" ,
city: "New York" ,
}));
const addressChanges = user . getChanges (). for ( "Address" );
console . log ( addressChanges . hasCreates ()); // true
Removing an Entity
const user = new User ({
id: Id . from ( "user-1" ),
address: new Address ({ id: Id . from ( "addr-1" ), ... }),
});
user . removeAddress ();
const addressChanges = user . getChanges (). for ( "Address" );
console . log ( addressChanges . deleteIds ); // ["addr-1"]
Updating an Entity
const user = new User ({
id: Id . from ( "user-1" ),
address: new Address ({ id: Id . from ( "addr-1" ), street: "Old St" , ... }),
});
user . address . changeStreet ( "New Street" );
const addressChanges = user . getChanges (). for ( "Address" );
console . log ( addressChanges . updates [ 0 ]. changed ); // { street: "New Street" }
Replacing an Entity
const user = new User ({
id: Id . from ( "user-1" ),
address: new Address ({ id: Id . from ( "addr-1" ), ... }),
});
// Replace with completely new address (different ID)
user . setAddress ( new Address ({
street: "789 Pine Rd" ,
city: "Boston" ,
}));
const addressChanges = user . getChanges (). for ( "Address" );
console . log ( addressChanges . hasDeletes ()); // true - old address
console . log ( addressChanges . hasCreates ()); // true - new address
Deeply Nested Changes
Change tracking works at any depth automatically:
// Structure: User → Post → Comment → Like
const user = new User ({
id: Id . from ( "user-1" ),
posts: [
new Post ({
id: Id . from ( "post-1" ),
comments: [
new Comment ({
id: Id . from ( "comment-1" ),
likes: [],
}),
],
}),
],
});
// Add a like (depth 3)
user . posts [ 0 ]. comments [ 0 ]. addLike ( new Like ({ ... }));
// Update comment (depth 2)
user . posts [ 0 ]. comments [ 0 ]. changeText ( "Updated" );
// Add new comment (depth 2)
user . posts [ 0 ]. addComment ( new Comment ({ ... }));
const changes = user . getChanges ();
console . log ( changes . for ( "Comment" ). hasCreates ()); // true
console . log ( changes . for ( "Comment" ). hasUpdates ()); // true
console . log ( changes . for ( "Like" ). hasCreates ()); // true
Cascading Deletes
When you delete a parent entity, all children are automatically tracked as deletes:
const user = new User ({
posts: [
new Post ({
id: Id . from ( "post-1" ),
comments: [
new Comment ({ id: Id . from ( "comment-1" ), likes: [ ... ] }),
new Comment ({ id: Id . from ( "comment-2" ), likes: [] }),
],
}),
],
});
// Delete post (cascades to comments and likes)
user . removePost ( Id . from ( "post-1" ));
const changes = user . getChanges ();
console . log ( changes . for ( "Post" ). deleteIds ); // ["post-1"]
console . log ( changes . for ( "Comment" ). deleteIds ); // ["comment-1", "comment-2"]
console . log ( changes . for ( "Like" ). deleteIds ); // [...]
// Batch operations handle correct order automatically
const batch = changes . toBatchOperations ();
// Order: Like → Comment → Post (leaf to root)
Cascading Creates
When you create a parent with children, all are tracked:
user . addPost ( new Post ({
title: "New Post" ,
comments: [
new Comment ({
text: "First comment" ,
likes: [
new Like ({ ... }),
new Like ({ ... }),
],
}),
],
}));
const changes = user . getChanges ();
console . log ( changes . for ( "Post" ). creates . length ); // 1
console . log ( changes . for ( "Comment" ). creates . length ); // 1
console . log ( changes . for ( "Like" ). creates . length ); // 2
// Batch operations handle correct order automatically
const batch = changes . toBatchOperations ();
// Order: Post → Comment → Like (root to leaf)
Value Objects with Identity Key
Value Objects don’t have IDs, but you can define an identity key for tracking:
Single Identity Key
class TagReference extends ValueObject <{ tagId : string ; name : string }> {
static readonly identityKey = "tagId" ;
get tagId () { return this . props . tagId ; }
get name () { return this . props . name ; }
}
Composite Identity Key
class Like extends ValueObject <{ postId : string ; userId : string ; createdAt : Date }> {
static readonly identityKey = [ "postId" , "userId" ];
get postId () { return this . props . postId ; }
get userId () { return this . props . userId ; }
}
How Identity Keys Work
The identity key is used to detect additions and removals:
const user = new User ({
id: Id . from ( "user-1" ),
tags: [
new TagReference ({ tagId: "tag-1" , name: "JavaScript" }),
new TagReference ({ tagId: "tag-2" , name: "TypeScript" }),
],
});
// Remove a tag
user . removeTag ( "tag-1" );
// Add a new tag
user . addTag ( new TagReference ({ tagId: "tag-3" , name: "Node.js" }));
const changes = user . getChanges ();
const tagChanges = changes . for ( "TagReference" );
console . log ( tagChanges . deleteIds ); // ["tag-1"]
console . log ( tagChanges . createIds ); // ["tag-3"]
Composite Key Example
const comment = new Comment ({
id: Id . from ( "comment-1" ),
text: "Great post!" ,
likes: [
new Like ({ postId: "post-1" , userId: "user-1" , createdAt: new Date () }),
new Like ({ postId: "post-1" , userId: "user-2" , createdAt: new Date () }),
],
});
// Remove a specific like using composite key
comment . removeLike ( "post-1" , "user-2" );
const changes = comment . getChanges ();
const likeChanges = changes . for ( "Like" );
console . log ( likeChanges . deleteIds ); // ["post-1:user-2"]
Type-Safe Changes
For better TypeScript support, define an entity map:
type OrderEntities = {
Order : Order ;
OrderItem : OrderItem ;
ItemDiscount : ItemDiscount ;
ShippingAddress : Address ;
};
// Get typed changes
const changes = order . getChanges < OrderEntities >();
// Now 'for' has autocomplete
const itemChanges = changes . for ( "OrderItem" );
// ^? "Order" | "OrderItem" | "ItemDiscount" | "ShippingAddress"
// Returns are properly typed
itemChanges . creates . forEach (( item ) => {
console . log ( item . quantity ); // item is OrderItem
});
Helper Method Pattern
class Order extends Aggregate < OrderProps > {
getTypedChanges () {
type Entities = {
Order : Order ;
OrderItem : OrderItem ;
ItemDiscount : ItemDiscount ;
};
return this . getChanges < Entities >();
}
}
// Usage
const changes = order . getTypedChanges ();
const items = changes . for ( "OrderItem" ); // Fully typed!
Complete Example with Prisma
class OrderService {
constructor (
private prisma : PrismaClient ,
private orderRepo : OrderRepository
) {}
async confirmOrder ( orderId : string ) : Promise < void > {
// Load order
const order = await this . orderRepo . findById ( orderId );
if ( ! order ) throw new Error ( "Order not found" );
// Business logic
order . confirm ();
order . items . forEach (( item ) => {
if ( item . quantity > 10 ) {
item . applyBulkDiscount ( 5 );
}
});
// Get changes
const changes = order . getTypedChanges ();
if ( changes . isEmpty ()) {
return ; // Nothing to persist
}
// Log what's changing
console . log ( "Affected entities:" , changes . getAffectedEntities ());
console . log ( "Total operations:" , changes . count );
// Persist
const batch = changes . toBatchOperations ();
await this . prisma . $transaction ( async ( tx ) => {
// Process in correct order
await this . processDeletes ( tx , batch . deletes );
await this . processCreates ( tx , batch . creates );
await this . processUpdates ( tx , batch . updates );
});
// Clear change history
order . markAsClean ();
}
private async processDeletes ( tx : any , deletes : BatchOperations [ "deletes" ]) {
for ( const del of deletes ) {
await tx [ this . getTableName ( del . entity )]. deleteMany ({
where: { id: { in: del . ids } },
});
}
}
private async processCreates ( tx : any , creates : BatchOperations [ "creates" ]) {
for ( const create of creates ) {
const table = this . getTableName ( create . entity );
await tx [ table ]. createMany ({
data: create . items . map (( item ) =>
this . mapToDatabase ( create . entity , item . data , item . parentId )
),
});
}
}
private async processUpdates ( tx : any , updates : BatchOperations [ "updates" ]) {
for ( const update of updates ) {
const table = this . getTableName ( update . entity );
for ( const item of update . items ) {
await tx [ table ]. update ({
where: { id: item . id },
data: item . changedFields ,
});
}
}
}
private getTableName ( entity : string ) : string {
const map : Record < string , string > = {
Order: "order" ,
OrderItem: "orderItem" ,
ItemDiscount: "itemDiscount" ,
};
return map [ entity ];
}
private mapToDatabase ( entity : string , data : any , parentId ?: string ) : any {
// Map domain entity to database record
// ... implementation
}
}
API Reference
AggregateChanges
Method Returns Description creates()CreateOperation[]All creates, ordered root → leaf updates()UpdateOperation[]All updates deletes()DeleteOperation[]All deletes, ordered leaf → root for(entity)EntityChanges<T>Filter changes by entity name toBatchOperations()BatchOperationsGroup changes for bulk operations hasCreates()booleanHas any creates hasUpdates()booleanHas any updates hasDeletes()booleanHas any deletes hasChanges()booleanHas any operations isEmpty()booleanNo operations getAffectedEntities()string[]List of entity names with changes operations()GeneratorIterate all operations in order toArray()Operation[]All operations as array countnumberTotal number of operations
EntityChanges
Property/Method Returns Description createsT[]Created entities updatesArray<{entity: T, changed: Record}>Updated entities with changes deletesT[]Deleted entities createIdsstring[]IDs of created entities updateIdsstring[]IDs of updated entities deleteIdsstring[]IDs of deleted entities hasCreates()booleanHas any creates hasUpdates()booleanHas any updates hasDeletes()booleanHas any deletes hasChanges()booleanHas any operations isEmpty()booleanNo operations countnumberTotal operations
BatchOperations
interface BatchOperations {
deletes : Array <{
entity : string ;
depth : number ;
ids : string [];
}>;
creates : Array <{
entity : string ;
depth : number ;
items : Array <{
data : any ;
parentId ?: string ;
}>;
}>;
updates : Array <{
entity : string ;
items : Array <{
id : string ;
changedFields : Record < string , any >;
}>;
}>;
}