Mert Özen Further With Every Line
Software Architecture Domain-Driven Design — #09

Aggregate Design: Consistency Boundaries, Small Aggregates, and Referencing by Id

Mert Özen Nov 7, 2026 10 min 3 views
Aggregate Design: Consistency Boundaries, Small Aggregates, and Referencing by Id

An aggregate is a consistency boundary that keeps true invariants consistent within a single transaction; the outside references only the root, and other aggregates are referenced only by id. We apply Vernon's four rules to Bookstore's Order and StockItem aggregates and work through a real EF Core run where a navigation property silently changes the catalog price, eventual consistency for stock, the 'whose job is it?' question and the Aggregate Design Canvas.

Version note: The code in this post was built and run with C# 14 and .NET 10.

Guess First

The back-to-school campaign has started and a developer gets the task "apply a 20% discount to Ada's order". In the old model the order line is tied to the book through a navigation property, and applying the discount is a single loop:

using System.Globalization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

File.Delete("bookstore09.db");
await using (BookstoreDbContext db = new())
{
    await db.Database.EnsureCreatedAsync();
    Book dune = new() { Title = "Dune", ListPrice = 12.50m };
    db.Orders.Add(new Order { CustomerEmail = "ada@example.com", Items = [new OrderItem { Book = dune, Quantity = 2 }] });
    await db.SaveChangesAsync();
}

await using (BookstoreDbContext db = new(log: true))
{
    Order order = await db.Orders.Include(o => o.Items).ThenInclude(i => i.Book).FirstAsync();

    // Back-to-school campaign: 20% off for this order
    foreach (OrderItem item in order.Items)
        item.Book.ListPrice *= 0.8m;

    await db.SaveChangesAsync();
}

await using (BookstoreDbContext db = new())
{
    Book dune = await db.Books.SingleAsync();
    Console.WriteLine(string.Create(CultureInfo.InvariantCulture, $"Catalog price of {dune.Title}: {dune.ListPrice}"));
}

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public decimal ListPrice { get; set; }
}

public class Order
{
    public int Id { get; set; }
    public string CustomerEmail { get; set; } = "";
    public List<OrderItem> Items { get; set; } = [];
}

public class OrderItem
{
    public int Id { get; set; }
    public int Quantity { get; set; }
    public int BookId { get; set; }
    public Book Book { get; set; } = null!;
}

public sealed class BookstoreDbContext(bool log = false) : DbContext
{
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<Book> Books => Set<Book>();

    protected override void OnConfiguring(DbContextOptionsBuilder options)
    {
        options.UseSqlite("Data Source=bookstore09.db");
        if (log) options.LogTo(Console.WriteLine, [DbLoggerCategory.Database.Command.Name], LogLevel.Information);
    }
}

Which UPDATE, against which table, does the SaveChangesAsync in the second using block send to the database? What price does the last line print for Dune in the catalog? And meanwhile, which price does Grace see when she looks at Dune on the website?

What Is an Aggregate?

The output:

      UPDATE "Books" SET "ListPrice" = @p0
      WHERE "Id" = @p1
      RETURNING 1;
Catalog price of Dune: 10.0

A discount meant for one order changed the list price of the book in the catalog. Grace, and every customer after her, sees Dune at 10.0. The code compiled and the tests probably passed, because Ada's order really is discounted. The problem is this: a change that started at Order modified something Order's rules know nothing about, the Book in the Catalog bounded context.

Aggregates exist precisely to prevent this. Eric Evans's definition from the DDD (Domain-Driven Design) Reference, in my own words: treat tightly related entities and value objects as a cluster, give the cluster a boundary, choose one entity in the cluster as the root, and let everything outside hold references only to the root. Changes go through the root; the root is responsible for the rules of everything inside the boundary. The point Vaughn Vernon's 2011 essay series "Effective Aggregate Design" stresses is that an aggregate isn't designed for convenient object graph navigation but as a transactional consistency boundary. When a transaction commits, everything inside the boundary is consistent; the consistency of what's outside is not that aggregate's concern.

.NET's collections are a small example of the idea. List<T> keeps its internal array and _size closed to the outside, bumps a counter called _version on every change, and its enumerator checks that counter at every step:

List<int> quantities = [1, 2, 3];
try
{
    foreach (int quantity in quantities)
        if (quantity == 2) quantities.Add(4);
}
catch (InvalidOperationException ex) { Console.WriteLine(ex.Message); }
Collection was modified; enumeration operation may not execute.

The internal state is reachable only through "root" methods such as Add and Remove, and code that catches an inconsistent moment is rejected. Aggregates use the same pair: changes only through the root, and a version check against concurrent changes. We'll build the version part with Entity Framework Core (EF Core) in the next post.

Vernon's Four Rules

In his essays Vernon ties aggregate design to four rules. Through Bookstore:

