Updated on

An aggregate groups domain entities that change together, and the aggregate root is its main entity. The root is the only way into the aggregate, and it enforces the invariants, the business rules that must hold at all times.

We will get to know the main design principles behind aggregates. We will also design and implement a simple use case in .NET using these principles, and learn how to organize business logic and data access.

To download the source code for this article, you can visit our GitHub repository.

Let’s start.

What’s an Aggregate in Domain-Driven Design?

In Domain-Driven Design, an aggregate is a set of domain entities that are meant to be managed as a single one.

Domain entities are classes that represent well-known business concepts and keep together application data and business logic or behaviors. That’s because in DDD, we organize our business logic around domain entities.

For example, a hypothetical Product domain entity will hold all the properties of a retail product like Name, Category, or Price while also containing all the related business behaviors like ApplyDiscount() or IncreaseStock().

The canonical example of an aggregate is an Order entity and the OrderItems inside it, forming an aggregate that contains both entities.

The entities of an aggregate change together, and we load and save them together.

That matters when a business rule covers more than one of them. An order can’t take more money than its items cost, and only something that sees both the order and its items can check that.

Invariant Business Rules

In Domain-Driven Design, invariant business rules (or simply invariants) are rules that define the core business logic and constraints that must hold at all times. These rules represent essential business requirements.

Examples of invariant business rules might include things like:

  • A bank account balance cannot be negative
  • A customer’s date of birth must be before the current date
  • A product cannot be sold for less than its cost

Rather than implementation details or technical constraints, invariant business rules are high-level, domain-specific rules that govern the behavior of the system as a whole.

Some of them span several entities. Take “the paid amount can’t exceed the order total”. The total comes from the items, while the paid amount lives on the order, so only an object that sees both can check that rule.

What Is an Aggregate Root?

The aggregate root is the entity through which the rest of the application uses an aggregate.

An aggregate defines a boundary that separates the entities inside it from the rest of the application. The entity representing the central business concept in the aggregate will act as the aggregate root.

The aggregate root will be the main entity of the group. This entity will expose business behaviors and enforce the business invariant rules related to the entities that are internal to the aggregate.

The outside world will interact with the aggregate only through its root. In this way, we will always be able to enforce business invariants and maintain a consistent internal state.

For data access, aggregates become the basic unit for data storage and retrieval. We will typically have a single repository per aggregate. The repository’s interface will be based on the aggregate root entity rather than the internal ones.

Microsoft’s .NET microservices architecture guide has a good name for that job: the root is “the aggregate’s consistency guardian”.

In our order aggregate, the root is Order, and every change goes through one of its methods, such as AddItem(), AddPayment() or Ship(). In the diagram below, follow the arrows that start at the outside code. Only one of them gets through the boundary:

Outside code, such as a use case or a test, has one arrow into the Order aggregate. It points at Order, the aggregate root, and is labeled AddItem, AddPayment and Ship. Order branches to two OrderItem boxes inside a dashed boundary. A second arrow from the outside code toward an OrderItem stops at the boundary, marked no direct access.

Outside code calls the root. Only the root changes the items.

The Anemic Domain Model Anti-Pattern

Let’s consider a scenario where we already implemented our online shop order management software using a domain-driven approach.

Unfortunately, we didn’t apply aggregate design principles upfront. In that case, we will probably end up with entity classes that are just classes that match our business concepts with all public properties. Please don’t copy them into your own code.

To start with, we would have defined an enumeration type to represent the different possible states an order can be in:

public enum OrderStatus { PendingPayment, ReadyForShipping, InTransit }

Then, another class for the information related to each of the products inside the order:

public class OrderItem
{
    public long OrderItemId { get; set; }
    public Order? Order { get; set; }
    public string ItemName { get; set; } = "";
    public int Quantity { get; set; }
    public decimal UnitPrice { get; set; }
}

And finally, a class to represent the order itself:

public class Order
{
    public long OrderId { get; set; }
    public DateTime CreationDate { get; set; }
    public decimal PaidAmount { get; set; }
    public OrderStatus Status { get; set; }
    public ICollection<OrderItem> Items { get; set; } = new List<OrderItem>();
}

Although these objects model the data structures and the relationships in our domain correctly, they do not contain any business logic.

On top of that, they do nothing to enforce our business invariants since they expose all the properties and their default constructors.

This allows any component to create orders in ways that may not comply with all our business rules. Furthermore, anyone can update perfectly valid orders in ways that would make them invalid:

var order = new Order();

order.Items.Add(new OrderItem { ItemName = "Cloudsoft Women's Running Shoes", Quantity = 1, UnitPrice = 59.99m });
order.PaidAmount = 500m;
order.Status = OrderStatus.InTransit;

Every line compiles. When we ran it, the order ended up marked InTransit, with 500 paid for 59.99 worth of shoes.

