Skip to main content

What is Change Tracking?

Change tracking is implemented by BaseEntity, so it is available to both Entity and Aggregate. JavaScript Proxies observe property assignments and collection mutations, while snapshots are used to calculate create, update, and delete operations for nested entities. In normal DDD usage, call getChanges() on the aggregate root so the returned operations represent a single consistency and persistence boundary.
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

Persist detected updates instead of rewriting every child

Zero Boilerplate

Changes tracked automatically - no manual code needed

Correct Ordering

Creates and deletes ordered by object-graph depth

Batch Operations

Group changes by entity type for optimized database operations
Construction establishes the initial baseline. getChanges() reports mutations made after construction or after the most recent markAsClean()/markAsPersisted() call. It does not emit a create operation for the root object itself; repositories normally use isNew() for that decision.

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.of("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;

Filtering by Relation

Use forRelation() to select operations associated with a property in the parent object:
const itemRelationChanges = changes.forRelation("items");

console.log(itemRelationChanges.hasChanges());
console.log(itemRelationChanges.getAffectedRelations()); // ["items"]

Excluding Entities

Use without() when you need the inverse of of() — all changes except one or more entity types. It returns a new AggregateChanges instance; the original is unchanged.
const profileChanges = changes.of("Profile");

if (profileChanges.hasUpdates()) {
  await this.context.factoryProfile.update({
    where: { factoryId: factory.id.value },
    data: this.registry.mapFields("Profile", profileChanges.updates[0].changed),
  });
}

const remaining = changes.without("Profile");

if (!remaining.isEmpty()) {
  await super.onUpdate(remaining, factory);
}
Pass a single entity name or an array to exclude multiple types:
changes.without("Profile");
changes.without(["Profile", "Tag"]);
This is useful in custom onUpdate implementations when one entity needs manual persistence (nested writes, custom PK, etc.) and the rest should go through PrismaBatchExecutor.

Resetting the Baseline

The component that successfully persists the object graph should reset its tracking baseline:
await persistChanges(order.getChanges());
order.markAsClean();

// Now tracking starts fresh
const changes = order.getChanges();
console.log(changes.isEmpty()); // true
markAsClean() recursively clears changes in nested entities without changing whether their IDs are new.
PrismaRepository.save() already calls markAsPersisted() after its mapper completes successfully. Do not add a second cleanup call when using that repository. For a custom repository or direct batch executor, reset the baseline only after persistence succeeds.

After First Save

When implementing a custom repository, call markAsPersisted() after the first successful INSERT. It clears tracking and marks the root and nested IDs as not new:
await insertAggregate(order);
order.markAsPersisted();

console.log(order.isNew()); // false
console.log(order.getChanges().isEmpty()); // true

order.updateStatus("confirmed");
await updateAggregate(order);
order.markAsClean();
markAsPersisted() changes ID state; it does not perform an INSERT by itself. The repository or mapper decides how isNew() affects persistence.

Batch Operations

Use toBatchOperations() to group changes by entity, relation, and parent metadata:
toBatchOperations() is a data transformation only. It does not execute a transaction, map domain fields to database columns, or decide whether a relation is owned or referenced. A persistence adapter must handle those concerns.
const batch = changes.toBatchOperations();

// Deletes are grouped and ordered leaf → root
for (const del of batch.deletes) {
  console.log(del.entity, del.ids, {
    parentId: del.parentId,
    parentEntity: del.parentEntity,
    relationField: del.relationField,
  });
}

// Creates are grouped and ordered parent → child
for (const create of batch.creates) {
  for (const item of create.items) {
    console.log(create.entity, item.data, {
      parentId: item.parentId,
      parentEntity: item.parentEntity,
      relationField: item.relationField,
    });
  }
}

// Updates are grouped by entity and are not depth-sorted
for (const update of batch.updates) {
  for (const item of update.items) {
    console.log(update.entity, item.id, item.changedFields);
  }
}
The ordering reflects the tracked object graph:
  • Deletes: Children first, then parents
  • Creates: Parents first, then children
  • Updates: Grouped by entity without depth ordering
This ordering helps an adapter satisfy foreign keys, but it cannot guarantee database correctness by itself. Relationship configuration and transaction handling still belong to the adapter.

Operation Ordering

The library orders create and delete operations by their depth in the tracked object graph. Depth 0 is the object on which getChanges() is called. The tracker can emit an update for that root, but it does not emit a create or delete for the root itself.

Deletes: Leaf → Root

Children must be deleted before parents to avoid FK violations:
// Given changes below an Order root:
// OrderItem
// └── ItemDiscount

const deletes = changes.deletes();
// Returns in order:
// 1. ItemDiscount (depth: 2)
// 2. OrderItem (depth: 1)
// The Order root itself is saved by its repository.

Creates: Root → Leaf

Parents must be created before children so FKs can reference them:
const creates = changes.creates();
// Returns in order:
// 1. OrderItem (depth: 1)
// 2. ItemDiscount (depth: 2)

Updates: Any Order

Updates are returned in detection order and grouped by entity in batch output:
const updates = changes.updates();
// No depth ordering guarantee

Working with Collections

Collections of Entity/Aggregate instances produce item-level create, update, and delete operations. Arrays containing only primitive values are reported as an update to the property of their owning entity.

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().of("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().of("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().of("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().of("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().of("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().of("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().of("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().of("Address");
console.log(addressChanges.hasDeletes()); // true - old address
console.log(addressChanges.hasCreates()); // true - new address

Deeply Nested Changes

Change tracking recursively follows nested BaseEntity instances and has no fixed depth limit:
// 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.of("Comment").hasCreates()); // true
console.log(changes.of("Comment").hasUpdates()); // true
console.log(changes.of("Like").hasCreates());    // true
Circular entity references are rejected during change comparison. Across aggregate boundaries, prefer storing another aggregate’s Id instead of an object reference.

Cascading Deletes

When a parent is removed from the tracked graph, its nested entities are also reported as delete operations:
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: [] }),
      ],
    }),
  ],
});

