Aggregate Design — Deep Dive

Level: Advanced
Pre-reading: 02 · Domain-Driven Design · 02.03 · Tactical Building Blocks


What is an Aggregate?

An aggregate is a cluster of entities and value objects with a consistency boundary. One entity acts as the aggregate root — the only entry point for external access.

graph TD
    subgraph Order Aggregate
        AR[Order - Aggregate Root]
        E1[OrderLine - Entity]
        E2[OrderLine - Entity]
        VO1[ShippingAddress - Value Object]
        VO2[OrderStatus - Value Object]
    end
    AR --> E1
    AR --> E2
    AR --> VO1
    AR --> VO2
    EXT[External Code] --> AR
    EXT -.->|NOT ALLOWED| E1

Aggregate Rules

Rule 1: Reference Aggregate Root Only

External code can only hold references to the aggregate root. Internal entities are accessed through the root.

// WRONG: Direct access to internal entity
OrderLine line = orderLineRepository.findById(lineId);
line.setQuantity(5);

// RIGHT: Access through aggregate root
Order order = orderRepository.findById(orderId);
order.updateLineQuantity(lineId, 5);  // Root enforces invariants

Rule 2: One Transaction = One Aggregate

A single transaction should only modify one aggregate. Cross-aggregate changes use domain events and eventual consistency.

// WRONG: Modify two aggregates in one transaction
@Transactional
public void placeOrder(Order order, Inventory inventory) {
    order.place();
    inventory.reserve(order.getItems());  // Two aggregates
    // If inventory fails, order is rolled back — tight coupling
}

// RIGHT: Single aggregate per transaction; use events
@Transactional
public void placeOrder(Order order) {
    order.place();  // Raises OrderPlaced event
    orderRepository.save(order);
}

// Another service handles InventoryReserved via event
@EventListener
public void onOrderPlaced(OrderPlaced event) {
    Inventory inventory = inventoryRepository.findByWarehouse(warehouseId);
    inventory.reserve(event.getItems());
    inventoryRepository.save(inventory);
}

Rule 3: Reference Other Aggregates by ID Only

Don't hold object references to other aggregates. Use IDs. This enforces loose coupling and enables distribution.

// WRONG: Object reference to another aggregate
public class Order {
    private Customer customer;  // Object reference
}

// RIGHT: ID reference
public class Order {
    private CustomerId customerId;  // ID only
}

Rule 4: Aggregates Are Consistency Boundaries

All invariants within an aggregate are enforced immediately (strong consistency). Invariants across aggregates are eventually consistent.

Invariant Scope Consistency Model
Within aggregate Immediate (ACID)
Across aggregates Eventual (events)

Designing Aggregate Boundaries

Model Around Invariants

An invariant is a business rule that must always hold true. Include in the aggregate only what's needed to enforce invariants.

Invariant Aggregate Design
"Order total must equal sum of line amounts" Order + OrderLines in same aggregate
"Customer cannot exceed credit limit" Customer aggregate checks limit on order placement? NO — use events
"SKU must be unique per product" Product aggregate

Keep Aggregates Small

Large aggregates cause:

  • Performance problems (loading everything)
  • Contention (locking large data structures)
  • Complexity (too many invariants)
graph TD
    subgraph Too Large
        A[Order Root]
        A --> B[Lines]
        A --> C[Payments]
        A --> D[Shipments]
        A --> E[Returns]
        A --> F[Customer]
        A --> G[Reviews]
    end
graph TD
    subgraph Right Size
        O[Order] --> OL[OrderLines]
        P[Payment] --> PI[PaymentItems]
        S[Shipment] --> SI[ShipmentItems]
    end
    O -->|ID| P
    O -->|ID| S

Split by Rate of Change

Entities that change independently should be in separate aggregates.

Entity Changes When Aggregate
Order Order placed, modified, cancelled Order
Shipment Shipped, delivered, returned Shipment (separate)
Payment Charged, refunded Payment (separate)

Aggregate Design Heuristics

Heuristic 1: Start with Smallest Possible Aggregate

Begin with the aggregate root and its most essential value objects. Add entities only when invariants require it.

Heuristic 2: Question Every Entity in Aggregate

For each entity inside an aggregate, ask:

  • Can this exist independently?
  • Does the root need it to enforce invariants?
  • Could this be its own aggregate?

Heuristic 3: Use Value Objects Liberally

