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.

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

Accessing Operations

Filtering by Entity

Get changes for a specific entity type:

Filtering by Relation

Use forRelation() to select operations associated with a property in the parent object:

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.
Pass a single entity name or an array to exclude multiple types:
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:
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:
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.
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:

Creates: Root → Leaf

Parents must be created before children so FKs can reference them:

Updates: Any Order

Updates are returned in detection order and grouped by entity in batch output:

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

Removing Items

Updating Items

Mixed Operations

Working with Single Entities (1:1)

Setting an Entity

Removing an Entity

Updating an Entity

Replacing an Entity

Deeply Nested Changes

Change tracking recursively follows nested BaseEntity instances and has no fixed depth limit:
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:
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:
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:
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:

How Entity Tracking Works

Entities are tracked by their ID:

Nested Entities Example

Type-Safe Changes

For better TypeScript support, define an entity map:

Helper Method Pattern

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".

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:
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:

AggregateChanges

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

BatchOperations