Often, these half-baked domain entities come hand-in-hand with a layer of so-called domain services. These services usually implement all the calculations and take care of storage concerns:

// Anemic domain model.

public interface IOrdersService
{
    Task AddAmountDiscountAsync(Order order, string description, decimal amount);
    Task<OrderItem> AddOrderItemAsync(Order order, string name, decimal price, int units);
    Task AddPercentageDiscountAsync(Order order, string description, decimal percentage);
    Task CancelOrderAsync(Order order);
    Task<Order> CreateOrderAsync();
    Task SendToCustomerAsync(Order order);
}

Such implementations tend to rely on specific storage technologies, like Entity Framework, and directly use domain entities as models for persistence.

If our domain classes look like this, most probably our design suffers from what is called the anemic domain model anti-pattern.

Is that always bad? Not when there are no rules to protect, and Microsoft’s guide says as much for simple CRUD services. Our order has rules, though.

The Order Aggregate

Let’s take steps to improve our domain model using aggregate design principles. First and foremost, we decide that the Order entity will be our aggregate root.

The Order class will be responsible for managing other entities inside the same aggregate and for keeping the internal state consistent.

We’ll build it from scratch, starting with a class library for the domain. In an empty folder, run:

dotnet new sln -n AggregateDesign
dotnet new classlib -n AggregateDesign.Domain
dotnet solution add AggregateDesign.Domain

Then delete the Class1.cs file that the template created. From the .NET 10 SDK on, dotnet new sln writes the XML-based AggregateDesign.slnx file.

The domain project won’t need a single NuGet package.

Refactoring the Order Class

The order states stay the same as in the anemic model. Create AggregateDesign.Domain/OrderStatus.cs:

namespace AggregateDesign.Domain;

public enum OrderStatus
{
    PendingPayment,
    ReadyForShipping,
    InTransit
}

Now, to prevent code outside the aggregate from changing internal data arbitrarily, we must make our property setters private. Create AggregateDesign.Domain/Order.cs:

namespace AggregateDesign.Domain;

public sealed class Order
{
    private readonly List<OrderItem> _items = [];

    private Order(DateTime createdAtUtc)
    {
        CreatedAtUtc = createdAtUtc;
        Status = OrderStatus.PendingPayment;
    }

    public long Id { get; private set; }
    public DateTime CreatedAtUtc { get; private set; }
    public DateTime? ShippedAtUtc { get; private set; }
    public decimal PaidAmount { get; private set; }
    public OrderStatus Status { get; private set; }

    public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();

    public decimal Total => _items.Sum(item => item.Quantity * item.UnitPrice);
    public decimal AmountDue => Total - PaidAmount;

    public static Order Create(TimeProvider clock) => new(clock.GetUtcNow().UtcDateTime);
}

This time, the constructor is private, and the static Create() method is the creation point for the entire aggregate. It takes a TimeProvider instead of reading DateTime.Now, so a test can decide what time it is.

The constructor initializes the Order class in a way that is consistent with our business rules. In this case, that means setting the Status to PendingPayment.

The next important thing to realize is that we will have a lot of business invariants related to the OrderItems collection.

Our aggregate root and no one else will have to enforce them, so we will make the _items collection private while exposing a read-only version via the Items public property. In this way, we let consumers access the information while, at the same time, we keep the aggregate root as the only component able to update it.

Finally, the read-only Total and AmountDue properties are calculated on every read, so they can’t drift away from the items.

Refactoring the OrderItem Class

Now let’s apply the same principles to our OrderItem entity. As we did with the Order class, we will keep all the property setters private. Create AggregateDesign.Domain/OrderItem.cs:

namespace AggregateDesign.Domain;

public sealed class OrderItem
{
    internal OrderItem(string name, int quantity, decimal unitPrice)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(name);
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(unitPrice);

        Name = name;
        Quantity = quantity;
        UnitPrice = unitPrice;
    }

    public string Name { get; private set; }
    public int Quantity { get; private set; }
    public decimal UnitPrice { get; private set; }
}

Considering that the OrderItem entity is internal to our aggregate, we want to prevent instances of this entity from being created outside of it.

There isn’t an easy way to enforce this behavior. However, we can make the constructor internal so the creation of this object is at least restricted to our domain assembly.

Here, we implement an internal constructor that takes parameters for item name, item quantity, and unit price. The constructor signature reflects a business rule: It’s not allowed to have an order item missing any of those elements.

Also, we perform a series of validations aligned to our business invariants, like “item must have a name” or “unit price must be above zero”. And since there’s no parameterless constructor, no one can skip them.

Did you notice that OrderItemId is gone? Inside its order, the name identifies an item.

Implementing Behaviors

Until this point, we used well-known OOP techniques to implement the data structures and relationships that define our orders aggregate.

However, Domain-Driven Design is all about keeping business logic and data together, so now it is time to implement our business behaviors.

