Entities and Aggregates are domain objects defined by their identity, not by their attributes. Two objects with the same attributes but different IDs are considered distinct objects.The main difference lies in their architectural role, not in their change
tracking implementation:
Entity: Domain object with identity that normally lives inside an Aggregate
Aggregate: Root entity that defines a consistency boundary and is the entry point for repositories and domain events
Entity
Objects with identity that live inside Aggregates. Not accessed directly through repositories.
Aggregate
Root of a cluster of related entities. Models the consistency boundary persisted by a repository.
Entity and Aggregate both inherit change tracking from BaseEntity.
Therefore, either one can technically contain and track nested entities.
The distinction is architectural: repositories in the core API operate on
aggregate roots, and only Aggregate provides domain-event management.
Although nesting one Aggregate inside another is technically supported by
the object graph tracker, it usually crosses aggregate boundaries. Prefer
referencing another aggregate root by Id. Persistence adapters may treat an
embedded aggregate as an owned child unless its relationship is configured as
a reference.
Entities are identity-bearing domain objects, typically used as part of an
Aggregate. They have the same validation, serialization, and change tracking
capabilities inherited from BaseEntity, but they do not manage domain events:
import { Entity, Id, DomainError } from "@woltz/rich-domain";interface OrderItemProps { id: Id; productId: string; quantity: number; unitPrice: number;}class OrderItem extends Entity<OrderItemProps> { get productId() { return this.props.productId; } get quantity() { return this.props.quantity; } get subtotal() { return this.props.quantity * this.props.unitPrice; } updateQuantity(quantity: number) { if (quantity <= 0) { throw new DomainError("Quantity must be positive"); } this.props.quantity = quantity; }}// New entity (auto-generated ID)const item = new OrderItem({ productId: "prod-123", quantity: 2, unitPrice: 49.99,});console.log(item.isNew()); // true// Existing entity (from database)const existingItem = new OrderItem({ id: Id.from("item-456"), productId: "prod-123", quantity: 2, unitPrice: 49.99,});console.log(existingItem.isNew()); // false
Sometimes you need properties that are required in the entity but optional when constructing it, because they’re generated internally via hooks. Use the second generic parameter to specify optional input fields:
import { z } from "zod";import { Aggregate, Id, EntityValidation, EntityHooks } from "@woltz/rich-domain";const userSchema = z.object({ id: z.custom<Id>((val) => val instanceof Id), email: z.string().email(), password: z.string().min(8), // Required in schema name: z.string(), createdAt: z.date(), // Required in schema});type UserProps = z.infer<typeof userSchema>;// Make 'password' and 'createdAt' optional in constructorclass User extends Aggregate<UserProps, "password" | "createdAt"> { protected static validation: EntityValidation<UserProps> = { schema: userSchema, }; protected static hooks: EntityHooks<UserProps, User> = { onBeforeCreate: (props) => { // Generate password if not provided if (!props.password) { props.password = generateEncryptedPassword(); } // Set createdAt if not provided if (!props.createdAt) { props.createdAt = new Date(); } }, }; get email() { return this.props.email; } get password() { return this.props.password; }}// ✅ Works without password and createdAtconst user = new User({ email: "user@example.com", name: "John Doe",});// ✅ Also works with explicit valuesconst userWithPassword = new User({ email: "user@example.com", name: "John Doe", password: "custom-password-12345678",});
Fields marked as optional input are still required in the entity and validated by the schema. The difference is they’re optional when calling new User() because they’ll be generated in onBeforeCreate.
Model aggregate methods so their invariants are valid before persistence.
Eventual Consistency
Coordinate changes across aggregate boundaries with domain events or application services.
Rich Domain supplies the building blocks, but it does not automatically define
your invariants or open a database transaction. The application and persistence
adapter remain responsible for executing a save inside the required
transactional boundary.
Both entities and aggregates track changes to their own properties and nested
BaseEntity instances. In normal DDD usage, call getChanges() on the aggregate
root so the result represents the persistence boundary:
const order = new Order({ id: Id.from("order-123"), customerId: "cust-456", status: "draft", items: [ new OrderItem({ id: Id.from("item-1"), productId: "prod-a", quantity: 2, unitPrice: 29.99, }), new OrderItem({ id: Id.from("item-2"), productId: "prod-b", quantity: 1, unitPrice: 19.99, }), ], shippingAddress: new Address({ street: "123 Main", city: "NYC", zipCode: "10001", }), createdAt: new Date(),});// Make changes after the initial tracking baselineorder.addItem("prod-c", 1, 49.99); // Createorder.items[0].updateQuantity(5); // Updateorder.removeItem(Id.from("item-2")); // Delete// Get all changesconst changes = order.getChanges();console.log(changes.hasCreates()); // trueconsole.log(changes.hasUpdates()); // trueconsole.log(changes.hasDeletes()); // true// Group and order operations for a persistence adapterconst batch = changes.toBatchOperations();console.log(batch.deletes[0].ids); // ["item-2"]console.log(batch.creates[0].items[0].parentId); // "order-123"console.log(batch.updates[0].items[0].changedFields); // { quantity: 5 }
Construction establishes the initial tracking baseline. getChanges() reports
mutations made after construction or after markAsClean(); it does not emit a
create operation for the root object itself. Repositories use isNew() to
decide whether the aggregate root requires an INSERT.