Loading content...
Picture a real e-commerce system: a customer places an order containing multiple items, each item references a product, the order requires a shipping address, payment information, possibly discount codes, and needs to calculate totals correctly. Now imagine this order is being modified by the customer, processed by a payment gateway, and updated by inventory management—all potentially at the same time.
How do you ensure the order remains valid throughout all these operations? How do you prevent partial updates that leave data in an inconsistent state? How do you decide what should be saved together as a unit?
This is the fundamental challenge that Aggregates solve in Domain-Driven Design.
By the end of this page, you will understand what aggregates are, why they exist, and how they serve as the fundamental unit of consistency and persistence in DDD. You'll learn to see aggregates not as arbitrary groupings, but as carefully designed boundaries that protect the integrity of your domain model.
In any sufficiently complex domain, objects don't exist in isolation. They form webs of relationships—an Order contains multiple OrderItems, each OrderItem references a Product, a Customer may have multiple Addresses, a ShoppingCart holds items pending checkout.
Without careful design, these relationships become a tangled mess where:
Aggregates are the solution to this chaos. An aggregate is a cluster of domain objects that are treated as a single unit for the purpose of data changes.
An Aggregate is a cluster of associated objects that we treat as a unit for data changes. Each aggregate has a root entity (the Aggregate Root) and a boundary. External objects may only hold references to the root, never to internal entities. All changes to the aggregate pass through the root, which enforces invariants and ensures consistency.
Consider the distinction between objects that belong together versus objects that merely relate to each other:
Order and its OrderItems belong together—you cannot have an order item without its order, and the order's total depends on its items.Order and a Product relate to each other—but products exist independently of any particular order, and many orders can reference the same product.The first relationship forms an aggregate; the second is a reference between aggregates.
To appreciate aggregates fully, let's examine the problems they solve. Without aggregates, domain models face three critical challenges:
A concrete example: The Order Total Problem
Consider an e-commerce order with these invariants:
Without aggregates, any code can modify an OrderItem directly:
12345678910111213141516171819202122232425262728293031323334353637
// ❌ Without aggregates: Direct manipulation of internal objectsclass OrderItem { constructor( public productId: string, public quantity: number, public unitPrice: number ) {} // Anyone can change quantity directly setQuantity(quantity: number) { this.quantity = quantity; // But who updates the order total? // Who checks if we exceeded 10 items? // Who verifies the credit limit? }} class Order { public items: OrderItem[] = []; // Exposed for direct modification! public total: number = 0; // Total can become stale if items are modified externally recalculateTotal() { this.total = this.items.reduce( (sum, item) => sum + item.quantity * item.unitPrice, 0 ); }} // Problem: External code can modify items directlyfunction dangerousOperation(order: Order) { // Nothing stops us from doing this: order.items.push(new OrderItem("prod-1", 100, 99.99)); // Order total is now stale // Item limit invariant is bypassed // Credit limit check is skipped}The fundamental issue is lack of encapsulation at the aggregate level. When internal objects are directly accessible, invariants cannot be reliably enforced because enforcement logic is bypassed.
Aggregates solve these problems by establishing a single point of control for all modifications. The aggregate root acts as a gatekeeper, ensuring that all changes pass through methods that enforce business rules.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
// ✅ With aggregates: All modifications through the rootclass OrderItem { private constructor( private readonly _productId: string, private _quantity: number, private readonly _unitPrice: number ) {} static create(productId: string, quantity: number, unitPrice: number): OrderItem { if (quantity <= 0) throw new Error("Quantity must be positive"); if (unitPrice < 0) throw new Error("Price cannot be negative"); return new OrderItem(productId, quantity, unitPrice); } get productId(): string { return this._productId; } get quantity(): number { return this._quantity; } get unitPrice(): number { return this._unitPrice; } get total(): number { return this._quantity * this._unitPrice; } // Package-private: Only Order can call this _updateQuantity(quantity: number): void { if (quantity <= 0) throw new Error("Quantity must be positive"); this._quantity = quantity; }} class Order { private readonly _items: Map<string, OrderItem> = new Map(); private readonly MAX_ITEMS = 10; private constructor( private readonly _id: string, private readonly _customerId: string, private readonly _creditLimit: number ) {} static create(id: string, customerId: string, creditLimit: number): Order { return new Order(id, customerId, creditLimit); } // All invariants enforced through this single entry point addItem(productId: string, quantity: number, unitPrice: number): void { // Invariant: Max 10 items if (this._items.size >= this.MAX_ITEMS) { throw new Error(`Order cannot have more than ${this.MAX_ITEMS} items`); } // Invariant: No duplicate products (combine instead) if (this._items.has(productId)) { throw new Error("Product already in order. Use updateItemQuantity instead."); } const item = OrderItem.create(productId, quantity, unitPrice); // Invariant: Total cannot exceed credit limit const newTotal = this.total + item.total; if (newTotal > this._creditLimit) { throw new Error(`Order total ${newTotal} exceeds credit limit ${this._creditLimit}`); } this._items.set(productId, item); } updateItemQuantity(productId: string, newQuantity: number): void { const item = this._items.get(productId); if (!item) throw new Error(`Product ${productId} not in order`); const oldTotal = item.total; const newItemTotal = newQuantity * item.unitPrice; const projectedTotal = this.total - oldTotal + newItemTotal; // Invariant: Total cannot exceed credit limit if (projectedTotal > this._creditLimit) { throw new Error(`Updated total ${projectedTotal} exceeds credit limit`); } item._updateQuantity(newQuantity); } removeItem(productId: string): void { if (!this._items.has(productId)) { throw new Error(`Product ${productId} not in order`); } this._items.delete(productId); } // Invariant: Total always equals sum of items (computed property) get total(): number { let sum = 0; for (const item of this._items.values()) { sum += item.total; } return sum; } get itemCount(): number { return this._items.size; } // Read-only view of items get items(): ReadonlyArray<OrderItem> { return Array.from(this._items.values()); }}With proper aggregate design, invariants are enforced by construction. There is no way to violate business rules because violations are structurally impossible—all modifications pass through methods that validate constraints before making changes.
Every aggregate consists of three essential parts:
1. The Aggregate Root (Root Entity)
2. Internal Entities
3. Value Objects
| Component | Identity Scope | Accessibility | Lifecycle | Invariant Scope |
|---|---|---|---|---|
| Aggregate Root | Global (across system) | Publicly referenceable | Independent | Aggregate-wide + local |
| Internal Entity | Local (within aggregate) | Only via root | Tied to root | Local to entity |
| Value Object | None (by value) | Freely sharable | Transient | Self-contained |
Let's examine a more complex aggregate to see these parts in action:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
// ============================================// VALUE OBJECTS - Immutable, no identity// ============================================ class Money { private constructor( private readonly _amount: number, private readonly _currency: string ) {} static create(amount: number, currency: string): Money { if (amount < 0) throw new Error("Money cannot be negative"); return new Money(amount, currency); } get amount(): number { return this._amount; } get currency(): string { return this._currency; } add(other: Money): Money { if (this._currency !== other._currency) { throw new Error("Cannot add different currencies"); } return new Money(this._amount + other._amount, this._currency); } multiply(factor: number): Money { return new Money(this._amount * factor, this._currency); } equals(other: Money): boolean { return this._amount === other._amount && this._currency === other._currency; }} class Address { private constructor( private readonly _street: string, private readonly _city: string, private readonly _postalCode: string, private readonly _country: string ) {} static create(street: string, city: string, postalCode: string, country: string): Address { if (!street || !city || !postalCode || !country) { throw new Error("All address fields are required"); } return new Address(street, city, postalCode, country); } // Getters... get street(): string { return this._street; } get city(): string { return this._city; } get postalCode(): string { return this._postalCode; } get country(): string { return this._country; } equals(other: Address): boolean { return this._street === other._street && this._city === other._city && this._postalCode === other._postalCode && this._country === other._country; }} // ============================================// INTERNAL ENTITY - Local identity only// ============================================ class OrderLine { private _quantity: number; private constructor( private readonly _lineId: string, // Local identity private readonly _productId: string, private readonly _productName: string, private readonly _unitPrice: Money, quantity: number ) { this._quantity = quantity; } static create( lineId: string, productId: string, productName: string, unitPrice: Money, quantity: number ): OrderLine { if (quantity <= 0) throw new Error("Quantity must be positive"); return new OrderLine(lineId, productId, productName, unitPrice, quantity); } get lineId(): string { return this._lineId; } 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 lineTotal(): Money { return this._unitPrice.multiply(this._quantity); } // Package-private for root to call _adjustQuantity(newQuantity: number): void { if (newQuantity <= 0) throw new Error("Quantity must be positive"); this._quantity = newQuantity; }} // ============================================// AGGREGATE ROOT - Global identity, controls access// ============================================ type OrderStatus = 'draft' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled'; class Order { private readonly _lines: Map<string, OrderLine> = new Map(); private _shippingAddress: Address | null = null; private _status: OrderStatus = 'draft'; private _lineIdCounter = 0; private static readonly MAX_LINES = 20; private static readonly MAX_ORDER_TOTAL = Money.create(10000, 'USD'); private constructor( private readonly _orderId: string, // Global identity private readonly _customerId: string, // Reference to another aggregate private readonly _createdAt: Date ) {} static create(orderId: string, customerId: string): Order { return new Order(orderId, customerId, new Date()); } // ============================================ // INVARIANT-PROTECTING OPERATIONS // ============================================ addLine(productId: string, productName: string, unitPrice: Money, quantity: number): string { this.ensureModifiable(); // Invariant: Max 20 lines if (this._lines.size >= Order.MAX_LINES) { throw new Error(`Order cannot have more than ${Order.MAX_LINES} lines`); } // Create line const lineId = `line-${++this._lineIdCounter}`; const line = OrderLine.create(lineId, productId, productName, unitPrice, quantity); // Invariant: Total cannot exceed maximum const projectedTotal = this.calculateTotal().add(line.lineTotal); if (projectedTotal.amount > Order.MAX_ORDER_TOTAL.amount) { throw new Error(`Order total cannot exceed ${Order.MAX_ORDER_TOTAL.amount}`); } this._lines.set(lineId, line); return lineId; } updateLineQuantity(lineId: string, newQuantity: number): void { this.ensureModifiable(); const line = this._lines.get(lineId); if (!line) throw new Error(`Line ${lineId} not found`); // Calculate projected total const currentLineTotal = line.lineTotal; const newLineTotal = line.unitPrice.multiply(newQuantity); const projectedTotal = this.calculateTotal() .add(newLineTotal) .add(Money.create(-currentLineTotal.amount, currentLineTotal.currency)); // Invariant: Total cannot exceed maximum if (projectedTotal.amount > Order.MAX_ORDER_TOTAL.amount) { throw new Error(`Order total cannot exceed ${Order.MAX_ORDER_TOTAL.amount}`); } line._adjustQuantity(newQuantity); } removeLine(lineId: string): void { this.ensureModifiable(); if (!this._lines.has(lineId)) { throw new Error(`Line ${lineId} not found`); } this._lines.delete(lineId); } setShippingAddress(address: Address): void { this.ensureModifiable(); this._shippingAddress = address; } confirm(): void { // Invariant: Must have at least one line if (this._lines.size === 0) { throw new Error("Cannot confirm order with no items"); } // Invariant: Must have shipping address if (!this._shippingAddress) { throw new Error("Cannot confirm order without shipping address"); } // Invariant: Can only confirm from draft if (this._status !== 'draft') { throw new Error(`Cannot confirm order in ${this._status} status`); } this._status = 'confirmed'; } cancel(): void { // Invariant: Can only cancel draft or confirmed orders if (this._status === 'shipped' || this._status === 'delivered') { throw new Error(`Cannot cancel order in ${this._status} status`); } this._status = 'cancelled'; } // ============================================ // QUERY METHODS (read-only) // ============================================ get orderId(): string { return this._orderId; } get customerId(): string { return this._customerId; } get status(): OrderStatus { return this._status; } get shippingAddress(): Address | null { return this._shippingAddress; } get createdAt(): Date { return new Date(this._createdAt.getTime()); } get lines(): ReadonlyArray<OrderLine> { return Array.from(this._lines.values()); } get lineCount(): number { return this._lines.size; } calculateTotal(): Money { let total = Money.create(0, 'USD'); for (const line of this._lines.values()) { total = total.add(line.lineTotal); } return total; } // ============================================ // PRIVATE HELPERS // ============================================ private ensureModifiable(): void { if (this._status !== 'draft') { throw new Error(`Cannot modify order in ${this._status} status`); } }}A critical aspect of aggregate design is understanding what belongs inside versus outside the aggregate boundary. This determines:
Aggregates reference other aggregates by identity only (typically the root's ID), never by direct object reference. This prevents one aggregate from modifying another's internals and enables independent loading, saving, and scaling of different aggregates.
Consider why Order references Customer by ID rather than embedding:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
// ❌ WRONG: Direct object references between aggregatesclass Order { private customer: Customer; // Direct reference - BAD! private items: OrderItem[]; getCustomerAddress(): Address { // This creates coupling and loading issues return this.customer.getShippingAddress(); } confirmOrder(): void { // What happens if we accidentally modify customer? // Should this save the customer too? this.customer.updateLastOrderDate(new Date()); // Inappropriate! }} // ✅ CORRECT: Reference by identityclass Order { private readonly _customerId: string; // ID reference only - GOOD! private readonly _lines: Map<string, OrderLine> = new Map(); // To get customer details, we go through a service or query // This is explicit, not hidden behind object navigation get customerId(): string { return this._customerId; } confirmOrder(): void { // Order only updates its own state // If customer needs updating, that's a separate operation // handled by an application service this._status = 'confirmed'; }} // Application service coordinates across aggregatesclass OrderConfirmationService { constructor( private orderRepository: OrderRepository, private customerRepository: CustomerRepository, private eventPublisher: EventPublisher ) {} async confirmOrder(orderId: string): Promise<void> { const order = await this.orderRepository.findById(orderId); // Order only manages its own state order.confirm(); await this.orderRepository.save(order); // Customer update is a separate concern, if needed // Could be eventually consistent via events this.eventPublisher.publish(new OrderConfirmedEvent( order.orderId, order.customerId, order.calculateTotal() )); }}One of the most practical benefits of aggregates is that they define the unit of persistence. When you save an aggregate, you save the entire cluster atomically; when you load it, you load everything needed to enforce invariants.
This has profound implications:
Prefer smaller aggregates over larger ones. A smaller aggregate means less data to load, fewer locking conflicts under concurrency, and faster transactions. If you find yourself with an aggregate that loads 100 objects, reconsider the boundaries. Often what looks like one aggregate should be several smaller ones communicating via references and events.
The Repository Pattern and Aggregates
Repositories should only provide access to aggregate roots, never to internal entities. This enforces the aggregate boundary at the persistence layer:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
// ✅ CORRECT: Repository for aggregate root onlyinterface OrderRepository { findById(orderId: string): Promise<Order | null>; save(order: Order): Promise<void>; delete(orderId: string): Promise<void>; // Query methods return aggregate roots findByCustomerId(customerId: string): Promise<Order[]>; findPendingOrders(): Promise<Order[]>;} // ❌ WRONG: No repository for internal entities// This would bypass the aggregate root!interface OrderItemRepository { // DON'T DO THIS findById(itemId: string): Promise<OrderItem>; save(item: OrderItem): Promise<void>;} // ✅ Implementation loads/saves entire aggregate atomicallyclass SqlOrderRepository implements OrderRepository { async findById(orderId: string): Promise<Order | null> { // Single query or optimized joins to load entire aggregate const orderData = await this.db.query(` SELECT o.*, ol.* FROM orders o LEFT JOIN order_lines ol ON o.id = ol.order_id WHERE o.id = $1 `, [orderId]); if (!orderData.length) return null; // Reconstitute the complete aggregate return this.mapToAggregate(orderData); } async save(order: Order): Promise<void> { await this.db.transaction(async (tx) => { // Save order root await tx.query(` INSERT INTO orders (id, customer_id, status, shipping_address, created_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (id) DO UPDATE SET status = $3, shipping_address = $4 `, [order.orderId, order.customerId, order.status, order.shippingAddress, order.createdAt]); // Delete existing lines and re-insert (simple approach) await tx.query('DELETE FROM order_lines WHERE order_id = $1', [order.orderId]); for (const line of order.lines) { await tx.query(` INSERT INTO order_lines (id, order_id, product_id, product_name, unit_price, quantity) VALUES ($1, $2, $3, $4, $5, $6) `, [line.lineId, order.orderId, line.productId, line.productName, line.unitPrice.amount, line.quantity]); } }); }}We've covered the fundamental concept of aggregates in Domain-Driven Design. Let's consolidate the essential points:
What's Next:
In the next page, we'll dive deeper into the Aggregate Root pattern—understanding how to identify the right root, what responsibilities it should carry, and how to design root interfaces that are both expressive and protective of invariants.
You now understand what aggregates are and why they're essential to DDD. Aggregates solve the fundamental problem of maintaining consistency in complex domain models by establishing clear boundaries, single points of control, and well-defined units of persistence. Next, we'll explore the Aggregate Root pattern in detail.