Articles · Mediator & CQRS ·002

Your First Aggregate

Runnable sample on GitHub

Sample: samples/002-first-aggregate — a pure domain library (Ordering.Domain) with an xUnit test suite. No database, no web host: dotnet test and go.

Prerequisite: 001 — Getting Started.

Overview

In article 001 the Product was a plain record and the “store” was a dictionary. That is fine for a catalog, but it puts the rules of your business outside the data: any code anywhere could set a price to a negative number, confirm an empty order, or charge in two currencies at once. An aggregate moves those rules inside a single object that owns its state and refuses to enter an invalid one.

This article introduces Relay’s domain building blocks — AggregateRoot<TId>, Entity<TId>, ValueObject, Guard, and DomainEvent — by modelling an Order. Every change to the order goes through a method that checks invariants and then raises a domain event, and because state is mutated in exactly one place, the aggregate can be perfectly rebuilt from its events. That last property is the bridge to event sourcing (article 005); here we get all of its design benefits with none of its infrastructure.

Why this exists

Two failure modes plague data-centric services:

  1. Anaemic models. The “domain” is a bag of public setters and the rules live in service classes that may or may not be called. Nothing stops order.Status = Confirmed on an order with no lines, because the order does not know its own rules. Bugs are “we forgot to call Validate() here.”
  2. Scattered invariants. The rule “an order’s lines must all share its currency” is enforced in the create handler, forgotten in the import job, and half-implemented in the admin tool. The same rule, three times, slightly differently.

An aggregate fixes both by being a consistency boundary: a cluster of objects (the root and the entities/value objects it owns) that is always saved and loaded as a unit, and that exposes behavior (Confirm(), AddLine(...)) rather than setters. The only way to change it is to ask it to do something, and it gets to say no. The rule lives in one place — the aggregate — so every caller gets it for free.

AggregateRoot<TId> gives you the machinery: a place to raise domain events, version tracking for optimistic concurrency, and a replay mechanism. ValueObject and Entity<TId> give you the vocabulary to model the pieces inside the boundary. Guard makes invariant checks one readable line.

When to use this

Model something as an aggregate when it has invariants that must always hold and it changes over time through meaningful operations:

  • Order management. An Order must have at least one line to be confirmed; a confirmed order is immutable; lines share a currency. These are order rules, so they belong on the Order.
  • Payments. A Payment moves through Authorized → Captured → Refunded; you cannot capture more than was authorised, or refund a payment that never captured. The state machine is the aggregate.
  • Inventory. A StockItem cannot be reserved below zero; a reservation and its release must balance. The quantity-on-hand invariant lives on the item.
  • Booking systems. A Reservation cannot double-book a seat or be cancelled after check-in.
  • Logistics. A Shipment’s legs must form a connected route; it cannot be marked delivered before it is dispatched.
  • Customer management. A Subscription cannot be both trialing and past-due; plan changes have rules about proration.

In each case the aggregate is drawn around what must be consistent together, right now. An order and its lines must be consistent in the same instant (you cannot confirm a half-saved order), so they are one aggregate. An order and the customer’s lifetime stats need not be consistent in the same instant, so they are not.

When not to use this

  • Reference and lookup data. Currencies, countries, tax-rate tables — these have no behavior and no invariants beyond “the row is valid.” A record and a validator (article 001) are enough; an aggregate is ceremony.
  • Reporting and read models. Data you only ever read and never enforce rules on should be a flat read model (article 007), not an aggregate. Aggregates are for the write side.
  • CRUD-shaped editing. If the operation truly is “set these five fields to whatever the user typed,” there is no invariant to protect and an aggregate adds indirection for nothing.
  • Prototypes. Modelling invariants is an investment that pays off over a system’s life. For a throwaway spike, a mutable record is faster to write.

The costs are real: an aggregate is more code than a record with setters, it asks the team to think in terms of behavior and invariants rather than fields, and getting the boundary wrong (too big and it becomes a contention bottleneck; too small and you cannot enforce a rule that spans two of them) is the single hardest modelling decision in DDD. Use aggregates where invariants justify that investment, and plain records where they do not.

Concepts

Entity. An object with identity that persists through change. Two entities are equal when they have the same id and type, regardless of their other fields — your bank account is the same account after a deposit. Entity<TId> provides the id, audit timestamps (CreatedAt/UpdatedAt), and identity-based equality.

