System Design · Lesson 8 of 26
Domain-Driven Design (DDD)
What Is DDD?
Domain-Driven Design is an approach to software where the code structure mirrors the business domain. The language the developers use is the same language the business uses. The boundaries in the code reflect real boundaries in the business.
DDD was introduced by Eric Evans in Domain-Driven Design: Tackling Complexity in the Heart of Software (2003). The core insight: complex software problems are fundamentally domain problems, not technical ones.
The Two Parts of DDD
Strategic DDD — the high-level decisions:
- How to divide the system into bounded contexts
- How those contexts communicate
Tactical DDD — the building blocks:
- Entities, Value Objects, Aggregates, Repositories, Domain Events, Domain Services
Start with strategic — wrong boundaries make tactical patterns worthless.
Strategic DDD
Bounded Context
A bounded context is a clear boundary within which a particular model applies. The same word can mean different things in different contexts.
┌──────────────────────┐ ┌──────────────────────┐
│ Sales Context │ │ Shipping Context │
│ │ │ │
│ "Customer" = │ │ "Customer" = │
│ someone who places │ │ a delivery address │
│ orders │ │ and contact info │
│ │ │ │
│ Order has: items, │ │ Order has: address, │
│ total, discount │ │ weight, courier │
└──────────────────────┘ └──────────────────────┘Each context has its own model, its own database schema, and its own codebase. They do NOT share entity classes.
Context Map
The context map shows how bounded contexts relate:
Sales Context ──── (Customer/Supplier) ──── Inventory Context
│
│ (Published Language: OrderCreated event)
│
▼
Shipping Context ──── (Conformist) ──── External Courier APIRelationship types:
- Customer/Supplier: downstream team depends on upstream — they negotiate the API
- Conformist: downstream accepts upstream's model as-is (e.g., external API)
- Anti-Corruption Layer (ACL): downstream translates upstream's model into its own — protects domain purity
- Published Language: shared canonical format for inter-context communication (usually events)
Tactical DDD Building Blocks
Entity
An entity has an identity — two instances with the same attributes are still different objects if they have different IDs.
public class Order
{
public OrderId Id { get; private set; }
public CustomerId CustomerId { get; private set; }
public OrderStatus Status { get; private set; }
private readonly List<OrderItem> _items = [];
// Factory method — never public constructor for aggregates
public static Order Create(CustomerId customerId)
{
return new Order
{
Id = OrderId.New(),
CustomerId = customerId,
Status = OrderStatus.Draft,
};
}
// Behaviour lives on the entity, not in a service
public void AddItem(ProductId productId, int quantity, Money unitPrice)
{
if (Status != OrderStatus.Draft)
throw new DomainException("Cannot modify a confirmed order.");
var existing = _items.FirstOrDefault(i => i.ProductId == productId);
if (existing is not null)
existing.IncreaseQuantity(quantity);
else
_items.Add(new OrderItem(productId, quantity, unitPrice));
}
public void Confirm()
{
if (!_items.Any())
throw new DomainException("Cannot confirm an empty order.");
Status = OrderStatus.Confirmed;
// Raise a domain event
AddDomainEvent(new OrderConfirmedEvent(Id, CustomerId, Total));
}
public Money Total => _items.Aggregate(Money.Zero, (sum, i) => sum + i.LineTotal);
}Value Object
A value object has no identity — two instances with the same attributes are equal. They are immutable.
// Money is a value object — $10 USD == $10 USD regardless of which "instance"
public record Money(decimal Amount, string Currency)
{
public static Money Zero => new(0, "GBP");
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new DomainException($"Cannot add {Currency} and {other.Currency}.");
return new Money(Amount + other.Amount, Currency);
}
public static Money operator +(Money a, Money b) => a.Add(b);
public static Money operator *(Money price, int qty) => new(price.Amount * qty, price.Currency);
public override string ToString() => $"{Amount:F2} {Currency}";
}// Address is a value object — two addresses with same fields are the same address
public record Address(string Street, string City, string PostalCode, string Country)
{
// Validation in the factory
public static Address Create(string street, string city, string postalCode, string country)
{
if (string.IsNullOrWhiteSpace(street)) throw new DomainException("Street is required.");
if (string.IsNullOrWhiteSpace(postalCode)) throw new DomainException("Postal code is required.");
return new(street.Trim(), city.Trim(), postalCode.Trim(), country.Trim());
}
}Aggregate
An aggregate is a cluster of entities and value objects treated as a single unit. The aggregate root is the entry point — nothing outside the aggregate touches inner objects directly.
┌──────────────────────────────────────────────┐
│ Order Aggregate │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ Order (Aggregate Root) │ │
│ │ - OrderId, Status, CustomerId, Total │ │
│ └─────────────────────────────────────────┘ │
│ │ 1..* │
│ ┌─────────────────────────────────────────┐ │
│ │ OrderItem (Entity inside aggregate) │ │
│ │ - ProductId, Quantity, UnitPrice │ │
│ └─────────────────────────────────────────┘ │
│ │ 1 │
│ ┌─────────────────────────────────────────┐ │
│ │ Address (Value Object) │ │
│ │ - Street, City, PostalCode, Country │ │
│ └─────────────────────────────────────────┘ │
└──────────────────────────────────────────────┘Rules:
- Load the entire aggregate from the repository — never partial
- Only the aggregate root has a repository
- Reference other aggregates by ID only, never by object reference
- All business rules that span multiple entities inside an aggregate live on the root
// OrderItem — inside the aggregate, no public constructor
public class OrderItem
{
public ProductId ProductId { get; private set; }
public int Quantity { get; private set; }
public Money UnitPrice { get; private set; }
internal OrderItem(ProductId productId, int quantity, Money unitPrice)
{
if (quantity <= 0) throw new DomainException("Quantity must be positive.");
ProductId = productId;
Quantity = quantity;
UnitPrice = unitPrice;
}
internal void IncreaseQuantity(int by) => Quantity += by;
public Money LineTotal => UnitPrice * Quantity;
}Domain Events
Domain events capture something that happened in the domain that other parts of the system may care about.
public record OrderConfirmedEvent(
OrderId OrderId,
CustomerId CustomerId,
Money Total
) : IDomainEvent;
// Base class for aggregate event raising
public abstract class AggregateRoot
{
private readonly List<IDomainEvent> _events = [];
protected void AddDomainEvent(IDomainEvent e) => _events.Add(e);
public IReadOnlyList<IDomainEvent> PopDomainEvents()
{
var copy = _events.ToList();
_events.Clear();
return copy;
}
}Dispatch domain events after SaveChanges:
public class AppDbContext : DbContext
{
private readonly IMediator _mediator;
public override async Task<int> SaveChangesAsync(CancellationToken ct = default)
{
var result = await base.SaveChangesAsync(ct);
// Dispatch events after the DB write succeeds
var aggregates = ChangeTracker.Entries<AggregateRoot>()
.Select(e => e.Entity)
.Where(a => a.HasDomainEvents);
var events = aggregates.SelectMany(a => a.PopDomainEvents()).ToList();
foreach (var @event in events)
await _mediator.Publish(@event, ct);
return result;
}
}Repository
Repositories provide collection-like access to aggregates. They abstract the persistence mechanism from the domain.
// Domain layer — no EF Core reference
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct = default);
Task<IReadOnlyList<Order>> GetByCustomerAsync(CustomerId customerId, CancellationToken ct = default);
Task AddAsync(Order order, CancellationToken ct = default);
void Update(Order order);
}// Infrastructure layer — EF Core implementation
public class OrderRepository : IOrderRepository
{
private readonly AppDbContext _db;
public OrderRepository(AppDbContext db) => _db = db;
public async Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct = default)
=> await _db.Orders
.Include(o => o.Items) // always load the full aggregate
.FirstOrDefaultAsync(o => o.Id == id, ct);
public async Task AddAsync(Order order, CancellationToken ct = default)
=> await _db.Orders.AddAsync(order, ct);
public void Update(Order order)
=> _db.Orders.Update(order);
}Domain Service
A domain service encapsulates business logic that doesn't naturally belong to a single entity.
// Pricing logic spans multiple aggregates — belongs in a domain service
public interface IPricingService
{
Money CalculateOrderTotal(Order order, Customer customer);
}
public class PricingService : IPricingService
{
public Money CalculateOrderTotal(Order order, Customer customer)
{
var subtotal = order.Items.Aggregate(Money.Zero, (sum, i) => sum + i.LineTotal);
// Business rule: VIP customers get 10% off orders over £100
if (customer.IsVip && subtotal.Amount > 100)
subtotal = new Money(subtotal.Amount * 0.9m, subtotal.Currency);
return subtotal;
}
}DDD + Clean Architecture
DDD tactical patterns map cleanly to Clean Architecture layers:
Domain Layer → Entities, Value Objects, Aggregates, Domain Events, Repository Interfaces
Application Layer → Use Cases (Commands/Queries), Domain Service calls
Infrastructure Layer→ Repository Implementations (EF Core), External APIs
Presentation Layer → Controllers, Minimal API endpointssrc/
├── Domain/
│ ├── Orders/
│ │ ├── Order.cs ← Aggregate Root
│ │ ├── OrderItem.cs ← Entity (inside aggregate)
│ │ ├── OrderStatus.cs ← Enum
│ │ ├── Events/
│ │ │ └── OrderConfirmedEvent.cs
│ │ └── Repositories/
│ │ └── IOrderRepository.cs
│ └── Shared/
│ ├── Money.cs ← Value Object
│ └── AggregateRoot.cs
│
├── Application/
│ └── Orders/
│ └── Commands/
│ └── ConfirmOrder/
│ ├── ConfirmOrderCommand.cs
│ └── ConfirmOrderCommandHandler.cs
│
├── Infrastructure/
│ └── Persistence/
│ ├── AppDbContext.cs
│ └── Repositories/
│ └── OrderRepository.cs
│
└── Api/
└── Controllers/
└── OrdersController.csCommon DDD Mistakes
1. Anemic domain model:
// ❌ Entity with no behaviour — just a data bag
public class Order
{
public int Id { get; set; }
public string Status { get; set; }
public List<OrderItem> Items { get; set; }
}
// Business logic dumped into service
public class OrderService
{
public void ConfirmOrder(Order order)
{
if (!order.Items.Any()) throw new Exception("Empty");
order.Status = "Confirmed"; // service manipulates entity directly
}
}// ✅ Rich domain model — behaviour on the entity
public class Order : AggregateRoot
{
public void Confirm()
{
if (!_items.Any()) throw new DomainException("Cannot confirm empty order.");
_status = OrderStatus.Confirmed;
AddDomainEvent(new OrderConfirmedEvent(Id));
}
}2. Aggregate too large:
// ❌ Everything in one aggregate — loads all customer orders on every operation
public class Customer : AggregateRoot
{
public List<Order> Orders { get; } = []; // could be thousands
public List<Address> Addresses { get; } = [];
public List<PaymentMethod> PaymentMethods { get; } = [];
}Keep aggregates small. Load only what you need to enforce an invariant.
3. Sharing entities across bounded contexts:
// ❌ Sales context and Shipping context both reference the same Order entity
// Changes to one context break the other
// ✅ Each context has its own Order model:
// Sales.Order — items, total, discounts
// Shipping.Order — delivery address, weight, courier, tracking
// They're linked by ID onlyKey Takeaways
- Bounded Contexts are the most important concept — get the boundaries wrong and no tactical pattern saves you
- Aggregates enforce invariants — keep them small, load them fully, modify only through the root
- Value Objects eliminate primitive obsession — use
Money,Address,Emailinstead ofdecimal,string,string - Domain Events decouple within and across bounded contexts — handlers react without the aggregate knowing who's listening
- Rich domain models put behaviour on entities — avoid anemic models where services manipulate dumb data bags
- DDD is worth the investment when the domain is complex — don't apply it to CRUD apps or simple forms