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

  1. Table of Contents
  2. 1. What Is DDD and Why It Still Matters in 2026
    1. When DON'T You Need DDD?
  3. 2. Strategic Design — Bounded Context & Context Map
    1. 2.1 Bounded Context
    2. 2.2 Ubiquitous Language
    3. 2.3 Context Map — Relationships Between Contexts
  4. 3. Tactical Design — Entity, Value Object, Aggregate
    1. 3.1 Entity vs Value Object
    2. 3.2 Value Object in C# 14
      1. 💡 Tip: Prefer Value Objects
  5. 4. Aggregate Root — The Boundary for Data Consistency
    1. 4.1 The Golden Rules of Aggregates
      1. 4 rules you must not break
    2. 4.2 Order Aggregate Root — Real Code
    3. 4.3 AggregateRoot Base Class
      1. 💡 Strongly-Typed Ids in C# 14
  6. 5. Domain Events — Communication Between Aggregates
    1. 5.1 Domain Event Interface
    2. 5.2 Dispatching Domain Events via an EF Core Interceptor
      1. ⚠️ Careful: In-process vs Out-of-process Events
  7. 6. The Repository Pattern on .NET 10
    1. 6.1 Interface — Domain Layer
    2. 6.2 Implementation — Infrastructure Layer
    3. 6.3 Unit of Work
  8. 7. Implementing DDD on .NET 10 with C# 14
    1. 7.1 Solution Structure
    2. 7.2 Application Layer — Command Handler
    3. 7.3 Minimal API Endpoint
    4. 7.4 DI Registration
  9. 8. Common Anti-patterns
    1. 8.1 Anemic Domain Model
    2. 8.2 Oversized Aggregate
      1. ⚠️ God Aggregate — a dangerous anti-pattern
    3. 8.3 Repository Returning IQueryable
    4. 8.4 Anti-pattern Summary
  10. 9. Production Lessons
    1. 9.1 Concurrency Control with Optimistic Locking
    2. 9.2 Domain Exception Handling Middleware
    3. 9.3 Performance: Projections for the Read Side
      1. 💡 Lightweight CQRS: no need for two databases
    4. 9.4 Testing Domain Logic
      1. Unit tests without mocks
  11. 10. Conclusion
    1. References

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.

87% Enterprise projects adopting DDD (ThoughtWorks 2026)
3.2x Faster onboarding with Ubiquitous Language
45% Fewer business-logic bugs thanks to aggregate boundaries
.NET 10 C# 14 primary constructors + records = tighter DDD patterns

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:

TermSales ContextWarehouse ContextAccounting Context
OrderCustomer's orderStock-out slipSales invoice
ItemProduct in the cartSKU on the shelfInvoice line item
ReturnExchange/refund requestReturn receiptCredit note
DiscountDiscount codes, promotionsDoesn't existRevenue 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

PatternDescriptionWhen to use
Published LanguageTwo contexts agree on a shared schema (domain events, protobuf)Both teams control it
Open Host ServiceUpstream context exposes a standard API to many consumersMany downstream contexts need upstream data
Anti-Corruption LayerTranslation layer between an external model and the internal modelIntegrating with legacy systems or third-party APIs
ConformistDownstream adopts the upstream model as-isNo power to change upstream, translation is too costly
Shared KernelTwo contexts share a small common modelTeams 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:

CriterionEntityValue Object
IdentityHas its own identity (Id); two entities with the same attributes are still distinctNo identity; two VOs with the same values are equal
LifecycleHas a lifecycle, state changes over timeImmutable — create a new one when you need a different value
ExampleOrder, Customer, ProductMoney, Address, DateRange, Email
ComparisonCompared by IdCompared by all attributes
C# 14class with an Id propertyrecord 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-patternProblemSolution
Anemic Domain ModelLogic scattered across services, no encapsulationMove business logic into the Aggregate Root
God AggregateToo big, poor performance, concurrency conflictsSplit aggregates, use eventual consistency
CRUD RepositoryGeneric IRepository<T> exposes too many operationsRepository per Aggregate, only domain operations
Shared DatabaseMultiple contexts sharing tables → couplingEach Bounded Context gets its own schema/database
Missing Value ObjectsUsing string for Email, decimal for Money → subtle bugsModel every domain concept as an explicit type
Cross-Aggregate TransactionUpdating 3 aggregates in 1 transaction → deadlocksDomain 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:

StepActionOutput
1Event Storming with domain expertsA list of domain events, commands, aggregates
2Identify Bounded ContextsContext Map + integration patterns
3Build the Ubiquitous LanguageA shared glossary for the team
4Model the Aggregate RootA rich domain model with business rules
5Extract Value ObjectsImmutable types for every domain concept
6Implement Domain EventsLoose coupling between aggregates
7Write unit tests for the domain logic100% 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