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:
OrderhasCustomerOrderhasShipmentOrderhasInvoiceOrderhasPayment
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:
- an order must contain at least one line before confirmation
- a confirmed order may not be edited
- 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
DRAFTtoCONFIRMED
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:
- which state transitions must be atomic?
- which object should reject illegal transitions?
- 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