| name | ddd |
| description | Domain-Driven Design tactical patterns for .NET applications. Covers aggregates, aggregate roots, value objects, domain events, domain services, strongly-typed IDs, and repository patterns for aggregate persistence. Load this skill when implementing DDD, working with aggregates, value objects, domain events, bounded contexts, or when the architecture-advisor recommends DDD + Clean Architecture. Pair with the clean-architecture skill.
|
Domain-Driven Design (DDD)
Core Principles
- Aggregates define consistency boundaries — An aggregate is a cluster of entities and value objects treated as a single unit for data changes. All invariants within an aggregate are enforced in a single transaction. Cross-aggregate consistency is eventual.
- Value objects over primitives — Replace primitive obsession with value objects.
Money, EmailAddress, OrderNumber are not strings — they carry validation, equality, and behavior. Use C# records for immutable value objects.
- Domain events decouple side effects — When something meaningful happens in the domain (OrderPlaced, PaymentReceived), raise a domain event. Side effects (send email, update read model, notify another aggregate) subscribe to these events. The aggregate stays focused on its own rules.
- Aggregate root is the sole entry point — External code accesses an aggregate only through its root entity. Child entities are never loaded or modified independently. The root enforces all invariants for the entire aggregate.
- Repositories persist aggregates, not entities — One repository per aggregate root. The repository loads and saves the entire aggregate as a unit. No repository for child entities. The Infrastructure implementation uses
DbContext internally — this is a DDD tactical pattern for aggregate boundaries, not a generic CRUD wrapper.
Patterns
Aggregate Root
The aggregate root owns all access to its children and enforces invariants:
public sealed class Order : AggregateRoot
{
private readonly List<OrderLine> _lines = [];
private Order() { }
public OrderNumber Number { get; private set; } = null!;
public CustomerId CustomerId { get; private set; }
public Money Total { get; private set; } = Money.Zero("USD");
public OrderStatus Status { get; private set; }
public DateTimeOffset PlacedAt { get; private set; }
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();
public static Order Place(CustomerId customerId, OrderNumber number, DateTimeOffset now)
{
var order = new Order
{
Id = Guid.CreateVersion7(),
CustomerId = customerId,
Number = number,
Status = OrderStatus.Placed,
PlacedAt = now
};
order.RaiseDomainEvent(new OrderPlaced(order.Id, customerId, now));
return order;
}
public Result AddLine(ProductId productId, int quantity, Money unitPrice)
{
if (Status is not OrderStatus.Placed)
return Result.Failure("Cannot modify a confirmed or cancelled order");
if (quantity <= 0)
return Result.Failure("Quantity must be positive");
var existing = _lines.FirstOrDefault(l => l.ProductId == productId);
if (existing is not null)
{
existing.IncreaseQuantity(quantity);
}
else
{
_lines.Add(new OrderLine(productId, quantity, unitPrice));
}
RecalculateTotal();
return Result.Success();
}
public Result Confirm()
{
if (Status is not OrderStatus.Placed)
return Result.Failure("Only placed orders can be confirmed");
if (_lines.Count == 0)
return Result.Failure("Cannot confirm an order with no lines");
Status = OrderStatus.Confirmed;
RaiseDomainEvent(new OrderConfirmed(Id));
return Result.Success();
}
private void RecalculateTotal()
{
Total = _lines.Aggregate(Money.Zero(Total.Currency), (sum, line) => sum + line.Subtotal);
}
}
Value Objects as Records
Use C# records for immutable value objects with structural equality:
public sealed record Money
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
ArgumentOutOfRangeException.ThrowIfNegative(amount);
ArgumentException.ThrowIfNullOrWhiteSpace(currency);
Amount = amount;
Currency = currency.ToUpperInvariant();
}
public static Money Zero(string currency) => new(0, currency);
public static Money operator +(Money left, Money right)
{
if (left.Currency != right.Currency)
throw new InvalidOperationException($"Cannot add {left.Currency} and {right.Currency}");
return new Money(left.Amount + right.Amount, left.Currency);
}
}
Strongly-Typed IDs with EF Core Converters
Prevent mixing up GUIDs from different entities:
public readonly record struct CustomerId(Guid Value)
{
public static CustomerId New() => new(Guid.CreateVersion7());
public override string ToString() => Value.ToString();
}
public readonly record struct ProductId(Guid Value)
{
public static ProductId New() => new(Guid.CreateVersion7());
}
public readonly record struct OrderNumber(string Value)
{
public override string ToString() => Value;
}
public class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.HasKey(o => o.Id);
builder.Property(o => o.CustomerId)
.HasConversion(id => id.Value, value => new CustomerId(value));
builder.Property(o => o.Number)
.HasConversion(n => n.Value, value => new OrderNumber(value))
.HasMaxLength(50);
builder.ComplexProperty(o => o.Total, money =>
{
money.Property(m => m.Amount).HasColumnName("Total").HasPrecision(18, 2);
money.Property(m => m.Currency).HasColumnName("Currency").HasMaxLength(3);
});
builder.HasMany(o => o.Lines).WithOne().HasForeignKey("OrderId");
builder.Navigation(o => o.Lines).AutoInclude();
}
}
Domain Event Dispatching
Raise events in the aggregate, dispatch in SaveChangesAsync:
public abstract class AggregateRoot : Entity
{
private readonly List<IDomainEvent> _domainEvents = [];
public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
protected void RaiseDomainEvent(IDomainEvent domainEvent) => _domainEvents.Add(domainEvent);
public void ClearDomainEvents() => _domainEvents.Clear();
}
public interface IDomainEvent : INotification
{
DateTimeOffset OccurredAt { get; }
}
public sealed record OrderPlaced(Guid OrderId, CustomerId CustomerId, DateTimeOffset PlacedAt) : IDomainEvent
{
public DateTimeOffset OccurredAt => PlacedAt;
}
public override async Task<int> SaveChangesAsync(CancellationToken ct = default)
{
var aggregates = ChangeTracker.Entries<AggregateRoot>()
.Where(e => e.Entity.DomainEvents.Count > 0)
.Select(e => e.Entity)
.ToList();
var events = aggregates.SelectMany(a => a.DomainEvents).ToList();
var result = await base.SaveChangesAsync(ct);
foreach (var @event in events)
await _publisher.Publish(@event, ct);
foreach (var aggregate in aggregates)
aggregate.ClearDomainEvents();
return result;
}
Domain Services
For logic that does not belong to a single aggregate:
public sealed class PricingService(IDiscountPolicy discountPolicy)
{
public Money CalculatePrice(ProductId productId, int quantity, Money unitPrice, CustomerId customerId)
{
var subtotal = new Money(unitPrice.Amount * quantity, unitPrice.Currency);
var discount = discountPolicy.GetDiscount(customerId, productId, quantity);
return new Money(subtotal.Amount * (1 - discount), subtotal.Currency);
}
}
Anti-patterns
Oversized Aggregates
public class Customer : AggregateRoot
{
public List<Order> Orders { get; } = [];
public List<Payment> Payments { get; } = [];
public List<Address> Addresses { get; } = [];
public ShoppingCart Cart { get; set; }
}
public class Customer : AggregateRoot
{
public CustomerName Name { get; private set; }
public EmailAddress Email { get; private set; }
}
Domain Events for Intra-Aggregate Logic
order.RaiseDomainEvent(new OrderLineAdded(line));
_lines.Add(line);
RecalculateTotal();
Value Objects with Identity
public record Address
{
public Guid Id { get; init; }
public string Street { get; init; }
}
public record Address(string Street, string City, string PostalCode, string Country);
Anemic Aggregates
public class Order : AggregateRoot
{
public OrderStatus Status { get; set; }
public List<OrderLine> Lines { get; set; } = [];
}
order.Status = OrderStatus.Confirmed;
order.Lines.Add(newLine);
order.Confirm();
order.AddLine(productId, quantity, unitPrice);
Decision Guide
| Scenario | Recommendation |
|---|
| When to use DDD | Complex domain with business rules that go beyond CRUD |
| When to use value objects | Any concept with validation rules or equality based on attributes, not identity |
| Aggregate size | Keep small — typically 1 root entity + 0-3 child entities. Load the whole aggregate every time |
| Domain events vs integration events | Domain events: within bounded context, same transaction. Integration events: cross-context, via message bus |
| Strongly-typed IDs | Always for aggregate root IDs that cross boundaries. Optional for child entity IDs |
| When NOT to use DDD | Simple CRUD, settings, audit logs, read models — use plain entities |
| Repository vs DbContext | Repository per aggregate root for complex aggregates; IAppDbContext for simpler queries |
| Domain services | Only when logic requires multiple aggregates or external data the aggregate should not know about |