// Remove post from the tracked object graph
user.removePost(Id.from("post-1"));

const changes = user.getChanges();
console.log(changes.of("Post").deleteIds);    // ["post-1"]
console.log(changes.of("Comment").deleteIds); // ["comment-1", "comment-2"]
console.log(changes.of("Like").deleteIds);    // [...]

// Batch operations place deeper deletes first
const batch = changes.toBatchOperations();
// Order: Like → Comment → Post (leaf to root)
This is a cascade in the generated change set, not a database-level ON DELETE CASCADE. The adapter still decides whether a relation is owned and should be deleted or is a reference that should only be disconnected.

Cascading Creates

When a parent with nested entities is added after the baseline, the parent and its nested children are reported as creates:
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.of("Post").creates.length);    // 1
console.log(changes.of("Comment").creates.length); // 1
console.log(changes.of("Like").creates.length);    // 2

// Batch operations place shallower creates first
const batch = changes.toBatchOperations();
// Order: Post → Comment → Like (root to leaf)
Entities already present during construction are part of the baseline and are not reported as creates merely because their IDs are new.

Change History

getHistory() returns low-level writes observed by the entity’s tracker:
order.updateStatus("confirmed");

const history = order.getHistory();
console.log(history[0]);
// {
//   path: "status",
//   previousValue: "draft",
//   currentValue: "confirmed",
//   timestamp: 1710000000000
// }
History entries are useful for debugging, but they are not the same as AggregateChanges: multiple writes may collapse into one update operation, and changes reverted back to the baseline may produce no persistence operation.
Change history is in-memory diagnostic state, not a durable audit log. Persist explicit domain events or audit records when auditability is required.

Collections with Entities

Use entities when collection items need independent create, update, and delete operations. Value Objects remain appropriate for immutable values that are persisted as part of their owner:
class TagReference extends Entity<{ id: Id; tagId: string; name: string }> {
  get tagId() { return this.props.tagId; }
  get name() { return this.props.name; }
}

class Like extends Entity<{
  id: Id;
  postId: string;
  userId: string;
  createdAt: Date;
}> {
  get postId() { return this.props.postId; }
  get userId() { return this.props.userId; }
}

How Entity Tracking Works

Entities are tracked by their ID:
const user = new User({
  id: Id.from("user-1"),
  tags: [
    new TagReference({
      id: Id.from("tag-ref-1"),
      tagId: "tag-1",
      name: "JavaScript"
    }),
    new TagReference({
      id: Id.from("tag-ref-2"),
      tagId: "tag-2",
      name: "TypeScript"
    }),
  ],
});

// Remove a tag
user.removeTag("tag-1");

// Add a new tag
user.addTag(new TagReference({
  id: new Id(),
  tagId: "tag-3",
  name: "Node.js"
}));

const changes = user.getChanges();
const tagChanges = changes.of("TagReference");

console.log(tagChanges.deletes.length); // 1
console.log(tagChanges.creates.length); // 1

Nested Entities Example

