# Aggregate Design in DDD - Rules, Boundaries, and Consistency

> Aggregates are the most important tactical pattern in Domain-Driven Design. They define consistency boundaries, enforce invariants, and protect your domain model. Here are the rules and practical guidance for designing aggregates in .NET.

Published: 2026-09-22. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/aggregate-design-ddd

An **aggregate** in Domain-Driven Design groups the objects whose business rules must hold within one transaction.
Its root controls changes and protects those invariants.
Design the smallest boundary that preserves required consistency, reference other aggregates by identity, and use reliable events when updates across boundaries can happen later.

## What Is an Aggregate?

An **Aggregate** is a cluster of domain objects that are treated as a single unit for data changes.

Every Aggregate has:

- An **Aggregate Root** - the single entry point for all modifications
- A **consistency boundary** - invariants within the Aggregate are always consistent after each operation
- **Internal entities and value objects** - children that can only be accessed through the root

External code requests changes through the aggregate root.
It can read exposed child entities, but their public API must prevent mutations that bypass the root.

![An Order aggregate boundary: application code calls methods on the Order root, cannot touch the internal LineItem entities directly, and the root references another aggregate by CustomerId](https://milanjovanovic.tech/blogs/articles/aggregate-design-ddd/aggregate-boundary.png)

This is one of the most fundamental patterns in [**Domain-Driven Design**](https://milanjovanovic.tech/blog/domain-driven-design-dotnet-getting-started).
If you want the implementation details of the root itself, I cover them in [**Aggregate Root in DDD**](https://milanjovanovic.tech/blog/aggregate-root-ddd).

## Why Aggregates Matter

Without Aggregates, you end up with:

- Domain invariants enforced inconsistently across services
- Multiple objects modified without coordination
- Race conditions when two operations modify related data simultaneously
- An [**anemic domain model**](https://milanjovanovic.tech/blog/refactoring-from-an-anemic-domain-model-to-a-rich-domain-model) where business rules leak into application services

Aggregates give these rules a home and route changes through controlled operations on the root.
Persistence must still protect concurrent writes, for example with an [**EF Core concurrency token**](https://learn.microsoft.com/en-us/ef/core/saving/concurrency) that changes whenever aggregate state changes.

## The Four Rules of Aggregate Design

Vaughn Vernon's [**Effective Aggregate Design**](https://www.dddcommunity.org/library/vernon_2011/) describes four guidelines for choosing these boundaries.

### Rule 1: Protect Business Invariants Inside Aggregate Boundaries

An [**invariant**](https://milanjovanovic.tech/blog/what-invariants-are-and-why-a-domain-model-is-the-best-place-to-enforce-them) is a business rule that must always be true.
The Aggregate is responsible for enforcing its invariants.

The following excerpts use application-defined IDs, events, and a `Money` value object with a non-negative amount and currency-aware arithmetic.
Add this operation to an `Order` whose constructor initializes its draft state and USD total:

```csharp
public class Order : AggregateRoot
{
    private readonly List<OrderLineItem> _lineItems = new();

    public Order(Guid id) : base(id)
    {
        Status = OrderStatus.Draft;
        TotalAmount = Money.Zero(Currency.Usd);
    }

    public IReadOnlyCollection<OrderLineItem> LineItems =>
        _lineItems.AsReadOnly();

    public OrderStatus Status { get; private set; }

    public Money TotalAmount { get; private set; }

    public void AddLineItem(ProductId productId, Money price, int quantity)
    {
        if (Status != OrderStatus.Draft)
        {
            throw new DomainException("Cannot modify a non-draft order.");
        }

        if (quantity <= 0)
        {
            throw new DomainException("Quantity must be positive.");
        }

        ArgumentNullException.ThrowIfNull(price);

        if (price.Currency != Currency.Usd)
        {
            throw new DomainException("Order prices must be in USD.");
        }

        var lineItem = new OrderLineItem(
            Guid.NewGuid(),
            productId,
            price,
            quantity);

        var newTotal = TotalAmount + lineItem.TotalPrice;
        _lineItems.Add(lineItem);
        TotalAmount = newTotal;
    }

}
```

The `Order` Aggregate enforces:

- You can only add items to draft orders
- Quantity must be positive
- The total is always recalculated when items change

The total is calculated before the collection changes, so a failed currency or arithmetic operation does not leave a partially updated order.
Child entities also need non-public setters and mutation methods.

### Rule 2: Design Small Aggregates

Large Aggregates cause performance problems, concurrency conflicts, and tangled dependencies.

**Bad - too large:**

```csharp
// Don't do this
public class Customer : AggregateRoot
{
    public List<Order> Orders { get; }         // Could be thousands
    public List<Address> Addresses { get; }
    public List<PaymentMethod> PaymentMethods { get; }
    public ShoppingCart Cart { get; }
    public LoyaltyAccount Loyalty { get; }
}
```

Loading all of these relationships for every customer operation can make queries expensive.
The object graph alone does not load or lock every related row: queries, loading configuration, and database isolation determine that behavior.

**Better - small and focused:**

```csharp
public class Customer : AggregateRoot
{
    public string Name { get; private set; }
    public Email Email { get; private set; }
}

public class Order : AggregateRoot
{
    public CustomerId CustomerId { get; }  // Reference by ID, not object
    private readonly List<OrderLineItem> _lineItems = new();
    // ...
}

public class ShoppingCart : AggregateRoot
{
    public CustomerId CustomerId { get; }
    private readonly List<CartItem> _items = new();
    // ...
}
```

Each concept is its own Aggregate.
They reference each other by ID, not by direct object references.

### Rule 3: Reference Other Aggregates by Identity

Prefer an ID when referencing another aggregate so loading and updating it remain explicit:

```csharp
// Don't do this - direct reference creates coupling
public class Order : AggregateRoot
{
    public Customer Customer { get; }  // Navigation to another aggregate
}

// Do this - reference by identity
public class Order : AggregateRoot
{
    public CustomerId CustomerId { get; }  // Just the ID
}
```

This keeps Aggregates independent.
You can load, save, and reason about each one separately.

If you need data from another Aggregate in a use case, load it separately in the [**Application layer**](https://milanjovanovic.tech/blog/application-layer-clean-architecture):

```csharp
var order = await _orderRepository.GetByIdAsync(command.OrderId);
var customer = await _customerRepository.GetByIdAsync(order.CustomerId);

// Use both in the use case
```

### Rule 4: Update Other Aggregates Using Eventual Consistency

When a business operation spans multiple Aggregates, use [**domain events**](https://milanjovanovic.tech/blog/domain-events-vs-integration-events) for eventual consistency:
If those events become the durable source of aggregate state rather than coordination messages, you have crossed into [**event sourcing**](https://milanjovanovic.tech/blog/event-sourcing-dotnet-beginners-guide).

![Cross-aggregate eventual consistency: the Order aggregate raises a domain event in the first transaction, and after commit an event handler updates the LoyaltyAccount aggregate in a second transaction](https://milanjovanovic.tech/blogs/articles/aggregate-design-ddd/eventual-consistency.png)


```csharp
public class Order : AggregateRoot
{
    public void Complete()
    {
        if (Status != OrderStatus.Confirmed)
        {
            throw new DomainException("Order must be confirmed before completing.");
        }

        Status = OrderStatus.Completed;

        RaiseDomainEvent(new OrderCompletedDomainEvent(Id, CustomerId, TotalAmount));
    }
}
```

A domain event handler updates the other Aggregate:

```csharp
public class UpdateLoyaltyPointsHandler(
    ILoyaltyAccountRepository loyaltyRepository)
    : IDomainEventHandler<OrderCompletedDomainEvent>
{
    public async Task Handle(
        OrderCompletedDomainEvent domainEvent,
        CancellationToken cancellationToken)
    {
        var account = await loyaltyRepository
            .GetByCustomerIdAsync(domainEvent.CustomerId)
            ?? throw new DomainException("Loyalty account not found.");

        account.AddPoints(domainEvent.TotalAmount);

        await loyaltyRepository.UpdateAsync(account);
    }
}
```

This handler runs in a separate transaction only if the dispatch infrastructure schedules it after the order commits.
Use an [**outbox**](https://milanjovanovic.tech/blog/implementing-the-outbox-pattern) for durable delivery and make awarding points idempotent by recording the processed order or event ID in the same transaction as the points update.
The handler above shows the domain operation; that persistence and deduplication infrastructure is required before using it with retries.

## Aggregate Root Base Class

Here's a practical Aggregate Root base class:

```csharp
public abstract class AggregateRoot : Entity
{
    private readonly List<IDomainEvent> _domainEvents = new();

    protected AggregateRoot() { }
    protected AggregateRoot(Guid id) => Id = id;

    public IReadOnlyCollection<IDomainEvent> DomainEvents =>
        _domainEvents.AsReadOnly();

    protected void RaiseDomainEvent(IDomainEvent domainEvent)
    {
        _domainEvents.Add(domainEvent);
    }

    public void ClearDomainEvents()
    {
        _domainEvents.Clear();
    }
}

public abstract class Entity
{
    public Guid Id { get; protected set; }
}
```

The root collects events; it does not dispatch them automatically.
Your infrastructure chooses whether handlers run before commit in the same transaction or later with eventual consistency, as [**Microsoft's domain-event guidance**](https://learn.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/domain-events-design-implementation) explains.
An [**EF Core interceptor**](https://milanjovanovic.tech/blog/how-to-use-ef-core-interceptors) can capture events into an outbox during saving.

## Finding the Right Boundaries

The hardest part of Aggregate design is finding the right boundaries.
Ask these questions:

1. **What must be consistent immediately?** That's your Aggregate.
2. **What can be eventually consistent?** That goes in a separate Aggregate with domain events.
3. **What data changes together?** Group it.
4. **What's the smallest unit I can lock without breaking invariants?** That's your ideal Aggregate size.

For example, an `Order` must be consistent with its `OrderLineItems` - the total must always reflect the items.
But the `Customer` loyalty points can update eventually after the order completes.

## Common Mistakes

**1. Making Aggregates too large.** Hundreds of child entities are a reason to review loading costs and invariants, not an automatic instruction to split the boundary.

**2. Exposing internal collections.** Return a read-only wrapper with `AsReadOnly()` and control modifications through the root.
Returning a mutable list as `IReadOnlyCollection` still allows callers to cast it back.

**3. Choosing eventual consistency without business agreement.** Prefer one aggregate per transaction, but keep an atomic operation when the business cannot tolerate an intermediate state.
Revisit the boundaries if that need is frequent.

**4. Using CRUD operations instead of domain methods.** `order.AddLineItem(...)` is better than `order.LineItems.Add(...)`. The domain method enforces invariants.

## Summary

Aggregates are the consistency boundaries of your domain model.
Design them small, reference them by ID, enforce invariants through the root, and use domain events for cross-Aggregate coordination.

Validate the boundary against real business rules, query costs, and concurrent updates.

Thanks for reading.

And stay awesome!

---

## Frequently asked questions

### How big should an aggregate be in DDD?

Keep it as small as its invariants allow. Many children or frequent concurrency conflicts are reasons to review the boundary, loading strategy, and business requirements before splitting it.

### Can one aggregate reference another aggregate?

Prefer identity references: an Order stores a CustomerId. This keeps loading and updating each aggregate explicit. A direct navigation increases coupling, but does not automatically load the whole object graph.

### Can I modify two aggregates in one transaction?

Avoid it as a rule. Modify one aggregate per transaction and use domain events to update the other aggregate eventually. Breaking this rule occasionally is fine, but it should be a conscious tradeoff.

### What is the difference between an aggregate and an aggregate root?

The aggregate is the whole cluster of objects treated as a unit, including internal entities and value objects. The aggregate root is the single entity inside it that external code is allowed to reference and call.

### Is a repository created per entity or per aggregate?

Use repositories for aggregate roots on the command side. Load the child state required to enforce each operation, and persist changes through the root. Read-only queries may project children directly.