Value objects have no identity overhead and can be copied freely. Prefer them over entities when possible.

Heuristic 4: Trust Eventual Consistency

Don't expand aggregates just to get immediate consistency. Many business rules tolerate eventual consistency with proper compensation.


Cross-Aggregate Consistency

When business logic spans aggregates, use domain events:

sequenceDiagram
    participant OA as Order Aggregate
    participant EB as Event Bus
    participant IA as Inventory Aggregate
    participant PA as Payment Aggregate

    OA->>OA: order.place()
    OA->>EB: OrderPlaced event
    EB->>IA: Handle OrderPlaced
    IA->>IA: inventory.reserve()
    IA->>EB: InventoryReserved event
    EB->>PA: Handle InventoryReserved
    PA->>PA: payment.charge()

Saga for Complex Workflows

When cross-aggregate operations can fail and require compensation:

Step Action Compensating Action
1 Reserve inventory Release reservation
2 Charge payment Issue refund
3 Create shipment Cancel shipment

Deep Dive: Saga Pattern


Aggregate and Persistence

One Repository Per Aggregate

public interface OrderRepository {
    Optional<Order> findById(OrderId id);
    void save(Order order);
}

// NOT one repository per entity
// OrderLineRepository — WRONG

Load Complete Aggregate

When loading, load the entire aggregate. Lazy loading internals defeats the consistency boundary.

// Load complete
Order order = orderRepository.findById(id);  // Includes all OrderLines

// NOT piecemeal
Order order = orderRepository.findById(id);
order.getLines();  // Lazy load — can cause consistency issues

Consider Snapshots for Large Aggregates

If an aggregate has many events (Event Sourcing) or deep object graphs, use snapshots to speed loading.


Common Aggregate Mistakes

Mistake Problem Fix
Too large Performance, contention Split; eventual consistency
Object references across aggregates Tight coupling; can't distribute Use IDs
Multiple aggregates in one transaction Scalability limits; distributed transactions Events; saga
Exposing internals Invariants bypassed Access only through root
Anemic root Logic scattered outside Behavior in aggregate

Aggregate Case Study: E-Commerce Order

Version 1: Too Large

graph TD
    subgraph Order Aggregate v1
        O[Order]
        O --> L[OrderLines]
        O --> P[Payment]
        O --> S[Shipment]
        O --> R[Returns]
        O --> I[Invoice]
    end

Problems:

  • Payment can change independently of order
  • Shipment has its own lifecycle
  • High contention on order modifications

Version 2: Right-Sized

graph TD
    subgraph Order Aggregate
        O[Order]
        O --> L[OrderLines]
        O --> SA[ShippingAddress]
    end
    subgraph Payment Aggregate
        P[Payment]
        P --> PI[PaymentAttempts]
    end
    subgraph Shipment Aggregate
        S[Shipment]
        S --> SI[ShipmentItems]
        S --> T[TrackingInfo]
    end
    O -->|orderId| P
    O -->|orderId| S

Relationships:

  • Payment references Order by ID
  • Shipment references Order by ID
  • OrderPlaced event triggers Payment and Shipment creation

Aggregate Invariant Examples

Aggregate Invariants
Order Total = sum of lines; at least one line; can only cancel if not shipped
Inventory Reserved + Available = Total; cannot reserve more than available
Account Balance cannot go negative (unless overdraft enabled)
Cart Max 100 items; item quantity ≥ 1

Why should aggregates be small?

(1) Performance: Loading a large aggregate is slow. (2) Contention: Concurrent updates conflict on large aggregates. (3) Memory: Large aggregates consume more memory. (4) Complexity: More invariants to reason about. (5) Transactions: Large aggregates mean larger, longer transactions. Start small; expand only when invariants require it.

How do you handle cross-aggregate transactions?

Use domain events and eventual consistency. Each aggregate is updated in its own transaction. Events notify other aggregates of changes. For complex multi-step processes, use the Saga pattern with compensating transactions. Avoid distributed transactions (2PC) — they don't scale and reduce availability.

When should an entity be inside an aggregate vs. its own aggregate?

Include an entity inside an aggregate if: (1) it cannot exist without the root (OrderLine without Order), (2) it's always modified through the root, (3) the root needs it to enforce invariants. Make it a separate aggregate if: (1) it has its own lifecycle, (2) it's accessed independently, (3) it changes at a different rate.