Domain-Driven Design in Practice on .NET 10 — Aggregate, Domain Event, and Bounded Context
Posted on: 4/20/2026 8:11:44 AM
Table of contents
- Table of Contents
- 1. What Is DDD and Why It Still Matters in 2026
- 2. Strategic Design — Bounded Context & Context Map
- 3. Tactical Design — Entity, Value Object, Aggregate
- 4. Aggregate Root — The Boundary for Data Consistency
- 5. Domain Events — Communication Between Aggregates
- 6. The Repository Pattern on .NET 10
- 7. Implementing DDD on .NET 10 with C# 14
- 8. Common Anti-patterns
- 9. Production Lessons
- 10. Conclusion
1. What Is DDD and Why It Still Matters in 2026
Domain-Driven Design (DDD) is a software design approach proposed by Eric Evans in 2003, focused on putting business domain modeling at the center of the architecture. Instead of letting technology drive the design, DDD places business language and rules first — code is only the most precise expression of the domain model.
In the 2026 landscape, where microservices are the norm and systems grow ever more complex, DDD isn't just "nice to have" — it's a survival tool to keep a codebase from becoming a big ball of mud.
When DON'T You Need DDD?
DDD is not a silver bullet. If your app is just simple CRUD (data entry forms, landing pages, admin panels), applying DDD would be over-engineering. DDD shines when the domain is complex — many business rules, many stakeholders, and frequently changing logic.
2. Strategic Design — Bounded Context & Context Map
Strategic Design is the most important but most-often-skipped part of DDD. This is where you partition the system into independent business regions (Bounded Contexts) before writing a single line of code.
2.1 Bounded Context
A Bounded Context is a boundary within which a specific model has a consistent meaning. The same concept "Customer" can mean something entirely different in different contexts:
graph TB
subgraph Sales["🛒 Sales Context"]
SC[Customer
─────────
CustomerId
Name
Email
CreditLimit
PreferredPayment]
end
subgraph Shipping["📦 Shipping Context"]
SH[Customer
─────────
RecipientId
FullName
ShippingAddress
Phone
DeliveryPreference]
end
subgraph Support["🎧 Support Context"]
SP[Customer
─────────
AccountId
DisplayName
SubscriptionTier
TicketHistory
SatisfactionScore]
end
Sales --- |"Customer Created Event"| Shipping
Sales --- |"Customer Created Event"| Support
style Sales fill:#e94560,stroke:#fff,color:#fff
style Shipping fill:#2c3e50,stroke:#fff,color:#fff
style Support fill:#4CAF50,stroke:#fff,color:#fff
Same "Customer" concept, but each Bounded Context has its own model tailored to its business
2.2 Ubiquitous Language
Each Bounded Context has its own Ubiquitous Language — a set of terms that developers and domain experts all understand the same way. For example, in e-commerce:
| Term | Sales Context | Warehouse Context | Accounting Context |
|---|---|---|---|
| Order | Customer's order | Stock-out slip | Sales invoice |
| Item | Product in the cart | SKU on the shelf | Invoice line item |
| Return | Exchange/refund request | Return receipt | Credit note |
| Discount | Discount codes, promotions | Doesn't exist | Revenue deduction |
2.3 Context Map — Relationships Between Contexts
A Context Map describes how Bounded Contexts interact with each other. Common patterns:
graph LR
subgraph Upstream
A["Order Context
(Core Domain)"]
end
subgraph Downstream
B["Shipping Context
(Supporting)"]
C["Notification Context
(Generic)"]
D["Analytics Context
(Generic)"]
end
A -->|"Published Language
(Domain Events)"| B
A -->|"Open Host Service
(REST API)"| C
A -->|"Conformist
(Event Stream)"| D
E["Payment Gateway
(External)"] -->|"Anti-Corruption Layer"| A
style A fill:#e94560,stroke:#fff,color:#fff
style B fill:#2c3e50,stroke:#fff,color:#fff
style C fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style D fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style E fill:#ff9800,stroke:#fff,color:#fff
Context Map: each arrow represents a different integration pattern
| Pattern | Description | When to use |
|---|---|---|
| Published Language | Two contexts agree on a shared schema (domain events, protobuf) | Both teams control it |
| Open Host Service | Upstream context exposes a standard API to many consumers | Many downstream contexts need upstream data |
| Anti-Corruption Layer | Translation layer between an external model and the internal model | Integrating with legacy systems or third-party APIs |
| Conformist | Downstream adopts the upstream model as-is | No power to change upstream, translation is too costly |
| Shared Kernel | Two contexts share a small common model | Teams trust each other, the shared model is stable |
3. Tactical Design — Entity, Value Object, Aggregate
Once you've identified Bounded Contexts, Tactical Design helps you model the detailed domain model inside each context.
3.1 Entity vs Value Object
This is the core distinction in DDD:
| Criterion | Entity | Value Object |
|---|---|---|
| Identity | Has its own identity (Id); two entities with the same attributes are still distinct | No identity; two VOs with the same values are equal |
| Lifecycle | Has a lifecycle, state changes over time | Immutable — create a new one when you need a different value |
| Example | Order, Customer, Product | Money, Address, DateRange, Email |
| Comparison | Compared by Id | Compared by all attributes |
| C# 14 | class with an Id property | record struct — immutable, structural equality for free |
3.2 Value Object in C# 14
// Value Object using record struct — immutable, free equality
public readonly record struct Money(decimal Amount, string Currency)
{
public static Money VND(decimal amount) => new(amount, "VND");
public static Money USD(decimal amount) => new(amount, "USD");
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException($"Cannot add {Currency} to {other.Currency}");
return this with { Amount = Amount + other.Amount };
}
public Money Multiply(int quantity) => this with { Amount = Amount * quantity };
}
// A more complex Value Object with validation
public readonly record struct Email
{
public string Value { get; }
public Email(string value)
{
if (string.IsNullOrWhiteSpace(value) || !value.Contains('@'))
throw new ArgumentException("Invalid email", nameof(value));
Value = value.Trim().ToLowerInvariant();
}
public static implicit operator string(Email e) => e.Value;
}
// Address — a classic Value Object
public readonly record struct Address(
string Street,
string City,
string Province,
string PostalCode,
string Country = "VN");
💡 Tip: Prefer Value Objects
When torn between Entity and Value Object, default to Value Object. Value Objects are simpler (immutable, no tracking), easier to test, and avoid many bugs related to shared mutable state. Only use an Entity when the object truly needs distinct identity.
4. Aggregate Root — The Boundary for Data Consistency
Aggregate is the hardest but most important concept in DDD. An Aggregate is a cluster of Entities and Value Objects treated as a unified unit for the purpose of data change.
graph TB
subgraph OrderAggregate["📦 Order Aggregate"]
OR["Order
(Aggregate Root)"]
OI1["OrderItem"]
OI2["OrderItem"]
SA["ShippingAddress
(Value Object)"]
PM["PaymentMethod
(Value Object)"]
OR --> OI1
OR --> OI2
OR --> SA
OR --> PM
end
subgraph ProductAggregate["🏷️ Product Aggregate"]
PR["Product
(Aggregate Root)"]
PV1["ProductVariant"]
PV2["ProductVariant"]
PC["Price
(Value Object)"]
PR --> PV1
PR --> PV2
PR --> PC
end
subgraph CustomerAggregate["👤 Customer Aggregate"]
CU["Customer
(Aggregate Root)"]
AD["Address[]
(Value Objects)"]
LY["LoyaltyPoints
(Value Object)"]
CU --> AD
CU --> LY
end
OR -.->|"Reference by Id"| PR
OR -.->|"Reference by Id"| CU
style OR fill:#e94560,stroke:#fff,color:#fff
style PR fill:#2c3e50,stroke:#fff,color:#fff
style CU fill:#4CAF50,stroke:#fff,color:#fff
style OI1 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style OI2 fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style PV1 fill:#f8f9fa,stroke:#2c3e50,color:#2c3e50
style PV2 fill:#f8f9fa,stroke:#2c3e50,color:#2c3e50
style SA fill:#fce4ec,stroke:#e94560,color:#2c3e50
style PM fill:#fce4ec,stroke:#e94560,color:#2c3e50
style PC fill:#e8f5e9,stroke:#4CAF50,color:#2c3e50
style AD fill:#e8f5e9,stroke:#4CAF50,color:#2c3e50
style LY fill:#e8f5e9,stroke:#4CAF50,color:#2c3e50
Three independent Aggregates — each with its own Aggregate Root, referencing each other only by Id
4.1 The Golden Rules of Aggregates
4 rules you must not break
1. Reference other Aggregates only by Id — don't hold direct references to objects inside another aggregate.
2. One transaction = one Aggregate — don't update multiple aggregates in the same transaction (use Domain Events for eventual consistency).
3. All changes go through the Aggregate Root — the outside world can't directly modify an OrderItem; it must call a method on Order.
4. Keep Aggregates small — only pack into an aggregate what MUST stay immediately consistent (strong consistency).
4.2 Order Aggregate Root — Real Code
public sealed class Order : AggregateRoot<OrderId>
{
private readonly List<OrderItem> _items = [];
public CustomerId CustomerId { get; private set; }
public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
public OrderStatus Status { get; private set; }
public Money TotalAmount { get; private set; }
public Address ShippingAddress { get; private set; }
public DateTime CreatedAt { get; private set; }
private Order() { } // EF Core
public static Order Create(CustomerId customerId, Address shippingAddress)
{
var order = new Order
{
Id = OrderId.New(),
CustomerId = customerId,
ShippingAddress = shippingAddress,
Status = OrderStatus.Draft,
TotalAmount = Money.VND(0),
CreatedAt = DateTime.UtcNow
};
order.RaiseDomainEvent(new OrderCreatedEvent(order.Id, customerId));
return order;
}
public void AddItem(ProductId productId, string productName, Money unitPrice, int quantity)
{
if (Status != OrderStatus.Draft)
throw new DomainException("Items can only be added while the order is in Draft");
if (quantity <= 0)
throw new DomainException("Quantity must be greater than 0");
var existing = _items.FirstOrDefault(i => i.ProductId == productId);
if (existing is not null)
{
existing.IncreaseQuantity(quantity);
}
else
{
_items.Add(new OrderItem(productId, productName, unitPrice, quantity));
}
RecalculateTotal();
}
public void RemoveItem(ProductId productId)
{
if (Status != OrderStatus.Draft)
throw new DomainException("Items can only be removed while the order is in Draft");
var item = _items.FirstOrDefault(i => i.ProductId == productId)
?? throw new DomainException($"Product {productId} not found");
_items.Remove(item);
RecalculateTotal();
}
public void Submit()
{
if (Status != OrderStatus.Draft)
throw new DomainException("Only Draft orders can be submitted");
if (_items.Count == 0)
throw new DomainException("An order must contain at least 1 item");
Status = OrderStatus.Submitted;
RaiseDomainEvent(new OrderSubmittedEvent(Id, TotalAmount, _items.Count));
}
public void Confirm()
{
if (Status != OrderStatus.Submitted)
throw new DomainException("Only Submitted orders can be confirmed");
Status = OrderStatus.Confirmed;
RaiseDomainEvent(new OrderConfirmedEvent(Id, CustomerId));
}
public void Cancel(string reason)
{
if (Status is OrderStatus.Shipped or OrderStatus.Delivered)
throw new DomainException("Cannot cancel an order that is shipped or delivered");
Status = OrderStatus.Cancelled;
RaiseDomainEvent(new OrderCancelledEvent(Id, reason));
}
private void RecalculateTotal()
{
TotalAmount = _items.Aggregate(
Money.VND(0),
(sum, item) => sum.Add(item.SubTotal));
}
}
4.3 AggregateRoot Base Class
public abstract class AggregateRoot<TId> where TId : notnull
{
private readonly List<IDomainEvent> _domainEvents = [];
public TId Id { get; protected set; } = default!;
public int Version { get; protected set; }
public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
protected void RaiseDomainEvent(IDomainEvent domainEvent)
=> _domainEvents.Add(domainEvent);
public void ClearDomainEvents() => _domainEvents.Clear();
}
// Strongly-typed Ids — avoid accidentally passing OrderId where CustomerId is expected
public readonly record struct OrderId(Guid Value)
{
public static OrderId New() => new(Guid.NewGuid());
}
public readonly record struct CustomerId(Guid Value);
public readonly record struct ProductId(Guid Value);
💡 Strongly-Typed Ids in C# 14
Using record struct for Ids entirely eliminates primitive obsession bugs. The compiler will fail immediately if you pass a ProductId into a method that expects an OrderId — something a raw Guid can't catch.
5. Domain Events — Communication Between Aggregates
Domain Events are the mechanism aggregates use to communicate without violating the "one transaction = one aggregate" rule. When an Order is submitted, instead of directly calling InventoryService to deduct stock, Order just emits an OrderSubmittedEvent — other handlers take care of their own work.
sequenceDiagram
participant Client
participant OrderAggregate as Order Aggregate
participant DB as Database
participant EventDispatcher as Event Dispatcher
participant InventoryHandler as Inventory Handler
participant NotificationHandler as Notification Handler
participant AnalyticsHandler as Analytics Handler
Client->>OrderAggregate: Submit()
OrderAggregate->>OrderAggregate: Validate business rules
OrderAggregate->>OrderAggregate: Status = Submitted
OrderAggregate->>OrderAggregate: Raise OrderSubmittedEvent
Note over OrderAggregate,DB: SaveChanges — same transaction
OrderAggregate->>DB: Persist Order + Events
DB-->>EventDispatcher: After commit
EventDispatcher->>InventoryHandler: OrderSubmittedEvent
EventDispatcher->>NotificationHandler: OrderSubmittedEvent
EventDispatcher->>AnalyticsHandler: OrderSubmittedEvent
InventoryHandler->>InventoryHandler: Reserve stock
NotificationHandler->>NotificationHandler: Send email
AnalyticsHandler->>AnalyticsHandler: Track conversion
Domain Event flow: raise events inside the aggregate, dispatch after successful persistence
5.1 Domain Event Interface
public interface IDomainEvent
{
Guid EventId { get; }
DateTime OccurredOn { get; }
}
public abstract record DomainEventBase : IDomainEvent
{
public Guid EventId { get; init; } = Guid.NewGuid();
public DateTime OccurredOn { get; init; } = DateTime.UtcNow;
}
// Concrete events
public sealed record OrderCreatedEvent(OrderId OrderId, CustomerId CustomerId)
: DomainEventBase;
public sealed record OrderSubmittedEvent(OrderId OrderId, Money TotalAmount, int ItemCount)
: DomainEventBase;
public sealed record OrderConfirmedEvent(OrderId OrderId, CustomerId CustomerId)
: DomainEventBase;
public sealed record OrderCancelledEvent(OrderId OrderId, string Reason)
: DomainEventBase;
5.2 Dispatching Domain Events via an EF Core Interceptor
public sealed class DomainEventDispatcherInterceptor(IMediator mediator)
: SaveChangesInterceptor
{
public override async ValueTask<int> SavedChangesAsync(
SaveChangesCompletedEventData eventData,
int result,
CancellationToken ct = default)
{
if (eventData.Context is not null)
await DispatchDomainEvents(eventData.Context, ct);
return result;
}
private async Task DispatchDomainEvents(DbContext context, CancellationToken ct)
{
var aggregates = context.ChangeTracker
.Entries<AggregateRoot<OrderId>>()
.Where(e => e.Entity.DomainEvents.Count != 0)
.Select(e => e.Entity)
.ToList();
var events = aggregates
.SelectMany(a => a.DomainEvents)
.ToList();
aggregates.ForEach(a => a.ClearDomainEvents());
foreach (var domainEvent in events)
await mediator.Publish(domainEvent, ct);
}
}
⚠️ Careful: In-process vs Out-of-process Events
The code above dispatches events in-process (same process, after SaveChanges). If a handler calls an external service (email, API) that fails, the order is already saved but the side effect hasn't happened. For production, combine this with the Outbox Pattern — save events to an Outbox table in the same transaction as the aggregate, then have a background worker publish them to the message broker.
6. The Repository Pattern on .NET 10
A Repository in DDD is not a generic IRepository<T> with full CRUD. Each Aggregate Root has its own repository that only exposes the operations the domain needs.
6.1 Interface — Domain Layer
// Domain layer — knows nothing about EF Core, SQL, or any infrastructure
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct = default);
Task<IReadOnlyList<Order>> GetByCustomerAsync(CustomerId customerId, CancellationToken ct = default);
Task AddAsync(Order order, CancellationToken ct = default);
// No Update — EF Core change tracking handles it
// No Delete — the domain uses Cancel/Archive instead of hard delete
}
6.2 Implementation — Infrastructure Layer
public sealed class OrderRepository(OrderDbContext db) : IOrderRepository
{
public async Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct)
=> await db.Orders
.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.Id == id, ct);
public async Task<IReadOnlyList<Order>> GetByCustomerAsync(
CustomerId customerId, CancellationToken ct)
=> await db.Orders
.Include(o => o.Items)
.Where(o => o.CustomerId == customerId)
.OrderByDescending(o => o.CreatedAt)
.ToListAsync(ct);
public async Task AddAsync(Order order, CancellationToken ct)
=> await db.Orders.AddAsync(order, ct);
}
6.3 Unit of Work
public interface IUnitOfWork
{
Task<int> SaveChangesAsync(CancellationToken ct = default);
}
// OrderDbContext implements IUnitOfWork
public sealed class OrderDbContext(DbContextOptions<OrderDbContext> options)
: DbContext(options), IUnitOfWork
{
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>(b =>
{
b.HasKey(o => o.Id);
b.Property(o => o.Id).HasConversion(id => id.Value, v => new OrderId(v));
b.Property(o => o.CustomerId).HasConversion(id => id.Value, v => new CustomerId(v));
b.ComplexProperty(o => o.TotalAmount);
b.ComplexProperty(o => o.ShippingAddress);
b.HasMany(o => o.Items).WithOne().HasForeignKey("OrderId");
b.Property(o => o.Version).IsConcurrencyToken();
});
}
}
7. Implementing DDD on .NET 10 with C# 14
7.1 Solution Structure
graph TB
subgraph Presentation["Presentation Layer"]
API["API Controllers
Minimal APIs"]
end
subgraph Application["Application Layer"]
CMD["Commands"]
QRY["Queries"]
HDL["Handlers"]
DTO["DTOs"]
end
subgraph Domain["Domain Layer
(No dependencies)"]
AGG["Aggregates"]
VO["Value Objects"]
EVT["Domain Events"]
REPO["Repository Interfaces"]
SVC["Domain Services"]
end
subgraph Infrastructure["Infrastructure Layer"]
EF["EF Core DbContext"]
REPOIMPL["Repository Implementations"]
MSG["Message Bus"]
EXT["External Services"]
end
API --> CMD
API --> QRY
CMD --> HDL
QRY --> HDL
HDL --> AGG
HDL --> REPO
REPOIMPL -.->|implements| REPO
EF -.-> AGG
style Domain fill:#e94560,stroke:#fff,color:#fff
style Application fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style Infrastructure fill:#f8f9fa,stroke:#2c3e50,color:#2c3e50
style Presentation fill:#2c3e50,stroke:#fff,color:#fff
Onion Architecture: Domain at the center, not depending on any layer
src/
├── MyShop.Domain/ # Zero dependencies
│ ├── Orders/
│ │ ├── Order.cs # Aggregate Root
│ │ ├── OrderItem.cs # Entity
│ │ ├── OrderStatus.cs # Enum
│ │ ├── Events/ # Domain Events
│ │ └── IOrderRepository.cs # Interface
│ ├── Customers/
│ │ ├── Customer.cs
│ │ └── ICustomerRepository.cs
│ ├── SharedKernel/
│ │ ├── AggregateRoot.cs
│ │ ├── Money.cs # Value Object
│ │ ├── Address.cs # Value Object
│ │ └── Email.cs # Value Object
│ └── Exceptions/
│ └── DomainException.cs
├── MyShop.Application/ # Depends on Domain only
│ ├── Orders/
│ │ ├── CreateOrder/
│ │ │ ├── CreateOrderCommand.cs
│ │ │ └── CreateOrderHandler.cs
│ │ └── GetOrderById/
│ │ ├── GetOrderByIdQuery.cs
│ │ └── GetOrderByIdHandler.cs
│ └── DependencyInjection.cs
├── MyShop.Infrastructure/ # Depends on Domain + Application
│ ├── Persistence/
│ │ ├── OrderDbContext.cs
│ │ ├── OrderRepository.cs
│ │ └── Interceptors/
│ └── DependencyInjection.cs
└── MyShop.Api/ # Composition Root
├── Endpoints/
└── Program.cs
7.2 Application Layer — Command Handler
// Command
public sealed record CreateOrderCommand(
Guid CustomerId,
string Street,
string City,
string Province,
string PostalCode,
List<CreateOrderItemDto> Items) : IRequest<OrderId>;
public sealed record CreateOrderItemDto(
Guid ProductId,
string ProductName,
decimal UnitPrice,
int Quantity);
// Handler
public sealed class CreateOrderHandler(
IOrderRepository orderRepo,
IUnitOfWork unitOfWork) : IRequestHandler<CreateOrderCommand, OrderId>
{
public async Task<OrderId> Handle(CreateOrderCommand cmd, CancellationToken ct)
{
var address = new Address(cmd.Street, cmd.City, cmd.Province, cmd.PostalCode);
var order = Order.Create(new CustomerId(cmd.CustomerId), address);
foreach (var item in cmd.Items)
{
order.AddItem(
new ProductId(item.ProductId),
item.ProductName,
Money.VND(item.UnitPrice),
item.Quantity);
}
order.Submit();
await orderRepo.AddAsync(order, ct);
await unitOfWork.SaveChangesAsync(ct);
return order.Id;
}
}
7.3 Minimal API Endpoint
app.MapPost("/api/orders", async (
CreateOrderCommand command,
IMediator mediator,
CancellationToken ct) =>
{
var orderId = await mediator.Send(command, ct);
return Results.Created($"/api/orders/{orderId.Value}", new { orderId = orderId.Value });
})
.WithName("CreateOrder")
.Produces<object>(StatusCodes.Status201Created);
7.4 DI Registration
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// Domain + Application
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(CreateOrderHandler).Assembly));
// Infrastructure
builder.Services.AddDbContext<OrderDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("Orders")));
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<IUnitOfWork>(sp => sp.GetRequiredService<OrderDbContext>());
// EF Core Interceptor for Domain Events
builder.Services.AddSingleton<DomainEventDispatcherInterceptor>();
8. Common Anti-patterns
After years of applying DDD in .NET projects, these are the most common mistakes:
8.1 Anemic Domain Model
This is anti-pattern #1 — Entities have only getters/setters, and all logic lives in Services:
// ❌ ANEMIC — the Domain Model is just a data bag
public class Order
{
public Guid Id { get; set; }
public string Status { get; set; } // string instead of enum
public List<OrderItem> Items { get; set; } = [];
public decimal Total { get; set; }
}
public class OrderService
{
public void SubmitOrder(Order order) // Logic lives outside the aggregate
{
if (order.Status != "Draft") throw new Exception("...");
if (order.Items.Count == 0) throw new Exception("...");
order.Status = "Submitted";
order.Total = order.Items.Sum(i => i.Price * i.Qty);
}
}
// ✅ RICH — logic lives inside the Aggregate Root
public sealed class Order : AggregateRoot<OrderId>
{
public void Submit() // Order knows how to validate and transition itself
{
if (Status != OrderStatus.Draft)
throw new DomainException("...");
if (_items.Count == 0)
throw new DomainException("...");
Status = OrderStatus.Submitted;
RecalculateTotal();
RaiseDomainEvent(new OrderSubmittedEvent(Id, TotalAmount, _items.Count));
}
}
8.2 Oversized Aggregate
⚠️ God Aggregate — a dangerous anti-pattern
If your aggregate contains 20+ entities, takes seconds to load, and every change locks the entire object graph — it's a sign the aggregate is too big. Split it. For example, Order shouldn't directly contain Product or Customer — just hold ProductId and CustomerId.
8.3 Repository Returning IQueryable
// ❌ Leaky abstraction — callers can build queries themselves, breaking encapsulation
public interface IOrderRepository
{
IQueryable<Order> GetAll(); // A controller can .Where().Include() however it wants
}
// ✅ The Repository only exposes specific operations
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct);
Task<IReadOnlyList<Order>> GetPendingOrdersAsync(CancellationToken ct);
}
8.4 Anti-pattern Summary
| Anti-pattern | Problem | Solution |
|---|---|---|
| Anemic Domain Model | Logic scattered across services, no encapsulation | Move business logic into the Aggregate Root |
| God Aggregate | Too big, poor performance, concurrency conflicts | Split aggregates, use eventual consistency |
| CRUD Repository | Generic IRepository<T> exposes too many operations | Repository per Aggregate, only domain operations |
| Shared Database | Multiple contexts sharing tables → coupling | Each Bounded Context gets its own schema/database |
| Missing Value Objects | Using string for Email, decimal for Money → subtle bugs | Model every domain concept as an explicit type |
| Cross-Aggregate Transaction | Updating 3 aggregates in 1 transaction → deadlocks | Domain Events + eventual consistency |
9. Production Lessons
9.1 Concurrency Control with Optimistic Locking
In production, two requests can read and modify the same aggregate simultaneously. Use a Version field to detect conflicts:
// EF Core configuration
modelBuilder.Entity<Order>()
.Property(o => o.Version)
.IsConcurrencyToken();
// On SaveChanges, EF Core automatically checks:
// UPDATE Orders SET ..., Version = Version + 1
// WHERE Id = @id AND Version = @expectedVersion
// If 0 rows affected → throws DbUpdateConcurrencyException
9.2 Domain Exception Handling Middleware
public sealed class DomainExceptionMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context)
{
try
{
await next(context);
}
catch (DomainException ex)
{
context.Response.StatusCode = StatusCodes.Status422UnprocessableEntity;
await context.Response.WriteAsJsonAsync(new
{
error = ex.Message,
type = "domain_error"
});
}
catch (DbUpdateConcurrencyException)
{
context.Response.StatusCode = StatusCodes.Status409Conflict;
await context.Response.WriteAsJsonAsync(new
{
error = "Data has been changed by someone else. Please try again.",
type = "concurrency_error"
});
}
}
}
9.3 Performance: Projections for the Read Side
DDD aggregates are optimized for write operations. For reads (lists, search, reports), use a separate read model — don't load the entire aggregate graph:
// Read model — flat DTO with exactly the data the UI needs
public sealed record OrderSummaryDto(
Guid OrderId,
string CustomerName,
string Status,
decimal TotalAmount,
int ItemCount,
DateTime CreatedAt);
// Query directly against the DB, bypassing the Repository
public sealed class GetOrdersHandler(OrderDbContext db)
: IRequestHandler<GetOrdersQuery, PagedList<OrderSummaryDto>>
{
public async Task<PagedList<OrderSummaryDto>> Handle(
GetOrdersQuery query, CancellationToken ct)
{
return await db.Orders
.AsNoTracking()
.Where(o => o.Status == query.StatusFilter)
.Select(o => new OrderSummaryDto(
o.Id.Value,
o.CustomerName,
o.Status.ToString(),
o.TotalAmount.Amount,
o.Items.Count,
o.CreatedAt))
.ToPagedListAsync(query.Page, query.PageSize, ct);
}
}
💡 Lightweight CQRS: no need for two databases
CQRS doesn't require a separate database for the read side. The simplest approach is to use the same database but separate the write model (Aggregate + Repository) from the read model (direct queries + DTOs). This is "lightweight CQRS" — enough to solve 90% of use cases without additional complexity.
9.4 Testing Domain Logic
public class OrderTests
{
[Fact]
public void Submit_WithItems_ShouldChangeStatusAndRaiseEvent()
{
// Arrange
var order = Order.Create(
new CustomerId(Guid.NewGuid()),
new Address("123 Nguyen Hue", "HCMC", "HCM", "70000"));
order.AddItem(
new ProductId(Guid.NewGuid()),
"iPhone 17",
Money.VND(35_000_000),
quantity: 1);
// Act
order.Submit();
// Assert
Assert.Equal(OrderStatus.Submitted, order.Status);
Assert.Equal(35_000_000m, order.TotalAmount.Amount);
Assert.Contains(order.DomainEvents, e => e is OrderSubmittedEvent);
}
[Fact]
public void Submit_WithNoItems_ShouldThrowDomainException()
{
var order = Order.Create(
new CustomerId(Guid.NewGuid()),
new Address("456 Le Loi", "Da Nang", "DN", "55000"));
var ex = Assert.Throws<DomainException>(() => order.Submit());
Assert.Contains("at least 1 item", ex.Message);
}
[Fact]
public void Cancel_ShippedOrder_ShouldThrowDomainException()
{
var order = CreateConfirmedOrder();
order.Ship(); // transition to Shipped
Assert.Throws<DomainException>(() => order.Cancel("Changed my mind"));
}
}
Unit tests without mocks
When domain logic lives in the right place (inside the Aggregate), unit tests become extremely simple — no database mocks, no service mocks, just create the aggregate and call its methods. This is one of DDD's biggest benefits: domain logic is fully testable in-memory.
10. Conclusion
Domain-Driven Design isn't a framework or library — it's a way of thinking about software. On .NET 10, with C# 14 records, primary constructors, and EF Core 10 complex types, implementing DDD has never been cleaner.
Checklist for applying DDD to your next project:
| Step | Action | Output |
|---|---|---|
| 1 | Event Storming with domain experts | A list of domain events, commands, aggregates |
| 2 | Identify Bounded Contexts | Context Map + integration patterns |
| 3 | Build the Ubiquitous Language | A shared glossary for the team |
| 4 | Model the Aggregate Root | A rich domain model with business rules |
| 5 | Extract Value Objects | Immutable types for every domain concept |
| 6 | Implement Domain Events | Loose coupling between aggregates |
| 7 | Write unit tests for the domain logic | 100% coverage of business rules, no mocks needed |
DDD requires upfront investment to understand the domain — but that investment pays off many times over when the system scales, the team scales, and business rules change constantly. Start from Bounded Context and Aggregate — the two foundational concepts — and the rest will clarify itself.
References
Load Balancing: The Art of Traffic Distribution for Million-Request Systems
Redis 8.4 Hybrid Search — Combining Full-Text and Vector Search for AI
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.