StackPractices
advanced By Mathias Paulenko

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.

Topics: design

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.
An Order aggregate root controls its internal LineItem entities and Address value object; external code can only reference it by ID.

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 (OrderLine identified by product ID within its order). Reachable, but only through the root.
  • Value Objects — immutable and identity-less (Money, Address). Two Money(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

ApproachConsistencyComplexityWhen it wins
Free graph navigationAnything can update anythingLow to write, high to reason aboutScripts, prototypes
Aggregate boundaryRoot enforces invariantsModerateBusiness logic, shared domains
Event-sourced aggregateEvents are the stateHighAudit 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

VariantScopeUse Case
Standard AggregateRoot + entities + value objectsOrder with lines, customer with addresses
Large AggregateRoot with many levelsProduct catalog with categories, variants, prices
Event-Sourced AggregateRehydrated from event streamBank 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

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.