Domain-Driven Design thực chiến trên .NET 10 — Aggregate, Domain Event và Bounded Context

Posted on: 4/20/2026 8:11:44 AM

Table of contents

  1. Mục lục
  2. 1. DDD là gì và tại sao vẫn cực kỳ quan trọng năm 2026
    1. Khi nào KHÔNG cần DDD?
  3. 2. Strategic Design — Bounded Context & Context Map
    1. 2.1 Bounded Context
    2. 2.2 Ubiquitous Language
    3. 2.3 Context Map — Mối quan hệ giữa các Context
  4. 3. Tactical Design — Entity, Value Object, Aggregate
    1. 3.1 Entity vs Value Object
    2. 3.2 Value Object trong C# 14
      1. 💡 Tip: Ưu tiên Value Object
  5. 4. Aggregate Root — Ranh giới nhất quán dữ liệu
    1. 4.1 Quy tắc vàng của Aggregate
      1. 4 quy tắc không được vi phạm
    2. 4.2 Order Aggregate Root — Code thực tế
    3. 4.3 AggregateRoot Base Class
      1. 💡 Strongly-Typed Id trong C# 14
  6. 5. Domain Event — Giao tiếp giữa các Aggregate
    1. 5.1 Domain Event Interface
    2. 5.2 Dispatch Domain Events qua EF Core Interceptor
      1. ⚠️ Cẩn thận: In-process vs Out-of-process Events
  7. 6. Repository Pattern trên .NET 10
    1. 6.1 Interface — Domain Layer
    2. 6.2 Implementation — Infrastructure Layer
    3. 6.3 Unit of Work
  8. 7. Triển khai DDD trên .NET 10 với C# 14
    1. 7.1 Cấu trúc Solution
    2. 7.2 Application Layer — Command Handler
    3. 7.3 Minimal API Endpoint
    4. 7.4 DI Registration
  9. 8. Anti-patterns thường gặp
    1. 8.1 Anemic Domain Model
    2. 8.2 Aggregate quá lớn
      1. ⚠️ God Aggregate — Anti-pattern nguy hiểm
    3. 8.3 Repository trả về IQueryable
    4. 8.4 Bảng tổng hợp Anti-patterns
  10. 9. Bài học thực tế từ Production
    1. 9.1 Concurrency Control với Optimistic Locking
    2. 9.2 Domain Exception Handling Middleware
    3. 9.3 Performance: Projection cho Read Side
      1. 💡 CQRS nhẹ: không cần hai database
    4. 9.4 Testing Domain Logic
      1. Unit test không cần mock
  11. 10. Kết luận
    1. Tài liệu tham khảo

1. DDD là gì và tại sao vẫn cực kỳ quan trọng năm 2026

Domain-Driven Design (DDD) là phương pháp thiết kế phần mềm do Eric Evans đề xuất năm 2003, tập trung vào việc mô hình hóa nghiệp vụ (domain) làm trung tâm của kiến trúc phần mềm. Thay vì để kỹ thuật dẫn dắt thiết kế, DDD đặt ngôn ngữ và quy tắc nghiệp vụ lên trước — code chỉ là cách diễn đạt chính xác nhất của domain model.

Trong bối cảnh 2026, khi microservices trở thành tiêu chuẩn và hệ thống ngày càng phức tạp, DDD không chỉ là "nice to have" mà là công cụ sinh tồn để giữ cho codebase không biến thành big ball of mud.

87% Enterprise projects áp dụng DDD (ThoughtWorks 2026)
3.2x Tốc độ onboarding nhanh hơn với Ubiquitous Language
45% Giảm bug nghiệp vụ nhờ Aggregate boundaries
.NET 10 C# 14 primary constructors + records = DDD patterns gọn hơn

Khi nào KHÔNG cần DDD?

DDD không phải silver bullet. Nếu ứng dụng chỉ là CRUD đơn giản (form nhập liệu, landing page, admin panel), áp dụng DDD sẽ là over-engineering. DDD tỏa sáng khi domain phức tạp — nhiều quy tắc nghiệp vụ, nhiều stakeholder, và logic thay đổi thường xuyên.

2. Strategic Design — Bounded Context & Context Map

Strategic Design là phần quan trọng nhất nhưng thường bị bỏ qua của DDD. Đây là nơi bạn phân chia hệ thống thành các vùng nghiệp vụ độc lập (Bounded Context) trước khi viết bất kỳ dòng code nào.

2.1 Bounded Context

Bounded Context là ranh giới mà trong đó một model cụ thể có ý nghĩa nhất quán. Cùng một khái niệm "Customer" có thể có ý nghĩa hoàn toàn khác nhau trong các context khác nhau:

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

Cùng concept "Customer" nhưng mỗi Bounded Context có model riêng phù hợp nghiệp vụ

2.2 Ubiquitous Language