Model true invariants in consistency boundaries. The rules from the previous post (at least one item, at most 10 copies per book, a single currency, one coupon) look only at Order and OrderItem. All of them must be consistent in the same transaction, so they belong to the same aggregate. Vernon's warning points the other way: "false invariants" made up by developers. "Load all of a customer's orders together" isn't an invariant, it's a habit.

Design small aggregates. The Product in Vernon's example was a large aggregate containing all its backlog items, releases and sprints; two users making two completely unrelated changes collided because they hit the same version number. In Bookstore the equivalent would be a Customer holding all its Orders in a collection: Ada's new order and customer service handling a return on an old order would both try to write the same aggregate. On one project Vernon cites, about 70 percent of the aggregates consisted of just a root and a few value objects.

Reference other aggregates by identity. The opening bug came from here. Once OrderItem has a Book navigation, code that loads an Order can change the Book too, and EF Core's change tracker faithfully records it. In the right model OrderItem knows the book by its BookId and copies the title and price at order time as its own values:

public sealed class OrderItem
{
    internal OrderItem(BookId bookId, string title, Money unitPrice, int quantity)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(title);
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);

        BookId = bookId;
        Title = title;
        UnitPrice = unitPrice;
        Quantity = quantity;
    }

    public BookId BookId { get; }
    public string Title { get; }
    public Money UnitPrice { get; }
    public int Quantity { get; private set; }
    public Money LineTotal => UnitPrice * Quantity;

    internal void Increase(int quantity) => Quantity += quantity;
}

This copy isn't duplication, it's a business rule: the glossary's UnitPrice is "the price written into the order, with the campaign applied"; even if the list price in the catalog changes tomorrow, Ada's invoice shouldn't. The discount becomes a method of Order itself and touches only Order's items.

Use eventual consistency outside the boundary. If a change affects two aggregates, the second one is updated in a separate transaction, in reaction to a domain event. The most important example of this is stock.

09-order-aggregate-en.png

Where Does Stock Live? StockItem and Eventual Consistency

In the Clean, Onion and Hexagonal Architecture series, to keep things simple, we called book.TryReserve from inside Order.TryAddItem: order and stock changed in the same transaction. With a single user that's fine. During a campaign, though, the targets in the Introduction to Architecture series put the peak at 120 orders per minute, and a good share of them go to the same handful of bestsellers. If every order tries to lock the same stock row, or bump its version, in its own transaction, unrelated customers start knocking each other's transactions over.

The model we use throughout the series moves stock into a separate aggregate in the Inventory bounded context:

using Bookstore.Ordering;

namespace Bookstore.Inventory;

public readonly record struct StockItemId(Guid Value)
{
    public static StockItemId New() => new(Guid.CreateVersion7());
}

public sealed record StockReservation(OrderId OrderId, int Quantity);

public sealed class StockItem
{
    private readonly List<StockReservation> _reservations = [];

    public StockItem(BookId bookId, int onHand)
    {
        ArgumentOutOfRangeException.ThrowIfNegative(onHand);
        Id = StockItemId.New();
        BookId = bookId;
        OnHand = onHand;
    }

    public StockItemId Id { get; }
    public BookId BookId { get; }
    public int OnHand { get; private set; }
    public int Reserved => _reservations.Sum(r => r.Quantity);
    public int Available => OnHand - Reserved;

    public bool TryReserve(OrderId orderId, int quantity)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);
        if (_reservations.Exists(r => r.OrderId == orderId))
            return true;    // if the same OrderPlaced arrives twice, do not reserve again
        if (quantity > Available)
            return false;

        _reservations.Add(new StockReservation(orderId, quantity));
        return true;
    }

    public void Release(OrderId orderId) => _reservations.RemoveAll(r => r.OrderId == orderId);
}
using Bookstore.Inventory;
using Bookstore.Ordering;

StockItem dune = new(BookId.New(), onHand: 3);
OrderId first = OrderId.New();
OrderId second = OrderId.New();

Console.WriteLine($"first:  {dune.TryReserve(first, 2)}  available={dune.Available}");
Console.WriteLine($"second: {dune.TryReserve(second, 2)} available={dune.Available}");
Console.WriteLine($"first again: {dune.TryReserve(first, 2)} available={dune.Available}");
dune.Release(first);
Console.WriteLine($"after release: available={dune.Available}");
first:  True  available=1
second: False available=1
first again: True available=1
after release: available=3

StockItem's invariant lives inside it: the reserved quantity can't exceed what's on hand. It doesn't know Order; it only keeps the OrderId as the reservation's key. If the same OrderPlaced event arrives twice, the second reservation isn't made; the "first again" line in the output shows that. In a world where events are delivered at least once, this idempotency is part of the price of eventual consistency. TryReserve also follows the decision from the previous post: running out of copies is a routine outcome that can't be known in advance, so it's a bool, not an exception.

