Home Design Patterns Aggregate

Aggregate

Foundations

Treat a cluster of objects as one unit: one root, one entry point, one consistency boundary.

Purpose

An Aggregate is a boundary drawn around a group of objects that have to be correct at the same moment. One object inside is nominated as the root. Code outside the boundary holds a reference to the root and to nothing else, calls methods on the root and on nothing else, and the root is responsible for keeping the rules that span the whole group. The cluster is loaded as a unit and saved as a unit.

The term is Eric Evans's, from Domain-Driven Design in 2003, and it is worth being clear that it is not one of the Gang of Four patterns. It is also not the same thing as the word "aggregate" in the Iterator chapter, where it means nothing more than "collection". That name collision has cost people a lot of confusion, mine included, so it is worth getting out of the way early.

Design

Five rules do the work. One member is the root, and it has an identity that outlives any single request. Members may reference each other freely inside the boundary, but nothing outside may hold a reference to a member. The root enforces every invariant that spans more than one member, because it is the only object that can see them all. One transaction changes one aggregate, which is a statement about locking rather than about tidiness, and it is the rule that gives the pattern its value. References to other aggregates are held by identity rather than by pointer: you store a customer id, not a customer.

UML class diagram: a WorkOrderRepository points at the WorkOrder root, which owns OrderStep, ToolAssignment and Schedule by composition.
The diamonds are filled rather than open on purpose. The members do not outlive the root, and there is exactly one arrow arriving from outside the cluster.

The hard part is deciding where the line goes, and the usual mistake is to draw it by containment. "An order has steps, so the steps are inside" is an argument about the shape of the data. The question the boundary actually answers is which facts must be true simultaneously, and that is a question about concurrency. If two users can change two things at once without either of them being wrong, those two things belong to different aggregates.

Example

The Autobot Robotics shop floor runs on a PostgreSQL-backed ERP, and the central object in it is a work order: a set of steps, the tools each step assigns, and a schedule saying which shift runs which step. Two rules span the whole thing. A step cannot be scheduled before the order is released, and an order cannot be marked complete while a step is still queued.

Both rules were enforced in the screens, because both rules were discovered while writing a screen. The scheduling view loaded steps directly, since there was a step repository and it was the obvious thing to call, edited one, and saved it. The order view did the same to the order. One evening two people did both at once: a planner moved a step onto the night shift, a supervisor closed the order, and each save was valid on its own. The result was a completed order with a step still queued against a tool that had been released to another job, and the night shift built a batch with the wrong end effector fitted.

The repair was to name the boundary. The work order became the root, the step repository was deleted, steps became reachable only through the order, and every mutation became a method on the order that could check both rules with everything in front of it. One transaction, one order, with a version column on the order row so the second writer loses and retries. The second-order effect is worth recording too, because it is the honest cost: the schedule then had to be pulled out into an aggregate of its own and referenced by id, because two shift planners editing different weeks of the same long-running order collided constantly. The boundary is a dial, and we did not get it right on the first turn.

class WorkOrder                          // the aggregate root
{
    private OrderId id;
    private OrderState state;
    private List<OrderStep> steps;

    public void addStep(ToolId tool, int qty) {
        if (state != OrderState.Draft) {
            // steps cannot be added after release
        }
        steps.Add(new OrderStep(tool, qty));
    }

    public void complete() {
        foreach (OrderStep s in steps) {
            // refuse if any step is still queued
        }
        state = OrderState.Complete;
    }

    // a read-only view, so no caller can add a step behind our back
    public IReadOnlyList<OrderStep> getSteps() { return steps.AsReadOnly(); }
}

class OrderStep                          // a member, not a root
{
    internal OrderStep(ToolId tool, int qty) { }
    public int duration() { }
}

class WorkOrderRepository                // one repository, for the root only
{
    public WorkOrder find(OrderId id) { }
    public void save(WorkOrder order) { }
    // there is deliberately no OrderStepRepository
}
The internal constructor on OrderStep and the missing step repository are the pattern. Without those two lines every rule in WorkOrder is a suggestion.

Seen in the Wild

Entity Framework Core has owned entity types, which have no DbSet, cannot be queried on their own, and are always loaded and saved with their owner: an aggregate boundary expressed directly in the mapping. JPA and Hibernate get to the same place by hand with cascade and orphanRemoval on a one-to-many. MongoDB makes the boundary physical, since writes to a single document are atomic and writes across documents historically were not, so the document is the consistency boundary whether you planned it or not. Axon Framework in Java goes furthest and puts it in the type system with @Aggregate and @AggregateIdentifier.

Comparisons

  • Composite is a tree of interchangeable parts behind one interface. An Aggregate is a cluster of different types with one nominated root, and it makes a promise about consistency that a Composite does not.
  • Facade also puts one door in front of many objects, but it does not own them, and going around a Facade is a legitimate thing to do. Going around an aggregate root is the bug.
  • Repository is the natural partner: one repository per aggregate root, and no repository for anything else. That single rule is what keeps the boundary honest in practice.
  • Iterator uses the word "aggregate" for the collection being walked, which is a different idea entirely.
  • Private Class Data is the same argument inside one object: narrow the set of places that are able to write.
  • Lazy Load is what people reach for when loading the whole cluster starts to hurt, and it is also how the boundary quietly stops being enforced.

Why It Exists

My take

In a database-backed system that has been alive for a few years, nobody has written down which rows have to be true at the same time. The knowledge exists, distributed across the people who wrote each screen, and every new screen draws its transaction around whatever the user had open. Aggregates exist to make that one decision once, in the model, rather than forty times by accident. The benefit is not tidiness; it is that "what does one transaction cover" has a written answer.

I would argue the real subject here is concurrency, not modeling, and the domain vocabulary obscures that. The moment you say one transaction changes one aggregate, you have chosen your locking granularity, your retry story, and the places where you are going to have to live with data being briefly out of step. Those choices get made in every system. Without a boundary they get made by whoever writes the third screen, and they are discovered later by a supervisor standing in front of a machine that has done the wrong thing.

The cost is real and it shows up as contention rather than as bugs. A large aggregate is correct and slow: two people who want to change unrelated corners of it queue behind each other for no reason a user would accept. Every time we had a boundary in the wrong place on the ERP, the symptom was a retry storm, not a broken invariant. That turns out to be a useful property. Wrong boundaries announce themselves in a way you can measure.

Criticisms

It is fair to say this is not a design pattern in the sense the rest of this site uses the word. It is a modeling rule from one particular methodology, and it presumes a domain rich enough to have invariants that span objects. On an application that is genuinely a set of forms over tables, drawing aggregate boundaries adds indirection and buys nothing, and you should not feel bad about noticing that.

Aggregate size is the hard problem and the standard advice, keep them as small as possible, is not actionable until you have already been burned. Too large and unrelated work serializes behind one row. Too small and the invariants leak back into application services, which is where they were before you started, except now there is more ceremony in the way. There is no rule that gets this right without measurement.

The boundary also only ever applies to writes. Reads want joins across aggregates, whole screens want data from six of them, and loading three clusters to render one page is intolerable. So every mature system grows a read model that ignores the boundary completely, and you maintain two shapes of the same data. And the tooling fights you throughout: an ORM with lazy loading will happily walk from any object to any other, so the rule that outside code cannot reach a member is a convention held up by code review, not by the compiler.