Practical Domain-Driven Design: End-to-End Guide
A complete walkthrough from Event Storming to Code: Defining Aggregates, writing Entities, and exposing them via APIs. Seeing the big picture in .NET.
Introduction
Domain-Driven Design (DDD) is often discussed in abstract terms. “Bounded Contexts,” “Ubiquitous Language,” “Aggregates.” These terms sound great in a conference talk, but how do they translate to Monday morning coding?
At BlueRobin, DDD isn’t just theory; it’s how we structure our solution to keep complexity manageable as the system grows. In this guide, we will walk through a single feature—“Archiving a Document”—from the initial whiteboard session to the final API endpoint.
Why This Matters:
- Clarity: It bridges the gap between business requirements and variable names.
- Maintainability: It isolates business logic from infrastructure concerns (Database, API).
- Consistency: It gives the team a shared mental model.
What We’ll Build
We will implement the Document Aggregate. You will see:
- Event Storming: Identifying the Domain Events.
- The Aggregate: Modeling the invariants in C#.
- The Repository: Persisting the state.
- The API: Exposing the command.
Architecture Overview
The flow of data and control follows the “Onion Architecture” (or Clean Architecture), moving from the outside (API) inwards to the Domain.
flowchart LR
API[Controller/Endpoint] -->|Command| App[Application Service]
App -->|Load| Repo[Repository]
Repo -->|Rehydrate| Domain[Domain Aggregate]
subgraph Domain Layer
Domain -->|Produces| Events[Domain Events]
end
subgraph Infrastructure
Repo --> DB[("Database")]
end
classDef primary fill:#7c3aed,color:#fff
classDef secondary fill:#06b6d4,color:#fff
classDef db fill:#f43f5e,color:#fff
classDef warning fill:#fbbf24,color:#000
class API,Domain primary
class App secondary
class DB db
Step 1: Event Storming
We start not with code, but with sticky notes. We ask: “What happens in the system?”
For our document system, we identified these key events:
DocumentProcessingRequestedDocumentVisualizedDocumentArchived
We realized that a Document isn’t just a file. It has lifecycle states. It can’t be archived if it hasn’t been processed. It can’t be modified after archiving. These rules are our Invariants.
Step 2: The Domain Aggregate
The Aggregate is the transaction boundary. It ensures our invariants are never violated.
public class Document : AggregateRoot
{
public DocumentId Id { get; private set; }
public UserId OwnerId { get; private set; }
public DocumentStatus Status { get; private set; }
public DateTime? ArchivedAt { get; private set; }
// Private constructor for ORM/Serialization
private Document() { }
// Factory Method acts as the 'genesis' event
public static Document Create(DocumentId id, UserId ownerId, string filename)
{
var doc = new Document
{
Id = id,
OwnerId = ownerId,
Status = DocumentStatus.Draft
};
doc.AddDomainEvent(new DocumentCreated(id, ownerId));
return doc;
}
// Business Method - Enforcing Invariants
public void Archive()
{
if (Status == DocumentStatus.Archived)
throw new DomainException("Document is already archived.");
if (Status == DocumentStatus.Processing)
throw new DomainException("Cannot archive a document while it is processing.");
Status = DocumentStatus.Archived;
ArchivedAt = DateTime.UtcNow;
AddDomainEvent(new DocumentArchived(Id));
}
}
Step 3: The Repository
The repository interface lives in the Domain/Core project, but the implementation lives in Infrastructure. This verifies that our Domain knows nothing about the database.
// Core/Interfaces/IDocumentRepository.cs
public interface IDocumentRepository
{
Task<Document?> GetByIdAsync(DocumentId id);
Task AddAsync(Document document);
Task SaveChangesAsync();
}
Step 4: The API Endpoint
Finally, we expose this capability to the world. We use the Command Pattern. The endpoint simply translates the HTTP request into a Domain Command and orchestrates the transaction.
public class ArchiveDocumentEndpoint : Endpoint<ArchiveDocumentRequest>
{
private readonly IDocumentRepository _repository;
public ArchiveDocumentEndpoint(IDocumentRepository repository)
{
_repository = repository;
}
public override async Task HandleAsync(ArchiveDocumentRequest req, CancellationToken ct)
{
// 1. Load Aggregate
var document = await _repository.GetByIdAsync(req.Id);
if (document is null)
{
await SendNotFoundAsync(ct);
return;
}
// 2. Invoke Domain Behavior
// This is where the business rules in the Aggregate are checked.
// If it throws, our global exception handler returns 400 Bad Request.
document.Archive();
// 3. Persist
await _repository.SaveChangesAsync();
await SendOkAsync(ct);
}
}
Conclusion
By following this path, we’ve achieved something powerful.
The API layer doesn’t know rules about archiving (like “can’t archive while processing”); it only knows how to call the method. The Database layer doesn’t enforce business logic; it only stores state.
All the complexity is encapsulated in the Document Aggregate. If the rules for archiving change tomorrow, we change them in one place—the Domain—and the whole system updates safely.
Next Steps:
- Learn about Event Sourcing as an alternative to state-based persistence.
- Dive deeper into Value Objects to replace primitive types like
stringandint.