Aggregate Pattern: Consistency Boundaries in DDD
Encapsulate a cluster of domain objects treated as a single unit for data changes. An Aggregate Root controls access to its internal entities and value objects.
Overview
Every codebase has that object graph where anything can reach anything — change a line item directly and the order total goes stale, skip the parent and an invariant quietly breaks. The Aggregate pattern draws the boundary: a cluster of related objects treated as one unit for data changes, with a single root entity — the Aggregate Root — controlling all access.
Outside code can only reference the root. Internal entities and value objects can’t be modified independently — every change goes through the root, which is exactly what lets the aggregate enforce invariants spanning entities and value objects.
How the Boundary Holds
The rules that make it work in practice:
- One aggregate = one transaction. If a change can’t fit inside the boundary, either the boundary is wrong or the change spans aggregates — and cross-aggregate consistency is eventual, not immediate.
- References by ID only. Aggregates hold each other’s identifiers, not object references — lazy-loading traps and cascade updates disappear.
- One repository per aggregate. The repo loads and saves the whole aggregate; no partial persistence, no leaking internals.
- Small is right. An aggregate that wraps half the domain is a monolith in disguise — when the boundary stops fitting one transaction, split it.
When to Split a Large Aggregate
A bloated aggregate is the pattern’s most common failure — Order growing OrderHistory, OrderAnalytics, OrderNotifications until loading one order costs half the database. Split when: entities don’t share invariants (if OrderAnalytics never constrains Order, it’s its own aggregate), the transaction doesn’t need them together, or contention shows up (concurrent edits on different parts of the same aggregate mean the boundary is too big).
The tell: if you’re reaching for @Version conflicts on fields that logically shouldn’t contend, the aggregate swallowed a separate consistency unit.
Event-driven or not, the discipline holds: one transaction per aggregate, references by ID, root as the only door. Break any of the three and the pattern is decoration — the boundary exists in the diagram but not in the behavior, which is the only place it counts.
When to Use
Use the Aggregate Pattern when:
- A business rule involves consistency across related objects
- You need to enforce invariants that span a cluster of entities
- Changes to internal objects must be controlled and validated
- The domain model has natural transactional boundaries
When to Avoid
- Simple CRUD on independent entities doesn’t need aggregate boundaries
- Overly large aggregates cause concurrency bottlenecks (avoid “god aggregates”)
- The system uses event sourcing exclusively (aggregates may be modeled differently)
Solution
Python
from dataclasses import dataclass, field
from typing import List
from datetime import datetime
import uuid
@dataclass(frozen=True)
class OrderLine:
product_id: str
quantity: int
unit_price: float
def total(self) -> float:
return self.quantity * self.unit_price
class Order:
def __init__(self, customer_id: str):
self.id = str(uuid.uuid4())
self.customer_id = customer_id
self.lines: List[OrderLine] = []
self.status = "pending"
self.created_at = datetime.now()
self.version = 0
def add_line(self, product_id: str, quantity: int, unit_price: float):
if quantity <= 0:
raise ValueError("Quantity must be positive")
if self.status != "pending":
raise ValueError("Can't modify a non-pending order")
self.lines.append(OrderLine(product_id, quantity, unit_price))
self.version += 1
def remove_line(self, product_id: str):
if self.status != "pending":
raise ValueError("Can't modify a non-pending order")
self.lines = [line for line in self.lines if line.product_id != product_id]
self.version += 1
def total(self) -> float:
return sum(line.total() for line in self.lines)
def submit(self):
if not self.lines:
raise ValueError("Can't submit an empty order")
self.status = "submitted"
self.version += 1
# Usage
order = Order(customer_id="cust-123")
order.add_line("prod-1", 2, 9.99)
order.add_line("prod-2", 1, 19.99)
print(f"Total: {order.total():.2f}") # Total: 39.97
order.submit()
Java
import java.time.Instant;
import java.util.*;
public class Order {
private final UUID id;
private final String customerId;
private final List<OrderLine> lines = new ArrayList<>();
private String status = "pending";
private final Instant createdAt;
private int version = 0;
public Order(String customerId) {
this.id = UUID.randomUUID();
this.customerId = customerId;
this.createdAt = Instant.now();
}
public void addLine(String productId, int quantity, double unitPrice) {
if (quantity <= 0) throw new IllegalArgumentException("Quantity must be positive");
if (!"pending".equals(status)) throw new IllegalStateException("Can't modify a submitted order");
lines.add(new OrderLine(productId, quantity, unitPrice));
version++;
}
public void removeLine(String productId) {
if (!"pending".equals(status)) throw new IllegalStateException("Can't modify a submitted order");
lines.removeIf(line -> line.productId().equals(productId));
version++;
}
public double total() {
return lines.stream().mapToDouble(OrderLine::total).sum();
}
public void submit() {
if (lines.isEmpty()) throw new IllegalStateException("Can't submit an empty order");
status = "submitted";
version++;
}
public UUID getId() { return id; }
public String getStatus() { return status; }
public int getVersion() { return version; }
}
record OrderLine(String productId, int quantity, double unitPrice) {
public double total() { return quantity * unitPrice; }
}
JavaScript
class Order {
constructor(customerId) {
this.id = crypto.randomUUID();
this.customerId = customerId;
this.lines = [];
this.status = 'pending';
this.createdAt = new Date();
this.version = 0;
}
addLine(productId, quantity, unitPrice) {
if (quantity <= 0) throw new Error('Quantity must be positive');
if (this.status !== 'pending') throw new Error('Can't modify a submitted order');
this.lines.push({ productId, quantity, unitPrice });
this.version++;
}
removeLine(productId) {
if (this.status !== 'pending') throw new Error('Can't modify a submitted order');
this.lines = this.lines.filter(line => line.productId !== productId);
this.version++;
}
total() {
return this.lines.reduce((sum, line) => sum + line.quantity * line.unitPrice, 0);
}
submit() {
if (this.lines.length === 0) throw new Error('Can't submit an empty order');
this.status = 'submitted';
this.version++;
}
}
// Usage
const order = new Order('cust-123');
order.addLine('prod-1', 2, 9.99);
order.addLine('prod-2', 1, 19.99);
console.log(order.total().toFixed(2)); // 39.97
order.submit();
Explanation
An aggregate comes apart into three pieces, and only one of them gets a front door:
- Aggregate Root — the one entity outside code gets to talk to. It owns the global identity; everything else hides behind it.
- Internal Entities — objects whose identity only means something inside the boundary (
OrderLineidentified by product ID within its order). Reachable, but only through the root. - Value Objects — immutable and identity-less (
Money,Address). TwoMoney(10, USD)are interchangeable; nobody asks which one.
Every modification goes through the root — which is precisely how invariants like “an order can’t submit empty” stay enforced: there’s no other door to sneak a change through.
The part that surprises people: aggregates are cheap to write and expensive to get wrong at the boundaries. The mistake isn’t inside — it’s drawing the boundary around objects that don’t share invariants, or letting two aggregates reach through each other anyway. The rules in the next section exist because those two failure modes are where most real codebases end up.
Aggregates and Bounded Context
The aggregate draws a consistency boundary; the bounded context draws a meaning boundary — where a word (“order”, “customer”, “account”) means one thing. They’re different tools that often get confused: aggregates split within a context (Order and Customer are separate aggregates in the same Sales context), while contexts split across the domain (the “Order” in Shipping is not the same object as the “Order” in Sales, and shouldn’t try to be).
Getting this right upstream changes everything downstream: if the bounded context boundary is wrong, aggregates get built around the wrong invariants and no amount of transaction discipline fixes it. The sequence that works: context first (what does “order” mean here?), then aggregates (which objects must change together under that meaning?).
Aggregates vs. the Alternatives
| Approach | Consistency | Complexity | When it wins |
|---|---|---|---|
| Free graph navigation | Anything can update anything | Low to write, high to reason about | Scripts, prototypes |
| Aggregate boundary | Root enforces invariants | Moderate | Business logic, shared domains |
| Event-sourced aggregate | Events are the state | High | Audit trails, temporal queries |
Most CRUD apps don’t need aggregates at all — a well-designed table and a service method does the job. The pattern pays when invariants span objects: order totals, inventory reservations, workflow states. If the invariant lives in one row, the aggregate is ceremony.
One more rule of thumb worth keeping: design aggregates by invariant, not by relationship. A has-a between Order and LineItem says “these relate”; the aggregate asks a harder question — “must these be consistent within a single transaction?” If yes, they live inside the same boundary. If no, they link by ID and the system accepts eventual consistency. Most oversized aggregates are relationships mistaken for invariants.
Variants
| Variant | Scope | Use Case |
|---|---|---|
| Standard Aggregate | Root + entities + value objects | Order with lines, customer with addresses |
| Large Aggregate | Root with many levels | Product catalog with categories, variants, prices |
| Event-Sourced Aggregate | Rehydrated from event stream | Bank account rebuilt from Deposit / Withdraw events |
What Works
The rules that hold up in production are the boring ones. Keep aggregates small — if it doesn’t fit in memory and load in one query, it’s not an aggregate, it’s a module wearing a costume. Reference other aggregates by ID, never by object reference — that’s how lazy-load traps and surprise cascade updates happen. One transaction per aggregate: two aggregates in one transaction means either the boundary is wrong or you’re about to learn why distributed locks hurt. A version field for optimistic locking costs a column and buys you protection against every concurrent-edit race you’ll ever have. And put the invariants in the aggregate, not in the service layer — rules that live outside the boundary stop being rules.
Common Mistakes
The failures follow a pattern too. God aggregates — loading hundreds of objects because the boundary swallowed the domain — split them until each fits one transaction. Reaching inside: code that modifies an internal entity directly has broken encapsulation by definition; the root exists for a reason. Transactions spanning aggregates: lock contention and coupling in one package — publish a domain event instead. Anemic aggregates: bags of getters and setters that enforce nothing — if the aggregate can’t say no to a bad state, it’s a struct, not a boundary. And pretending eventual consistency isn’t real: two aggregates can’t be simultaneously consistent by definition — design for the gap or it’ll design you.
Aggregates in the Wild
The pattern’s signature is everywhere once you look: JPA/Hibernate’s @Aggregate and @OneToMany(orphanRemoval) encode it, Axon and EventSourcingDB model it directly, and every ORM’s “root entity” convention is the pattern wearing different clothes. The honest signal of a real aggregate: a deleteOrder() that doesn’t exist — you go through order.cancel() because the root is the only door.
Real-World Examples
E-Commerce Order
An Order aggregate contains OrderLines, a ShippingAddress value object, and Payment references. The order root enforces that totals match line sums and that submitted orders can’t be modified.
Banking Account
An Account aggregate contains Transaction entities. The root keeps the balance from going below zero (overdraft rules) and that transactions are immutable once posted.
Shopping Cart
A Cart aggregate holds CartItem entities. Adding an item for an existing product increments quantity instead of adding a duplicate line.
Inventory Reservation
A Product aggregate in the warehouse domain holds StockLevel plus Reservation entities. Reserving stock goes through product.reserve(qty) — the root checks available = stock − reservations before it says yes. When two checkouts race for the last unit, the version field decides who gets it.
Further Reading
- Eric Evans — Domain-Driven Design — the book that defined aggregates
- Vaughn Vernon — Implementing Domain-Driven Design — the practical guide to aggregate sizing
- Microsoft — Design a DDD-oriented microservice — aggregates in practice
Frequently Asked Questions
How large should an aggregate be?
As small as it can be while still guarding its invariants. If two objects can change independently, they belong in different aggregates.
Can I reference another aggregate inside an aggregate?
By ID, never by object reference. An Order holds customerId, not a Customer — that's what keeps each aggregate independently loadable.
How does an aggregate enforce rules across aggregates?
It doesn't, and it's not supposed to. Cross-aggregate consistency rides on domain events and eventual consistency — a transaction spanning two aggregates is a design smell.
What happens when two users edit the same aggregate at once?
The version field catches it. The first writer commits and bumps the version; the second writer's save fails the optimistic-lock check and reloads — no silent lost update.
Should every entity become an aggregate root?
No — roots are for entities that own invariants. A LineItem only exists inside Order, so it gets no door of its own. Make everything a root and you've rebuilt the free-for-all graph the pattern exists to prevent.
Related Resources
Value Object Pattern
Model domain concepts by value rather than identity. An immutable object defined by its attributes, not by a unique ID.
PatternRepository Pattern
Abstract data access logic behind a clean interface. An architectural design pattern for testable, maintainable data layers.
PatternOutbox Pattern
Reliably publish domain events by persisting them in an outbox table within the same database transaction as the business operation.
PatternDomain Event Pattern
Capture and publish major occurrences within a domain model to decouple side effects from core business logic and enable reactive workflows.
PatternNull Object Pattern
Use a default object instead of null references to eliminate null checks and simplify client code. A behavioral pattern for safer defaults.
PatternPlugin Pattern
Enable third-party extensions by defining extension points in a host system that loads and executes live external modules.