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 naturallyorder.confirm();order.items[0].updateQuantity(5);order.addItem("prod-456", 1, 49.99);// Get all changes with a single callconst changes = order.getChanges();console.log(changes.hasUpdates()); // true - order status and item quantity changedconsole.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.
const changes = order.getChanges();// Check what changedchanges.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 countchanges.count; // Total number of 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 operationsfor (const op of changes.operations()) { console.log(op.type, op.entity, op.depth);}
Use without() when you need the inverse of of() — all changes except one or more entity types. It returns a newAggregateChanges instance; the original is unchanged.
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.
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.
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:
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 → rootfor (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 → childfor (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-sortedfor (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.
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.
Children must be deleted before parents to avoid FK violations:
// Given changes below an Order root:// OrderItem// └── ItemDiscountconst deletes = changes.deletes();// Returns in order:// 1. ItemDiscount (depth: 2)// 2. OrderItem (depth: 1)// The Order root itself is saved by its repository.
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.
Change tracking recursively follows nested BaseEntity instances and has no
fixed depth limit:
// Structure: User → Post → Comment → Likeconst 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()); // trueconsole.log(changes.of("Comment").hasUpdates()); // trueconsole.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.
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 graphuser.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 firstconst 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.
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.
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; }}
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:
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".
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.
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.