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 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:
// Domain/Orders/Order.cspublicsealedclassOrder : AggregateRoot
{
privatereadonly List<OrderLine> _lines = [];
privateOrder() { } // EF Corepublic OrderNumber Number { get; privateset; } = null!;
public CustomerId CustomerId { get; privateset; }
public Money Total { get; privateset; } = Money.Zero("USD");
public OrderStatus Status { get; privateset; }
public DateTimeOffset PlacedAt { get; privateset; }
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();
publicstatic 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 isnot 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 isnotnull)
{
existing.IncreaseQuantity(quantity);
}
else
{
_lines.Add(new OrderLine(productId, quantity, unitPrice));
}
RecalculateTotal();
return Result.Success();
}
public Result Confirm()
{
if (Status isnot 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();
}
privatevoidRecalculateTotal()
{
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:
// Domain/Common/Money.cspublicsealedrecordMoney
{
publicdecimal Amount { get; }
publicstring Currency { get; }
publicMoney(decimal amount, string currency)
{
ArgumentOutOfRangeException.ThrowIfNegative(amount);
ArgumentException.ThrowIfNullOrWhiteSpace(currency);
Amount = amount;
Currency = currency.ToUpperInvariant();
}
publicstatic Money Zero(string currency) => new(0, currency);
publicstatic Money operator +(Money left, Money right)
{
if (left.Currency != right.Currency)
thrownew InvalidOperationException($"Cannot add {left.Currency} and {right.Currency}");
returnnew Money(left.Amount + right.Amount, left.Currency);
}
}
// Other value objects (EmailAddress, OrderNumber, etc.) follow the same pattern:// sealed record, constructor validation, no public setters
Raise events in the aggregate, dispatch in SaveChangesAsync:
// Domain/Common/AggregateRoot.cspublicabstractclassAggregateRoot : Entity
{
privatereadonly List<IDomainEvent> _domainEvents = [];
public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
protectedvoidRaiseDomainEvent(IDomainEvent domainEvent) => _domainEvents.Add(domainEvent);
publicvoidClearDomainEvents() => _domainEvents.Clear();
}
// INotification comes from the MIT-licensed Mediator package (not MediatR —// see the packages rule). Use a plain marker interface if you don't use a mediator.publicinterfaceIDomainEvent : INotification
{
DateTimeOffset OccurredAt { get; }
}
// Domain/Orders/Events/OrderPlaced.cspublicsealedrecordOrderPlaced(Guid OrderId, CustomerId CustomerId, DateTimeOffset PlacedAt) : IDomainEvent
{
public DateTimeOffset OccurredAt => PlacedAt;
}
// Infrastructure/Persistence/AppDbContext.cs — publisher injected via primary constructorpublicclassAppDbContext(DbContextOptions<AppDbContext> options, IPublisher publisher)
: DbContext(options)
{
publicoverrideasync 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 = awaitbase.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:
// Domain/Orders/Services/PricingService.cs// Coordinates logic across aggregates — takes domain interfaces, returns value objectspublicsealedclassPricingService(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);
returnnew Money(subtotal.Amount * (1 - discount), subtotal.Currency);
}
}
Anti-patterns
Oversized Aggregates
// BAD — Customer aggregate owns everything the customer touchespublicclassCustomer : AggregateRoot
{
public List<Order> Orders { get; } = []; // should be separate aggregatepublic List<Payment> Payments { get; } = []; // should be separate aggregatepublic List<Address> Addresses { get; } = []; // might be OK as childpublic ShoppingCart Cart { get; set; } // should be separate aggregate
}
// GOOD — small, focused aggregates linked by IDpublicclassCustomer : AggregateRoot
{
public CustomerName Name { get; privateset; }
public EmailAddress Email { get; privateset; }
// Orders, Payments, Cart are separate aggregates referencing CustomerId
}
Domain Events for Intra-Aggregate Logic
// BAD — using events for logic within the same aggregate
order.RaiseDomainEvent(new OrderLineAdded(line));
// Then a handler recalculates the total... but you're in the same aggregate!// GOOD — just call the method directly within the aggregate
_lines.Add(line);
RecalculateTotal(); // private method, no event needed
Value Objects with Identity
// BAD — value object with an Id (it's an entity then!)publicrecordAddress
{
public Guid Id { get; init; } // value objects don't have identitypublicstring Street { get; init; }
}
// GOOD — value objects are defined by their attributes, not an IdpublicrecordAddress(string Street, string City, string PostalCode, string Country);
Anemic Aggregates
// BAD — aggregate is just a data bag, service does all the workpublicclassOrder : AggregateRoot
{
public OrderStatus Status { get; set; } // public setter!public List<OrderLine> Lines { get; set; } = [];
}
// Service directly manipulates order state
order.Status = OrderStatus.Confirmed; // no invariant check!
order.Lines.Add(newLine); // no validation!// GOOD — aggregate encapsulates rules (see Aggregate Root pattern above)
order.Confirm(); // validates status, raises event
order.AddLine(productId, quantity, unitPrice); // validates, recalculates
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