There are a few business processes we need to support:

  • Add and remove items in an order and update the quantity of an order item
  • Accept payments to an order
  • Manage order status and be able to ship an order to a customer

Returning Broken Rules as Errors

But what should a behavior do when a business rule says no?

A customer trying to pay more than they owe isn’t a bug. So our behaviors will return that failure as a value instead of throwing an exception. Create AggregateDesign.Domain/Result.cs:

namespace AggregateDesign.Domain;

public enum ErrorType
{
    NotFound,
    Conflict
}

public sealed record Error(string Code, string Description, ErrorType Type);

public class Result
{
    protected Result(Error? error) => Error = error;

    public Error? Error { get; }
    public bool IsSuccess => Error is null;

    public static Result Success() => new(null);

    public static implicit operator Result(Error error) => new(error);
}

The caller checks IsSuccess, and on a failure, it reads the Error. Thanks to the implicit conversion, a method can return an Error, and C# turns it into a failed Result for us. If this is your first time returning errors as values, check out our Result pattern article.

Exceptions still have a job here. A blank item name or a quantity of zero means the calling code has a bug, so those cases throw.

Let’s keep every error our order can return in one place. Create AggregateDesign.Domain/OrderErrors.cs:

namespace AggregateDesign.Domain;

public static class OrderErrors
{
    public static Error ItemsLocked =>
        new("Order.ItemsLocked", "Items can't change once payment has started.", ErrorType.Conflict);

    public static Error DuplicateItem(string name) =>
        new("Order.DuplicateItem", $"The order already has an item named '{name}'.", ErrorType.Conflict);

    public static Error ItemNotFound(string name) =>
        new("Order.ItemNotFound", $"The order has no item named '{name}'.", ErrorType.NotFound);

    public static Error LastUnit(string name, int quantity) =>
        new("Order.LastUnit", $"'{name}' has {quantity} left and needs at least one. Remove the item instead.", ErrorType.Conflict);

    public static Error Overpayment(decimal amountDue, decimal amount) =>
        new("Order.Overpayment", $"Only {amountDue} is due, but the payment is {amount}.", ErrorType.Conflict);

    public static Error Empty =>
        new("Order.Empty", "An order with no items can't ship.", ErrorType.Conflict);

    public static Error NotPaid(decimal amountDue) =>
        new("Order.NotPaid", $"The order can't ship while {amountDue} is still due.", ErrorType.Conflict);

    public static Error AlreadyShipped =>
        new("Order.AlreadyShipped", "The order has already shipped.", ErrorType.Conflict);
}

Order Items and Quantities

Adding and withdrawing units from a specific order item is something we want to support, so, in the same OrderItem entity, we will have to provide the means for it.

The Quantity property itself has a private setter to avoid anyone messing with it unexpectedly.

However, we can always provide entity methods that better model our domain semantics while enforcing business rules. Let’s add two of them to AggregateDesign.Domain/OrderItem.cs:

internal void AddQuantity(int quantity) => Quantity += quantity;

internal Result WithdrawQuantity(int quantity)
{
    if (quantity >= Quantity)
        return OrderErrors.LastUnit(Name, Quantity);

    Quantity -= quantity;

    return Result.Success();
}

Both methods are internal, so only code in our domain assembly, like the aggregate root, can call them. WithdrawQuantity() compares before it subtracts, and it won’t take an item down to zero units.

Now, in the Order entity, let’s create methods to add and remove order items. Let’s also add a couple of wrapper methods for adding and withdrawing item quantity.

Another business rule shows up here, “The order items can’t be modified once payment has started”. Add these methods to AggregateDesign.Domain/Order.cs:

public Result AddItem(string name, int quantity, decimal unitPrice)
{
    if (PaidAmount > 0)
        return OrderErrors.ItemsLocked;

    if (FindItem(name) is not null)
        return OrderErrors.DuplicateItem(name);

    _items.Add(new OrderItem(name, quantity, unitPrice));

    return Result.Success();
}

public Result RemoveItem(string name)
{
    if (PaidAmount > 0)
        return OrderErrors.ItemsLocked;

    var item = FindItem(name);
    if (item is null)
        return OrderErrors.ItemNotFound(name);

    _items.Remove(item);

    return Result.Success();
}

public Result AddQuantity(string name, int quantity)
{
    ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);

    if (PaidAmount > 0)
        return OrderErrors.ItemsLocked;

    var item = FindItem(name);
    if (item is null)
        return OrderErrors.ItemNotFound(name);

    item.AddQuantity(quantity);

    return Result.Success();
}

public Result WithdrawQuantity(string name, int quantity)
{
    ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);

    if (PaidAmount > 0)
        return OrderErrors.ItemsLocked;

    var item = FindItem(name);
    if (item is null)
        return OrderErrors.ItemNotFound(name);

    return item.WithdrawQuantity(quantity);
}

