Domain-Driven Design (DDD)
Domain-Driven Design (DDD) is an approach to software development that centers the design around the business domain. Introduced by Eric Evans, it provides vocabulary and patterns for tackling complex domains.
Strategic Design
Section titled “Strategic Design”Bounded Context
Section titled “Bounded Context”A Bounded Context is an explicit boundary within which a domain model applies. The same term can mean different things in different contexts.
┌─────────────────────┐ ┌─────────────────────┐│ Order Context │ │ Shipping Context ││ │ │ ││ Customer: buyer │ │ Customer: recipient││ Product: item │ │ Product: package ││ Order: purchase │ │ Order: shipment │└─────────────────────┘ └─────────────────────┘Ubiquitous Language
Section titled “Ubiquitous Language”Use the same terms everywhere — in conversations, code, tests, and documentation. If the business says “fulfil an order”, the code should say order.Fulfil(), not order.UpdateStatus(OrderStatus.Fulfilled).
Tactical Design Building Blocks
Section titled “Tactical Design Building Blocks”Entity
Section titled “Entity”Has a unique identity that persists over time. Two entities with the same attributes are still different if their IDs differ.
public class Order{ public Guid Id { get; private set; } public OrderStatus Status { get; private set; } private readonly List<OrderLine> _lines = new();
public Order(Guid customerId) { Id = Guid.NewGuid(); CustomerId = customerId; Status = OrderStatus.Draft; }
public void AddLine(ProductId productId, Money price, int quantity) { if (Status != OrderStatus.Draft) throw new InvalidOperationException("Cannot modify a submitted order"); _lines.Add(new OrderLine(productId, price, quantity)); }
public void Submit() { if (!_lines.Any()) throw new InvalidOperationException("Cannot submit an empty order"); Status = OrderStatus.Submitted; AddDomainEvent(new OrderSubmitted(Id)); }}Value Object
Section titled “Value Object”Defined by its attributes, not identity. Immutable. Two value objects with the same values are equal.
public record Money(decimal Amount, string Currency){ public Money Add(Money other) { if (Currency != other.Currency) throw new InvalidOperationException("Currency mismatch"); return new Money(Amount + other.Amount, Currency); }
public Money Multiply(int factor) => new(Amount * factor, Currency);}
public record Address(string Street, string City, string PostalCode, string Country);
public record ProductId(Guid Value){ public static ProductId New() => new(Guid.NewGuid());}Aggregate
Section titled “Aggregate”A cluster of entities and value objects treated as a single unit. The Aggregate Root is the only entry point — external code never holds references to inner entities.
// Order is the aggregate rootpublic class Order // Aggregate Root{ public Guid Id { get; private set; } private readonly List<OrderLine> _lines = new(); // inner entities
// All modifications go through the root public void AddLine(ProductId productId, Money unitPrice, int qty) { var existing = _lines.FirstOrDefault(l => l.ProductId == productId); if (existing is not null) existing.IncreaseQuantity(qty); else _lines.Add(new OrderLine(productId, unitPrice, qty)); }
public Money Total => _lines.Aggregate( Money.Zero("GBP"), (sum, line) => sum.Add(line.LineTotal));}
// OrderLine is an inner entity — never accessed directly from outsideinternal class OrderLine{ internal ProductId ProductId { get; private set; } internal Money UnitPrice { get; private set; } internal int Quantity { get; private set; } internal Money LineTotal => UnitPrice.Multiply(Quantity);
internal void IncreaseQuantity(int amount) => Quantity += amount;}Repository
Section titled “Repository”Provides collection-like access to aggregates. Hides persistence details from the domain.
// Interface in the domain layerpublic interface IOrderRepository{ Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default); Task AddAsync(Order order, CancellationToken ct = default); Task<IReadOnlyList<Order>> GetByCustomerAsync(Guid customerId, CancellationToken ct = default);}
// Implementation in the infrastructure layerpublic class EfOrderRepository : IOrderRepository{ private readonly AppDbContext _db;
public EfOrderRepository(AppDbContext db) => _db = db;
public Task<Order?> GetByIdAsync(Guid id, CancellationToken ct) => _db.Orders.Include("_lines").FirstOrDefaultAsync(o => o.Id == id, ct);
public async Task AddAsync(Order order, CancellationToken ct) { await _db.Orders.AddAsync(order, ct); await _db.SaveChangesAsync(ct); }}Domain Service
Section titled “Domain Service”Logic that doesn’t naturally belong to a single entity or value object:
public class PricingService{ private readonly IDiscountRepository _discounts;
public PricingService(IDiscountRepository discounts) => _discounts = discounts;
public async Task<Money> CalculateTotalAsync(Order order, Guid customerId) { var baseTotal = order.Total; var discount = await _discounts.GetActiveDiscountForCustomerAsync(customerId); return discount is null ? baseTotal : discount.Apply(baseTotal); }}Folder Structure
Section titled “Folder Structure”src/├── Domain/ # No external dependencies│ ├── Orders/│ │ ├── Order.cs│ │ ├── OrderLine.cs│ │ ├── OrderStatus.cs│ │ └── IOrderRepository.cs│ └── Shared/│ ├── Money.cs│ └── Entity.cs├── Application/ # Depends on Domain only│ └── Orders/│ ├── PlaceOrderCommand.cs│ └── PlaceOrderHandler.cs└── Infrastructure/ # Depends on Application + Domain └── Persistence/ └── EfOrderRepository.csWhen to Use DDD
Section titled “When to Use DDD”Good fit for:
- Complex business logic with many rules and workflows
- Large teams needing clear domain ownership
- Long-lived systems where the domain evolves over years
Overkill for:
- Simple CRUD applications
- Data pipelines or reporting tools
- Prototypes and MVPs