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 testand 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:
- 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 = Confirmedon an order with no lines, because the order does not know its own rules. Bugs are “we forgot to callValidate()here.” - 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
Ordermust 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 theOrder. - Payments. A
Paymentmoves throughAuthorized → Captured → Refunded; you cannot capture more than was authorised, or refund a payment that never captured. The state machine is the aggregate. - Inventory. A
StockItemcannot be reserved below zero; a reservation and its release must balance. The quantity-on-hand invariant lives on the item. - Booking systems. A
Reservationcannot 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
Subscriptioncannot 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<Guid>]
L1[OrderLine : Entity<Guid>]
L2[OrderLine : Entity<Guid>]
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
Orderand itsOrderLines(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.
Versionis 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.Statushas 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. KeepApplyEventthe 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
OrderLineso a caller can change its quantity reintroduces the scattered-invariant problem. ExposeIReadOnlyList<OrderLine>and route changes through the root. - One giant aggregate. A
Customerthat 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’sAggregateRootis 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
ApplyEventsOnRaisefrom 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)