What are Entities and Aggregates?
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:
Entity : Domain object with identity that lives inside an Aggregate
Aggregate : Root entity that defines a consistency boundary and controls access to all entities within it
Entity Objects with identity that live inside Aggregates. Not accessed directly through repositories.
Aggregate Root of a cluster of related entities. Defines transactional and consistency boundaries.
Key Differences
Feature Entity Aggregate Has identity ✅ ✅ Can be persisted ✅ ✅ Consistency boundary ❌ ✅ Accessed directly from repository ❌ ✅ Manages child entities ❌ ✅ Emits domain events ❌ ✅ Change tracking for children ❌ ✅
Creating Entities
Entities are simpler objects, typically used as part of an Aggregate:
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
Creating Aggregates
Aggregates are the root of a cluster of entities and define consistency boundaries:
import { Aggregate , Id , DomainError } from "@woltz/rich-domain" ;
interface OrderProps {
id : Id ;
customerId : string ;
status : "draft" | "confirmed" | "shipped" | "delivered" ;
items : OrderItem [];
shippingAddress : Address ;
createdAt : Date ;
}
class Order extends Aggregate < OrderProps > {
get customerId () {
return this . props . customerId ;
}
get status () {
return this . props . status ;
}
get items () {
return this . props . items ;
}
get total () {
return this . props . items . reduce (( sum , item ) => sum + item . subtotal , 0 );
}
addItem ( productId : string , quantity : number , unitPrice : number ) {
if ( this . props . status !== "draft" ) {
throw new DomainError ( "Cannot modify confirmed order" );
}
this . props . items . push ( new OrderItem ({ productId , quantity , unitPrice }));
}
removeItem ( itemId : Id ) {
if ( this . props . status !== "draft" ) {
throw new DomainError ( "Cannot modify confirmed order" );
}
this . props . items = this . props . items . filter (
( item ) => ! item . id . equals ( itemId )
);
}
confirm () {
if ( this . props . items . length === 0 ) {
throw new DomainError ( "Cannot confirm empty order" );
}
this . props . status = "confirmed" ;
}
ship () {
if ( this . props . status !== "confirmed" ) {
throw new DomainError ( "Order must be confirmed before shipping" );
}
this . props . status = "shipped" ;
}
}
Identity & Equality
Both Entities and Aggregates are compared by ID, not by attributes:
const item1 = new OrderItem ({
id: Id . from ( "item-1" ),
productId: "prod-123" ,
quantity: 2 ,
unitPrice: 49.99 ,
});
const item2 = new OrderItem ({
id: Id . from ( "item-1" ),
productId: "prod-999" , // different attributes
quantity: 10 ,
unitPrice: 99.99 ,
});
const item3 = new OrderItem ({
id: Id . from ( "item-2" ), // different ID
productId: "prod-123" ,
quantity: 2 ,
unitPrice: 49.99 ,
});
item1 . equals ( item2 ); // true - same ID
item1 . equals ( item3 ); // false - different IDs
item1 . equals ( "item-1" ); // true - can compare with string
item1 . equals ( Id . from ( "item-1" )); // true - can compare with Id
Validation
Both support validation with Zod schemas:
import { z } from "zod" ;
import { EntityValidation , EntityHooks } from "@woltz/rich-domain" ;
// Entity validation
const orderItemSchema = z . object ({
id: z . custom < Id >(( val ) => val instanceof Id ),
productId: z . string (). min ( 1 ),
quantity: z . number (). int (). positive (),
unitPrice: z . number (). positive (),
});
type OrderItemProps = z . infer < typeof orderItemSchema >;
class OrderItem extends Entity < OrderItemProps > {
protected static validation : EntityValidation < OrderItemProps > = {
schema: orderItemSchema ,
config: {
onCreate: true ,
onUpdate: true ,
throwOnError: true ,
},
};
}
// Aggregate validation with business rules
const orderSchema = z . object ({
id: z . custom < Id >(( val ) => val instanceof Id ),
customerId: z . string (). min ( 1 ),
status: z . enum ([ "draft" , "confirmed" , "shipped" , "delivered" ]),
items: z . array ( z . custom < OrderItem >(( val ) => val instanceof OrderItem )),
shippingAddress: z . custom < Address >(( val ) => val instanceof Address ),
createdAt: z . date (),
});
type OrderProps = z . infer < typeof orderSchema >;
class Order extends Aggregate < OrderProps > {
protected static validation : EntityValidation < OrderProps > = {
schema: orderSchema ,
config: {
onCreate: true ,
onUpdate: true ,
throwOnError: true ,
},
};
protected static hooks : EntityHooks < OrderProps , Order > = {
rules : ( order ) => {
// Business rule: shipped orders must have at least one item
if ( order . status === "shipped" && order . items . length === 0 ) {
throw new DomainError ( "Shipped orders must have items" );
}
},
};
}
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 constructor
class 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 createdAt
const user = new User ({
email: "user@example.com" ,
name: "John Doe" ,
});
// ✅ Also works with explicit values
const 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.
Common Use Cases:
Auto-generated passwords or tokens
Timestamps (createdAt, updatedAt)
Computed identifiers
Default configurations
Consistency Boundaries
Immediate Consistency All invariants within an Aggregate are enforced immediately within a single transaction.
Eventual Consistency Changes to other Aggregates happen asynchronously through domain events.
Change Tracking
Aggregates automatically track changes to all nested entities and collections:
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 ,
}),
],
shippingAddress: new Address ({
street: "123 Main" ,
city: "NYC" ,
zipCode: "10001" ,
}),
createdAt: new Date (),
});
// Make changes
order . addItem ( "prod-b" , 1 , 49.99 ); // Create
order . items [ 0 ]. updateQuantity ( 5 ); // Update
order . removeItem ( Id . from ( "item-1" )); // Delete
// Get all changes
const changes = order . getChanges ();
console . log ( changes . hasCreates ()); // true
console . log ( changes . hasUpdates ()); // true
console . log ( changes . hasDeletes ()); // true
// Get batch operations for persistence
const batch = changes . toBatchOperations ();
// {
// deletes: [{ entity: "OrderItem", ids: ["item-1"] }],
// creates: [{ entity: "OrderItem", items: [...] }],
// updates: [{ entity: "OrderItem", items: [...] }]
// }
Changes are tracked automatically through proxies. No manual tracking needed.
Domain Events
Aggregates can emit domain events for cross-aggregate communication:
import { DomainEvent } from "@woltz/rich-domain" ;
class OrderConfirmedEvent extends DomainEvent {
constructor (
aggregateId : Id ,
public readonly customerId : string ,
public readonly total : number
) {
super ( aggregateId );
}
protected getPayload () {
return { customerId: this . customerId , total: this . total };
}
}
class Order extends Aggregate < OrderProps > {
confirm () {
if ( this . props . items . length === 0 ) {
throw new DomainError ( "Cannot confirm empty order" );
}
this . props . status = "confirmed" ;
// Emit event
this . addDomainEvent (
new OrderConfirmedEvent ( this . id , this . customerId , this . total )
);
}
}
// Dispatch events after saving
await orderRepository . save ( order );
await order . dispatchAll ( eventBus );
order . clearEvents ();
See more in Domain Events
Serialization
Both can be converted to JSON for APIs or persistence:
const item = new OrderItem ({
id: Id . from ( "item-123" ),
productId: "prod-456" ,
quantity: 3 ,
unitPrice: 29.99 ,
});
const itemJson = item . toJSON ();
// {
// id: "item-123",
// productId: "prod-456",
// quantity: 3,
// unitPrice: 29.99
// }
const orderJson = order . toJSON ();
// {
// id: "order-123",
// customerId: "cust-456",
// status: "confirmed",
// items: [
// { id: "item-1", productId: "prod-a", quantity: 2, unitPrice: 29.99 }
// ],
// shippingAddress: { street: "123 Main", city: "NYC", zipCode: "10001" },
// createdAt: "2024-01-15T10:30:00.000Z"
// }