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.
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:
- Define the Root: Create a C# class that strictly controls access to its state.
- Encapsulate State: Replace public setters with semantic methods (
Process,Tag). - 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?
- Performance: Loading a Document shouldn’t load the User and all their other Documents.
- 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
- [Effective Aggregate Design] — Vaughn Vernon , 2011 — The three-part series that shaped modern thinking about aggregate sizing and boundaries.
- [Domain-Driven Design: Tackling Complexity in the Heart of Software] — Eric Evans , 2003 — Eric Evans’ foundational text on aggregates, entities, and consistency boundaries.
- [Encapsulation in Domain-Driven Design] — Vladimir Khorikov , 2017 — Practical guidance on encapsulating collections and enforcing invariants within aggregates.