Value object. An object defined entirely by its values, with no identity. Money(10, "USD") is equal to any other Money(10, "USD"); “this ten dollars” is meaningless. Value objects are immutable and self-validating — an invalid one cannot be constructed — which is why they are the perfect home for small invariants like “currency is a 3-letter code.” ValueObject provides value equality from a single GetEqualityComponents() method.

Aggregate root. The one entity in the boundary that the outside world is allowed to reference. Callers get an Order; they never get an OrderLine directly, because changing a line is the order’s business. AggregateRoot<TId> is the root’s base class.

Domain event. An immutable record of something that happened in the domain, named in the past tense (OrderConfirmed, not ConfirmOrder). Aggregates raise events to announce changes. Events carry primitives so they serialise cleanly. DomainEvent is their base record.

Invariant. A rule that is always true for a valid aggregate (“a confirmed order has ≥ 1 line”). The aggregate enforces invariants on every operation, so an invalid state is unrepresentable. Guard turns an invariant check into one expressive line.

Architecture

An aggregate is a small graph with one entry point. Outside code only ever holds the root; the entities and value objects inside are reached through it.

graph TD
    subgraph "Order aggregate (consistency boundary)"
        Root[Order : AggregateRoot&lt;Guid&gt;]
        L1[OrderLine : Entity&lt;Guid&gt;]
        L2[OrderLine : Entity&lt;Guid&gt;]
        M1[Money : ValueObject]
        S1[Sku : ValueObject]
        Root --> L1
        Root --> L2
        L1 --> M1
        L1 --> S1
    end
    Caller[Command handler] -->|"AddLine() / Confirm()"| Root
    Root -->|raises| E[("OrderPlaced, OrderLineAdded, OrderConfirmed …")]

Every command method follows the same two-step shape:

sequenceDiagram
    participant C as Caller
    participant A as Order (aggregate)
    C->>A: Confirm()
    A->>A: 1. check invariants (Guard) — throw DomainException if broken
    A->>A: 2. RaiseEvent(new OrderConfirmed(Id))
    A->>A: ApplyEvent(OrderConfirmed) — Status = Confirmed (the ONLY state mutation)
    A-->>C: (event now in GetUncommittedChanges())

The crucial design choice is that state is mutated only inside ApplyEvent, never in the command method itself. The command method validates and raises; ApplyEvent applies. Relay supports this by calling ApplyEvent automatically when you raise an event (enabled with protected override bool ApplyEventsOnRaise => true) and when it replays history. One mutation path means the live object and an object rebuilt from events are guaranteed identical — which we prove in the tests, and which article 005 relies on to store nothing but the events.

AggregateRoot tracks a Version (it starts at -1 for a brand-new aggregate with no persisted events) and an uncommitted-events list. Each RaiseEvent stamps the event with the next contiguous version (0, 1, 2, …); GetUncommittedChanges() hands those events to a repository to persist; MarkChangesAsCommitted() advances the version and clears the list once they are saved.

Building it step by step

The full model is in samples/002-first-aggregate/Ordering.Domain.

1. Project setup

A domain model needs only Relay.Core. Deliberately nothing else — no EF Core, no ASP.NET — so the domain stays pure and its tests run in milliseconds:

<ItemGroup>
  <ProjectReference Include="$(RelaySrc)/Nuvora.Nexus.Relay.Core/Nuvora.Nexus.Relay.Core.csproj" />
</ItemGroup>

2. Value objects (the small invariants)

Money is immutable, equal-by-value, and impossible to construct invalid. GetEqualityComponents is the single source of truth for equality and hashing:

public sealed class Money : ValueObject
{
    public decimal Amount { get; }
    public string Currency { get; }

    public Money(decimal amount, string currency)
    {
        Guard.AgainstNullOrWhiteSpace(currency, nameof(currency));
        Guard.Against(currency.Trim().Length != 3, "Currency must be a 3-letter ISO code.");
        Guard.AgainstNegative(amount, nameof(amount));
        Amount = decimal.Round(amount, 2, MidpointRounding.ToEven);
        Currency = currency.Trim().ToUpperInvariant();
    }

    public Money Add(Money other)
    {
        Guard.Against(other.Currency != Currency,
            $"Cannot combine amounts in different currencies ({Currency} and {other.Currency}).");
        return new Money(Amount + other.Amount, Currency);
    }

    public Money Multiply(int factor) => new(Amount * factor, Currency);

    protected override IEnumerable<object?> GetEqualityComponents()
    {
        yield return Amount;
        yield return Currency;
    }
}

