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" );
}
},
};
}
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"
// }