The flow is now: Order.Place commits in its own transaction and publishes OrderPlaced; Inventory reacts to that event and runs TryReserve in its own transaction; the result comes back as StockReserved or OutOfStock, and in the latter case Ordering cancels the order with CancellationReason.OutOfStock. It's the very policy we saw on the wall in the EventStorming post. How events are collected and dispatched during SaveChanges is the topic of post 12, and carrying them reliably between processes (the outbox) belongs to the upcoming Messaging and Event-Driven Architecture series.

The "Whose Job Is It?" Question

Choosing between transactional and eventual isn't always this easy. Vernon passes on a guideline from a conversation with Eric Evans: if it's the job of the user executing the use case to make the data consistent, try transactional consistency (while following the other aggregate rules); if it's another user's job or the system's job, allow eventual consistency.

Applied to Bookstore: keeping the order lines and the coupon consistent is Ada's job, it must be consistent when she places the order, same aggregate. Reserving stock isn't Ada's job, it's the warehouse's and the system's; a delay of a few seconds and a rare OutOfStock cancellation is a cost the business accepted in the EventStorming session. Capturing the payment is the job of the payment provider and the Payments context. The guideline doesn't give a definitive answer, but it moves the discussion from "technical preference" to "business rule".

Deciding with the Aggregate Design Canvas

DDD Crew's Aggregate Design Canvas is a good tool for putting these decisions on one page. The version we filled in for Order:

SectionOrder
DescriptionA single purchase by one customer; items, shipping address, coupon and status
State transitionsPlaced → PaymentPending/Paid → Shipped → Delivered; Cancelled before Shipped
Enforced invariantsAt least one item, at most 10 copies per book, single currency, one coupon, a cancelled order doesn't change
Corrective policiesOn OutOfStock, cancel the order or the item and notify the customer
Handled commandsPlaceOrder, ApplyCoupon, CancelOrder, CancelItem, RequestReturn
Created eventsOrderPlaced, OrderCancelled, ReturnRequested
ThroughputAn order receives a handful of commands over its life; concurrent writers are at most the customer, customer service and incoming events
SizeTypically 1–5 items; the practical upper bound is the size of a cart

Fill in the same canvas for StockItem and the difference shows immediately. The throughput row says "dozens of reservations per minute for a bestseller during a campaign", the size row says "grows with the number of open reservations". Those two rows tell you that reservations of shipped orders must be cleaned up with Release, and that StockItem is a much hotter aggregate than Order. We'll make the optimistic concurrency decision in the next post based on these rows.

Common Pitfalls

Changing another aggregate through a navigation property. The opening example. Putting a navigation to another aggregate is the easiest and most dangerous way to reference it; for reading, use a query or a read model; for writing, use the id and an event.

The large aggregate. "A customer has orders" sounds like composition, but like Vernon's Product it loads hundreds of rows for every operation and makes unrelated changes collide. Composition in the UML (Unified Modeling Language) series post "Class Diagrams 2: Association, Aggregation, Composition, Generalization and Dependency — with C# Equivalents" isn't the same as a DDD aggregate; the first is a lifecycle relationship, the second a consistency boundary.

Changing two aggregates in one transaction because "it's the same database anyway". Today both live in the same database, but once the contexts move to separate teams that transaction turns into a distributed transaction or a hidden coupling. Vernon's reasons for bending the rule (UI (user interface) convenience, lack of technical mechanisms, global transactions, query performance) are real, but each one should be written down as a deliberate decision.

Punching through the boundary with lazy loading. A virtual Book navigation and lazy-loading proxies make the opening bug invisible on top of everything: the code loads and changes the other aggregate without writing an Include. We covered lazy loading itself in the EF Core series post "Loading Related Data in EF Core: Include, Explicit and Lazy Loading, Split Queries".

Treating every table as an aggregate. The existence of an OrderItems table doesn't make OrderItem an aggregate; it shouldn't have its own repository or its own DbSet. Aggregates come from rules, not from tables.

A Small Experiment

★☆☆ Do the opening campaign task in the right model: add ApplyDiscount(decimal percent) to Order so that it changes only the items' UnitPrice. What should happen on a cancelled order?

★★☆ Fill in the Aggregate Design Canvas for StockItem. In "Corrective policies", write when, and with which event, the reservation is released if the payment fails.

★★★ The business says "never sell a book that isn't on the shelf, zero tolerance". How would you approximate this rule without pulling stock into the same transaction as Order? Compare checking stock from a read model, reserving at cart time, and compensating after OutOfStock, using Vernon's "whose job is it" question.

We have drawn the aggregate's boundary, but only in code. In the next post we take it to the database: backing fields with EF Core 10, materialization through a private constructor, one repository per aggregate, and why the concurrency token has to sit on the root.