Sku is the same idea for a validated, normalised identifier — wrapping a string in a type so the validation lives in one place and a method can ask for a Sku, not “a string that had better be a SKU.”

3. Entities inside the aggregate

OrderLine has identity (two lines for the same SKU are still two lines), so it is an Entity<Guid>. It has no public command methods — it is only ever created and changed by the Order root:

public sealed class OrderLine : Entity<Guid>
{
    public Sku Sku { get; }
    public int Quantity { get; }
    public Money UnitPrice { get; }
    public Money LineTotal => UnitPrice.Multiply(Quantity);

    public OrderLine(Guid id, Sku sku, int quantity, Money unitPrice) : base(id)
    {
        Guard.AgainstNegativeOrZero(quantity, nameof(quantity));
        Sku = sku; Quantity = quantity; UnitPrice = unitPrice;
    }
}

4. Domain events

Events are immutable records, past-tense, carrying primitives (not value objects) so they serialise cleanly when they later become event-store rows. Each points AggregateId at its order:

public sealed record OrderConfirmed(Guid OrderId) : DomainEvent
{
    public override Guid AggregateId => OrderId;
}

public sealed record OrderLineAdded(
    Guid OrderId, Guid LineId, string Sku, int Quantity, decimal UnitPriceAmount, string Currency)
    : DomainEvent
{
    public override Guid AggregateId => OrderId;
}

5. The aggregate root

Each command method checks invariants then raises an event; ApplyEvent is the only place fields change. [AggregateType("ordering.order")] pins a stable name for the stream (important once events are persisted — renaming the class then must not change where its events live):

[AggregateType("ordering.order")]
public sealed class Order : AggregateRoot<Guid>
{
    private readonly List<OrderLine> _lines = new();
    public string Currency { get; private set; } = string.Empty;
    public OrderStatus Status { get; private set; } = OrderStatus.Draft;
    public IReadOnlyList<OrderLine> Lines => _lines;
    public Money Total => _lines.Aggregate(Money.Zero(Currency), (sum, l) => sum.Add(l.LineTotal));

    protected override bool ApplyEventsOnRaise => true;   // state changes ONLY in ApplyEvent
    private Order() { }                                    // for rehydration

    public static Order Place(Guid id, string customerId, string currency)
    {
        Guard.AgainstEmptyGuid(id, nameof(id));
        Guard.AgainstNullOrWhiteSpace(customerId, nameof(customerId));
        var order = new Order();
        order.RaiseEvent(new OrderPlaced(id, customerId.Trim(), currency.Trim().ToUpperInvariant()));
        return order;
    }

    public void AddLine(string sku, int quantity, Money unitPrice)
    {
        Guard.Against(Status != OrderStatus.Draft, "Lines can only be added while the order is a draft.");
        var parsedSku = new Sku(sku);
        Guard.Against(_lines.Any(l => l.Sku == parsedSku), $"SKU '{parsedSku}' is already on the order.");
        Guard.Against(unitPrice.Currency != Currency, "Line currency must match the order currency.");
        RaiseEvent(new OrderLineAdded(Id, Guid.NewGuid(), parsedSku.Value, quantity, unitPrice.Amount, unitPrice.Currency));
    }

    public void Confirm()
    {
        Guard.Against(Status != OrderStatus.Draft, "Only a draft order can be confirmed.");
        Guard.Against(_lines.Count == 0, "Cannot confirm an order with no lines.");
        RaiseEvent(new OrderConfirmed(Id));
    }

    protected override void ApplyEvent(IDomainEvent domainEvent)
    {
        switch (domainEvent)
        {
            case OrderPlaced e: SetId(e.OrderId); Currency = e.Currency; Status = OrderStatus.Draft; break;
            case OrderLineAdded e: _lines.Add(new OrderLine(e.LineId, new Sku(e.Sku), e.Quantity, new Money(e.UnitPriceAmount, e.Currency))); break;
            case OrderConfirmed: Status = OrderStatus.Confirmed; break;
            // … OrderLineRemoved, OrderCancelled
        }
    }
}

Two details worth dwelling on. First, Guard.Against(_lines.Any(l => l.Sku == parsedSku), …) compares SKUs with ==, which works because Sku is a value object — value equality is exactly what “is this SKU already on the order?” means. Second, Place and FromHistory (below) both funnel through ApplyEvent, so there is genuinely one code path that sets Currency, whether the order is new or rebuilt from a thousand historical events.

6. Rehydration — the payoff

