Aggregates and Invariants
Most teams build aggregates by looking at data relationships: "Order has many LineItems, so Order is the aggregate root and LineItems are inside it." That's the wrong starting point. You should start with the invariant.
An invariant is a rule that must always be true. "An order cannot be shipped unless all line items are in stock" is an invariant. "An order total must equal the sum of line item totals after discounts" is an invariant. Your aggregate is sized to protect exactly those invariants — not to group related data.
Start with the Invariant, Not the Data
The question to ask is: what must be true, always, atomically? Whatever set of objects must participate in that check — that's your aggregate.
If you can verify "total = sum of line items" by checking only the Order and its LineItem children, then Order is a valid aggregate root for that invariant. If you needed to look at the Customer or the Product to verify the rule, something is wrong with your model.
The Order aggregate enforces its own invariants. It references Product and Customer by ID only — never by object reference. This keeps the aggregate boundary clean.
A Concrete Invariant: Line Item Totals
public class Order {
private final OrderId id;
private final CustomerId customerId; // ID reference, not Customer object
private final List<LineItem> lineItems;
private Money discount;
private OrderStatus status;
public void addLineItem(ProductId productId, Quantity qty, Money unitPrice) {
if (status != OrderStatus.DRAFT) {
throw new IllegalStateException("Cannot modify a confirmed order");
}
lineItems.add(new LineItem(productId, qty, unitPrice));
// Invariant: enforced inside the aggregate boundary, atomically
}
public Money total() {
Money subtotal = lineItems.stream()
.map(LineItem::subtotal)
.reduce(Money.ZERO, Money::add);
return subtotal.subtract(discount);
}
public void confirm() {
if (lineItems.isEmpty()) {
throw new DomainException("Cannot confirm an empty order");
}
if (total().isNegative()) {
throw new DomainException("Order total cannot be negative");
}
this.status = OrderStatus.CONFIRMED;
// emit domain event
}
}Every invariant check lives inside the Order class. Nothing outside the aggregate can put the order into an invalid state because all mutations go through the aggregate root.
Sizing Aggregates: The Rules of Thumb
Small is almost always right. If your aggregate has more than a handful of child entities, ask whether those children really need to be in the same consistency boundary.
A common mistake: putting OrderShipment, OrderPayment, OrderReturn, and OrderNote all inside the Order aggregate because they're "about the order." Ask the invariant question for each: does confirming an order require checking shipments? No. Does adding a note require checking payments? No. These probably belong in their own aggregates, referencing the OrderId.
// Anti-pattern: over-sized aggregate
class Order {
lineItems: LineItem[];
shipments: Shipment[]; // own aggregate?
payments: Payment[]; // own aggregate?
returns: Return[]; // own aggregate?
notes: Note[]; // own aggregate?
// 200-field consistency nightmare
}
// Better: small aggregates, consistent in isolation
class Order {
readonly id: OrderId;
private lineItems: LineItem[];
private status: OrderStatus;
// Only what's needed to protect order invariants
}
class Shipment {
readonly id: ShipmentId;
readonly orderId: OrderId; // reference by ID
private items: ShipmentItem[];
private trackingNumber: TrackingNumber | null;
// Protects shipment invariants independently
}
class Payment {
readonly id: PaymentId;
readonly orderId: OrderId; // reference by ID
private amount: Money;
private status: PaymentStatus;
// Protects payment invariants independently
}Handling Cross-Aggregate Invariants
What if an invariant genuinely crosses two aggregates? For example: "The total amount of all payments for an order must not exceed the order total."
You have options:
- Accept eventual consistency. Process a
PaymentRecordedevent and check the invariant asynchronously. Compensate if violated. This works when the violation window is acceptable. - Move the invariant into one aggregate. Sometimes the rule reveals that one concept belongs inside the other after all.
- Use a saga/process manager. A coordinator that orchestrates the check across aggregates.
Option 1 is often the right call. True strict consistency across two aggregates usually requires a distributed transaction — and that's almost never worth it.
// Eventual consistency: check the invariant asynchronously
@DomainEventHandler
public void on(PaymentRecorded event) {
Order order = orderRepository.findById(event.getOrderId());
List<Payment> payments = paymentRepository.findByOrderId(event.getOrderId());
Money totalPaid = payments.stream()
.map(Payment::amount)
.reduce(Money.ZERO, Money::add);
if (totalPaid.isGreaterThan(order.total())) {
// Compensate: flag for refund, notify, etc.
commandBus.send(new FlagOverpayment(event.getOrderId(), totalPaid));
}
}Repository = One Per Aggregate Root
The repository rule is simple: one repository per aggregate root. You don't have a LineItemRepository. You fetch the Order aggregate and access line items through it.
interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
// No findLineItemById — that would bypass the aggregate boundary
}This forces all mutations through the aggregate root, which is how invariants stay protected.
Key Takeaways
- Size an aggregate by starting with its invariants, not its data relationships — the aggregate must be exactly large enough to enforce all of its invariants atomically.
- Reference other aggregates by ID only — never by object reference — to keep consistency boundaries clean.
- When an aggregate grows to contain unrelated concerns, split it: ask "does this child's invariant require checking the root?" If not, it's a separate aggregate.
- Cross-aggregate invariants are usually best handled with eventual consistency and compensation rather than distributed transactions.
- One repository per aggregate root — never expose repositories for child entities.
- Small aggregates are nearly always better: they reduce lock contention, simplify reasoning, and make eventual-consistency patterns feasible.