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
- Mục lục
- 1. DDD là gì và tại sao vẫn cực kỳ quan trọng năm 2026
- 2. Strategic Design — Bounded Context & Context Map
- 3. Tactical Design — Entity, Value Object, Aggregate
- 4. Aggregate Root — Ranh giới nhất quán dữ liệu
- 5. Domain Event — Giao tiếp giữa các Aggregate
- 6. Repository Pattern trên .NET 10
- 7. Triển khai DDD trên .NET 10 với C# 14
- 8. Anti-patterns thường gặp
- 9. Bài học thực tế từ Production
- 10. Kết luận
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.
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 Context | Warehouse Context | Accounting Context |
|---|---|---|---|
| Order | Đơn hàng của khách | Phiếu xuất kho | Hóa đơn bán hàng |
| Item | Sản phẩm trong giỏ hàng | SKU trong kệ hàng | Dòng chi tiết trên invoice |
| Return | Yêu cầu đổi/trả | Phiếu nhập kho hoàn | Credit note |
| Discount | Mã giảm giá, promotion | Không tồn tại | Khoả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
| Pattern | Mô tả | Khi nào dùng |
|---|---|---|
| Published Language | Hai context thỏa thuận một schema chung (domain events, protobuf) | Cả hai team đều kiểm soát được |
| Open Host Service | Context upstream expose API chuẩn cho nhiều consumer | Nhiều downstream context cần dữ liệu từ upstream |
| Anti-Corruption Layer | Tầ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 |
| Conformist | Downstream chấp nhận model của upstream nguyên xi | Không có quyền thay đổi upstream, cost translate quá cao |
| Shared Kernel | Hai 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í | Entity | Value Object |
|---|---|---|
| Identity | Có identity riêng (Id), hai entity có cùng thuộc tính vẫn khác nhau | Không có identity, hai VO có cùng giá trị là bằng nhau |
| Lifecycle | Có vòng đời, thay đổi trạng thái theo thời gian | Immutable — tạo mới khi cần giá trị khác |
| Ví dụ | Order, Customer, Product | Money, Address, DateRange, Email |
| So sánh | So sánh bằng Id | So sánh bằng tất cả thuộc tính |
| C# 14 | class với Id property | record 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ữ ProductId và CustomerId.
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-pattern | Vấn đề | Giải pháp |
|---|---|---|
| Anemic Domain Model | Logic nằm rải rác trong Service, không encapsulation | Đưa business logic vào Aggregate Root |
| God Aggregate | Aggregate quá lớn, performance kém, concurrency conflict | Tách aggregate, dùng eventual consistency |
| CRUD Repository | Generic IRepository<T> expose quá nhiều operation | Repository per Aggregate, chỉ expose domain operations |
| Shared Database | Nhiều context truy cập chung bảng → coupling | Mỗi Bounded Context có schema/database riêng |
| Missing Value Objects | Dùng string cho Email, decimal cho Money → bug tinh tế | Model mọi domain concept thành explicit type |
| Cross-Aggregate Transaction | Update 3 aggregate trong 1 transaction → deadlock | Domain 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ước | Hành động | Output |
|---|---|---|
| 1 | Event Storming với domain expert | Danh sách domain events, commands, aggregates |
| 2 | Xác định Bounded Context | Context Map + integration patterns |
| 3 | Xây dựng Ubiquitous Language | Glossary chung cho team |
| 4 | Model Aggregate Root | Rich domain model với business rules |
| 5 | Tách Value Objects | Immutable types cho mọi domain concept |
| 6 | Implement Domain Events | Loose coupling giữa aggregates |
| 7 | Write unit tests cho domain logic | 100% 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
Load Balancing: Nghệ thuật Phân tải cho Hệ thống Triệu Request
Redis 8.4 Hybrid Search — Tìm kiếm kết hợp Full-Text và Vector cho 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.