What is an Aggregate?
An Aggregate is a cluster of domain objects treated as a single unit for data changes. It has a root entity (the Aggregate itself) that controls access to all objects inside it.
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 }));
}
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" ;
}
}
Key Characteristics
Consistency Boundary All invariants within an Aggregate are enforced immediately. Changes to
other Aggregates are eventually consistent.
Single Unit of Persistence Aggregates are loaded and saved as a whole. Repositories work with
Aggregates, not individual entities.
Transactional Boundary All changes within an Aggregate happen in a single transaction.
Encapsulation External objects can only reference the Aggregate root, never internal
entities directly.
Creating Aggregates
New Aggregate
const order = new Order ({
customerId: "cust-123" ,
status: "draft" ,
items: [],
shippingAddress: new Address ({
street: "123 Main St" ,
city: "New York" ,
zipCode: "10001" ,
}),
createdAt: new Date (),
});
console . log ( order . isNew ()); // true
From Database
const order = new Order ({
id: Id . from ( dbRow . id ),
customerId: dbRow . customerId ,
status: dbRow . status ,
items: dbRow . items . map (
( item ) =>
new OrderItem ({
id: Id . from ( item . id ),
productId: item . productId ,
quantity: item . quantity ,
unitPrice: item . unitPrice ,
})
),
shippingAddress: new Address ( dbRow . shippingAddress ),
createdAt: dbRow . createdAt ,
});
console . log ( order . isNew ()); // false
Validation
Add schema validation to ensure Aggregates are always valid:
import { z } from "zod" ;
import {
Aggregate ,
EntityValidation ,
EntityHooks ,
Id ,
} from "@woltz/rich-domain" ;
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" );
}
},
};
// ... methods
}
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 Error ( "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
Convert the entire Aggregate (including nested entities) to JSON:
const json = order . toJSON ();
// {
// id: "order-123",
// customerId: "cust-456",
// status: "confirmed",
// items: [
// { id: "item-1", productId: "prod-a", quantity: 2, unitPrice: 29.99 },
// { id: "item-2", productId: "prod-b", quantity: 1, unitPrice: 49.99 }
// ],
// shippingAddress: { street: "123 Main", city: "NYC", zipCode: "10001" },
// createdAt: "2024-01-15T10:30:00.000Z"
// }
Design Guidelines
Large Aggregates lead to concurrency issues and performance problems. Include only what’s needed to enforce invariants. // ❌ Too large
class Customer extends Aggregate < CustomerProps > {
orders : Order []; // Could be thousands
reviews : Review []; // Independent lifecycle
wishlist : Product []; // Different consistency needs
}
// ✅ Separate Aggregates
class Customer extends Aggregate < CustomerProps > { }
class Order extends Aggregate < OrderProps > { customerId : string ; }
class Review extends Aggregate < ReviewProps > { customerId : string ; }
Reference other Aggregates by ID
Don’t hold direct references to other Aggregates. Use IDs instead. // ❌ Direct reference
class Order extends Aggregate < OrderProps > {
customer : Customer ; // Creates tight coupling
}
// ✅ Reference by ID
class Order extends Aggregate < OrderProps > {
customerId : string ;
}
All business rules should be enforced through Aggregate methods, never bypassed. class Order extends Aggregate < OrderProps > {
// ✅ Enforces business rules
addItem ( productId : string , quantity : number , unitPrice : number ) {
if ( this . status !== "draft" ) {
throw new Error ( "Cannot modify confirmed order" );
}
if ( quantity <= 0 ) {
throw new Error ( "Quantity must be positive" );
}
this . props . items . push ( new OrderItem ({ productId , quantity , unitPrice }));
}
}
Use factory methods for complex creation
When creation logic is complex, use static factory methods. class Order extends Aggregate < OrderProps > {
static createFromCart ( cart : Cart , customerId : string ) : Order {
const order = new Order ({
customerId ,
status: "draft" ,
items: cart . items . map (
( item ) => new OrderItem ({
productId: item . productId ,
quantity: item . quantity ,
unitPrice: item . unitPrice ,
})
),
shippingAddress: cart . shippingAddress ,
createdAt: new Date (),
});
order . addDomainEvent ( new OrderCreatedEvent ( order . id , customerId ));
return order ;
}
}