const comment = new Comment({
  id: Id.from("comment-1"),
  text: "Great post!",
  likes: [
    new Like({
      id: new Id(),
      postId: "post-1",
      userId: "user-1",
      createdAt: new Date()
    }),
    new Like({
      id: new Id(),
      postId: "post-1",
      userId: "user-2",
      createdAt: new Date()
    }),
  ],
});

// Remove a specific like
comment.removeLike("post-1", "user-2");

const changes = comment.getChanges();
const likeChanges = changes.of("Like");

console.log(likeChanges.deletes.length); // 1

Type-Safe Changes

For better TypeScript support, define an entity map:
type OrderEntities = {
  Order: Order;
  OrderItem: OrderItem;
  ItemDiscount: ItemDiscount;
  Address: Address;
};

// Get typed changes
const changes = order.getChanges<OrderEntities>();

// Now 'of' has autocomplete
const itemChanges = changes.of("OrderItem");
//                              ^? "Order" | "OrderItem" | "ItemDiscount" | "Address"

// 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.of("OrderItem"); // Fully typed!
We strongly recommend defining a getTypedChanges() helper method directly in your Aggregate class. This provides a cleaner API and avoids repeating the entity map every time:
class Order extends Aggregate<OrderProps> {
  getTypedChanges() {
    interface Entities {
      OrderItem: OrderItem;
      ItemDiscount: ItemDiscount;
    }
    return this.getChanges<Entities>();
  }
}

// Usage - clean and type-safe
const changes = order.getTypedChanges();
const items = changes.of("OrderItem"); // Autocomplete works!
Entity-map keys must match the runtime class names stored in operations. For example, an Address instance produces the entity name "Address", not the property name "shippingAddress".

Persistence Example with Prisma

PrismaToPersistence uses PrismaBatchExecutor for updates by default. The executor understands relation metadata, schema mappings, owned collections, and reference collections, while PrismaRepository.save() resets the aggregate with markAsPersisted() after persistence:
class OrderService {
  constructor(private readonly orderRepo: OrderRepository) {}

  async confirmOrder(orderId: string): Promise<void> {
    const order = await this.orderRepo.findById(orderId);
    if (!order) throw new Error("Order not found");

    order.confirm();

    const changes = order.getTypedChanges();
    if (changes.isEmpty()) return;

    await this.orderRepo.save(order);

    console.log(order.getChanges().isEmpty()); // true
  }
}
Override PrismaToPersistence.onUpdate() only when part of the graph needs custom persistence. Use without() to pass the remaining operations to the default executor.

API Reference

BaseEntity Tracking

These methods are available on both Entity and Aggregate:
MethodReturnsDescription
getChanges<TEntityMap>()AggregateChanges<TEntityMap>Calculate operations since the current baseline
getHistory()HistoryEntry[]Copy of low-level writes observed by the tracker
markAsClean()voidRecursively establish a new baseline
markAsPersisted()voidRecursively establish a new baseline and mark IDs as not new

AggregateChanges

MethodReturnsDescription
creates()CreateOperation[]All creates, ordered root → leaf
updates()UpdateOperation[]All updates
deletes()DeleteOperation[]All deletes, ordered leaf → root
of(entity)EntityChanges<T>Filter changes by entity name
forRelation(relation)AggregateChangesNew instance filtered by relation field
without(entity | entities)AggregateChangesNew instance excluding the given entity/entities
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
getAffectedRelations()string[]List of relation fields with changes
operations()GeneratorIterate all operations in order
toArray()Operation[]All operations as array
rawOperationsOperation[]Copy of operations without ordering
clone()AggregateChangesIndependent copy of the operation list
clear()voidClear this result object’s operations
countnumberTotal number of operations
AggregateChanges.clear() only mutates that result object. It does not reset the entity tracker; a later entity.getChanges() will calculate the changes again. Use entity.markAsClean() to establish a new baseline.

EntityChanges

Property/MethodReturnsDescription
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
rawOperationsOperation<T>[]Copy of the filtered raw operations
countnumberTotal operations

BatchOperations

interface BatchOperations {
  deletes: Array<{
    entity: string;
    depth: number;
    ids: string[];
    parentId?: string;
    parentEntity?: string;
    relationField?: string;
    items?: Array<{
      id: string;
      relationField?: string;
    }>;
  }>;

  creates: Array<{
    entity: string;
    depth: number;
    parentEntity?: string;
    relationField?: string;
    items: Array<{
      data: any;
      parentId?: string;
      parentEntity?: string;
      relationField?: string;
    }>;
  }>;

  updates: Array<{
    entity: string;
    items: Array<{
      id: string;
      changedFields: Record<string, any>;
    }>;
  }>;
}