Domain-Driven Design & CQRS Patterns
DDD Layered Architecture
┌──────────────────────────────────────────┐
│ Presentation Layer │
│ (Blazor Web App / API Controllers) │
├──────────────────────────────────────────┤
│ Application Layer │
│ (Commands, Queries, Handlers, DTOs) │
│ (MediatR, Validation, Mapping) │
├──────────────────────────────────────────┤
│ Domain Layer │
│ (Entities, Value Objects, Aggregates) │
│ (Domain Events, Domain Services) │
│ (Repository Interfaces, Specifications) │
├──────────────────────────────────────────┤
│ Infrastructure Layer │
│ (EF Core, Repositories, Event Bus) │
│ (External Services, Messaging) │
└──────────────────────────────────────────┘
Key rule: Dependencies point inward. Domain has zero external dependencies.
Entity Base Class
public abstract class Entity
{
private int? _requestedHashCode;
private int _id;
public virtual int Id
{
get => _id;
protected set => _id = value;
}
private readonly List<INotification> _domainEvents = [];
public IReadOnlyCollection<INotification> DomainEvents => _domainEvents.AsReadOnly();
public void AddDomainEvent(INotification eventItem) => _domainEvents.Add(eventItem);
public void RemoveDomainEvent(INotification eventItem) => _domainEvents.Remove(eventItem);
public void ClearDomainEvents() => _domainEvents.Clear();
public bool IsTransient() => Id == default;
public override bool Equals(object? obj)
{
if (obj is not Entity other) return false;
if (ReferenceEquals(this, other)) return true;
if (GetType() != other.GetType()) return false;
if (other.IsTransient() || IsTransient()) return false;
return Id == other.Id;
}
public override int GetHashCode()
{
if (!IsTransient())
{
_requestedHashCode ??= Id.GetHashCode() ^ 31;
return _requestedHashCode.Value;
}
return base.GetHashCode();
}
public static bool operator ==(Entity? left, Entity? right) => Equals(left, right);
public static bool operator !=(Entity? left, Entity? right) => !Equals(left, right);
}
Value Objects
public abstract class ValueObject
{
protected abstract IEnumerable<object?> GetEqualityComponents();
public override bool Equals(object? obj)
{
if (obj is null || obj.GetType() != GetType()) return false;
var other = (ValueObject)obj;
return GetEqualityComponents().SequenceEqual(other.GetEqualityComponents());
}
public override int GetHashCode() =>
GetEqualityComponents()
.Select(x => x?.GetHashCode() ?? 0)
.Aggregate((x, y) => x ^ y);
public static bool operator ==(ValueObject? left, ValueObject? right) =>
Equals(left, right);
public static bool operator !=(ValueObject? left, ValueObject? right) =>
!Equals(left, right);
}
// Example: Address value object
public sealed class Address : ValueObject
{
public string Street { get; }
public string City { get; }
public string State { get; }
public string Country { get; }
public string ZipCode { get; }
public Address(string street, string city, string state, string country, string zipCode)
{
Street = street;
City = city;
State = state;
Country = country;
ZipCode = zipCode;
}
protected override IEnumerable<object?> GetEqualityComponents()
{
yield return Street;
yield return City;
yield return State;
yield return Country;
yield return ZipCode;
}
}
// Example: Money value object
public sealed class Money : ValueObject
{
public decimal Amount { get; }
public string Currency { get; }
public Money(decimal amount, string currency)
{
if (amount < 0) throw new ArgumentException("Amount cannot be negative");
Amount = amount;
Currency = currency ?? throw new ArgumentNullException(nameof(currency));
}
public Money Add(Money other)
{
if (Currency != other.Currency) throw new InvalidOperationException("Cannot add different currencies");
return new Money(Amount + other.Amount, Currency);
}
protected override IEnumerable<object?> GetEqualityComponents()
{
yield return Amount;
yield return Currency;
}
}
Aggregate Root
public abstract class AggregateRoot : Entity, IAggregateRoot { }
// Example: Order aggregate
public sealed class Order : AggregateRoot
{
private readonly List<OrderItem> _orderItems = [];
public IReadOnlyCollection<OrderItem> OrderItems => _orderItems.AsReadOnly();
public DateTime OrderDate { get; private set; }
public Address ShippingAddress { get; private set; } = null!;
public OrderStatus Status { get; private set; }
public int? BuyerId { get; private set; }
// Private constructor for EF Core
private Order() { }
// Factory method enforces invariants
public Order(int buyerId, Address shippingAddress)
{
BuyerId = buyerId;
ShippingAddress = shippingAddress ?? throw new ArgumentNullException(nameof(shippingAddress));
Status = OrderStatus.Submitted;
OrderDate = DateTime.UtcNow;
// Raise domain event
AddDomainEvent(new OrderStartedDomainEvent(this, buyerId));
}
public void AddOrderItem(int productId, string productName, decimal unitPrice, int units)
{
var existingItem = _orderItems.SingleOrDefault(o => o.ProductId == productId);
if (existingItem is not null)
{
existingItem.AddUnits(units);
}
else
{
var orderItem = new OrderItem(productId, productName, unitPrice, units);
_orderItems.Add(orderItem);
}
}
public void SetShippedStatus()
{
if (Status != OrderStatus.Paid)
throw new OrderingDomainException("Cannot ship an order that is not paid.");
Status = OrderStatus.Shipped;
AddDomainEvent(new OrderShippedDomainEvent(this));
}
public void SetPaidStatus()
{
if (Status != OrderStatus.Submitted)
throw new OrderingDomainException("Cannot pay for an order that is not submitted.");
Status = OrderStatus.Paid;
AddDomainEvent(new OrderPaidDomainEvent(Id));
}
public void SetCancelledStatus()
{
if (Status == OrderStatus.Shipped)
throw new OrderingDomainException("Cannot cancel a shipped order.");
Status = OrderStatus.Cancelled;
AddDomainEvent(new OrderCancelledDomainEvent(this));
}
public decimal GetTotal() => _orderItems.Sum(i => i.UnitPrice * i.Units);
}
Enumeration Class (Smart Enum)
public abstract class Enumeration : IComparable
{
public string Name { get; }
public int Id { get; }
protected Enumeration(int id, string name) => (Id, Name) = (id, name);
public override string ToString() => Name;
public static IEnumerable<T> GetAll<T>() where T : Enumeration =>
typeof(T).GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly)
.Select(f => f.GetValue(null))
.Cast<T>();
public int CompareTo(object? other) => Id.CompareTo(((Enumeration)other!).Id);
}
public sealed class OrderStatus : Enumeration
{
public static readonly OrderStatus Submitted = new(1, nameof(Submitted));
public static readonly OrderStatus AwaitingValidation = new(2, nameof(AwaitingValidation));
public static readonly OrderStatus StockConfirmed = new(3, nameof(StockConfirmed));
public static readonly OrderStatus Paid = new(4, nameof(Paid));
public static readonly OrderStatus Shipped = new(5, nameof(Shipped));
public static readonly OrderStatus Cancelled = new(6, nameof(Cancelled));
private OrderStatus(int id, string name) : base(id, name) { }
}
Domain Events
// Domain event (using MediatR INotification)
public sealed record OrderStartedDomainEvent(Order Order, int BuyerId) : INotification;
public sealed record OrderPaidDomainEvent(int OrderId) : INotification;
public sealed record OrderShippedDomainEvent(Order Order) : INotification;
// Domain event handler
public sealed class OrderStartedDomainEventHandler(
IBuyerRepository buyerRepo,
ILogger<OrderStartedDomainEventHandler> logger)
: INotificationHandler<OrderStartedDomainEvent>
{
public async Task Handle(OrderStartedDomainEvent notification, CancellationToken ct)
{
logger.LogInformation("Order started for buyer {BuyerId}", notification.BuyerId);
var buyer = await buyerRepo.FindAsync(notification.BuyerId, ct);
if (buyer is null)
{
buyer = new Buyer(notification.BuyerId);
buyerRepo.Add(buyer);
}
await buyerRepo.UnitOfWork.SaveEntitiesAsync(ct);
}
}
Repository Pattern (DDD Style)
// Repository interface in DOMAIN layer (no EF Core dependency)
public interface IOrderRepository : IRepository<Order>
{
Order Add(Order order);
Order Update(Order order);
Task<Order?> GetAsync(int orderId, CancellationToken ct = default);
}
public interface IRepository<T> where T : IAggregateRoot
{
IUnitOfWork UnitOfWork { get; }
}
public interface IUnitOfWork : IDisposable
{
Task<int> SaveChangesAsync(CancellationToken ct = default);
Task<bool> SaveEntitiesAsync(CancellationToken ct = default);
}
// Implementation in INFRASTRUCTURE layer
public sealed class OrderRepository(OrderingContext context) : IOrderRepository
{
public IUnitOfWork UnitOfWork => context;
public Order Add(Order order) => context.Orders.Add(order).Entity;
public Order Update(Order order) => context.Orders.Update(order).Entity;
public async Task<Order?> GetAsync(int orderId, CancellationToken ct = default) =>
await context.Orders
.Include(o => o.OrderItems)
.FirstOrDefaultAsync(o => o.Id == orderId, ct);
}
CQRS with MediatR
// Command
public sealed record CreateOrderCommand(
int BuyerId,
Address ShippingAddress,
List<OrderItemDto> OrderItems) : IRequest<int>;
// Command Handler
public sealed class CreateOrderCommandHandler(
IOrderRepository orderRepo,
ILogger<CreateOrderCommandHandler> logger)
: IRequestHandler<CreateOrderCommand, int>
{
public async Task<int> Handle(CreateOrderCommand request, CancellationToken ct)
{
var order = new Order(request.BuyerId, request.ShippingAddress);
foreach (var item in request.OrderItems)
{
order.AddOrderItem(item.ProductId, item.ProductName, item.UnitPrice, item.Units);
}
orderRepo.Add(order);
await orderRepo.UnitOfWork.SaveEntitiesAsync(ct);
logger.LogInformation("Order {OrderId} created for buyer {BuyerId}", order.Id, request.BuyerId);
return order.Id;
}
}
// Query (separate from commands)
public sealed record GetOrderByIdQuery(int OrderId) : IRequest<OrderDetailDto?>;
public sealed class GetOrderByIdQueryHandler(
IReadOnlyRepository<Order> readRepo)
: IRequestHandler<GetOrderByIdQuery, OrderDetailDto?>
{
public async Task<OrderDetailDto?> Handle(GetOrderByIdQuery request, CancellationToken ct) =>
await readRepo.Query<Order>()
.AsNoTracking()
.Where(o => o.Id == request.OrderId)
.Select(o => new OrderDetailDto(
o.Id, o.OrderDate, o.Status.Name,
o.OrderItems.Select(i => new OrderItemDto(i.ProductName, i.UnitPrice, i.Units)).ToList(),
o.GetTotal()))
.FirstOrDefaultAsync(ct);
}
// API endpoint using MediatR
app.MapPost("/api/orders", async (CreateOrderCommand command, IMediator mediator, CancellationToken ct) =>
{
var orderId = await mediator.Send(command, ct);
return TypedResults.Created($"/api/orders/{orderId}", new { orderId });
});
Domain Model Validation
// Self-validating entities (invariants enforced in domain)
public sealed class OrderItem : Entity
{
public int ProductId { get; private set; }
public string ProductName { get; private set; }
public decimal UnitPrice { get; private set; }
public int Units { get; private set; }
private OrderItem() { } // EF Core
public OrderItem(int productId, string productName, decimal unitPrice, int units)
{
if (units <= 0) throw new OrderingDomainException("Invalid number of units");
if (unitPrice < 0) throw new OrderingDomainException("Price cannot be negative");
ProductId = productId;
ProductName = productName ?? throw new ArgumentNullException(nameof(productName));
UnitPrice = unitPrice;
Units = units;
}
public void AddUnits(int units)
{
if (units <= 0) throw new OrderingDomainException("Invalid units");
Units += units;
}
}
// FluentValidation at application layer (for command validation)
public sealed class CreateOrderCommandValidator : AbstractValidator<CreateOrderCommand>
{
public CreateOrderCommandValidator()
{
RuleFor(x => x.BuyerId).GreaterThan(0);
RuleFor(x => x.ShippingAddress).NotNull();
RuleFor(x => x.OrderItems).NotEmpty().WithMessage("Order must have at least one item");
RuleForEach(x => x.OrderItems).ChildRules(item =>
{
item.RuleFor(i => i.ProductId).GreaterThan(0);
item.RuleFor(i => i.Units).GreaterThan(0);
item.RuleFor(i => i.UnitPrice).GreaterThanOrEqualTo(0);
});
}
}
EF Core Mapping for DDD
// Configure value objects as owned types
public sealed class OrderEntityTypeConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> builder)
{
builder.ToTable("Orders");
builder.HasKey(o => o.Id);
builder.Ignore(o => o.DomainEvents);
// Value object as owned type
builder.OwnsOne(o => o.ShippingAddress, a =>
{
a.Property(p => p.Street).HasMaxLength(200).IsRequired();
a.Property(p => p.City).HasMaxLength(100).IsRequired();
a.Property(p => p.State).HasMaxLength(100);
a.Property(p => p.Country).HasMaxLength(100).IsRequired();
a.Property(p => p.ZipCode).HasMaxLength(20).IsRequired();
});
// Enumeration as value conversion
builder.Property(o => o.Status)
.HasConversion(
s => s.Id,
id => Enumeration.GetAll<OrderStatus>().Single(s => s.Id == id));
// Private collection navigation
var navigation = builder.Metadata.FindNavigation(nameof(Order.OrderItems))!;
navigation.SetPropertyAccessMode(PropertyAccessMode.Field);
builder.HasMany(o => o.OrderItems).WithOne().HasForeignKey("OrderId");
}
}
Dispatching Domain Events
// DbContext dispatches domain events on SaveChanges
public sealed class OrderingContext : DbContext, IUnitOfWork
{
private readonly IMediator _mediator;
public OrderingContext(DbContextOptions<OrderingContext> options, IMediator mediator)
: base(options)
{
_mediator = mediator;
}
public DbSet<Order> Orders => Set<Order>();
public async Task<bool> SaveEntitiesAsync(CancellationToken ct = default)
{
// Dispatch domain events before saving (same transaction)
await DispatchDomainEventsAsync(ct);
await base.SaveChangesAsync(ct);
return true;
}
private async Task DispatchDomainEventsAsync(CancellationToken ct)
{
var domainEntities = ChangeTracker
.Entries<Entity>()
.Where(x => x.Entity.DomainEvents.Count != 0)
.ToList();
var domainEvents = domainEntities
.SelectMany(x => x.Entity.DomainEvents)
.ToList();
domainEntities.ForEach(entity => entity.Entity.ClearDomainEvents());
foreach (var domainEvent in domainEvents)
{
await _mediator.Publish(domainEvent, ct);
}
}
}
When to Use DDD vs CRUD
| Criteria | Simple CRUD | DDD/CQRS | |----------|------------|-----------| | Business rules | Few, simple | Complex, many invariants | | Domain complexity | Low (data in/out) | High (behavior-rich) | | Team size | 1-3 developers | 3+ developers | | Expected changes | Stable requirements | Evolving business rules | | Entities | Anemic (data bags) | Rich (behavior + data) | | Validation | At API boundary only | Domain + application |
Reference Documentation
- Domain model: https://learn.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/microservice-domain-model
- Application layer: https://learn.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/microservice-application-layer-web-api-design
- Domain events: https://learn.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/domain-events-design-implementation
- EF Core persistence: https://learn.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/infrastructure-persistence-layer-implementation-entity-framework-core
- Simplified CQRS: https://learn.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/apply-simplified-microservice-cqrs-ddd-patterns
- DDD-oriented microservice: https://learn.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/ddd-oriented-microservice