Backend Advanced 22 min

Building Rich Domain Models with Aggregates

Learn how to design aggregates that enforce business invariants, manage consistency boundaries, and encapsulate complex domain logic in your C# applications.

By Victor Robin Updated:

Introduction

Designing the aggregate boundaries in BlueRobin was one of the hardest architectural decisions I faced. My first attempt made Archive the aggregate root that contained all its Document entities—which meant loading an archive with 500 documents pulled the entire collection into memory just to add one new document. It was a classic mistake of making aggregates too large. This article captures what I learned about keeping aggregates small, focused, and performant.

In typical “Anemic” models, entities are just bags of getters and setters, and services hold all the logic. This leads to code where invalid states are pervasive—a document can have a null owner, or a negative file size. Aggregates are the antidote. [Domain-Driven Design: Tackling Complexity in the Heart of Software] — Eric Evans , 2003 They are clusters of objects treated as a single unit, guaranteeing that if you hold an object, it is valid.

Why Aggregates Matter:

  • Invariants: Aggregates ensure business rules are always true (e.g., “A document cannot be processed without content”).
  • Consistency: They define clear transaction boundaries. You load, modify, and save the entire aggregate at once. [Effective Aggregate Design] — Vaughn Vernon , 2011
  • Encapsulation: They hide internal complexity. The outside world talks only to the “Aggregate Root”.

What We’ll Build

In this guide, we will design the core Document aggregate for our system. You will learn how to:

  1. Define the Root: Create a C# class that strictly controls access to its state.
  2. Encapsulate State: Replace public setters with semantic methods (Process, Tag).
  3. Handle Collections: Manage list of child entities (Chunks) without exposing the raw list. [Encapsulation in Domain-Driven Design] — Vladimir Khorikov , 2017

Architecture Overview

An Aggregate is more than just a class; it’s a boundary.

classDiagram
    direction TB
    class Document {
        <<Aggregate Root>>
        +CustomId Id
        +CustomId OwnerId
        +DocumentTitle Title
        +ProcessingStatus Status
        +List~Tag~ Tags
        +AddTag()
        +RemoveTag()
        +StartProcessing()
        -List~DocumentChunk~ _chunks
    }

    class DocumentChunk {
        <<Entity>>
        +CustomId Id
        +int Index
        +string Content
    }

    class Tag {
        <<Value Object>>
        +string Name
        +string Color
    }

    Document "1" *-- "many" DocumentChunk : manages
    Document "1" *-- "many" Tag : has

    classDef primary fill:#7c3aed,stroke:#fff,color:#fff
    classDef secondary fill:#06b6d4,stroke:#fff,color:#fff
    classDef db fill:#f43f5e,stroke:#fff,color:#fff
    classDef warning fill:#fbbf24,stroke:#fff,color:#fff

    class Document,DocumentChunk,Tag primary

Section 1: The Aggregate Root

The “Root” is the gateway. No external object can hold a reference to a DocumentChunk directly; they must find it through the Document. [Implementing Domain-Driven Design] — Vaughn Vernon , 2013

public sealed class Document : AggregateRoot<CustomId>
{
    // 1. Private Setters: State changes only happen via methods
    public CustomId OwnerId { get; private set; }
    public DocumentTitle Title { get; private set; }
    public ProcessingStatus Status { get; private set; }
    public DocumentMetadata Metadata { get; private set; }

    // 2. Encapsulated Collections
    private readonly List<DocumentChunk> _chunks = [];
    public IReadOnlyCollection<DocumentChunk> Chunks => _chunks.AsReadOnly();

    private readonly List<Tag> _tags = [];
    public IReadOnlyCollection<Tag> Tags => _tags.AsReadOnly();

    // 3. Static Factory (Naming Intent)
    public static Document Create(
        CustomId ownerId, 
        DocumentTitle title, 
        DocumentMetadata metadata)
    {
        var doc = new Document
        {
            Id = CustomId.New(),
            OwnerId = ownerId,
            Title = title,
            Metadata = metadata,
            Status = ProcessingStatus.Pending
        };
        
        // Raising Domain Event
        doc.AddDomainEvent(new DocumentCreatedEvent(doc.Id, ownerId));
        return doc;
    }
}

Section 2: Enforcing Invariants

Business rules live inside the methods. This makes it impossible to put the Aggregate into an invalid state.

public void StartProcessing()
{
    // Invariant: Cannot re-process a completed document
    if (Status == ProcessingStatus.Completed)
    {
        throw new DomainException("Cannot process a completed document.");
    }
    
    Status = ProcessingStatus.Processing;
    AddDomainEvent(new DocumentProcessingStartedEvent(Id));
}

public void AddChunk(int index, string content)
{
    // Invariant: Uniqueness
    if (_chunks.Any(c => c.Index == index))
    {
        throw new DomainException($"Chunk {index} already exists.");
    }

    _chunks.Add(new DocumentChunk(CustomId.New(), index, content));
}

Section 3: Reference by Identity

One of the hardest lessons in DDD is: Don’t link Aggregates with object references. [Domain-Driven Design Reference] — Eric Evans , 2015 If a User owns a Document, do not add a public User Owner { get; set; } property.

Why?

  1. Performance: Loading a Document shouldn’t load the User and all their other Documents.
  2. Consistency: You cannot guarantee consistency across two Aggregates in one transaction (usually).
// ❌ Bad: Object Reference implies strong coupling
public class Document
{
    public User Owner { get; set; } 
}

// ✅ Good: Reference by ID
public class Document
{
    public CustomId OwnerId { get; private set; }
}

Conclusion

By using Aggregates, we moved logic from “Service Classes” into the domain objects themselves. The Document class is no longer just data; it is a behavior-rich model that protects itself from invalid usage.

The journey from anemic models to rich aggregates fundamentally changed how I think about software design. The aggregate pattern forces you to confront questions about consistency, performance, and boundaries that are easy to defer with procedural code. Every time I design a new feature in BlueRobin, the first question I ask is: “What is the aggregate boundary?”—and the answer shapes everything that follows.

Next Steps

  • See how Domain Events let aggregates talk to each other without coupling.
  • Learn about Value Objects that compose within aggregates to model complex domain concepts.

Further Reading