Java

Aggregate design and invariants in rich domain models

6 min read Updated Mar 27, 2026

Advanced LLD and OOP Design in Java

Aggregate design becomes useful the moment a system has state that must never drift, even when multiple services, jobs, and developers touch it. That is what aggregates are really for. They are not an excuse to make object graphs large. They are a way to give one boundary the job of protecting one set of rules.

Quick Summary

Design question Good aggregate signal Bad aggregate signal
What belongs together? data that must change under one invariant everything related by foreign key
Why does the boundary exist? to protect consistency rules to mirror a database diagram
How large should it be? only as large as the invariant requires as large as the use case feels convenient
Who may mutate internals? aggregate methods and owned entities random services and setters

Part 1 is about the baseline choice: draw the boundary around the rule you cannot afford to violate.

What an Aggregate Really Is

An aggregate is a consistency boundary. Inside that boundary, one root decides whether state transitions are legal.

That means the right starting question is not: “Which objects seem related?”

It is: “Which state must be changed together so an invariant cannot be broken?”

Typical aggregate-grade invariants look like:

  • an order cannot be confirmed with zero lines
  • a booking cannot exceed room capacity
  • an invoice cannot be paid twice
  • a transfer cannot move money from a closed account

If the rule matters transactionally, it probably needs an aggregate owner.

A Common Mistake: Boundaries Drawn Around Navigation

Teams often create oversized aggregates because object relationships feel intuitive:

  • Order has Customer
  • Order has Shipment
  • Order has Invoice
  • Order has Payment

So the temptation is to pack them together into one rich model.

That usually makes the design worse. Why? Because “related” is not the same as “must stay consistent in one transaction.”

The boundary should be drawn around invariants, not around convenience of traversal.

Example: Order Aggregate With Real Rules

Suppose we care about these rules:

  1. an order must contain at least one line before confirmation
  2. a confirmed order may not be edited
  3. the total must equal the sum of lines at the moment of confirmation

That gives us a compact, meaningful aggregate:

public final class Order {
    private final OrderId id;
    private final List<OrderLine> lines = new ArrayList<>();
    private OrderStatus status = OrderStatus.DRAFT;
    private Money confirmedTotal;

    public Order(OrderId id) {
        this.id = id;
    }

    public void addLine(ProductId productId, int quantity, Money unitPrice) {
        ensureDraft();
        if (quantity <= 0) {
            throw new IllegalArgumentException("quantity must be positive");
        }
        lines.add(new OrderLine(productId, quantity, unitPrice));
    }

    public void confirm() {
        ensureDraft();
        if (lines.isEmpty()) {
            throw new IllegalStateException("cannot confirm an order without lines");
        }

        Money calculatedTotal = lines.stream()
                .map(OrderLine::lineTotal)
                .reduce(Money.zero(), Money::add);

        this.confirmedTotal = calculatedTotal;
        this.status = OrderStatus.CONFIRMED;
    }

    public Money confirmedTotal() {
        if (status != OrderStatus.CONFIRMED) {
            throw new IllegalStateException("total is final only after confirmation");
        }
        return confirmedTotal;
    }

    private void ensureDraft() {
        if (status != OrderStatus.DRAFT) {
            throw new IllegalStateException("only draft orders can be modified");
        }
    }
}

The important part is not the use of classes. It is that illegal state becomes difficult to create.

Why This Boundary Works

The aggregate root owns:

  • line mutation
  • confirmation timing
  • total finalization
  • state transition from DRAFT to CONFIRMED

It does not try to own:

  • payment authorization
  • shipping orchestration
  • email sending
  • customer profile updates

Those may interact with the order, but they do not belong in the same consistency boundary by default.

What Should Usually Stay Outside

A strong aggregate does not absorb every business action in the system. Keep these outside unless the invariant says otherwise:

  • external gateway calls
  • cross-aggregate workflows
  • read-model assembly
  • long-running saga coordination
  • search and reporting projections

A good application service can coordinate those concerns while still respecting the aggregate’s invariant boundary.

Signs the Aggregate Is Too Large

Watch for these smells:

Most methods do orchestration, not rule enforcement

If the root mainly coordinates repositories and remote clients, it is not acting like a consistency boundary.

Multiple child objects are mutated independently by outside code

That means the root is not really in charge.

One use case needs the whole graph loaded just to update one rule

That usually points to a boundary drawn around convenience instead of invariants.

Transaction size keeps growing because “it is all one aggregate anyway”

That is a sign the model is mixing consistency concerns with workflow concerns.

Signs the Aggregate Is Too Small

Undersized aggregates are also a problem.

If a business rule is enforced by:

  • one service check
  • one repository query
  • one entity setter guard
  • one event listener

then the invariant already leaked.

That is the classic smell: the rule exists, but no single object truly owns it.

A Better Design Rule

Choose the boundary by asking:

  1. which state transitions must be atomic?
  2. which object should reject illegal transitions?
  3. which rule would become dangerous if duplicated in three places?

If the same answer keeps appearing, that is probably your aggregate root.

Testing Strategy

Aggregate tests should be narrow and state-transition focused.

Useful tests:

  • cannot confirm an order with zero lines
  • confirmed orders reject new lines
  • confirmed total equals sum of lines at confirmation time
  • draft orders do not expose final total

These tests are valuable because they encode the invariant directly. They are much more useful than tests that merely check field assignment.

When Simpler Design Is Better

Not every class needs aggregate treatment.

Keep it simpler when:

  • the object is just a query projection
  • there is no important invariant to protect
  • setters are not exposing business risk
  • the real rule lives at a different boundary

The goal is not “more domain objects.” The goal is “fewer places where important rules can drift.”

Key Takeaways

  • Aggregate boundaries should be drawn around invariants, not around object relationship diagrams.
  • A good root makes illegal state hard to create and easy to test.
  • Related data is not enough reason to share one consistency boundary.
  • If a rule is duplicated across services and setters, the aggregate is probably too weak.

Categories

Tags

Continue reading

Previous Service mesh tradeoffs: retries, mTLS, and overhead (Part 3) Next Composition over inheritance in extensible Java systems