Because state changes only in ApplyEvent, rebuilding an order from its events yields an identical object. That is what an event-sourced repository does, and we can do it by hand:

public static Order FromHistory(IEnumerable<IDomainEvent> history)
{
    var order = new Order();
    order.LoadFromHistory(history); // replays each event through ApplyEvent
    return order;
}

Complete source code

File Contents
Money.cs Value object: amount + currency, arithmetic, invariants
Sku.cs Value object: validated, normalised SKU
OrderLine.cs Entity inside the aggregate
OrderEvents.cs The domain events
Order.cs The aggregate root and its apply-only state machine

Running the example

There is no host to run — a domain library is exercised through its tests, which is the point. But it is illuminating in a REPL (dotnet script or a throwaway console):

var order = Order.Place(Guid.NewGuid(), "cust-42", "USD");
order.AddLine("SKU-CHAIR", 2, new Money(100m, "USD"));
order.AddLine("SKU-DESK", 1, new Money(250m, "USD"));
order.Confirm();
Console.WriteLine(order.Total);                          // 450.00 USD
Console.WriteLine(order.GetUncommittedChanges().Count);  // 4 events ready to persist

Testing

Pure domain code is the easiest code in the system to test — no mocks, no host, no database — and that is a feature, not a coincidence. The sample’s OrderTests.cs proves three categories of thing.

Invariants reject invalid operations. Each of these exists because the corresponding rule is a promise the rest of the system relies on:

[Fact] public void Confirm_requires_at_least_one_line()
{
    var order = Order.Place(Guid.NewGuid(), "cust-1", "USD");
    var act = order.Confirm;
    act.Should().Throw<DomainException>();
}

Note the exception type: Guard’s domain-rule clauses throw DomainException, while its argument clauses (AgainstNullOrWhiteSpace) throw ArgumentException. The tests assert the right one, which documents the distinction: a blank customer id is a caller bug (ArgumentException); confirming an empty order is a domain rule (DomainException).

Operations record the right events. Behavior is observable through the uncommitted-events list, including the contiguous versioning the event store will rely on:

[Fact] public void Uncommitted_events_are_recorded_in_order_with_contiguous_versions()
{
    var order = ADraftWithOneLine(out _);
    order.AddLine("SKU-DESK", 1, new Money(250m, "USD"));
    order.Confirm();
    var events = order.GetUncommittedChanges().ToList();
    events.Select(e => e.GetType()).Should().Equal(
        typeof(OrderPlaced), typeof(OrderLineAdded), typeof(OrderLineAdded), typeof(OrderConfirmed));
    events.Select(e => e.Version).Should().Equal(0, 1, 2, 3);
}

Replay reproduces state exactly. This is the most important test in the file, because it proves the apply-only discipline holds — and therefore that this aggregate is ready to be event-sourced:

[Fact] public void An_order_rebuilt_from_history_is_identical_to_the_original()
{
    var original = ADraftWithOneLine(out var id);
    original.AddLine("SKU-DESK", 1, new Money(250m, "USD"));
    original.Confirm();

    var rebuilt = Order.FromHistory(original.GetUncommittedChanges().ToList());

    rebuilt.Status.Should().Be(OrderStatus.Confirmed);
    rebuilt.Total.Should().Be(original.Total);
    rebuilt.Version.Should().Be(3);
    rebuilt.GetUncommittedChanges().Should().BeEmpty(); // replay raises nothing new
}
dotnet test samples/002-first-aggregate/Ordering.Domain.Tests

Production considerations

  • Choosing the boundary. This is the decision that ages best or worst. Keep an aggregate as small as the invariants allow: an Order and its OrderLines (consistent together) but not the whole customer’s order history (only eventually consistent). Too-large aggregates become write-contention hotspots; too-small ones cannot enforce a cross-entity rule. When two aggregates must coordinate, that is a saga’s job (article 015), not a bigger aggregate.
  • Concurrency. Version is the optimistic-concurrency token. When two requests load the same order and both try to save, the event store rejects the second with a concurrency conflict (article 006). The aggregate’s job is just to track the version honestly; it gets that for free.
  • Event design is a forever decision. Once events are persisted they are history and cannot be edited. Name them well, keep them primitive, and never reuse an event type to mean a new thing. Schema evolution has a whole article (024); for now, design events as if they are permanent, because they are.
  • Stable type names. [AggregateType("ordering.order")] decouples the persisted stream name from the CLR class name. Set it from day one — adding it later, after events exist under the old full-type-name, silently fragments the stream.
  • Identity. Event-sourced aggregates must be keyed by Guid (the framework enforces this). Prefer generating the id in the application (Guid.NewGuid() / a sequential GUID) over a database-assigned one, so the aggregate has its identity before it is ever saved.

