.NET & C# Development · Lesson 58 of 92
DDD in .NET — Rich Domain Models That Express Business Rules
Ubiquitous Language and Bounded Contexts
DDD starts with language. Every term in your code should match what the business calls it — not a programmer approximation. If the business says "fulfil an order", your method is order.Fulfil(), not order.SetStatusToFulfilled().
A bounded context is a boundary inside which a particular model applies. Your Order in the Sales context has a buyer name and line items. The same Order in the Shipping context has a delivery address and a carrier. These are different classes with different responsibilities — don't try to merge them.
Entities vs Value Objects
An entity has identity that persists over time. Two Customer objects with different IDs are different customers even if every other field matches.
A value object has no identity — it is defined entirely by its values. Two Money(100, "GBP") objects are equal. Use C# record for value objects:
public record Money(decimal Amount, string Currency)
{
public static Money Zero(string currency) => new(0, currency);
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException("Cannot add different currencies.");
return new Money(Amount + other.Amount, Currency);
}
public Money Multiply(int quantity) => new(Amount * quantity, Currency);
}
public record Address(string Street, string City, string PostCode, string Country);Value objects are immutable by design. EF Core 8+ supports owned types for persisting them without a separate table.
Aggregates and Aggregate Roots
An aggregate is a cluster of domain objects treated as a single unit for data changes. The aggregate root is the only entry point — all invariants are enforced through it.
Order is the root. OrderItem only exists within an Order. Nothing outside should hold a reference to OrderItem and mutate it directly.
public class Order : AggregateRoot
{
public int Id { get; private set; }
public string CustomerId { get; private set; }
public OrderStatus Status { get; private set; }
public Money Total { get; private set; }
private readonly List<OrderItem> _items = new();
public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();
private Order() { } // EF Core needs this
public static Order Create(string customerId)
{
ArgumentException.ThrowIfNullOrEmpty(customerId);
var order = new Order
{
CustomerId = customerId,
Status = OrderStatus.Draft,
Total = Money.Zero("GBP")
};
order.RaiseDomainEvent(new OrderCreatedEvent(order));
return order;
}
public void AddItem(int productId, string productName, Money unitPrice, int qty)
{
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Cannot modify a non-draft order.");
var existing = _items.FirstOrDefault(i => i.ProductId == productId);
if (existing is not null)
existing.IncreaseQuantity(qty);
else
_items.Add(OrderItem.Create(productId, productName, unitPrice, qty));
RecalculateTotal();
}
public void Submit()
{
if (!_items.Any())
throw new InvalidOperationException("Cannot submit an empty order.");
if (Status != OrderStatus.Draft)
throw new InvalidOperationException("Order is already submitted.");
Status = OrderStatus.Submitted;
RaiseDomainEvent(new OrderSubmittedEvent(Id, CustomerId, Total));
}
private void RecalculateTotal() =>
Total = _items.Aggregate(Money.Zero("GBP"),
(sum, item) => sum.Add(item.UnitPrice.Multiply(item.Quantity)));
}
public class OrderItem
{
public int Id { get; private set; }
public int ProductId { get; private set; }
public string ProductName { get; private set; }
public Money UnitPrice { get; private set; }
public int Quantity { get; private set; }
private OrderItem() { }
internal static OrderItem Create(int productId, string name, Money price, int qty) => new()
{
ProductId = productId,
ProductName = name,
UnitPrice = price,
Quantity = qty
};
internal void IncreaseQuantity(int qty)
{
if (qty <= 0) throw new ArgumentException("Quantity must be positive.");
Quantity += qty;
}
}internal on OrderItem.Create and IncreaseQuantity enforces that only Order (same assembly) can create or modify items.
Domain Events
Domain events communicate that something meaningful happened in the domain. They are raised inside the aggregate and dispatched after the transaction commits.
public interface IDomainEvent { }
public record OrderCreatedEvent(Order Order) : IDomainEvent;
public record OrderSubmittedEvent(int OrderId, string CustomerId, Money Total) : IDomainEvent;
public abstract class AggregateRoot
{
private readonly List<IDomainEvent> _events = new();
public IReadOnlyCollection<IDomainEvent> DomainEvents => _events.AsReadOnly();
protected void RaiseDomainEvent(IDomainEvent domainEvent) => _events.Add(domainEvent);
public void ClearDomainEvents() => _events.Clear();
}Dispatching Events After SaveChanges
Override SaveChangesAsync in your DbContext to dispatch after the transaction succeeds:
public class AppDbContext(DbContextOptions options, IPublisher publisher) : DbContext(options)
{
public DbSet<Order> Orders => Set<Order>();
public override async Task<int> SaveChangesAsync(CancellationToken ct = default)
{
var result = await base.SaveChangesAsync(ct);
var events = ChangeTracker.Entries<AggregateRoot>()
.SelectMany(e => e.Entity.DomainEvents)
.ToList();
foreach (var entry in ChangeTracker.Entries<AggregateRoot>())
entry.Entity.ClearDomainEvents();
foreach (var domainEvent in events)
await publisher.Publish(domainEvent, ct); // MediatR IPublisher
return result;
}
}Event handlers are MediatR INotificationHandler<T> — completely decoupled from the domain.
public class OrderSubmittedHandler(IEmailService email)
: INotificationHandler<OrderSubmittedEvent>
{
public async Task Handle(OrderSubmittedEvent notification, CancellationToken ct)
{
await email.SendOrderConfirmationAsync(notification.CustomerId, notification.OrderId, ct);
}
}Repositories for Aggregates Only
Repositories abstract persistence for aggregates — not for every entity. You do not have an OrderItemRepository. You load and save Order and its items travel with it.
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(int id, CancellationToken ct = default);
void Add(Order order);
}
public class OrderRepository(AppDbContext db) : IOrderRepository
{
public Task<Order?> GetByIdAsync(int id, CancellationToken ct) =>
db.Orders.Include(o => o.Items).FirstOrDefaultAsync(o => o.Id == id, ct);
public void Add(Order order) => db.Orders.Add(order);
}Domain Services
When a business operation involves logic that doesn't belong to a single aggregate, put it in a domain service.
// Checking inventory for an order involves two aggregates: Order and Inventory
public class OrderFulfillmentService(IInventoryRepository inventory)
{
public async Task<bool> CanFulfil(Order order, CancellationToken ct)
{
foreach (var item in order.Items)
{
var stock = await inventory.GetStockAsync(item.ProductId, ct);
if (stock < item.Quantity) return false;
}
return true;
}
}The Anemic Domain Model Anti-Pattern
An anemic model has data but no behaviour — all logic lives in services. The class is just a bag of properties.
// Anemic — don't do this
public class Order
{
public int Id { get; set; }
public OrderStatus Status { get; set; }
public List<OrderItem> Items { get; set; } = new();
}
// Business logic scattered in a service
public class OrderService
{
public void Submit(Order order)
{
if (!order.Items.Any()) throw new InvalidOperationException("...");
order.Status = OrderStatus.Submitted; // mutating state from outside
}
}This defeats the purpose of OOP. Invariants are unprotected. Any code anywhere can set Status = OrderStatus.Cancelled without going through business rules. Put behaviour where the data lives.