Mỗi Bounded Context có ngôn ngữ chung (Ubiquitous Language) riêng — tập hợp thuật ngữ mà cả developer và domain expert đều hiểu giống nhau. Ví dụ trong e-commerce:

Thuật ngữSales ContextWarehouse ContextAccounting Context
OrderĐơn hàng của kháchPhiếu xuất khoHóa đơn bán hàng
ItemSản phẩm trong giỏ hàngSKU trong kệ hàngDòng chi tiết trên invoice
ReturnYêu cầu đổi/trảPhiếu nhập kho hoànCredit note
DiscountMã giảm giá, promotionKhông tồn tạiKhoản giảm trừ doanh thu

2.3 Context Map — Mối quan hệ giữa các Context

Context Map mô tả cách các Bounded Context tương tác với nhau. Các pattern phổ biến:

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: mỗi mũi tên thể hiện một integration pattern khác nhau

PatternMô tảKhi nào dùng
Published LanguageHai context thỏa thuận một schema chung (domain events, protobuf)Cả hai team đều kiểm soát được
Open Host ServiceContext upstream expose API chuẩn cho nhiều consumerNhiều downstream context cần dữ liệu từ upstream
Anti-Corruption LayerTầng translate giữa model của hệ thống bên ngoài và model nội bộTích hợp với legacy system hoặc third-party API
ConformistDownstream chấp nhận model của upstream nguyên xiKhông có quyền thay đổi upstream, cost translate quá cao
Shared KernelHai context chia sẻ một phần model nhỏHai team trust lẫn nhau, model chung ổn định

3. Tactical Design — Entity, Value Object, Aggregate

Sau khi xác định Bounded Context, Tactical Design giúp bạn mô hình hóa chi tiết domain model bên trong mỗi context.

3.1 Entity vs Value Object

Đây là sự phân biệt cốt lõi nhất trong DDD:

Tiêu chíEntityValue Object
IdentityCó identity riêng (Id), hai entity có cùng thuộc tính vẫn khác nhauKhông có identity, hai VO có cùng giá trị là bằng nhau
LifecycleCó vòng đời, thay đổi trạng thái theo thời gianImmutable — tạo mới khi cần giá trị khác
Ví dụOrder, Customer, ProductMoney, Address, DateRange, Email
So sánhSo sánh bằng IdSo sánh bằng tất cả thuộc tính
C# 14class với Id propertyrecord struct — immutable, structural equality miễn phí

3.2 Value Object trong C# 14

// Value Object dùng record struct — immutable, equality miễn phí
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 };
}

// Value Object phức tạp hơn với validation
public readonly record struct Email
{
    public string Value { get; }

    public Email(string value)
    {
        if (string.IsNullOrWhiteSpace(value) || !value.Contains('@'))
            throw new ArgumentException("Email không hợp lệ", nameof(value));
        Value = value.Trim().ToLowerInvariant();
    }

    public static implicit operator string(Email e) => e.Value;
}

// Address — Value Object điển hình
public readonly record struct Address(
    string Street,
    string City,
    string Province,
    string PostalCode,
    string Country = "VN");

💡 Tip: Ưu tiên Value Object

Khi phân vân giữa Entity và Value Object, hãy mặc định chọn Value Object. Value Object đơn giản hơn (immutable, không cần tracking), dễ test hơn, và tránh được nhiều bug liên quan đến shared mutable state. Chỉ dùng Entity khi đối tượng thực sự cần identity riêng biệt.

4. Aggregate Root — Ranh giới nhất quán dữ liệu

Aggregate là khái niệm khó nhất nhưng cũng quan trọng nhất của DDD. Một Aggregate là cluster các Entity và Value Object được đối xử như một đơn vị thống nhất cho mục đích thay đổi dữ liệu.

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 -.->|"Tham chiếu bằng Id"| PR OR -.->|"Tham chiếu bằng 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

Ba Aggregate độc lập — mỗi cái có Aggregate Root riêng, chỉ tham chiếu nhau qua Id

4.1 Quy tắc vàng của Aggregate

4 quy tắc không được vi phạm

1. Chỉ tham chiếu Aggregate khác bằng Id — không hold reference trực tiếp đến object thuộc aggregate khác.
2. Một transaction = một Aggregate — không update nhiều aggregate trong cùng một transaction (dùng Domain Event cho eventual consistency).
3. Mọi thay đổi đều đi qua Aggregate Root — bên ngoài không được trực tiếp sửa OrderItem, phải gọi method trên Order.
4. Giữ Aggregate nhỏ — chỉ gom vào aggregate những gì BẮT BUỘC phải nhất quán ngay lập tức (strong consistency).

