Skip to content

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.

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 │
└─────────────────────┘ └─────────────────────┘

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).

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));
}
}

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());
}

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 root
public 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 outside
internal 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;
}

Provides collection-like access to aggregates. Hides persistence details from the domain.

// Interface in the domain layer
public 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 layer
public 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);
}
}

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);
}
}
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.cs

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