Loading content...
You understand what aggregates are, how roots work, and how consistency boundaries function. Now comes the practical question: How do you actually design good aggregates?
Aggregate design is where theory meets reality. Poor design leads to:
This page provides the rules, patterns, and heuristics that experienced practitioners use to design aggregates that work well in production. These aren't theoretical ideals—they're battle-tested guidelines from real systems.
By the end of this page, you will have a complete toolkit for aggregate design: sizing rules, composition patterns, modeling techniques, and red flags to avoid. You'll be able to design aggregates that are performant, maintainable, and correctly express your domain.
Vaughn Vernon, a DDD expert, established four fundamental rules for aggregate design. These rules form the foundation of effective aggregate design:
These rules should be followed by default but can be bent when there's a compelling reason. The key is understanding why the rule exists so you know the cost of breaking it.
Let's examine each rule in depth with practical examples.
The first rule asks: What truly must be consistent at all times?
An invariant is a business rule that must always be true. Examples:
The Test: If violating this rule, even for a millisecond, would cause real business harm, it's a true invariant requiring immediate consistency. If temporary violation is acceptable, eventual consistency is fine.
Example: The Product-Inventory Dilemma
Should Product and Inventory be one aggregate or two?
Analysis:
Does the product information need to be consistent with inventory?
Does inventory for one location need to be consistent with another?
Conclusion: Product and InventoryItem (per-location) should be separate aggregates. This allows:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
// Product Aggregate - manages product catalog informationclass Product { constructor( private readonly _productId: string, private _name: string, private _description: string, private _price: Money, private _categoryIds: Set<string> ) {} updateDetails(name: string, description: string): void { this._name = name; this._description = description; // No inventory impact } updatePrice(price: Money): void { if (price.amount < 0) throw new Error("Price cannot be negative"); this._price = price; // Price change doesn't require inventory lock }} // InventoryItem Aggregate - manages stock for one product at one locationclass InventoryItem { constructor( private readonly _productId: string, private readonly _locationId: string, private _quantityOnHand: number, private _reservedQuantity: number ) {} // Invariant: Available = OnHand - Reserved ≥ 0 get availableQuantity(): number { return this._quantityOnHand - this._reservedQuantity; } reserve(quantity: number, orderId: string): Reservation { if (quantity > this.availableQuantity) { throw new InsufficientStockError( this._productId, this.availableQuantity, quantity ); } this._reservedQuantity += quantity; return new Reservation( this._productId, this._locationId, orderId, quantity ); } release(reservation: Reservation): void { this._reservedQuantity -= reservation.quantity; } confirmShipment(reservation: Reservation): void { this._quantityOnHand -= reservation.quantity; this._reservedQuantity -= reservation.quantity; } receiveStock(quantity: number, receivingId: string): void { if (quantity <= 0) throw new Error("Quantity must be positive"); this._quantityOnHand += quantity; }} // Order references products by ID, not by objectclass Order { private readonly _customerId: string; private readonly _lines: Map<string, OrderLine> = new Map(); addLine( productId: string, // Reference by ID productName: string, // Snapshot for display productPrice: Money, // Snapshot at time of order quantity: number ): void { // Order doesn't check inventory - that's handled by // coordination with InventoryItem aggregate }}The mantra of aggregate design is: smaller is better.
Large aggregates cause real problems:
The ideal aggregate contains just the root entity. If invariants require it, add value objects and a small number of entities (typically < 5). If your aggregate regularly loads dozens of entities, reconsider the boundaries.
Example: Breaking Up a Large Aggregate
Consider a Forum that contains Threads, which contain Posts, which have Reactions:
Forum (root)
└── Thread (100s per forum)
└── Post (100s per thread)
└── Reaction (many per post)
This structure could mean loading thousands of objects to add a single reaction. Let's redesign:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
// ❌ WRONG: Everything in one aggregateclass MonolithicForum { private threads: Thread[] = []; // Each has posts, each has reactions addReaction(threadId: string, postId: string, userId: string, type: string): void { // Must load entire forum to add one reaction! const thread = this.threads.find(t => t.id === threadId); const post = thread?.posts.find(p => p.id === postId); post?.addReaction(userId, type); }} // ✅ CORRECT: Multiple focused aggregates // Forum Aggregate - manages forum metadata and rulesclass Forum { constructor( private readonly _forumId: string, private _name: string, private _isLocked: boolean, private _moderatorIds: Set<string> ) {} // Invariant: Only moderators can lock lock(moderatorId: string): void { if (!this._moderatorIds.has(moderatorId)) { throw new Error("Only moderators can lock forum"); } this._isLocked = true; }} // Thread Aggregate - manages thread and posting rulesclass Thread { private _postCount: number = 0; constructor( private readonly _threadId: string, private readonly _forumId: string, // Reference to forum private readonly _authorId: string, private _title: string, private _isLocked: boolean, private _isPinned: boolean ) {} // Thread tracks post count for queries, but doesn't contain posts incrementPostCount(): void { this._postCount++; } lock(): void { this._isLocked = true; }} // Post Aggregate - manages single post and its reactionsclass Post { private readonly _reactions: Map<string, Reaction> = new Map(); private static readonly MAX_REACTIONS = 1000; constructor( private readonly _postId: string, private readonly _threadId: string, // Reference to thread private readonly _authorId: string, private _content: string, private readonly _createdAt: Date ) {} // Post is small: just content + reactions addReaction(userId: string, type: ReactionType): void { const reactionId = `${userId}-${type}`; // Invariant: One reaction per type per user if (this._reactions.has(reactionId)) { throw new Error("User already has this reaction"); } // Invariant: Max reactions per post if (this._reactions.size >= Post.MAX_REACTIONS) { throw new Error("Maximum reactions reached"); } this._reactions.set(reactionId, new Reaction(userId, type)); } removeReaction(userId: string, type: ReactionType): void { const reactionId = `${userId}-${type}`; this._reactions.delete(reactionId); } edit(newContent: string, editorId: string): void { // Invariant: Only author can edit if (editorId !== this._authorId) { throw new Error("Only author can edit"); } this._content = newContent; }} type ReactionType = 'like' | 'love' | 'laugh' | 'angry' | 'sad'; class Reaction { constructor( readonly userId: string, readonly type: ReactionType, readonly createdAt: Date = new Date() ) {}}The Benefits:
| Operation | Monolithic | Decomposed |
|---|---|---|
| Add reaction | Load entire forum | Load single post |
| Lock thread | Load entire forum | Load single thread |
| Edit post | Load entire forum | Load single post |
| Concurrency | One forum = one lock | Many posts = many independent operations |
Aggregates should reference other aggregates by identity (ID) only, never by direct object reference. This rule is fundamental to aggregate independence.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
// ❌ WRONG: Direct object referencesclass BadOrder { private customer: Customer; // Object reference private shippingAddress: Address; // Object reference to Address aggregate private items: BadOrderItem[] = []; confirm(): void { // Tempting to reach into customer if (!this.customer.isVerified) { throw new Error("Customer not verified"); } // Tempting to modify customer this.customer.setLastOrderDate(new Date()); // BAD! // This creates: // 1. Loading dependency on Customer // 2. Potential modification of Customer // 3. Unclear transaction boundary }} class BadOrderItem { private product: Product; // Object reference to Product aggregate getPrice(): Money { // What if product price changed? // Should order reflect original or current price? return this.product.price; // Ambiguous! }} // ✅ CORRECT: Reference by identity + snapshotsclass GoodOrder { private readonly _customerId: string; // ID reference private readonly _shippingAddressId: string; // ID reference private _shippingAddressSnapshot: AddressSnapshot; // Snapshot at order time private readonly _items: Map<string, GoodOrderItem> = new Map(); constructor( orderId: string, customerId: string, shippingAddress: AddressSnapshot // Value object snapshot ) { this._customerId = customerId; this._shippingAddressSnapshot = shippingAddress; } // Order doesn't know about Customer aggregate - just its ID get customerId(): string { return this._customerId; } // Address is snapshotted - changes to customer's address don't affect order get shippingAddress(): AddressSnapshot { return this._shippingAddressSnapshot; }} class GoodOrderItem { // Snapshot of product details at time of order private readonly _productId: string; private readonly _productName: string; // Snapshot private readonly _unitPrice: Money; // Snapshot private _quantity: number; constructor( lineId: string, productId: string, productName: string, // Snapshotted from Product unitPrice: Money, // Snapshotted from Product quantity: number ) { this._productId = productId; this._productName = productName; this._unitPrice = unitPrice; this._quantity = quantity; } // Price is fixed at order time - product price changes don't affect order get unitPrice(): Money { return this._unitPrice; } get lineTotal(): Money { return this._unitPrice.multiply(this._quantity); }} // Value object for address snapshotclass AddressSnapshot { constructor( readonly street: string, readonly city: string, readonly postalCode: string, readonly country: string ) {} // Immutable - no setters}When you need data from another aggregate at a specific point in time (like product price at order time), create a snapshot value object. This captures the data when the operation occurs and prevents changes to the source aggregate from affecting historical records.
The fourth rule acknowledges reality: you cannot always have immediate consistency across aggregates. Accepting eventual consistency enables scalable, loosely-coupled systems.
The Pattern:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293
// Aggregate publishes event as part of its transactionclass Order { private readonly _events: DomainEvent[] = []; confirm(): void { this.validateForConfirmation(); this._status = 'confirmed'; // Event added to aggregate, not published yet this._events.push(new OrderConfirmedEvent( this._orderId, this._customerId, this.calculateTotal(), this.getItemsSnapshot() )); } get pendingEvents(): DomainEvent[] { return [...this._events]; } clearEvents(): void { this._events.length = 0; }} // Repository saves aggregate AND events in same transaction (outbox)class OrderRepository { async save(order: Order): Promise<void> { await this.db.transaction(async (tx) => { // Save the aggregate await tx.query(` INSERT INTO orders (...) VALUES (...) ON CONFLICT (id) DO UPDATE SET ... `); // Save events to outbox (same transaction!) for (const event of order.pendingEvents) { await tx.query(` INSERT INTO outbox_events (event_id, event_type, payload, created_at) VALUES ($1, $2, $3, NOW()) `, [event.eventId, event.type, JSON.stringify(event)]); } order.clearEvents(); }); }} // Background process publishes events from outboxclass OutboxProcessor { async processOutbox(): Promise<void> { const events = await this.db.query(` SELECT * FROM outbox_events WHERE published_at IS NULL ORDER BY created_at LIMIT 100 `); for (const eventRow of events) { try { await this.eventPublisher.publish(eventRow.payload); await this.db.query(` UPDATE outbox_events SET published_at = NOW() WHERE event_id = $1 `, [eventRow.event_id]); } catch (error) { // Log and continue - will retry later console.error(`Failed to publish event ${eventRow.event_id}`, error); } } }} // Handlers update other aggregates eventuallyclass InventoryHandler { async handleOrderConfirmed(event: OrderConfirmedEvent): Promise<void> { for (const item of event.items) { const inventory = await this.inventoryRepo.findByProductId(item.productId); inventory.commitReservation(event.orderId); await this.inventoryRepo.save(inventory); } }} class CustomerHandler { async handleOrderConfirmed(event: OrderConfirmedEvent): Promise<void> { const customer = await this.customerRepo.findById(event.customerId); customer.addLoyaltyPoints(this.calculatePoints(event.totalAmount)); await this.customerRepo.save(customer); }}Don't publish events directly from the application layer. If the event is published but the aggregate save fails (or vice versa), you have inconsistency. The outbox pattern saves events in the same transaction as the aggregate, ensuring atomicity.
Beyond the four rules, here are practical patterns that improve aggregate design:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
class Reservation { // ============================================ // PRIVATE STATE - No public setters // ============================================ private _status: ReservationStatus; private readonly _slots: TimeSlot[] = []; private _cancellation: Cancellation | null = null; // ============================================ // FACTORY METHOD - Creation logic encapsulated // ============================================ private constructor( private readonly _reservationId: string, private readonly _resourceId: string, private readonly _customerId: string, private readonly _createdAt: Date ) { this._status = ReservationStatus.Pending; } static create( reservationId: string, resourceId: string, customerId: string, slot: TimeSlot ): Reservation { const reservation = new Reservation( reservationId, resourceId, customerId, new Date() ); // Invariant check during creation if (slot.duration.minutes < 30) { throw new ReservationError("Minimum reservation is 30 minutes"); } if (slot.startTime < new Date()) { throw new ReservationError("Cannot reserve in the past"); } reservation._slots.push(slot); return reservation; } // ============================================ // STATE PATTERN - Explicit status transitions // ============================================ confirm(): void { if (this._status !== ReservationStatus.Pending) { throw new ReservationError( `Cannot confirm reservation in ${this._status} status` ); } this._status = ReservationStatus.Confirmed; } checkIn(): void { if (this._status !== ReservationStatus.Confirmed) { throw new ReservationError("Can only check in confirmed reservations"); } // Business rule: Can only check in within time window const now = new Date(); const firstSlot = this._slots[0]; const checkInWindow = 15 * 60 * 1000; // 15 minutes if (now < new Date(firstSlot.startTime.getTime() - checkInWindow)) { throw new ReservationError("Too early to check in"); } this._status = ReservationStatus.CheckedIn; } cancel(reason: string, cancelledBy: string): void { if (!this.canBeCancelled()) { throw new ReservationError( `Cannot cancel reservation in ${this._status} status` ); } this._status = ReservationStatus.Cancelled; this._cancellation = { reason, cancelledBy, cancelledAt: new Date() }; } private canBeCancelled(): boolean { return this._status === ReservationStatus.Pending || this._status === ReservationStatus.Confirmed; } // ============================================ // COMPUTED PROPERTY - Derived from state // ============================================ get totalDuration(): Duration { return this._slots.reduce( (total, slot) => total.add(slot.duration), Duration.zero() ); } get isPast(): boolean { const lastSlot = this._slots[this._slots.length - 1]; return lastSlot.endTime < new Date(); } // ============================================ // COLLECTION ENCAPSULATION - Read-only access // ============================================ get slots(): ReadonlyArray<TimeSlot> { return [...this._slots]; // Return copy } // ============================================ // DOMAIN-SPECIFIC EXCEPTIONS // ============================================ addSlot(slot: TimeSlot): void { // Business rule: Slots can't overlap for (const existing of this._slots) { if (slot.overlaps(existing)) { throw new ReservationError( "New slot overlaps with existing reservation slot" ); } } // Business rule: Max 4 hours total const newTotal = this.totalDuration.add(slot.duration); if (newTotal.hours > 4) { throw new ReservationError( "Total reservation cannot exceed 4 hours" ); } this._slots.push(slot); }} enum ReservationStatus { Pending = 'pending', Confirmed = 'confirmed', CheckedIn = 'checked_in', Completed = 'completed', Cancelled = 'cancelled', NoShow = 'no_show'} class ReservationError extends Error { constructor(message: string) { super(message); this.name = 'ReservationError'; }}Recognizing anti-patterns helps you avoid common mistakes in aggregate design:
| Anti-Pattern | Problem | Solution |
|---|---|---|
| God Aggregate | One aggregate encompasses entire domain | Split by true invariant boundaries |
| Anemic Aggregate | Aggregate is just data; logic in services | Move behavior to aggregates |
| Bidirectional References | A references B, B references A | One-way references; use events |
| Public Collection | Internal collections are mutable externally | Return read-only views or copies |
| Distributed Transaction | One transaction spans multiple aggregates | Eventual consistency with events |
| Chatty Aggregate | Many small operations instead of atomic ones | Design coarse-grained operations |
| Database-Driven Design | Aggregate matches database tables | Model domain, adjust persistence |
| Missing Invariants | Invariants enforced in application layer | Push invariants into aggregate |
The most common anti-pattern is the anemic domain model: aggregates that are just data structures with getters/setters, while all business logic lives in application services. This defeats the purpose of DDD. Aggregates should contain behavior, not just state.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
// ❌ ANEMIC: Just data, no behaviorclass AnemicOrder { orderId: string = ''; customerId: string = ''; status: string = 'draft'; items: AnemicOrderItem[] = []; total: number = 0;} class OrderApplicationService { confirmOrder(order: AnemicOrder): void { // All logic in service, not aggregate if (order.items.length === 0) { throw new Error("Order must have items"); } if (order.status !== 'draft') { throw new Error("Can only confirm draft orders"); } order.status = 'confirmed'; order.total = order.items.reduce((sum, i) => sum + i.price * i.qty, 0); // Order doesn't protect itself }} // ✅ RICH: Behavior lives with the dataclass RichOrder { private readonly _items: Map<string, OrderItem> = new Map(); private _status: OrderStatus = 'draft'; private constructor( private readonly _orderId: string, private readonly _customerId: string ) {} static create(orderId: string, customerId: string): RichOrder { return new RichOrder(orderId, customerId); } // Behavior encapsulated in aggregate addItem(productId: string, name: string, price: Money, quantity: number): void { this.ensureModifiable(); if (this._items.size >= 50) { throw new OrderError("Maximum 50 items per order"); } const item = OrderItem.create(productId, name, price, quantity); this._items.set(productId, item); } confirm(): OrderConfirmation { // Invariants enforced by aggregate, not services if (this._items.size === 0) { throw new OrderError("Order must have at least one item"); } if (this._status !== 'draft') { throw new OrderError(`Cannot confirm ${this._status} order`); } this._status = 'confirmed'; return new OrderConfirmation( this._orderId, this._customerId, this.calculateTotal(), new Date() ); } // Total is computed, never stored private calculateTotal(): Money { return Array.from(this._items.values()) .reduce((sum, item) => sum.add(item.lineTotal), Money.zero('USD')); } private ensureModifiable(): void { if (this._status !== 'draft') { throw new OrderError(`Cannot modify ${this._status} order`); } }}We've covered the complete toolkit for aggregate design. Let's consolidate the essential principles:
Module Complete:
You've now mastered aggregates in Domain-Driven Design. You understand:
With this knowledge, you can design domain models that are robust, scalable, and correctly express the semantics of your business domain.
Congratulations! You've completed the Aggregates module. You now have the knowledge to design aggregates that protect invariants, define clear boundaries, and enable both correctness and scalability. In the next module, we'll explore Repositories—the gateway to aggregate persistence.