private OrderItem? FindItem(string name) => _items.Find(item => item.Name == name);

Every method guards its arguments, checks the business rules and only then changes state. A failed rule returns its error while the order is still untouched.

Why Is Quantity an int and Not a uint?

A uint quantity looks safer, because it can’t hold a negative number. However, unsigned subtraction wraps around, so a guard like if (Quantity - quantity <= 0) can’t see an overdraw. Don’t copy this one:

uint quantity = 2;
uint withdraw = 3;
Console.WriteLine(quantity - withdraw);
Console.WriteLine(quantity - withdraw <= 0);

This prints:

4294967295
False

Were you expecting -1? A uint can’t go below zero, so 2 minus 3 wraps around to the largest uint value.

We ran the same guard inside a full order class. Withdrawing three units from an item with two left it holding 4,294,967,295 units, and the order total became 257,655,088,027.05.

With int and a comparison before the subtraction, the same request comes back as Order.LastUnit.

Payments Management

Let’s continue with the Order class and implement our payment management features. We already have the read-only Total and AmountDue properties, so let’s add an AddPayment() method to AggregateDesign.Domain/Order.cs, above the FindItem() helper:

public Result AddPayment(decimal amount)
{
    ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount);

    if (amount > AmountDue)
        return OrderErrors.Overpayment(AmountDue, amount);

    PaidAmount += amount;

    if (AmountDue == 0)
        Status = OrderStatus.ReadyForShipping;

    return Result.Success();
}

Within AddPayment() we check a couple of business rules: “payment amounts must be positive” and “we can’t pay more than what’s still due”.

Also, we are implementing another business-related behavior: The order becomes ready for shipping once the Total has been paid.

Why Do the Items Lock Once Payment Starts?

All four item methods begin their rule checks with if (PaidAmount > 0). Why does a rule about items protect a rule about money?

“The paid amount can’t exceed the order total” compares two numbers that live in different places.

AddPayment() guards the paid amount, and the four item methods move the total. If even one of them skips the lock, a rule about money is no longer guarded on that path.

We ran two scenarios against an order class that locked its items only after full payment, and only in AddItem() and RemoveItem(), while its quantity methods checked nothing:

  • An order for 116.96 took a payment of 100.00, and then its T-shirts were removed. It was left with 100.00 paid against a total of 59.99, and it could not ship or take another payment until its total grew past 100.00.
  • An order for 59.99 was paid in full, and then nine more pairs of shoes were added. It shipped with 599.90 of goods and 59.99 paid.

Locking the items at the first payment closes both holes. A rule that spans two objects has to be checked on every path that can change either of them.

Order Shipping

Lastly, back to the Order entity, let’s implement our Ship() method.

This method will take the order to the InTransit state, but not before performing some more validations.

This time we will check that the order has at least one item, hasn’t been shipped before and is fully paid. Add it to AggregateDesign.Domain/Order.cs, above the FindItem() helper:

public Result Ship(TimeProvider clock)
{
    if (_items.Count == 0)
        return OrderErrors.Empty;

    if (Status == OrderStatus.InTransit)
        return OrderErrors.AlreadyShipped;

    if (Status != OrderStatus.ReadyForShipping)
        return OrderErrors.NotPaid(AmountDue);

    ShippedAtUtc = clock.GetUtcNow().UtcDateTime;
    Status = OrderStatus.InTransit;

    return Result.Success();
}

Like Create(), it asks a TimeProvider for the time.

Putting It All Together

With these changes in place, we now have a rich domain model that encapsulates our business rules and keeps our data in a consistent state.

Our code becomes more intuitive and safer since the logic embedded in the domain objects prevents them from going into illegal states:

var order = Order.Create(TimeProvider.System);
order.AddItem("Cloudsoft Women's Running Shoes", 1, 59.99m);
order.AddItem("Gildone Men's Crew T-Shirt", 3, 18.99m);
order.AddPayment(20.0m);

var result = order.Ship(TimeProvider.System); // Order.NotPaid: The order can't ship while 96.96 is still due.

The order stays in PendingPayment, and the error tells us why.

Testing the Order Aggregate

Our rules live in plain C# classes, so we can test them without a database. In the solution folder, run:

dotnet new classlib -n AggregateDesign.Tests
dotnet solution add AggregateDesign.Tests

Delete its Class1.cs as well, and replace AggregateDesign.Tests/AggregateDesign.Tests.csproj with:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
    <IsTestProject>true</IsTestProject>
  </PropertyGroup>

  <ItemGroup>
    <Using Include="Xunit" />
  </ItemGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
    <PackageReference Include="xunit.v3.mtp-off" Version="4.0.1" />
    <PackageReference Include="xunit.runner.visualstudio" Version="4.0.0" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\AggregateDesign.Domain\AggregateDesign.Domain.csproj" />
  </ItemGroup>

