⚡ 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

Introduction

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. 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.
  • 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.

Architecture Overview

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

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

    class DocumentChunk {
        <<Entity>>
        +BlueRobinId 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.

public sealed class Document : AggregateRoot<BlueRobinId>
{
    // 1. Private Setters: State changes only happen via methods
    public BlueRobinId 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(
        BlueRobinId ownerId, 
        DocumentTitle title, 
        DocumentMetadata metadata)
    {
        var doc = new Document
        {
            Id = BlueRobinId.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(BlueRobinId.New(), index, content));
}

Section 3: Reference by Identity

One of the hardest lessons in DDD is: Don’t link Aggregates with object references. 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 BlueRobinId 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.

Next Steps:

  • See how Domain Events let aggregates talk to each other.
  • Learn about Value Objects which allow DocumentTitle to be more than just a string.