Domain-Driven Design (Aggregates, Value Objects, Repositories)
Model complex business domains using tactical DDD patterns — value objects that enforce rules, aggregates that protect consistency, and domain events that decouple side effects.
What You'll Learn
- • Value Objects — immutable, self-validating types (Money, Email)
- • Aggregates — consistency boundaries with invariant protection
- • Domain Events — decouple side effects from business logic
- • Repository pattern in a DDD context
🏦 Real-World Analogy
A bank account is a perfect aggregate. The account (root) controls all access to transactions (children). You can't add a transaction without going through the account — it enforces rules like "balance can't go negative." Money is a value object: £10 is £10 regardless of which £10 note you have. Domain events are like receipts — they notify other systems (email, audit) that something happened.
Value Objects
Value objects have no identity — they're compared by value. They're immutable and self-validating, making invalid states impossible. Use C# record types for clean implementation.
Value Objects — Money, Email, Address
Create self-validating value objects with business logic built in.
namespace MyApp.Domain.ValueObjects;
// Value Object — no identity, compared by value
// Immutable, self-validating, encapsulates business rules
public record Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
if (amount < 0) throw new DomainException("Amount cannot be negative");
if (string.IsNullOrWhiteSpace(currency) || currency.Length != 3)
throw new DomainException("Currency mu
...Aggregates & Aggregate Roots
An aggregate is a cluster of objects treated as a single unit. The aggregate root is the only entry point — external code never modifies children directly. This protects invariants.
Aggregate Root — BankAccount
Build a bank account aggregate with deposits, withdrawals, and transfers.
namespace MyApp.Domain.Aggregates;
// Aggregate Root — consistency boundary
// External code can only interact through the root
public class BankAccount
{
public Guid Id { get; private set; }
public string OwnerId { get; private set; } = "";
public Money Balance { get; private set; } = new(0, "GBP");
private readonly List<Transaction> _transactions = new();
public IReadOnlyCollection<Transaction> Transactions => _transactions.AsReadOnly();
private readonly List<IDomainEv
...Domain Events
Domain events capture things that happened in the domain. They decouple side effects (emails, notifications, audit logs) from the core business logic. Dispatch them after the aggregate is saved.
Domain Events & Dispatcher
Publish domain events and handle them with decoupled event handlers.
namespace MyApp.Domain.Events;
// Domain Events — notify other parts of the system
public interface IDomainEvent
{
DateTime OccurredAt { get; }
}
public record AccountOpenedEvent(Guid AccountId, string OwnerId) : IDomainEvent
{
public DateTime OccurredAt { get; } = DateTime.UtcNow;
}
public record FundsDepositedEvent(Guid AccountId, Money Amount) : IDomainEvent
{
public DateTime OccurredAt { get; } = DateTime.UtcNow;
}
public record FundsWithdrawnEvent(Guid AccountId, Money Amoun
...Pro Tip
Keep aggregates small. A common mistake is creating huge aggregates with dozens of entities. If two concepts don't need to be consistent in the same transaction, they should be separate aggregates connected by ID references, not object references.
Common Mistakes
- • Using primitive types instead of value objects —
string emailallows invalid data - • Modifying aggregate children directly — bypasses invariant checks
- • Dispatching events before saving — if the save fails, handlers ran for nothing
Lesson Complete!
You've mastered Domain-Driven Design patterns. Next, learn to build microservices with gRPC, messaging, and resilience.
Sign up for free to track which lessons you've completed and get learning reminders.