</Project>

The mtp-off in xunit.v3.mtp-off means the Microsoft Testing Platform runner stays off, so dotnet test runs these tests through the classic runner.

Now the tests. Create AggregateDesign.Tests/OrderTests.cs:

using AggregateDesign.Domain;

namespace AggregateDesign.Tests;

public class OrderTests
{
    private const string Shoes = "Cloudsoft Women's Running Shoes";
    private const string TShirt = "Gildone Men's Crew T-Shirt";

    private static Order NewOrder()
    {
        var order = Order.Create(TimeProvider.System);
        order.AddItem(Shoes, 1, 59.99m);
        order.AddItem(TShirt, 3, 18.99m);

        return order;
    }

    [Fact]
    public void AddPayment_WhenItCoversTheTotal_MakesTheOrderReadyForShipping()
    {
        var order = NewOrder();

        var result = order.AddPayment(116.96m);

        Assert.True(result.IsSuccess);
        Assert.Equal(OrderStatus.ReadyForShipping, order.Status);
    }

    [Fact]
    public void AddPayment_WhenItExceedsTheAmountDue_ReturnsOverpayment()
    {
        var order = NewOrder();

        var result = order.AddPayment(200m);

        Assert.Equal("Order.Overpayment", result.Error!.Code);
        Assert.Equal(0m, order.PaidAmount);
    }

    [Fact]
    public void RemoveItem_AfterPaymentHasStarted_ReturnsItemsLocked()
    {
        var order = NewOrder();
        order.AddPayment(100m);

        var result = order.RemoveItem(TShirt);

        Assert.Equal("Order.ItemsLocked", result.Error!.Code);
        Assert.Equal(116.96m, order.Total);
    }

    [Fact]
    public void AddQuantity_AfterFullPayment_ReturnsItemsLocked()
    {
        var order = NewOrder();
        order.AddPayment(order.Total);

        var result = order.AddQuantity(Shoes, 9);

        Assert.Equal("Order.ItemsLocked", result.Error!.Code);
        Assert.Equal(order.PaidAmount, order.Total);
    }

    [Fact]
    public void WithdrawQuantity_WhenItWouldTakeEveryUnit_ReturnsLastUnit()
    {
        var order = NewOrder();

        var result = order.WithdrawQuantity(TShirt, 4);

        Assert.Equal("Order.LastUnit", result.Error!.Code);
        Assert.Equal(3, order.Items.Single(item => item.Name == TShirt).Quantity);
    }

    [Fact]
    public void AddQuantity_ForAnUnknownItem_ReturnsItemNotFound()
    {
        var order = NewOrder();

        var result = order.AddQuantity("Wool Socks", 2);

        Assert.Equal("Order.ItemNotFound", result.Error!.Code);
    }

    [Fact]
    public void AddItem_WithANameAlreadyInTheOrder_ReturnsDuplicateItem()
    {
        var order = NewOrder();

        var result = order.AddItem(Shoes, 1, 59.99m);

        Assert.Equal("Order.DuplicateItem", result.Error!.Code);
        Assert.Equal(2, order.Items.Count);
    }

    [Fact]
    public void AddItem_WithZeroQuantity_Throws()
    {
        var order = Order.Create(TimeProvider.System);

        Assert.Throws<ArgumentOutOfRangeException>(() => order.AddItem(Shoes, 0, 59.99m));
    }

    [Fact]
    public void Ship_WhenNotPaidInFull_ReturnsNotPaid()
    {
        var order = NewOrder();
        order.AddPayment(20m);

        var result = order.Ship(TimeProvider.System);

        Assert.Equal("Order.NotPaid", result.Error!.Code);
        Assert.Equal(OrderStatus.PendingPayment, order.Status);
    }

    [Fact]
    public void Ship_WithNoItems_ReturnsEmpty()
    {
        var order = Order.Create(TimeProvider.System);

        var result = order.Ship(TimeProvider.System);

        Assert.Equal("Order.Empty", result.Error!.Code);
    }

    [Fact]
    public void Ship_WhenPaidInFull_UsesTheClockForTheShippingDate()
    {
        var now = new DateTimeOffset(2026, 10, 1, 9, 30, 0, TimeSpan.Zero);
        var order = NewOrder();
        order.AddPayment(order.Total);

        var result = order.Ship(new FixedClock(now));

        Assert.True(result.IsSuccess);
        Assert.Equal(OrderStatus.InTransit, order.Status);
        Assert.Equal(now.UtcDateTime, order.ShippedAtUtc);
    }

    [Fact]
    public void Ship_Twice_ReturnsAlreadyShipped()
    {
        var order = NewOrder();
        order.AddPayment(order.Total);
        order.Ship(TimeProvider.System);

        var result = order.Ship(TimeProvider.System);

        Assert.Equal("Order.AlreadyShipped", result.Error!.Code);
    }
}

