Skip to content

// Domain-Driven Design · 06

Aggregate

Commandleads toone txAggregate rootleads toInvariants hold

A cluster changed as one unit, through one root, in one transaction.

How it fits together

Aggregate structureOrder aggregateOther aggregatesCheckout handlerAddLine(...)Submit()Order (root)Status, VersionTotal ≤ 10,000 EURno edits after SubmitOrderLineProductId, Qtychanged via root only«record»Moneyvalue objectCustomerown aggregateloaded separatelyProductown aggregate1 AddLine2 adds lineno direct editTotalCustomerId onlyProductId only
uses / callswraps / holdsfails / error path· numbered steps replay in order
  • Checkout handler uses / calls Order (root) (1 AddLine)
  • Order (root) wraps / holds OrderLine (2 adds line)
  • Checkout handler fails / error path OrderLine (no direct edit)
  • Order (root) wraps / holds Money (Total)
  • Order (root) uses / calls Customer (CustomerId only)
  • OrderLine uses / calls Product (ProductId only)

Spot it

  • Order lines edited directly, total now wrong
  • Deadlocks when saving a huge object graph
  • Rules checked in services, bypassed elsewhere

It costs

Rules that span aggregates become eventually consistent and need events or a saga.

In code

public sealed class Order(OrderId id, CustomerId customerId){    private const decimal MaxTotal = 10_000m;    private readonly List<OrderLine> lines = [];​    public OrderId Id { get; } = id;    public CustomerId CustomerId { get; } = customerId;    public OrderStatus Status { get; private set; } = OrderStatus.Draft;    public IReadOnlyList<OrderLine> Lines => lines;    public decimal Total => lines.Sum(l => l.Quantity * l.UnitPrice);​    public void AddLine(ProductId productId, int quantity, decimal unitPrice)    {        if (Status != OrderStatus.Draft)            throw new DomainException("A submitted order cannot change.");        if (quantity <= 0)            throw new DomainException("Quantity must be positive.");        if (Total + quantity * unitPrice > MaxTotal)            throw new DomainException("Order total cannot exceed 10,000 EUR.");        lines.Add(new OrderLine(productId, quantity, unitPrice));    }​    public void Submit()    {        if (lines.Count == 0) throw new DomainException("An empty order cannot be submitted.");        Status = OrderStatus.Submitted;    }}
Go deeperThe full explanation and what to ask for · 1 min readExpand

An aggregate is a cluster of entities and value objects treated as one unit for changes. Outside code holds a reference only to the root, and every change goes through it, so the root can enforce invariants: rules that must hold after every transaction, such as "a submitted order cannot change".

The usual design rules (Vaughn Vernon): protect true invariants inside the boundary, keep aggregates small, reference other aggregates by id, and modify one aggregate per transaction. Rules across aggregates, like reserving stock when an order is submitted, run through domain events and are eventually consistent.

Load and save the whole aggregate, and use optimistic concurrency (a version number) so two users editing the same order cannot silently overwrite each other. A huge aggregate, such as a Customer holding every order ever, causes lock contention and slow loads; that is a sign the boundary is wrong.

Ask for it

  • aggregate root
  • invariant
  • consistency boundary
  • reference by id
  • eventual consistency
  • optimistic concurrency

Make Order an aggregate root: lines change only via AddLine/RemoveLine, reject changes after Submit, cap the total at 10,000 EUR and 50 lines, reference Customer and Product by id, save it in one transaction with a version column, and reserve stock via an OrderSubmitted event.