| name | ddd |
| description | Domain-Driven Design patterns for .NET — aggregates, value objects, strongly-typed IDs, domain events, repositories, and layer rules. |
Domain-Driven Design Patterns
Reference material for applying DDD tactical patterns in .NET. Used by dnp-architect and dnp-planner.
When to Use DDD
Use DDD when: 3+ aggregates with complex business rules, cross-aggregate invariants that need enforcement, or event sourcing is being considered. For simpler CRUD-heavy APIs, Vertical Slice Architecture is lower ceremony. See knowledge/decisions/adr-005-multi-architecture.md.
Aggregate Root Base
All changes to an aggregate go through the root — never modify child entities directly from outside.
public abstract class AggregateRoot<TId>
{
private readonly List<IDomainEvent> _events = [];
public TId Id { get; protected set; } = default!;
public IReadOnlyList<IDomainEvent> DomainEvents => _events.AsReadOnly();
protected void Raise(IDomainEvent @event) => _events.Add(@event);
public void ClearEvents() => _events.Clear();
}
Aggregate Example
public sealed class Order : AggregateRoot<OrderId>
{
private readonly List<OrderItem> _items = [];
public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
public CustomerId CustomerId { get; private set; } = null!;
public OrderStatus Status { get; private set; }
public Money Total { get; private set; } = Money.Zero;
private Order() { }
public static Order Create(CustomerId customerId, IReadOnlyList<CreateOrderItemData> items)
{
ArgumentNullException.ThrowIfNull(customerId);
if (items.Count == 0) throw new DomainException("Order must have at least one item.");
var order = new Order
{
Id = OrderId.New(),
CustomerId = customerId,
Status = OrderStatus.Pending
};
foreach (var item in items)
order._items.Add(OrderItem.Create(item));
order.Total = order._items.Aggregate(Money.Zero, (sum, i) => sum + i.LineTotal);
order.Raise(new OrderCreatedEvent(order.Id, customerId));
return order;
}
public Result<Unit, OrderError> Cancel()
{
if (Status == OrderStatus.Shipped)
return OrderError.CannotCancelShippedOrder;
Status = OrderStatus.Cancelled;
Raise(new OrderCancelledEvent(Id));
return Result.Success(Unit.Value);
}
public Result<Unit, OrderError> AddItem(OrderItem item)
{
if (Status != OrderStatus.Pending)
return OrderError.CannotModifyNonPendingOrder;
_items.Add(item);
Total = Total + item.LineTotal;
return Result.Success(Unit.Value);
}
}
Key rules:
- Private setters on all properties
- Private or protected parameterless constructor for EF Core
- Factory methods (
Create) enforce invariants
- Business methods return
Result<TValue, TError> — never throw for expected failures
Value Objects
Immutable; equality by value, not identity. Use records.
public sealed record Money(decimal Amount, string Currency)
{
public static readonly Money Zero = new(0, "USD");
public static Money Of(decimal amount, string currency)
{
if (amount < 0) throw new DomainException("Money amount cannot be negative.");
return new(amount, currency);
}
public static Money operator +(Money a, Money b)
{
if (a.Currency != b.Currency) throw new DomainException("Cannot add different currencies.");
return new(a.Amount + b.Amount, a.Currency);
}
public override string ToString() => $"{Amount:F2} {Currency}";
}
Strongly-Typed IDs
Wrap Guid (or int) to prevent mixing IDs across types at compile time.
public sealed record OrderId(Guid Value)
{
public static OrderId New() => new(Guid.NewGuid());
public static OrderId From(Guid value) => new(value);
public override string ToString() => Value.ToString();
}
EF Core Value Converter
Configure in the entity's IEntityTypeConfiguration:
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.HasKey(o => o.Id);
builder.Property(o => o.Id)
.HasConversion(id => id.Value, value => OrderId.From(value));
builder.Property(o => o.CustomerId)
.HasConversion(id => id.Value, value => CustomerId.From(value));
builder.Property(o => o.Total)
.HasConversion(m => m.Amount, value => Money.Of(value, "USD"))
.HasColumnName("TotalAmount");
builder.OwnsMany(o => o.Items, items =>
{
items.WithOwner().HasForeignKey("OrderId");
items.Property(i => i.ProductId)
.HasConversion(id => id.Value, value => ProductId.From(value));
});
}
}
Domain Events
Raise events inside aggregates; publish after SaveChangesAsync completes so the DB write is committed first.
public sealed record OrderCreatedEvent(OrderId OrderId, CustomerId CustomerId) : IDomainEvent
{
public DateTime OccurredAt { get; } = DateTime.UtcNow;
}
Publishing after save (in a SaveChangesInterceptor or Application layer):
public class DomainEventPublisher(IPublisher publisher)
{
public async Task PublishAndClearAsync(IEnumerable<AggregateRoot<object>> aggregates, CancellationToken ct)
{
var events = aggregates.SelectMany(a => a.DomainEvents).ToList();
foreach (var aggregate in aggregates)
aggregate.ClearEvents();
foreach (var @event in events)
await publisher.Publish(@event, ct);
}
}
Repository Interface
Define in the Domain layer. Implement in Infrastructure. The interface must not expose IQueryable — that leaks EF Core into the domain.
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct);
Task<IReadOnlyList<Order>> GetByCustomerAsync(CustomerId customerId, CancellationToken ct);
void Add(Order order);
void Remove(Order order);
}
Infrastructure implementation:
public class OrderRepository(AppDbContext db) : IOrderRepository
{
public Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct)
=> db.Orders.Include(o => o.Items).FirstOrDefaultAsync(o => o.Id == id, ct);
public Task<IReadOnlyList<Order>> GetByCustomerAsync(CustomerId customerId, CancellationToken ct)
=> db.Orders.Where(o => o.CustomerId == customerId).ToListAsync(ct)
.ContinueWith(t => (IReadOnlyList<Order>)t.Result, ct);
public void Add(Order order) => db.Orders.Add(order);
public void Remove(Order order) => db.Orders.Remove(order);
}
Domain Services
For logic that doesn't belong to a single aggregate:
public class PricingService(IProductRepository products)
{
public async Task<Money> CalculateTotalAsync(
IReadOnlyList<OrderItem> items,
CancellationToken ct)
{
var total = Money.Zero;
foreach (var item in items)
{
var product = await products.GetByIdAsync(item.ProductId, ct)
?? throw new DomainException($"Product {item.ProductId} not found.");
total = total + Money.Of(product.Price.Amount * item.Quantity, product.Price.Currency);
}
return total;
}
}
Layer Rules
| Layer | Allowed dependencies | Forbidden |
|---|
| Domain | None | EF Core, HTTP, Polly, FluentValidation, MediatR |
| Application | Domain | EF Core (DbContext), HTTP, framework types |
| Infrastructure | Domain, Application | Domain interfaces must flow only inward |
| API | Application | Domain (except DTOs) |
Enforce via dotnet-pilot's architecture check: mcp__roslyn__check_architecture_violations.
Result Pattern
Prefer Result<TValue, TError> over exceptions for expected domain failures:
public readonly struct Result<TValue, TError>
{
private readonly TValue? _value;
private readonly TError? _error;
public bool IsSuccess { get; }
private Result(TValue value) { _value = value; IsSuccess = true; _error = default; }
private Result(TError error) { _error = error; IsSuccess = false; _value = default; }
public static Result<TValue, TError> Success(TValue value) => new(value);
public static implicit operator Result<TValue, TError>(TError error) => new(error);
public TResult Match<TResult>(Func<TValue, TResult> onSuccess, Func<TError, TResult> onFailure)
=> IsSuccess ? onSuccess(_value!) : onFailure(_error!);
}
See Also
knowledge/decisions/adr-005-multi-architecture.md — when to choose DDD vs VSA vs Clean Architecture
skills/clean-architecture/SKILL.md — layer wiring and project reference rules
skills/ef-core-patterns/SKILL.md — EF Core configuration for DDD entities