internal sealed class FixedClock(DateTimeOffset now) : TimeProvider
{
    public override DateTimeOffset GetUtcNow() => now;
}

Each test builds a small order, tries one thing and checks the error code or the new state. We compare codes, never descriptions, so the messages can change without breaking anything.

FixedClock at the bottom is a fake TimeProvider, frozen at the moment we pass in. Our article on TimeProvider in tests covers it in more depth.

Let’s run the tests:

dotnet test

The output ends with the test summary:

Test summary: total: 12, failed: 0, succeeded: 12, skipped: 0, duration: 1.4s
Build succeeded in 2.6s

What happens if we try the anemic tricks now? Let’s add this class to the test project as AggregateDesign.Tests/Sneaky.cs, knowing it isn’t meant to work:

using AggregateDesign.Domain;

namespace AggregateDesign.Tests;

public static class Sneaky
{
    public static void Break(Order order)
    {
        order.Items.Add(new OrderItem("Wool Socks", 1, 9.99m));
        order.PaidAmount = 500m;
    }
}

The build stops with three errors:

Sneaky.cs(9,29): error CS1729: 'OrderItem' does not contain a constructor that takes 3 arguments
Sneaky.cs(9,21): error CS1061: 'IReadOnlyCollection<OrderItem>' does not contain a definition for 'Add' and no accessible extension method 'Add' accepting a first argument of type 'IReadOnlyCollection<OrderItem>' could be found (are you missing a using directive or an assembly reference?)
Sneaky.cs(10,9): error CS0200: Property or indexer 'Order.PaidAmount' cannot be assigned to -- it is read only

Let’s delete Sneaky.cs again before we move on.

Decoupling Domain Model And Persistence in Aggregate Design

In Domain-Driven Design, we want our domain to remain focused on business logic. Consequently, we do not want other concerns, like data storage and persistence, to leak into it.

Data Access Abstractions as Part of the Domain

With that in mind, let’s add to our domain a repository abstraction that revolves around the aggregate concept.

There will be a single repository per aggregate, and its methods will use the aggregate root entity as the basic unit for data storage and retrieval. Create AggregateDesign.Domain/IOrderRepository.cs:

namespace AggregateDesign.Domain;

public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(long orderId, CancellationToken cancellationToken = default);

    void Add(Order order);

    Task SaveChangesAsync(CancellationToken cancellationToken = default);
}

There’s no update method. We load a tracked order, call its methods and then call SaveChangesAsync(), and EF Core works out what changed.

As mentioned before, the repository interface belongs to the domain, and it lives in the same project as the domain entities.

In contrast, the repository implementation deals with persistence concerns and must reside in the data access layer:

Two stacked project boxes. The top one, AggregateDesign.Domain, holds the Order aggregate (Order and OrderItem) and the IOrderRepository interface. The bottom one, AggregateDesign.Infrastructure, holds OrdersDbContext with the OwnsMany mapping and OrderRepository. An arrow labeled references points up from Infrastructure to Domain. Another arrow, labeled EF Core, points down from Infrastructure to a database cylinder labeled SQLite in our tests.

Infrastructure depends on the Domain, never the other way round, and it stores the whole Order aggregate as one unit.

With this setup, we avoid establishing a dependency between our domain and the data access code. This helps to keep it free from data access specifics and focused on business logic.

In a solution that also has an Application project, the interface often moves there. Either place works, as long as that project never references EF Core.

Domain Entities vs Data Models

Consider our domain entities as they were implemented in the anemic domain model example: It seems quite straightforward to use those classes as the data models for object-relational mappers such as Entity Framework.

However, as we start applying domain-driven design techniques, entities become more complex. Ours have private setters, a private _items field and no parameterless constructors. EF Core can still map them directly, so we don’t need a separate set of data models.

Let’s create the data access project, the only place outside the tests where EF Core appears:

dotnet new classlib -n AggregateDesign.Infrastructure
dotnet solution add AggregateDesign.Infrastructure
dotnet reference add AggregateDesign.Domain --project AggregateDesign.Infrastructure
dotnet package add Microsoft.EntityFrameworkCore --version 10.0.12 --project AggregateDesign.Infrastructure

Delete Class1.cs here too. Next, the DbContext. Create AggregateDesign.Infrastructure/OrdersDbContext.cs:

using AggregateDesign.Domain;
using Microsoft.EntityFrameworkCore;

namespace AggregateDesign.Infrastructure;

public sealed class OrdersDbContext(DbContextOptions<OrdersDbContext> options)
    : DbContext(options)
{
    public DbSet<Order> Orders => Set<Order>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Order>()
            .OwnsMany(o => o.Items, item => item.HasKey("OrderId", nameof(OrderItem.Name)));
    }
}