4.2 Order Aggregate Root — Code thực tế

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("Chỉ có thể thêm item khi đơn hàng ở trạng thái Draft");

        if (quantity <= 0)
            throw new DomainException("Số lượng phải lớn hơn 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("Chỉ có thể xóa item khi đơn hàng ở trạng thái Draft");

        var item = _items.FirstOrDefault(i => i.ProductId == productId)
            ?? throw new DomainException($"Không tìm thấy sản phẩm {productId}");

        _items.Remove(item);
        RecalculateTotal();
    }

    public void Submit()
    {
        if (Status != OrderStatus.Draft)
            throw new DomainException("Chỉ đơn hàng Draft mới có thể submit");

        if (_items.Count == 0)
            throw new DomainException("Đơn hàng phải có ít nhất 1 sản phẩm");

        Status = OrderStatus.Submitted;
        RaiseDomainEvent(new OrderSubmittedEvent(Id, TotalAmount, _items.Count));
    }

    public void Confirm()
    {
        if (Status != OrderStatus.Submitted)
            throw new DomainException("Chỉ đơn hàng Submitted mới có thể confirm");

        Status = OrderStatus.Confirmed;
        RaiseDomainEvent(new OrderConfirmedEvent(Id, CustomerId));
    }

    public void Cancel(string reason)
    {
        if (Status is OrderStatus.Shipped or OrderStatus.Delivered)
            throw new DomainException("Không thể hủy đơn đã giao hoặc đang vận chuyển");

        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 Id — tránh lỗi truyền nhầm OrderId vào chỗ CustomerId
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 Id trong C# 14

Dùng record struct cho Id tránh hoàn toàn lỗi primitive obsession. Compiler sẽ báo lỗi ngay nếu bạn truyền nhầm ProductId vào method yêu cầu OrderId — điều mà Guid thuần không làm được.

5. Domain Event — Giao tiếp giữa các Aggregate

Domain Event là cơ chế để các Aggregate giao tiếp mà không vi phạm nguyên tắc "một transaction = một aggregate". Khi Order được submit, thay vì trực tiếp gọi InventoryService để trừ kho, Order chỉ phát ra OrderSubmittedEvent — các handler khác tự xử lý.

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 — cùng 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: phát event trong aggregate, dispatch sau khi persist thành công

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 Dispatch Domain Events qua 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);
    }
}

⚠️ Cẩn thận: In-process vs Out-of-process Events

Code trên dispatch event in-process (cùng process, sau SaveChanges). Nếu handler gọi external service (gửi email, call API) mà fail, order đã được saved nhưng side-effect chưa xảy ra. Cho production, cần kết hợp Outbox Pattern — lưu event vào bảng Outbox cùng transaction với aggregate, rồi có background worker publish ra message broker.

6. Repository Pattern trên .NET 10

Repository trong DDD không phải là generic IRepository<T> với full CRUD. Mỗi Aggregate Root có repository riêng, chỉ expose những operation mà domain cần.

6.1 Interface — Domain Layer

// Domain layer — không biết gì về EF Core, SQL, hay bất kỳ infrastructure nào
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);
    // Không có Update — EF Core change tracking tự xử lý
    // Không có Delete — domain dùng Cancel/Archive thay vì xóa cứng
}

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 implement 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. Triển khai DDD trên .NET 10 với C# 14

7.1 Cấu trúc Solution

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
(Không dependency nào)"] 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 ở trung tâm, không phụ thuộc vào bất kỳ layer nào

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 cho Domain Events
builder.Services.AddSingleton<DomainEventDispatcherInterceptor>();

8. Anti-patterns thường gặp

Sau nhiều năm áp dụng DDD trong các dự án .NET, đây là những lỗi phổ biến nhất:

8.1 Anemic Domain Model

Đây là anti-pattern #1 — Entity chỉ có getter/setter, mọi logic nằm trong Service:

// ❌ ANEMIC — Domain Model chỉ là data bag
public class Order
{
    public Guid Id { get; set; }
    public string Status { get; set; }  // string thay vì enum
    public List<OrderItem> Items { get; set; } = [];
    public decimal Total { get; set; }
}

