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
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:
- 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.
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?
- 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 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
DocumentTitleto be more than just a string.