The whole mapping is one statement. OwnsMany() tells EF Core that order items are owned by their order.

The EF Core documentation on owned entity types says owned entities “are conceptually similar to aggregates”.

An owned type has no DbSet of its own, so no code can query items without their order. And loading an order loads its items in the same query.

HasKey() builds each item’s key from its order’s ID and its own name.

We need it on SQLite, because otherwise EF Core gives owned items a generated number as part of a two-column key, and SQLite can’t generate one. The first save then fails with “NOT NULL constraint failed: OrderItem.Id”.

EF Core finds the _items field by itself, since its name matches the Items property. It also calls our private and internal constructors, because their parameter names match the properties.

Then the repository. Create AggregateDesign.Infrastructure/OrderRepository.cs:

using AggregateDesign.Domain;
using Microsoft.EntityFrameworkCore;

namespace AggregateDesign.Infrastructure;

public sealed class OrderRepository(OrdersDbContext dbContext) : IOrderRepository
{
    public Task<Order?> GetByIdAsync(long orderId, CancellationToken cancellationToken = default) =>
        dbContext.Orders.FirstOrDefaultAsync(o => o.Id == orderId, cancellationToken);

    public void Add(Order order) => dbContext.Orders.Add(order);

    public Task SaveChangesAsync(CancellationToken cancellationToken = default) =>
        dbContext.SaveChangesAsync(cancellationToken);
}

GetByIdAsync() returns a tracked order, or null when the ID doesn’t exist. SaveChangesAsync() writes every change to the aggregate in one go.

Separate data models still pay off when the tables and the domain have very different shapes. But a mapping layer can lose data.

The previous version of this article mapped the Order to a fresh OrderModel on every update, detached the tracked copy and attached the new one. Don’t copy it:

public async Task<Order> UpdateAsync(Order entity)
{
    var dataModel = _mapper.Map<Order, OrderModel>(entity);
    var attached = await _dbContext.Orders
        .Include(x => x.Items)
        .SingleAsync(x => x.OrderId == entity.OrderId);

    _dbContext.Entry(attached).State = EntityState.Detached;
    foreach (var item in attached.Items.ToList())
        _dbContext.Entry(item).State = EntityState.Detached;

    var entry = _dbContext.Attach(dataModel);

    await _dbContext.SaveChangesAsync();

    return _mapper.Map<OrderModel, Order>(entry.Entity);
}

Can you see why the payment never reached the database?

Microsoft’s Attach() reference explains the catch: when the key is generated, an entity whose key is already set gets tracked as Unchanged.

So SaveChangesAsync() had nothing to write. In our run, the order showed a payment of 100.0 in memory and 0.0 in the database. Its test still passed, because it read the order back through the same DbContext.

If you copied UpdateAsync() from that version, replace it with a tracked load and SaveChangesAsync(), as our repository does.

Testing the Round Trip on SQLite

Do our changes really reach the database? SQLite can run in memory, so the tests can find out without a database server. The test project needs the data access project and the SQLite provider:

dotnet reference add AggregateDesign.Infrastructure --project AggregateDesign.Tests
dotnet package add Microsoft.EntityFrameworkCore.Sqlite --version 10.0.12 --project AggregateDesign.Tests

Create AggregateDesign.Tests/OrderRepositoryTests.cs:

using AggregateDesign.Domain;
using AggregateDesign.Infrastructure;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;

namespace AggregateDesign.Tests;

public sealed class OrderRepositoryTests : IDisposable
{
    private const string DutchOven = "Enameled Cast Iron Covered Dutch Oven";
    private const string Skillet = "Skillet with Red Silicone Hot Handle Holder, 12-inch";

    private readonly SqliteConnection _connection = new("DataSource=:memory:");
    private readonly DbContextOptions<OrdersDbContext> _options;

    public OrderRepositoryTests()
    {
        _connection.Open();
        _options = new DbContextOptionsBuilder<OrdersDbContext>().UseSqlite(_connection).Options;

        using var dbContext = new OrdersDbContext(_options);
        dbContext.Database.EnsureCreated();
    }

    [Fact]
    public async Task SavedOrder_LoadsBackWithItsItems()
    {
        var orderId = await SaveNewOrderAsync();

        var loaded = await LoadAsync(orderId);

        Assert.NotNull(loaded);
        Assert.Equal(2, loaded.Items.Count);
        Assert.Equal(106.25m, loaded.Total);
    }

