Loading content...
Every aggregate needs a leader—a single entity that stands at the boundary between the aggregate's internal complexity and the outside world. This entity is the Aggregate Root, and understanding its role is fundamental to effective DDD.
The aggregate root isn't just another entity that happens to be at the top of a hierarchy. It's a carefully designed guardian that:
Get the aggregate root right, and your domain model becomes robust and self-protecting. Get it wrong, and invariants leak, consistency erodes, and the aggregate's boundaries become meaningless.
By the end of this page, you will master the aggregate root pattern: how to identify the right root entity, what responsibilities it must carry, how to design its interface to be both expressive and protective, and common pitfalls that compromise aggregate integrity.
The first challenge in aggregate design is identifying which entity should be the root. This decision has far-reaching consequences for your model's usability and integrity.
The Natural Root Test
Ask yourself these questions about candidate entities:
The entity that answers 'yes' most strongly to these questions is your aggregate root.
| Domain | Candidate Entities | Aggregate Root | Why |
|---|---|---|---|
| E-commerce | Order, OrderLine, ShippingAddress | Order | Lines cannot exist without an order; orders are referenced by ID; order controls line limits |
| Banking | Account, Transaction, Balance | Account | Transactions belong to accounts; balance is derived from account state; accounts have global identity |
| Project Management | Project, Task, Subtask | Project | Tasks exist within projects; projects are the unit of planning; project controls task limits |
| Forum | Thread, Post, Reaction | Thread | Posts belong to threads; threads are browsed; thread controls posting rules |
| Vehicle | Car, Engine, Wheel | Car | Components exist only in a car; cars have VIN identity; car enforces component compatibility |
If you find yourself with an aggregate root that controls dozens of entities, you've probably made the aggregate too large. This creates loading problems, locking contention, and bloated interfaces. Consider breaking it into smaller aggregates connected by references.
Real-World Example: Blog Post Aggregate
Consider a blogging platform with Posts, Comments, Tags, and Authors. Which is the aggregate root?
The Post is the natural root because it has global identity (URL, ID), controls comment behavior (allow/disallow, moderation), and defines invariants (max comments, closed for comments).
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
// Post is clearly the aggregate rootclass Post { private readonly _comments: Map<string, Comment> = new Map(); private _commentsEnabled: boolean = true; private _status: PostStatus = 'draft'; private static readonly MAX_COMMENTS = 500; private constructor( private readonly _postId: string, // Global identity private readonly _authorId: string, // Reference to Author aggregate private _title: string, private _content: string, private _tags: Set<string> // Reference to Tag by ID ) {} static create(postId: string, authorId: string, title: string, content: string): Post { if (!title || title.length < 5) { throw new Error("Title must be at least 5 characters"); } return new Post(postId, authorId, title, content, new Set()); } // Post controls whether comments are allowed addComment(commentId: string, authorId: string, content: string): void { // Invariant: Comments must be enabled if (!this._commentsEnabled) { throw new Error("Comments are disabled for this post"); } // Invariant: Post must be published if (this._status !== 'published') { throw new Error("Cannot comment on unpublished posts"); } // Invariant: Maximum comments if (this._comments.size >= Post.MAX_COMMENTS) { throw new Error(`Maximum ${Post.MAX_COMMENTS} comments reached`); } const comment = Comment.create(commentId, authorId, content, this._postId); this._comments.set(commentId, comment); } disableComments(): void { this._commentsEnabled = false; } enableComments(): void { if (this._status !== 'published') { throw new Error("Cannot enable comments on unpublished posts"); } this._commentsEnabled = true; } publish(): void { if (this._status !== 'draft') { throw new Error(`Cannot publish post in ${this._status} status`); } if (!this._title || !this._content) { throw new Error("Cannot publish post without title and content"); } this._status = 'published'; } // Tags are managed by the post (references to Tag aggregate) addTag(tagId: string): void { if (this._tags.size >= 10) { throw new Error("Maximum 10 tags per post"); } this._tags.add(tagId); } removeTag(tagId: string): void { this._tags.delete(tagId); } // Getters... get postId(): string { return this._postId; } get authorId(): string { return this._authorId; } get title(): string { return this._title; } get commentCount(): number { return this._comments.size; } get comments(): ReadonlyArray<Comment> { return Array.from(this._comments.values()); }} // Comment is an internal entity - identity local to Postclass Comment { private constructor( private readonly _commentId: string, // Local identity private readonly _authorId: string, // Reference to Author aggregate private _content: string, private readonly _postId: string, private readonly _createdAt: Date ) {} static create(commentId: string, authorId: string, content: string, postId: string): Comment { if (!content || content.length < 2) { throw new Error("Comment must have content"); } if (content.length > 2000) { throw new Error("Comment cannot exceed 2000 characters"); } return new Comment(commentId, authorId, content, postId, new Date()); } get commentId(): string { return this._commentId; } get authorId(): string { return this._authorId; } get content(): string { return this._content; }} type PostStatus = 'draft' | 'published' | 'archived';The aggregate root carries specific, well-defined responsibilities that make it the cornerstone of aggregate design. Understanding these responsibilities helps you design root interfaces that are powerful yet protective.
The Interface Design Principle
The aggregate root's public interface should expose domain operations, not data manipulation. Instead of:
order.getItems().add(new OrderItem(...))
order.setTotal(calculateTotal())
The interface should express domain intent:
order.addItem(productId, quantity, unitPrice)
order.confirmOrder()
order.cancel(reason)
This ensures:
A well-designed aggregate root follows the 'Tell, Don't Ask' principle. Instead of exposing state for external code to make decisions and call setters, the root provides operations that encapsulate both the decision logic and the state changes. This keeps behavior where the data lives.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990
// ❌ BAD: Data-oriented interface - exposes internalsclass BadOrder { public items: OrderItem[] = []; public status: string = 'draft'; public total: number = 0; getItems(): OrderItem[] { return this.items; } setStatus(status: string): void { this.status = status; } setTotal(total: number): void { this.total = total; }} // Calling code is forced to know business rulesfunction confirmOrder_BAD(order: BadOrder) { if (order.getItems().length === 0) { throw new Error("Can't confirm empty order"); } order.setStatus('confirmed'); // Oops, forgot to update shipping status // Caller must know all the rules and steps} // ✅ GOOD: Domain-oriented interface - encapsulates behaviorclass GoodOrder { private readonly _items: Map<string, OrderItem> = new Map(); private _status: OrderStatus = 'draft'; private _confirmedAt: Date | null = null; // Domain operation: expresses WHAT, not HOW confirm(): OrderConfirmation { // All rules checked in one place if (this._items.size === 0) { throw new DomainError("Order must have at least one item"); } if (this._status !== 'draft') { throw new DomainError(`Cannot confirm order in ${this._status} status`); } // All state changes in one place this._status = 'confirmed'; this._confirmedAt = new Date(); // Return value object with confirmation details return new OrderConfirmation( this._orderId, this._confirmedAt, this.calculateTotal() ); } // Domain operation: clearly named, intent-revealing ship(trackingNumber: string, carrier: string): Shipment { if (this._status !== 'confirmed') { throw new DomainError("Only confirmed orders can be shipped"); } this._status = 'shipped'; const shipment = Shipment.create(this._orderId, trackingNumber, carrier); this._shipment = shipment; return shipment; } // Domain operation: handles the "sad path" too cancel(reason: CancellationReason, cancelledBy: string): void { // Business rules about what can be cancelled if (this._status === 'shipped' || this._status === 'delivered') { throw new DomainError(`Cannot cancel ${this._status} order`); } this._status = 'cancelled'; this._cancellation = { reason, cancelledBy, cancelledAt: new Date() }; } // Read-only accessors get orderId(): string { return this._orderId; } get status(): OrderStatus { return this._status; } get itemCount(): number { return this._items.size; }} type OrderStatus = 'draft' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled'; class DomainError extends Error { constructor(message: string) { super(message); this.name = 'DomainError'; }}A critical function of the aggregate root is to hide and protect internal entities. Direct access to internal entities undermines the aggregate pattern because:
Let's examine techniques to properly protect internals:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
class ShoppingCart { private readonly _items: Map<string, CartItem> = new Map(); private readonly _cartId: string; private readonly _customerId: string; // ============================================ // PROTECTED ACCESS PATTERNS // ============================================ // ✅ Return read-only snapshot, not the live collection get items(): ReadonlyArray<CartItemSnapshot> { return Array.from(this._items.values()).map(item => ({ productId: item.productId, productName: item.productName, quantity: item.quantity, unitPrice: item.unitPrice, total: item.total })); } // ✅ Accept ID, not object reference updateQuantity(productId: string, newQuantity: number): void { const item = this._items.get(productId); if (!item) { throw new Error(`Product ${productId} not in cart`); } if (newQuantity <= 0) { this._items.delete(productId); } else { // Root controls the mutation item._setQuantity(newQuantity); } } // ✅ Root creates internal entities addItem(productId: string, productName: string, quantity: number, unitPrice: number): void { if (this._items.has(productId)) { // Combine with existing const existing = this._items.get(productId)!; existing._setQuantity(existing.quantity + quantity); } else { // Root creates new item const item = CartItem._create(productId, productName, quantity, unitPrice); this._items.set(productId, item); } } // ✅ Query method returns value, not entity findItem(productId: string): CartItemSnapshot | null { const item = this._items.get(productId); if (!item) return null; return { productId: item.productId, productName: item.productName, quantity: item.quantity, unitPrice: item.unitPrice, total: item.total }; } // ✅ Calculations done within aggregate calculateTotal(): Money { let total = Money.zero('USD'); for (const item of this._items.values()) { total = total.add(item.total); } return total; }} // Internal entity with protected accessclass CartItem { private constructor( private readonly _productId: string, private readonly _productName: string, private _quantity: number, private readonly _unitPrice: Money ) {} // Factory is "internal" - only accessible within module static _create(productId: string, productName: string, quantity: number, unitPrice: number): CartItem { if (quantity <= 0) throw new Error("Quantity must be positive"); return new CartItem(productId, productName, quantity, Money.create(unitPrice, 'USD')); } // Setter is "internal" - only accessible within module _setQuantity(quantity: number): void { if (quantity <= 0) throw new Error("Quantity must be positive"); this._quantity = quantity; } get productId(): string { return this._productId; } get productName(): string { return this._productName; } get quantity(): number { return this._quantity; } get unitPrice(): Money { return this._unitPrice; } get total(): Money { return this._unitPrice.multiply(this._quantity); }} // Read-only snapshot - safe to return to callersinterface CartItemSnapshot { readonly productId: string; readonly productName: string; readonly quantity: number; readonly unitPrice: Money; readonly total: Money;}Different languages offer different mechanisms for protection: Java has package-private access, C# has internal visibility, TypeScript can use module boundaries or naming conventions (underscore prefix). Choose the approach that works best in your language while maintaining the principle: internals are not directly accessible from outside.
Within an aggregate, you must decide what should be an entity (with identity) versus a value object (identity-less). This decision affects how objects are tracked, compared, and stored.
The Key Question: Does the object need to be tracked over time, or is it fully described by its attributes?
The Shopping Cart Example
Consider items in a shopping cart:
Scenario A: You need to track when each item was added, allow updating individual items' quantity, and maintain an order of items (first added, etc.). → CartItem is an entity with local identity.
Scenario B: Items are just product-quantity pairs, you only care about the aggregate quantities per product, and items are always replaced entirely when updated. → CartItem could be a value object.
The right choice depends on the specific domain requirements.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
// ============================================// VALUE OBJECTS - Immutable, compared by value// ============================================ class Money { private constructor( private readonly _amount: number, private readonly _currency: string ) {} static create(amount: number, currency: string): Money { return new Money(amount, currency); } static zero(currency: string): Money { return new Money(0, currency); } // Immutable operations return new instances add(other: Money): Money { this.ensureSameCurrency(other); return new Money(this._amount + other._amount, this._currency); } multiply(factor: number): Money { return new Money(this._amount * factor, this._currency); } // Equality is by value equals(other: Money): boolean { return this._amount === other._amount && this._currency === other._currency; } get amount(): number { return this._amount; } get currency(): string { return this._currency; } private ensureSameCurrency(other: Money): void { if (this._currency !== other._currency) { throw new Error(`Cannot combine ${this._currency} and ${other._currency}`); } }} class ProductQuantity { private constructor( private readonly _productId: string, private readonly _quantity: number ) {} static create(productId: string, quantity: number): ProductQuantity { if (quantity <= 0) throw new Error("Quantity must be positive"); return new ProductQuantity(productId, quantity); } withQuantity(quantity: number): ProductQuantity { return ProductQuantity.create(this._productId, quantity); } // Equal if same product and quantity equals(other: ProductQuantity): boolean { return this._productId === other._productId && this._quantity === other._quantity; } get productId(): string { return this._productId; } get quantity(): number { return this._quantity; }} // ============================================// INTERNAL ENTITY - Has identity within aggregate// ============================================ class OrderLine { private _quantity: number; private constructor( private readonly _lineId: string, // Identity within Order private readonly _productId: string, private readonly _productName: string, private readonly _unitPrice: Money, quantity: number, private readonly _addedAt: Date ) { this._quantity = quantity; } static create( lineId: string, productId: string, productName: string, unitPrice: Money, quantity: number ): OrderLine { return new OrderLine(lineId, productId, productName, unitPrice, quantity, new Date()); } // Has identity - same lineId means same entity // Two lines with identical product/quantity are DIFFERENT if lineId differs // Can be mutated (through root) _updateQuantity(quantity: number): void { if (quantity <= 0) throw new Error("Quantity must be positive"); this._quantity = quantity; } get lineId(): string { return this._lineId; } get productId(): string { return this._productId; } get quantity(): number { return this._quantity; } get lineTotal(): Money { return this._unitPrice.multiply(this._quantity); } get addedAt(): Date { return new Date(this._addedAt.getTime()); }} // Order uses entities for lines, value objects for moneyclass Order { private readonly _lines: Map<string, OrderLine> = new Map(); private _shippingCost: Money = Money.zero('USD'); addLine(productId: string, productName: string, unitPrice: Money, quantity: number): string { // Creates entity with identity const lineId = this.generateLineId(); const line = OrderLine.create(lineId, productId, productName, unitPrice, quantity); this._lines.set(lineId, line); return lineId; } setShippingCost(cost: Money): void { // Replaces value object entirely (immutable) this._shippingCost = cost; } updateLineQuantity(lineId: string, quantity: number): void { // Updates entity by identity const line = this._lines.get(lineId); if (!line) throw new Error(`Line ${lineId} not found`); line._updateQuantity(quantity); } calculateTotal(): Money { let itemsTotal = Money.zero('USD'); for (const line of this._lines.values()) { itemsTotal = itemsTotal.add(line.lineTotal); } return itemsTotal.add(this._shippingCost); } private generateLineId(): string { return `line-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; }}When significant state changes occur within an aggregate, other parts of the system often need to react. Domain events are the mechanism for this communication. The aggregate root is responsible for raising events that describe what happened in domain terms.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
// ============================================// DOMAIN EVENTS - Immutable records of what happened// ============================================ interface DomainEvent { readonly eventId: string; readonly occurredAt: Date; readonly aggregateId: string; readonly aggregateType: string;} class OrderConfirmedEvent implements DomainEvent { readonly eventId: string; readonly occurredAt: Date; readonly aggregateType = 'Order'; constructor( readonly aggregateId: string, // orderId readonly customerId: string, readonly orderTotal: Money, readonly itemCount: number, readonly shippingAddress: AddressSnapshot ) { this.eventId = crypto.randomUUID(); this.occurredAt = new Date(); }} class OrderItemAddedEvent implements DomainEvent { readonly eventId: string; readonly occurredAt: Date; readonly aggregateType = 'Order'; constructor( readonly aggregateId: string, // orderId readonly lineId: string, readonly productId: string, readonly productName: string, readonly quantity: number, readonly unitPrice: Money, readonly newOrderTotal: Money ) { this.eventId = crypto.randomUUID(); this.occurredAt = new Date(); }} class OrderCancelledEvent implements DomainEvent { readonly eventId: string; readonly occurredAt: Date; readonly aggregateType = 'Order'; constructor( readonly aggregateId: string, // orderId readonly customerId: string, readonly reason: string, readonly orderTotal: Money ) { this.eventId = crypto.randomUUID(); this.occurredAt = new Date(); }} // ============================================// AGGREGATE ROOT - Raises events on state changes// ============================================ abstract class AggregateRoot { private readonly _domainEvents: DomainEvent[] = []; protected addDomainEvent(event: DomainEvent): void { this._domainEvents.push(event); } public getDomainEvents(): ReadonlyArray<DomainEvent> { return [...this._domainEvents]; } public clearDomainEvents(): void { this._domainEvents.length = 0; }} class Order extends AggregateRoot { private readonly _lines: Map<string, OrderLine> = new Map(); private _status: OrderStatus = 'draft'; private _shippingAddress: Address | null = null; private constructor( private readonly _orderId: string, private readonly _customerId: string ) { super(); } static create(orderId: string, customerId: string): Order { return new Order(orderId, customerId); } addItem(productId: string, productName: string, unitPrice: Money, quantity: number): string { this.ensureModifiable(); const lineId = this.generateLineId(); const line = OrderLine.create(lineId, productId, productName, unitPrice, quantity); this._lines.set(lineId, line); // Raise event with complete information this.addDomainEvent(new OrderItemAddedEvent( this._orderId, lineId, productId, productName, quantity, unitPrice, this.calculateTotal() )); return lineId; } confirm(): void { this.validateForConfirmation(); this._status = 'confirmed'; // Event contains all info needed by handlers this.addDomainEvent(new OrderConfirmedEvent( this._orderId, this._customerId, this.calculateTotal(), this._lines.size, this.addressSnapshot(this._shippingAddress!) )); } cancel(reason: string): void { this.validateForCancellation(); this._status = 'cancelled'; // Event allows handlers to process refunds, notifications, etc. this.addDomainEvent(new OrderCancelledEvent( this._orderId, this._customerId, reason, this.calculateTotal() )); } private validateForConfirmation(): void { if (this._lines.size === 0) { throw new Error("Cannot confirm order with no items"); } if (!this._shippingAddress) { throw new Error("Cannot confirm order without shipping address"); } if (this._status !== 'draft') { throw new Error(`Cannot confirm order in ${this._status} status`); } } private validateForCancellation(): void { if (this._status === 'shipped' || this._status === 'delivered') { throw new Error(`Cannot cancel ${this._status} order`); } } // ... other methods ...} // Repository publishes events after successful saveclass OrderRepository { constructor( private readonly db: Database, private readonly eventPublisher: EventPublisher ) {} async save(order: Order): Promise<void> { await this.db.transaction(async (tx) => { await this.persistOrder(tx, order); // Publish events after successful persistence const events = order.getDomainEvents(); for (const event of events) { await this.eventPublisher.publish(event); } order.clearDomainEvents(); }); }}Events should be published after the aggregate is successfully persisted, not before. This prevents situations where handlers react to events but the originating change fails. The repository typically handles this, publishing events only after the save transaction commits.
Even experienced developers make mistakes when designing aggregate roots. Understanding these pitfalls helps you avoid them:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
// ============================================// ANTI-PATTERN 1: Leaky Abstraction// ============================================ class LeakyOrder { private items: OrderItem[] = []; // ❌ Returns mutable internal collection getItems(): OrderItem[] { return this.items; // External code can modify! }} // External code can break invariants:const order = new LeakyOrder();order.getItems().push(new OrderItem(...)); // Bypasses all checks! // ✅ CORRECT: Return read-only viewclass ProperOrder { private readonly items: Map<string, OrderItem> = new Map(); get itemsList(): ReadonlyArray<OrderItemSnapshot> { return Array.from(this.items.values()).map(i => i.toSnapshot()); }} // ============================================// ANTI-PATTERN 2: Anemic Root// ============================================ class AnemicOrder { public orderId: string = ''; public customerId: string = ''; public status: string = 'draft'; public items: OrderItem[] = []; public total: number = 0; // Just data, no behavior} class OrderService { // All logic external to the order confirmOrder(order: AnemicOrder): void { if (order.items.length === 0) throw new Error("..."); order.status = 'confirmed'; order.total = this.calculateTotal(order); // Order doesn't protect itself }} // ✅ CORRECT: Rich domain modelclass RichOrder { private readonly _items: Map<string, OrderItem> = new Map(); private _status: OrderStatus = 'draft'; // Behavior WITH the data confirm(): OrderConfirmation { if (this._items.size === 0) { throw new DomainError("Cannot confirm empty order"); } this._status = 'confirmed'; return new OrderConfirmation(this._orderId, this.calculateTotal()); }} // ============================================// ANTI-PATTERN 3: Cross-Aggregate References// ============================================ class WrongOrder { // ❌ Direct reference to another aggregate root private customer: Customer; private product: Product; getCustomerName(): string { // Creates tight coupling, loading issues, transaction conflicts return this.customer.name; }} // ✅ CORRECT: Reference by IDclass CorrectOrder { private readonly _customerId: string; // ID only private readonly _items: Map<string, OrderItem> = new Map(); // If you need customer data, go through a service get customerId(): string { return this._customerId; }} // Application layer resolves references when neededclass OrderQueryService { async getOrderDetails(orderId: string): Promise<OrderDetailsDTO> { const order = await this.orderRepo.findById(orderId); const customer = await this.customerRepo.findById(order.customerId); return new OrderDetailsDTO(order, customer); }}We've explored the aggregate root pattern in depth. Let's consolidate the essential points:
What's Next:
In the next page, we'll explore Consistency Boundaries—how to determine what belongs inside an aggregate versus outside, and how to handle operations that span multiple aggregates while maintaining consistency.
You now understand the aggregate root pattern: how to identify the right root, what responsibilities it carries, and how to design its interface. The aggregate root is the foundation of aggregate design—get it right, and your domain model becomes robust and self-protecting.