Writing
18 June 2026

When one aggregate is quietly doing the work of five

How a single 'God aggregate' hides behind reasonable-looking code, why it slows every change, and the questions I ask to find its real seams.

The shape of the problem

Almost every codebase I read has one entity that grew too important. It started as Order. Then it learned about payments. Then shipping. Then invoicing, refunds, loyalty points, and a Notes collection nobody remembers adding. On paper it's one class. In practice it's five aggregates wearing the same name.

You feel it before you can name it. Every feature touches Order. Every merge conflict is in Order. The test file for Order is two thousand lines and nobody deletes anything from it because nobody is sure what still matters.

Why it costs you

An aggregate is a consistency boundary: the set of things that must be true together, in the same transaction. When one aggregate absorbs five responsibilities, it also absorbs five reasons to change and five sets of invariants that now have to be loaded, locked, and saved together whether the operation needs them or not.

The practical symptoms are consistent:

  • Loading an order to change its shipping address also loads its payment history and loyalty ledger.
  • Two unrelated operations — a refund and an address edit — contend for the same row.
  • A rule about refunds accidentally fires during an import, because everything runs through the same Save.

None of this shows up as a single dramatic bug. It shows up as everything being a little harder than it should be, forever.

What I look for

I'm not counting lines. I'm looking for clusters of fields that change together and never change with the rest. Those clusters are your real aggregates.

public sealed class Order
{
    // changes on checkout
    public Money Total { get; private set; }
    public IReadOnlyList<OrderLine> Lines { get; }

    // changes on fulfilment — different lifecycle entirely
    public ShippingState Shipping { get; private set; }
    public TrackingCode? Tracking { get; private set; }

    // changes on finance events — different actor, different rules
    public IReadOnlyList<Refund> Refunds { get; }
    public LoyaltyLedger Loyalty { get; }
}

Three comments, three lifecycles, three transactions that have no reason to share a lock. Shipping and Refunds don't need to be consistent with Total at every instant — they need to be eventually reconciled. That's the tell: the moment you'd accept "a second later" between two fields, they belong to different aggregates connected by an event, not by a shared Save.

The questions that find the seams

When I review a large entity, I ask three things about every field:

  1. Who changes this, and when? Different actors and different triggers usually mean different aggregates.
  2. What has to be true the instant this changes? That invariant defines the true boundary — everything the rule needs, nothing it doesn't.
  3. Would "a moment later" be acceptable? If yes, the two sides can be separate aggregates joined by a domain event.

Split along those answers and the God aggregate quietly becomes what it always was: a few small, sharp aggregates that each fit in your head, each lock only what they own, and each fail in isolation instead of together.

That's the kind of thing I'd flag in your code — not "this class is big," but "these three lifecycles are stuck in one transaction, and here's where they come apart."