Common mistakes

  • Public setters on the aggregate. The instant order.Status has a public setter, the boundary is gone and the invariant is unenforceable. Expose behavior (Confirm()), not state.
  • Mutating state in the command method instead of ApplyEvent. It works at first, then someone enables event sourcing and the rebuilt aggregate diverges from the live one because some mutation path skipped an event. Keep ApplyEvent the only mutator (the framework even throws if you raise an event during replay, to catch exactly this).
  • Anaemic events. OrderChanged { string Field, string NewValue } is not a domain event; it is a database audit row wearing a costume. Events should name what happened in business terms (OrderConfirmed, LineAdded), because handlers and read models will pattern-match on them.
  • Leaking entities out of the boundary. Returning a mutable OrderLine so a caller can change its quantity reintroduces the scattered-invariant problem. Expose IReadOnlyList<OrderLine> and route changes through the root.
  • One giant aggregate. A Customer that owns its orders, invoices, tickets and addresses becomes a lock everyone fights over and a class no one understands. Split along invariant boundaries.

Tradeoffs

Benefits. Invariants are enforced in one place and cannot be bypassed; the model is testable without any infrastructure; behavior is expressed in the domain’s own language; and the apply-only discipline makes the model event-sourcing-ready at zero extra cost.

Costs. More code than a record with setters; a genuine modelling skill (drawing boundaries, naming events) the team must build; and the temptation to over-apply the pattern to data that has no invariants worth protecting.

Alternatives

  • Anaemic model + transaction script. A record with setters and a service method that does the work. Less code up front; the rules end up scattered across services and drift. Fine for CRUD, corrosive for a behavior-rich domain.
  • Active Record. The entity knows how to persist itself (order.Save()). Convenient for simple apps, but it welds the domain to the database and makes the pure, fast unit tests above impossible.
  • Plain immutable records + pure functions. A functional core where confirm(order) -> order' returns a new state. A legitimate and elegant style; Relay’s AggregateRoot is the object-oriented equivalent that also gives you event tracking and replay out of the box. If your team thinks in FP, the functional core is a fine alternative — just keep the “one mutation path” discipline either way.

Objectively: the aggregate’s advantage over all three is enforced invariants plus built-in event tracking. Its disadvantage is verbosity. Match the choice to whether the domain has rules worth that verbosity.

Lessons from production systems

  • Boundaries are discovered, not decreed. Teams that draw aggregates up front from a diagram tend to get them wrong. The reliable path is to start from the invariants and the operations, let the boundary fall out of “what must be consistent in the same instant,” and refactor when a new rule spans two aggregates (which is the signal to introduce a saga, not to merge them).
  • The apply-only rule is what makes event sourcing painless later. Teams that adopt aggregates with mutation-in-command-methods hit a wall when they switch on event sourcing: live and rebuilt state disagree, and the bug is maddening to find. Adopting ApplyEventsOnRaise from the start — even before there is an event store — makes article 005 a configuration change rather than a rewrite.
  • Value objects eliminate a surprising class of bugs. “Currency mismatch,” “negative price,” “malformed SKU” simply stop happening once those concepts are types that cannot hold an invalid value. The investment is a few small classes; the return is whole categories of bug that never ship.
  • Resist the “just one setter” request. It always seems harmless. It is the first crack in the boundary, and six months later the invariant the aggregate was built to protect is being violated by an import job that set the field directly.

Should you use this?

Situation Recommendation
Entity with real invariants and a lifecycle (order, payment, reservation) Strong fit — this is exactly what aggregates are for
You plan to event-source the write model (article 005) Strong fit — model it apply-only now and switch on storage later
Concepts like money, SKU, email that are “a string with rules” Strong fit — make them value objects regardless of the rest
Reference/lookup data with no behavior Usually avoid — a record + validator is enough
Pure read models / reporting Avoid — that is the read side (article 007), not an aggregate
Throwaway prototype Usually avoid — invariant modelling is an investment in longevity

Next steps

Your Order already raises domain events — but right now those events just sit in a list. The next article shows how Relay dispatches domain events to handlers inside the same transaction that saves the aggregate, so one change can trigger others atomically (update a read model, notify another part of the system) without losing consistency.

➡️ 003 — Domain Events (planned — see the coverage plan)