public class OrderService
{
    public void SubmitOrder(Order order)  // Logic nằm ngoài 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 nằm trong Aggregate Root
public sealed class Order : AggregateRoot<OrderId>
{
    public void Submit()  // Order tự biết cách validate và chuyển trạng thái
    {
        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 Aggregate quá lớn

⚠️ God Aggregate — Anti-pattern nguy hiểm

Nếu aggregate của bạn có 20+ entity, load time hàng giây, và mọi thay đổi đều lock toàn bộ object graph — đó là dấu hiệu aggregate quá lớn. Hãy tách ra. Ví dụ: Order không nên chứa trực tiếp Product hay Customer — chỉ giữ ProductIdCustomerId.

8.3 Repository trả về IQueryable

// ❌ Leak abstraction — cho phép caller tự build query, phá vỡ encapsulation
public interface IOrderRepository
{
    IQueryable<Order> GetAll();  // Controller có thể .Where().Include() tuỳ ý
}

// ✅ Repository chỉ expose operation cụ thể
public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct);
    Task<IReadOnlyList<Order>> GetPendingOrdersAsync(CancellationToken ct);
}

8.4 Bảng tổng hợp Anti-patterns

Anti-patternVấn đềGiải pháp
Anemic Domain ModelLogic nằm rải rác trong Service, không encapsulationĐưa business logic vào Aggregate Root
God AggregateAggregate quá lớn, performance kém, concurrency conflictTách aggregate, dùng eventual consistency
CRUD RepositoryGeneric IRepository<T> expose quá nhiều operationRepository per Aggregate, chỉ expose domain operations
Shared DatabaseNhiều context truy cập chung bảng → couplingMỗi Bounded Context có schema/database riêng
Missing Value ObjectsDùng string cho Email, decimal cho Money → bug tinh tếModel mọi domain concept thành explicit type
Cross-Aggregate TransactionUpdate 3 aggregate trong 1 transaction → deadlockDomain Events + eventual consistency

9. Bài học thực tế từ Production

9.1 Concurrency Control với Optimistic Locking

Trong production, hai request có thể cùng đọc và sửa một aggregate. Dùng Version field để detect conflict:

// EF Core configuration
modelBuilder.Entity<Order>()
    .Property(o => o.Version)
    .IsConcurrencyToken();

// Khi SaveChanges, EF Core tự check:
// UPDATE Orders SET ..., Version = Version + 1
// WHERE Id = @id AND Version = @expectedVersion
// Nếu 0 rows affected → throw 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 = "Dữ liệu đã bị thay đổi bởi người khác. Vui lòng thử lại.",
                type = "concurrency_error"
            });
        }
    }
}

9.3 Performance: Projection cho Read Side

DDD aggregates tối ưu cho write operations. Cho read (danh sách, search, report), dùng read model riêng — không load toàn bộ aggregate graph:

// Read model — flat DTO, chỉ chứa đúng data cần hiển thị
public sealed record OrderSummaryDto(
    Guid OrderId,
    string CustomerName,
    string Status,
    decimal TotalAmount,
    int ItemCount,
    DateTime CreatedAt);

// Query trực tiếp từ DB, bypass 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);
    }
}

💡 CQRS nhẹ: không cần hai database

CQRS không bắt buộc phải có database riêng cho read side. Cách đơn giản nhất là dùng chung database nhưng tách biệt write model (Aggregate + Repository) và read model (Query trực tiếp + DTO). Đây là "CQRS nhẹ" — đủ để giải quyết 90% use case mà không thêm 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 Nguyễn Huệ", "TP.HCM", "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 Lê Lợi", "Đà Nẵng", "ĐN", "55000"));

        var ex = Assert.Throws<DomainException>(() => order.Submit());
        Assert.Contains("ít nhất 1 sản phẩm", ex.Message);
    }

    [Fact]
    public void Cancel_ShippedOrder_ShouldThrowDomainException()
    {
        var order = CreateConfirmedOrder();
        order.Ship(); // transition to Shipped

        Assert.Throws<DomainException>(() => order.Cancel("Đổi ý"));
    }
}

Unit test không cần mock

Khi domain logic nằm đúng chỗ (trong Aggregate), unit test trở nên cực kỳ đơn giản — không cần mock database, không cần mock service, chỉ cần tạo aggregate và gọi method. Đây là một trong những lợi ích lớn nhất của DDD: domain logic có thể test hoàn toàn in-memory.

10. Kết luận

Domain-Driven Design không phải là framework hay library — đó là cách tư duy về phần mềm. Trên .NET 10, với C# 14 records, primary constructors và EF Core 10 complex types, việc triển khai DDD chưa bao giờ gọn gàng hơn.

Checklist áp dụng DDD cho dự án tiếp theo:

BướcHành độngOutput
1Event Storming với domain expertDanh sách domain events, commands, aggregates
2Xác định Bounded ContextContext Map + integration patterns
3Xây dựng Ubiquitous LanguageGlossary chung cho team
4Model Aggregate RootRich domain model với business rules
5Tách Value ObjectsImmutable types cho mọi domain concept
6Implement Domain EventsLoose coupling giữa aggregates
7Write unit tests cho domain logic100% coverage cho business rules, không cần mock

DDD đòi hỏi đầu tư thời gian ban đầu để hiểu domain — nhưng khoản đầu tư đó sẽ trả lại gấp nhiều lần khi hệ thống scale, team scale, và nghiệp vụ thay đổi liên tục. Bắt đầu từ Bounded Context và Aggregate — hai khái niệm nền tảng — phần còn lại sẽ tự nhiên rõ dần.

Tài liệu tham khảo