    [Fact]
    public async Task ChangesToALoadedOrder_ReachTheDatabase()
    {
        var orderId = await SaveNewOrderAsync();

        await using (var dbContext = new OrdersDbContext(_options))
        {
            var repository = new OrderRepository(dbContext);
            var order = await repository.GetByIdAsync(orderId, TestContext.Current.CancellationToken);

            order!.RemoveItem(DutchOven);
            order.AddQuantity(Skillet, 1);
            order.AddPayment(59.80m);

            await repository.SaveChangesAsync(TestContext.Current.CancellationToken);
        }

        var reloaded = await LoadAsync(orderId);

        var skillet = Assert.Single(reloaded!.Items);
        Assert.Equal(2, skillet.Quantity);
        Assert.Equal(59.80m, reloaded.PaidAmount);
        Assert.Equal(OrderStatus.ReadyForShipping, reloaded.Status);
    }

    [Fact]
    public async Task GetByIdAsync_ForAnUnknownId_ReturnsNull()
    {
        var loaded = await LoadAsync(42);

        Assert.Null(loaded);
    }

    private async Task<long> SaveNewOrderAsync()
    {
        var order = Order.Create(TimeProvider.System);
        order.AddItem(DutchOven, 1, 76.35m);
        order.AddItem(Skillet, 1, 29.90m);

        await using var dbContext = new OrdersDbContext(_options);
        var repository = new OrderRepository(dbContext);
        repository.Add(order);
        await repository.SaveChangesAsync(TestContext.Current.CancellationToken);

        return order.Id;
    }

    private async Task<Order?> LoadAsync(long orderId)
    {
        await using var dbContext = new OrdersDbContext(_options);

        return await new OrderRepository(dbContext).GetByIdAsync(orderId, TestContext.Current.CancellationToken);
    }

    public void Dispose() => _connection.Dispose();
}

xUnit builds a new instance of the class per test, and with it a brand-new in-memory database.

Notice that every read uses a fresh DbContext. A context that’s still tracking the order hands back our changes even when none of them reached the database. That’s how the old update test stayed green.

ChangesToALoadedOrder_ReachTheDatabase() removes the Dutch oven, adds a second skillet and pays the new total, then saves once.

With EF Core’s command logging switched on, we’d see that save send three statements:

DELETE FROM "OrderItem"
WHERE "Name" = @p0 AND "OrderId" = @p1
RETURNING 1;

UPDATE "OrderItem" SET "Quantity" = @p0
WHERE "Name" = @p1 AND "OrderId" = @p2
RETURNING 1;

UPDATE "Orders" SET "PaidAmount" = @p0, "Status" = @p1
WHERE "Id" = @p2
RETURNING 1;

None of that needed an update method in our code. Let’s run the whole suite:

dotnet test

This time, the output ends with:

Test summary: total: 15, failed: 0, succeeded: 15, skipped: 0, duration: 2.6s
Build succeeded in 3.8s

Aggregate vs Entity: What’s the Difference?

An entity is an object with an identity that stays the same while its data changes. So both Order and OrderItem are entities.

An aggregate is a boundary drawn around a few entities, and often some value objects too, with one entity picked as the root. A value object has no identity at all: two prices of 59.99 are the same price.

So every aggregate root is an entity, but not every entity is a root. Here’s how the four terms line up in our sample:

TermIdentityCan outside code hold a reference to it?In our sample
EntityIts own, and it stays the same while the data changesOnly if the entity is a rootOrder and OrderItem
Value objectNone. Two equal values are interchangeableYes, it's copied like any other valueNone yet. A price could be one
AggregateThe root's identityOnly through its rootOrder together with its items
Aggregate rootGlobal. Other aggregates store its IDYes. It's the only way inOrder

If the only thing allowed to hold a reference to an object is its order, that object lives inside an aggregate. Our article on value objects covers the third kind.

How Big Should an Aggregate Be?

As small as its invariants allow.

Microsoft’s guide frames the test in terms of transactions: the objects that must stay consistent together in one transaction form an aggregate. Our order and its items share the payment rule, so they belong together.

The customer who placed the order doesn’t. No rule ties the customer’s data to the order’s data at every moment, so Order would store a CustomerId, not a Customer.

In practice, we refer to other aggregates by ID, keep each aggregate small and change one aggregate per transaction when we can.

Teams bend the last rule on purpose. Moving money between two accounts changes two aggregates, and in a single database we can save both in one transaction with the unit of work pattern. The other option is a domain event that updates the second aggregate afterwards.

Conclusion

In this article, we have learned what an aggregate is in Domain-Driven Design and which are the principles behind this design.

We have learned about the aggregate root concept and how it guards the invariants of the whole aggregate.

After that, we defined what the anemic domain model anti-pattern is, and went on to implement an Order aggregate following proper aggregate design and domain-driven practices.

Finally, we have learned how to design our data repositories around our aggregates. More importantly, we learned how to avoid coupling domain logic and persistence together.

Our course, Clean Architecture in .NET, takes this further in its domain module, with domain events and a rich event ticketing aggregate mapped in EF Core 10.

Tested with .NET 10.0.10 (SDK 10.0.302), EF Core 10.0.12 and xUnit v3 4.0.1.