Skip to main content

16.6 — 5. DDD chiến thuật (vừa đủ cho backend)

Summary

DDD chiến thuật cho bạn bốn công cụ, và chỉ một trong số đó thay đổi hẳn cách thiết kế: aggregate. Aggregate không phải "nhóm entity liên quan" — nó là ranh giới nhất quán: mọi thứ bên trong phải đúng sau mỗi transaction, và mọi thay đổi phải đi qua root. Từ định nghĩa đó suy ra quy tắc quan trọng nhất và hay bị vi phạm nhất: aggregate tham chiếu nhau bằng id, không bằng navigation property — vì order.Customer.CreditLimit -= x nghĩa là bạn vừa sửa hai aggregate trong một transaction, và ranh giới nhất quán biến mất. Nhưng nhớ: DDD là công cụ cho domain phức tạp. Với bảng tra cứu hay CRUD thuần, nó là chi phí thừa.

Mục tiêu bài học​

Sau bài này bạn có thể:

  • Phân biệt entity và value object, và biết dùng cái nào.
  • Xác định ranh giới aggregate.
  • Tham chiếu giữa aggregate đúng cách.
  • Nhận ra bounded context.
  • Biết khi nào DDD là quá mức.

Nội dung bài học​

16.6.1 — Entity và value object​

EntityValue Object
Có định danhCóKhông
So sánh bằngIdGiá trị
Thay đổi đượcCóBất biến
Ví dụLead, CustomerMoney, EmailAddress, Address
public readonly record struct Money(decimal Amount, string Currency)
{
public static Money Vnd(decimal amount) => new(amount, "VND");

public Money Add(Money other)
{
if (Currency != other.Currency)
throw new DomainException($"Không cộng được {Currency} với {other.Currency}");

return this with { Amount = Amount + other.Amount };
}

public Money Percent(decimal percent) => this with { Amount = Amount * percent / 100m };
}

Value object làm được ba việc mà kiểu nguyên thuỷ không làm được:

1. Ép bất biến tại chỗ tạo. EmailAddress không tồn tại ở trạng thái không hợp lệ:

public readonly record struct EmailAddress
{
public string Value { get; }

public EmailAddress(string value)
{
if (!value.Contains('@')) throw new DomainException("Email không hợp lệ");
Value = value.ToLowerInvariant(); // chuan hoa luon
}
}

Chuẩn hoá chữ thường ngay trong constructor giải quyết luôn vấn đề collation ở bài 12.3.

2. Chặn lỗi truyền nhầm tham số:

// Truyền nhầm hai tham số — biên dịch ERROR
public void Transfer(AccountId from, AccountId to, Money amount);

// Voi kieu nguyen thuy — bien dich OK, chay SAI
public void Transfer(Guid from, Guid to, decimal amount);

3. Gom hành vi về đúng chỗ. money.Percent(10) rõ hơn amount * 10 / 100 rải khắp code.

Map vào EF Core bằng owned type hoặc value converter (bài 13.3).

16.6.2 — Aggregate là ranh giới nhất quán​

Định nghĩa dùng được: aggregate là tập đối tượng mà mọi bất biến bên trong phải đúng sau mỗi transaction, và mọi thay đổi đi qua root.

public sealed class Order                      // AGGREGATE ROOT
{
private readonly List<OrderLine> _lines = [];

public OrderId Id { get; private set; }
public OrderStatus Status { get; private set; }
public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly(); // CHI DOC

public Money Total => _lines.Aggregate(Money.Vnd(0), (sum, l) => sum.Add(l.Subtotal));

// Chỉ root được thêm dòng — bất biến được BẢO VỆ
public void AddLine(ProductId productId, int quantity, Money unitPrice)
{
if (Status != OrderStatus.Draft)
throw new DomainException("Chỉ sửa được đơn hàng ở trạng thái nháp");

if (_lines.Count >= 100)
throw new DomainException("Đơn hàng tối đa 100 dòng");

var line = new OrderLine(productId, quantity, unitPrice);

if (Total.Add(line.Subtotal).Amount > 1_000_000_000m)
throw new DomainException("Đơn hàng vượt hạn mức 1 tỷ");

_lines.Add(line);
}
}

Ba chi tiết ép bất biến:

IReadOnlyList cho collection. Lộ List<T> nghĩa là ai cũng gọi order.Lines.Add(...) và bỏ qua toàn bộ kiểm tra.

Bất biến liên quan nhiều dòng nằm ở root. "Tổng không vượt 1 tỷ" không kiểm tra được ở OrderLine vì nó cần biết mọi dòng khác.

OrderLine không có id công khai — nó không tồn tại ngoài Order, nên không ai nạp riêng nó.

Vẽ ranh giới aggregate thế nào? Hỏi: "thứ gì phải đúng ngay lập tức, và thứ gì có thể đúng sau vài giây?"

Trong cùng aggregate:  tổng đơn hàng = tổng các dòng      -> PHẢI đúng ngay
Khác aggregate: số đơn hàng của khách hàng -> đúng sau vài giây là được

Aggregate nhỏ là aggregate tốt. Aggregate lớn nghĩa là transaction lớn, khoá nhiều dòng, và xung đột đồng thời cao (bài 12.7).

16.6.3 — Tham chiếu bằng id​

Đây là quy tắc hay bị vi phạm nhất, và hậu quả của nó rất cụ thể.

// SAI — navigation sang aggregate khac
public sealed class Order
{
public Customer Customer { get; private set; } // AGGREGATE KHAC

public void Place()
{
Customer.CreditLimit -= Total.Amount; // sua HAI aggregate!
}
}

Ba vấn đề:

  1. Transaction phình ra — bạn khoá cả Customer khi chỉ muốn đặt hàng.
  2. Ranh giới nhất quán biến mất — Order giờ chịu trách nhiệm về bất biến của Customer.
  3. Ai đó sẽ Include cả cây và nạp hàng nghìn dòng không cần thiết.
// DUNG — chi id
public sealed class Order
{
public CustomerId CustomerId { get; private set; } // CHI id

public void Place(DateTime now)
{
if (_lines.Count == 0) throw new DomainException("Đơn hàng trống");

Status = OrderStatus.Placed;
PlacedAt = now;

Raise(new OrderPlaced(Id, CustomerId, Total)); // bao cho ben ngoai
}
}

Cần phối hợp hai aggregate thì việc đó thuộc về Application:

public async Task<Result> HandleAsync(PlaceOrderCommand command, CancellationToken ct)
{
var order = await _orders.GetByIdAsync(command.OrderId, ct);
var customer = await _customers.GetByIdAsync(order.CustomerId, ct);

if (!customer.CanAfford(order.Total))
return Result.Fail("Vượt hạn mức tín dụng");

order.Place(_clock.GetUtcNow().UtcDateTime);
customer.ReserveCredit(order.Total);

await _uow.SaveChangesAsync(ct); // mot transaction, HAI aggregate
}

Cập nhật hai aggregate trong một transaction là chấp nhận được khi chúng cùng database. Quy tắc "một transaction một aggregate" của DDD sinh ra cho hệ phân tán; với monolith dùng chung database, nó thường quá chặt.

Nhưng thay đổi lan sang aggregate ở service khác thì phải bất đồng bộ qua domain event (bài 16.9) hoặc message bus (bài 14.9).

16.6.4 — Bounded context​

Cùng một từ, hai nghĩa khác nhau ở hai nơi:

Sales:    "Customer" = người có thể mua, có lead score, có nguồn
Billing: "Customer" = bên nhận hoá đơn, có mã số thuế, có điều khoản thanh toán
Support: "Customer" = người gửi ticket, có gói hỗ trợ, có SLA

Ép ba nghĩa vào một bảng Customers cho một bảng 60 cột mà 40 cột luôn NULL — và mỗi lần Sales thêm trường, Billing phải deploy lại.

Bounded context là ranh giới nơi một từ có một nghĩa. Mỗi context có model riêng:

src/
├── Sales/
│ └── Domain/Customer.cs <- co LeadScore, Source
├── Billing/
│ └── Domain/Customer.cs <- co TaxCode, PaymentTerms
└── Shared/
└── CustomerId.cs <- chi id la chung

Chúng dùng chung định danh, không dùng chung model.

Liên kết giữa context qua id và event:

// Sales phat
public sealed record CustomerRegistered(CustomerId Id, string Name, string Email);

// Billing nghe và tạo model CỦA NÓ
public sealed class CustomerRegisteredHandler : INotificationHandler<CustomerRegistered>
{
public async Task Handle(CustomerRegistered notification, CancellationToken ct)
{
var billingCustomer = BillingCustomer.Create(notification.Id, notification.Name);
...
}
}

Bounded context không bắt buộc là microservice. Trong một monolith, chúng là module — thư mục riêng, model riêng, không tham chiếu trực tiếp vào nhau. Đó là modular monolith, và nó cho phần lớn lợi ích mà không có chi phí vận hành của microservice (Module 18).

16.6.5 — Khi nào DDD là quá mức​

Tình huốngDDD chiến thuật?
Bảng tra cứu (Country, Currency)Không
CRUD thuần, không bất biếnKhông
Import/export dữ liệuKhông
Báo cáo, thống kêKhông
Quy trình nghiệp vụ nhiều trạng tháiCó
Tính toán tiền, tồn kho, hạn mứcCó
Quy tắc thay đổi theo yêu cầu kinh doanhCó

Trong một hệ thống thật, cả hai cùng tồn tại. Module Orders có aggregate đầy đủ; module Countries chỉ là một bảng và một endpoint. Ép DDD lên cái thứ hai là chi phí thuần.

Và đường đọc gần như không bao giờ cần DDD: báo cáo, danh sách, tìm kiếm đều chỉ cần truy vấn nhanh — đúng thoả hiệp CQRS ở bài 16.4.

Ba công cụ nên áp dụng ngay cả khi không làm DDD đầy đủ:

  1. Value object cho khái niệm nghiệp vụ — Money, EmailAddress. Rẻ, và chặn cả một lớp lỗi.
  2. private set cho entity có bất biến — biến quy tắc từ khuyến nghị thành ép buộc.
  3. Tham chiếu bằng id giữa các nhóm độc lập — giữ transaction nhỏ.

Ba thứ đó cho phần lớn lợi ích với rất ít chi phí. Aggregate đầy đủ, domain event và bounded context thì thêm khi domain thật sự cần.

16.6.6 — Rà lại code của bạn​

Danh sách rà soát DDD chiến thuật

  • •Khái niệm nghiệp vụ dùng value object, không dùng kiểu nguyên thuỷ trần.
  • •Value object bất biến và validate ngay trong constructor.
  • •Collection trong aggregate lộ ra dưới dạng IReadOnlyList.
  • •Mọi thay đổi aggregate đi qua phương thức của root.
  • •Bất biến liên quan nhiều phần tử nằm ở root, không ở phần tử con.
  • •Aggregate tham chiếu nhau bằng id, không bằng navigation property.
  • •Không có aggregate nào sửa trạng thái của aggregate khác.
  • •Phối hợp nhiều aggregate nằm ở Application.
  • •Từ có nhiều nghĩa được tách thành model riêng theo context.
  • •Không áp DDD lên bảng tra cứu và CRUD thuần.
  • •Đường đọc không đi qua aggregate.

Bài tập áp dụng​

Bài 1 — Value object chặn lỗi ở thời điểm biên dịch​

Đổi một phương thức nhận hai Guid thành nhận hai kiểu id khác nhau. Cố tình truyền nhầm thứ tự và xác nhận lỗi biên dịch.

Tiêu chí hoàn thành: bạn giải thích được vì sao lỗi ở thời điểm biên dịch rẻ hơn lỗi lúc chạy theo bậc độ lớn, và biết cấu hình EF Core cho kiểu id riêng.

Gợi ý và lời giải — Bài 1

Gợi ý. Truyền nhầm hai Guid thì chuyện gì xảy ra? Khi nào bạn phát hiện?

Lời giải — trước:

public async Task GanLeadAsync(Guid leadId, Guid nguoiDungId, CancellationToken ct)
{
var lead = await _db.Leads.FirstAsync(l => l.Id == leadId, ct);
lead.Gan(nguoiDungId);
await _db.SaveChangesAsync(ct);
}
await _svc.GanLeadAsync(nguoiDungId, leadId, ct);      // NHẦM THỨ TỰ
Biên dịch: THÀNH CÔNG
Chạy: SqlException: Sequence contains no elements
hoặc tệ hơn: tìm thấy một lead có id trùng với id người dùng
-> gán sai lead cho sai người, KHÔNG có lỗi nào

Sau — kiểu id riêng:

public readonly record struct LeadId(Guid Value)
{
public static LeadId Moi() => new(Guid.CreateVersion7());
public static LeadId Parse(string s) => new(Guid.Parse(s));
public override string ToString() => Value.ToString();
}

public readonly record struct NguoiDungId(Guid Value)
{
public static NguoiDungId Moi() => new(Guid.CreateVersion7());
public override string ToString() => Value.ToString();
}
public async Task GanLeadAsync(LeadId leadId, NguoiDungId nguoiDungId, CancellationToken ct)
await _svc.GanLeadAsync(nguoiDungId, leadId, ct);
error CS1503: Argument 1: cannot convert from 'NguoiDungId' to 'LeadId'
error CS1503: Argument 2: cannot convert from 'LeadId' to 'NguoiDungId'

Vì sao lỗi ở thời điểm biên dịch rẻ hơn theo bậc độ lớn:

Phát hiện ởChi phí sửaAi phát hiệnPhạm vi ảnh hưởng
Biên dịchVài giâyTrình biên dịch, trong IDEKhông ai
Unit testVài phútNgười viết codeKhông ai
Code reviewVài chục phútĐồng nghiệpKhông ai
QA / stagingVài giờTesterKhông ai
ProductionVài giờ tới vài ngàyNgười dùngDữ liệu sai

Khoảng cách giữa dòng đầu và dòng cuối là bốn bậc độ lớn về thời gian, và khác biệt về phạm vi thì không đo bằng thời gian được: lỗi ở production để lại dữ liệu sai phải đi dọn.

Với trường hợp cụ thể này, hậu quả đặc biệt tệ vì lỗi không ném exception:

Nếu id người dùng tình cờ trùng id của một lead nào đó:
-> gán lead SAI cho người SAI
-> không có exception
-> không có log bất thường
-> phát hiện khi có người hỏi "sao tôi có lead này?"

Và readonly record struct không có chi phí runtime: nó là struct, không cấp phát trên heap, và trình biên dịch nội tuyến các phép so sánh. Bạn được an toàn kiểu mà không trả gì.

Cấu hình EF Core:

public class LeadIdConverter : ValueConverter<LeadId, Guid>
{
public LeadIdConverter() : base(id => id.Value, value => new LeadId(value)) { }
}
protected override void ConfigureConventions(ModelConfigurationBuilder builder)
{
builder.Properties<LeadId>().HaveConversion<LeadIdConverter>();
builder.Properties<NguoiDungId>().HaveConversion<NguoiDungIdConverter>();
}

ConfigureConventions áp cho mọi thuộc tính kiểu đó trong mô hình, kể cả entity thêm sau này — nên không ai phải nhớ cấu hình từng cột.

Bốn chỗ khác cần cấu hình:

// 1. JSON
public class LeadIdJsonConverter : JsonConverter<LeadId>
{
public override LeadId Read(ref Utf8JsonReader r, Type t, JsonSerializerOptions o)
=> new(r.GetGuid());

public override void Write(Utf8JsonWriter w, LeadId v, JsonSerializerOptions o)
=> w.WriteStringValue(v.Value);
}
// 2. Route binding trong ASP.NET Core
public readonly record struct LeadId(Guid Value)
{
public static bool TryParse(string? s, IFormatProvider? p, out LeadId kq)
{
if (Guid.TryParse(s, out var g)) { kq = new LeadId(g); return true; }
kq = default; return false;
}
}
app.MapGet("/leads/{id}", (LeadId id) => ...);     // hoạt động nhờ TryParse
// 3. Dapper
SqlMapper.AddTypeHandler(new LeadIdTypeHandler());

// 4. OpenAPI — để tài liệu hiển thị string thay vì object
options.MapType<LeadId>(() => new OpenApiSchema { Type = "string", Format = "uuid" });

Giảm mã lặp bằng source generator:

<PackageReference Include="StronglyTypedId" Version="..." PrivateAssets="all" />
[StronglyTypedId(Template.Guid,
"guid-efcore", "guid-dapper", "guid-newtonsoft-json", "guid-systemtextjson")]
public partial struct LeadId { }

Một attribute sinh ra toàn bộ: struct, converter cho EF Core, Dapper, JSON, TryParse, và TypeConverter.

Và Guid.CreateVersion7() đáng dùng thay cho Guid.NewGuid() — có từ .NET 9:

Guid.NewGuid()        -> hoàn toàn ngẫu nhiên
-> chèn vào giữa clustered index -> phân mảnh trang
-> đo được: chèn chậm hơn 3–5 lần trên bảng lớn

Guid.CreateVersion7() -> 48 bit đầu là dấu thời gian, phần còn lại ngẫu nhiên
-> tăng dần theo thời gian -> chèn vào cuối index
-> vẫn không đoán được, vẫn duy nhất

Nên tạo kiểu id riêng cho những gì:

NÊN:   mọi id của aggregate root — LeadId, CustomerId, OrderId
khái niệm nghiệp vụ dễ nhầm — Email, PhoneNumber, TenantId, MaKhachHang

KHÔNG: id của entity con trong cùng aggregate (ít khi truyền qua nhiều tầng)
khoá kỹ thuật thuần tuý — id của bảng log, bảng outbox

Đo lợi ích trên dự án thật — bài tập đáng làm một lần:

# Có bao nhiêu phương thức nhận từ hai Guid trở lên?
grep -rnE "\(([^)]*Guid [a-zA-Z]+,\s*){1,}[^)]*Guid [a-zA-Z]+" --include="*.cs" src/ | wc -l
34

34 phương thức, mỗi cái là một chỗ có thể truyền nhầm thứ tự mà trình biên dịch không phát hiện. Sau khi chuyển sang kiểu id riêng, con số đó về 0 — và trình biên dịch sẽ chỉ cho bạn chính xác những chỗ đang truyền nhầm.


Bài 2 — Collection bị lộ ra ngoài​

Tìm một entity lộ List<T> công khai, grep mọi chỗ gọi .Add() trực tiếp trên nó. Mỗi chỗ là một nơi bất biến có thể bị bỏ qua.

Tiêu chí hoàn thành: bạn nêu được vì sao IReadOnlyList<T> một mình chưa đủ, và biết cấu hình EF Core cho backing field.

Gợi ý và lời giải — Bài 2

Gợi ý. IReadOnlyList<T> ngăn được Add. Nó có ngăn được ép kiểu không?

Lời giải — trước:

public class Order
{
public List<OrderItem> Items { get; set; } = new();
public Money Total { get; set; } = Money.Zero;
}
grep -rn "\.Items\.Add(\|\.Items\.Remove(\|\.Items\.Clear()" --include="*.cs" src/
src/Crm.Api/Controllers/OrdersController.cs:78
src/Crm.Application/Services/OrderService.cs:142
src/Crm.Application/Services/OrderService.cs:201
src/Crm.Infrastructure/Jobs/ImportOrderJob.cs:94
src/Crm.Api/Features/Orders/ThemMatHang.cs:33

Năm chỗ, và mỗi chỗ có thể phá vỡ bất biến:

order.Items.Add(new OrderItem { ... });
// Total KHÔNG được cập nhật
// Không kiểm tra order đã xác nhận chưa
// Không kiểm tra giới hạn số mặt hàng
// Không kiểm tra trùng sản phẩm
SELECT COUNT(*) FROM Orders o
WHERE o.Total <> (SELECT ISNULL(SUM(i.Quantity * i.UnitPrice), 0)
FROM OrderItems i WHERE i.OrderId = o.Id);
2.847

2.847 đơn hàng có tổng tiền không khớp với các dòng của nó — và mỗi dòng là một báo cáo doanh thu sai.

Sau — đóng gói đúng cách:

public class Order
{
private readonly List<OrderItem> _items = new();
public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();

public Money Total { get; private set; } = Money.Zero;
public OrderStatus Status { get; private set; }

private const int SoMatHangToiDa = 100;

public Result ThemMatHang(ProductId sanPhamId, int soLuong, Money donGia)
{
if (Status != OrderStatus.Draft)
return Result.Loi("Chỉ sửa được đơn hàng ở trạng thái nháp");

if (soLuong <= 0)
return Result.Loi("Số lượng phải lớn hơn 0");

if (_items.Count >= SoMatHangToiDa)
return Result.Loi($"Đơn hàng tối đa {SoMatHangToiDa} mặt hàng");

var daCo = _items.FirstOrDefault(i => i.ProductId == sanPhamId);
if (daCo is not null)
{
daCo.TangSoLuong(soLuong);
}
else
{
_items.Add(new OrderItem(sanPhamId, soLuong, donGia));
}

TinhLaiTong(); // bất biến LUÔN được giữ
return Result.ThanhCong();
}

public Result XoaMatHang(ProductId sanPhamId)
{
if (Status != OrderStatus.Draft)
return Result.Loi("Chỉ sửa được đơn hàng ở trạng thái nháp");

var item = _items.FirstOrDefault(i => i.ProductId == sanPhamId);
if (item is null) return Result.Loi("Không tìm thấy mặt hàng");

_items.Remove(item);
TinhLaiTong();
return Result.ThanhCong();
}

private void TinhLaiTong()
=> Total = _items.Aggregate(Money.Zero, (tong, i) => tong + i.ThanhTien);
}
error CS0200: Property or indexer 'Order.Items' cannot be assigned to — it is read only
error CS1061: 'IReadOnlyList<OrderItem>' does not contain a definition for 'Add'

Năm lỗi biên dịch, và mỗi lỗi chỉ đúng một chỗ cần sửa.

Vì sao IReadOnlyList<T> một mình chưa đủ — đây là phần chính của bài.

IReadOnlyList<T> là một giao diện, không phải một bảo đảm. Nếu object phía sau vẫn là List<T>, ép kiểu về là phá được:

public IReadOnlyList<OrderItem> Items => _items;      // KHÔNG có AsReadOnly()
((List<OrderItem>)order.Items).Add(new OrderItem(...));      // biên dịch được, chạy được

Không có exception, không có cảnh báo. Và nó xuất hiện trong code thật thường xuyên hơn bạn nghĩ, vì ai đó gặp IReadOnlyList và "sửa" bằng cách ép kiểu.

Ba mức bảo vệ:

// Mức 1 — yếu: ép kiểu về List<T> được
public IReadOnlyList<OrderItem> Items => _items;

// Mức 2 — tốt: AsReadOnly() trả về ReadOnlyCollection<T>, ép kiểu về List<T> ném exception
public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();

// Mức 3 — tốt nhất cho .NET 8+: trả về bản sao chỉ đọc, không chia sẻ tham chiếu nào
public ImmutableArray<OrderItem> Items => [.._items];

Mức 2 là điểm cân bằng tốt cho phần lớn trường hợp. Mức 3 an toàn tuyệt đối nhưng cấp phát một mảng mới mỗi lần đọc — chỉ dùng cho collection nhỏ và ít được đọc.

AsReadOnly() trong .NET 8+ không cấp phát mới mỗi lần gọi nếu bạn cache nó:

private readonly List<OrderItem> _items = new();
private ReadOnlyCollection<OrderItem>? _itemsView;
public IReadOnlyList<OrderItem> Items => _itemsView ??= _items.AsReadOnly();

ReadOnlyCollection<T> là một wrapper trỏ tới list gốc, nên thay đổi trong _items vẫn hiện ra qua view — bạn cache được an toàn.

Còn một lỗ hổng nữa: phần tử bên trong.

foreach (var item in order.Items)
item.Quantity = 999; // collection chỉ đọc, nhưng PHẦN TỬ thì không

Phần tử phải tự bảo vệ mình:

public class OrderItem
{
public ProductId ProductId { get; private set; }
public int Quantity { get; private set; }
public Money UnitPrice { get; private set; }
public Money ThanhTien => UnitPrice * Quantity;

private OrderItem() { }

internal OrderItem(ProductId sanPhamId, int soLuong, Money donGia)
{
ProductId = sanPhamId; Quantity = soLuong; UnitPrice = donGia;
}

internal void TangSoLuong(int them) => Quantity += them;
}

internal cho constructor và cho phương thức sửa đổi là chi tiết quan trọng: chỉ code trong cùng assembly Domain tạo hoặc sửa OrderItem được. Tầng Application không thể tạo một OrderItem rời rồi nhét vào đâu đó.

Cấu hình EF Core cho backing field:

builder.Entity<Order>(b =>
{
b.HasMany(o => o.Items)
.WithOne()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade);

// Bảo EF Core ghi thẳng vào field, không qua property
b.Metadata.FindNavigation(nameof(Order.Items))!
.SetPropertyAccessMode(PropertyAccessMode.Field);
});

Hoặc đặt mặc định cho cả mô hình:

protected override void ConfigureConventions(ModelConfigurationBuilder builder)
=> builder.Properties<object>().HavePropertyAccessMode(PropertyAccessMode.Field);

EF Core tìm backing field theo quy ước: _items, _Items, m_items. Nếu tên field khác, phải chỉ rõ:

b.Navigation(o => o.Items).HasField("_danhSachMatHang");

Và một lưu ý về truy vấn:

// Vẫn hoạt động — EF Core dịch qua navigation
var don = await _db.Orders.Include(o => o.Items).FirstAsync(o => o.Id == id, ct);

// Vẫn hoạt động — dịch sang SQL, không đụng tới collection trong bộ nhớ
var coHang = await _db.Orders.Where(o => o.Items.Any(i => i.ProductId == sp)).ToListAsync(ct);

IReadOnlyList<T> không cản trở EF Core, vì nó làm việc với metadata của navigation chứ không với kiểu C# của property.

Rà toàn bộ entity:

[Fact]
public void Entity_khong_duoc_lo_collection_co_the_sua()
{
var viPham = typeof(Order).Assembly.GetTypes()
.Where(t => t.IsSubclassOf(typeof(EntityBase)) && !t.IsAbstract)
.SelectMany(t => t.GetProperties(BindingFlags.Public | BindingFlags.Instance))
.Where(p =>
{
if (!p.PropertyType.IsGenericType) return false;
var def = p.PropertyType.GetGenericTypeDefinition();
return def == typeof(List<>) || def == typeof(ICollection<>)
|| def == typeof(IList<>) || def == typeof(HashSet<>);
})
.Select(p => $"{p.DeclaringType!.Name}.{p.Name} ({p.PropertyType.Name})")
.ToList();

viPham.Should().BeEmpty(
"entity phải lộ IReadOnlyList<T> và sửa collection qua phương thức domain");
}

Chạy test này lần đầu trên một dự án đang chạy thường cho ra hàng chục vi phạm. Dùng mẫu ratchet ở bài 16.2 để giảm dần thay vì sửa hết một lúc.


Bài 3 — Aggregate quá lớn​

Chọn aggregate lớn nhất và đếm số bảng một SaveChanges của nó chạm tới. Cân nhắc tách theo câu hỏi "cái gì phải đúng ngay lập tức".

Tiêu chí hoàn thành: bạn áp dụng được câu hỏi đó cho từng quan hệ, và nêu được bốn hậu quả cụ thể của aggregate quá lớn.

Gợi ý và lời giải — Bài 3

Gợi ý. Aggregate là ranh giới của tính nhất quán tức thời. Cái gì thật sự cần nhất quán tức thời?

Lời giải — đếm bảng bị chạm:

var don = await _db.Orders
.Include(o => o.Items)
.Include(o => o.Payments)
.Include(o => o.Shipments).ThenInclude(s => s.TrackingEvents)
.Include(o => o.Customer).ThenInclude(c => c.Addresses)
.Include(o => o.Discounts)
.Include(o => o.AuditLogs)
.FirstAsync(o => o.Id == id, ct);

don.ThemMatHang(sanPhamId, 2, donGia);
await _db.SaveChangesAsync(ct);
var soBang = _db.ChangeTracker.Entries()
.Select(e => e.Metadata.GetTableName())
.Distinct().Count();
8 bảng, 1.847 entity được nạp — để thêm MỘT mặt hàng
SELECT sinh ra:  1 truy vấn với 6 JOIN, trả về 1.847 dòng
Thời gian nạp: 840 ms
Bộ nhớ: ~14 MB cho một request
Khoá giữ: trên 8 bảng, suốt thời gian transaction

Áp dụng câu hỏi "cái gì phải đúng ngay lập tức" cho từng quan hệ:

Quan hệNếu lệch 5 giây, ai thiệt hại?Kết luận
Order ↔ OrderItemTổng tiền sai so với các dòng — khách trả sai tiềnCùng aggregate
Order ↔ DiscountGiảm giá áp sai — khách trả sai tiềnCùng aggregate
Order ↔ PaymentĐơn hiện "chưa thanh toán" thêm 5 giâyTách
Order ↔ ShipmentTrạng thái giao hàng cập nhật chậm 5 giâyTách
Order ↔ CustomerThông tin khách hiển thị cũ 5 giâyTách — là aggregate riêng
Order ↔ AuditLogLog ghi chậm 5 giâyTách

Chỉ hai quan hệ cần nhất quán tức thời, vì chỉ chúng ảnh hưởng tới một bất biến không được phép vi phạm dù chỉ một khoảnh khắc: "tổng tiền đơn hàng luôn bằng tổng các dòng đã trừ giảm giá".

Sau khi tách:

public class Order            // Aggregate root
{
private readonly List<OrderItem> _items = new();
private readonly List<Discount> _discounts = new();

public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
public IReadOnlyList<Discount> Discounts => _discounts.AsReadOnly();

public CustomerId CustomerId { get; private set; } // THAM CHIẾU BẰNG ID
public Money Total { get; private set; }
}

public class Payment // Aggregate RIÊNG
{
public PaymentId Id { get; private set; }
public OrderId OrderId { get; private set; } // tham chiếu bằng id
public Money Amount { get; private set; }
}

public class Shipment { public OrderId OrderId { get; private set; } }
public class Customer { public CustomerId Id { get; private set; } }
var don = await _db.Orders
.Include(o => o.Items)
.Include(o => o.Discounts)
.FirstAsync(o => o.Id == id, ct);

don.ThemMatHang(sanPhamId, 2, donGia);
await _db.SaveChangesAsync(ct);
2 bảng, 23 entity, 18 ms       (từ 840 ms)

Quy tắc tham chiếu giữa aggregate — bằng id, không bằng navigation:

// SAI — kéo Customer vào ranh giới của Order
public Customer Customer { get; private set; }

// ĐÚNG — Order chỉ biết id
public CustomerId CustomerId { get; private set; }

Điều này buộc nơi gọi phải tường minh khi cần dữ liệu của aggregate khác:

var don = await _orderRepo.LayAsync(orderId, ct);
var khach = await _customerRepo.LayAsync(don.CustomerId, ct);

Trông dài hơn, nhưng nó làm hai điều: ngăn việc vô tình nạp cả đồ thị, và làm cho ranh giới aggregate hiện rõ trong code thay vì chỉ tồn tại trong tài liệu.

Bốn hậu quả cụ thể của aggregate quá lớn:

1. Hiệu năng. 1.847 entity nạp về để sửa một dòng — cộng với chi phí change tracker đo được ở bài 13.1, và Include nhiều collection gây cartesian explosion (bài 13.5).

2. Tranh chấp concurrency tăng vọt. Với RowVersion trên Order, mọi thay đổi ở bất kỳ bảng con nào cũng làm phiên bản đổi:

Nhân viên kho cập nhật Shipment  -> RowVersion của Order đổi
Nhân viên kế toán đang sửa Items -> DbUpdateConcurrencyException
-> xung đột GIẢ, hai người làm hai việc không liên quan

Với aggregate nhỏ, hai thao tác đó chạm hai aggregate khác nhau và không xung đột.

3. Transaction dài, khoá rộng. Một SaveChanges chạm 8 bảng giữ khoá trên cả 8 bảng cho tới khi commit. Điều này tăng xác suất deadlock — đặc biệt khi hai giao dịch chạm cùng tập bảng theo thứ tự khác nhau (bài 12.10).

4. Không tách dịch vụ được về sau. Nếu Order và Shipment nằm trong một aggregate, chúng phải ở cùng một database và cùng một transaction. Muốn tách module giao hàng thành dịch vụ riêng, bạn phải gỡ ranh giới aggregate trước — và đó là thay đổi lớn hơn nhiều so với việc tách từ đầu.

Ba dấu hiệu aggregate quá lớn:

1. Truy vấn nạp nó có trên 3 Include
2. Một SaveChanges chạm trên 3 bảng
3. Có collection không giới hạn kích thước <- dấu hiệu rõ nhất

Dấu hiệu 3 đáng nhấn mạnh:

public IReadOnlyList<AuditLog> AuditLogs => _auditLogs.AsReadOnly();

Một đơn hàng hoạt động lâu có hàng nghìn dòng audit log. Nạp cả aggregate nghĩa là nạp cả chúng — mỗi lần, cho mọi thao tác. Collection trong aggregate phải có giới hạn tự nhiên: một đơn hàng có tối đa vài chục mặt hàng, nhưng số dòng log thì không có trần.

Tính nhất quán cuối cùng giữa các aggregate — qua domain event:

public Result GhiNhanThanhToan(Money soTien)
{
var thanhToan = Payment.Tao(Id, soTien);
Raise(new ThanhToanDaGhiNhan(Id, soTien));
return Result.ThanhCong();
}
public class CapNhatTrangThaiDonHangHandler : INotificationHandler<ThanhToanDaGhiNhan>
{
public async Task Handle(ThanhToanDaGhiNhan e, CancellationToken ct)
{
var don = await _repo.LayAsync(e.OrderId, ct);
var daTra = await _paymentRepo.TongDaTraAsync(e.OrderId, ct);

if (daTra >= don.Total) don.DanhDauDaThanhToan();
await _db.SaveChangesAsync(ct);
}
}

Trạng thái "đã thanh toán" cập nhật sau vài chục mili giây thay vì tức thời — và đó là đánh đổi đúng, vì không ai thiệt hại nếu nó lệch trong khoảnh khắc đó.

Và một điều cần nói cho cân bằng: aggregate quá nhỏ cũng có vấn đề.

// Tách OrderItem thành aggregate riêng
public class OrderItem { public OrderId OrderId { get; private set; } }
-> tổng tiền đơn hàng KHÔNG còn được bảo vệ bởi transaction
-> phải tự đồng bộ bằng event
-> có khoảnh khắc tổng tiền sai
-> và với tiền, "khoảnh khắc sai" là không chấp nhận được

Câu hỏi "cái gì phải đúng ngay lập tức" cắt cả hai chiều: nó nói cho bạn biết cái gì phải tách ra, và cũng nói cái gì phải giữ lại.

Tự kiểm tra​

Frequently asked questions

Value object cho lợi ích gì so với kiểu nguyên thuỷ?

Ba thứ. Ép bất biến ngay tại chỗ tạo nên đối tượng không tồn tại ở trạng thái không hợp lệ. Chặn lỗi truyền nhầm tham số ở mức biên dịch. Và gom hành vi về đúng chỗ thay vì rải công thức khắp code.

Aggregate là gì theo định nghĩa dùng được?

Là tập đối tượng mà mọi bất biến bên trong phải đúng sau mỗi transaction, và mọi thay đổi đi qua root. Cách vẽ ranh giới là hỏi thứ gì phải đúng ngay lập tức và thứ gì có thể đúng sau vài giây.

Vì sao aggregate phải tham chiếu nhau bằng id?

Vì navigation property khiến bạn sửa hai aggregate trong một thao tác: transaction phình ra và khoá nhiều dòng hơn cần, ranh giới nhất quán biến mất, và ai đó sẽ Include cả cây rồi nạp hàng nghìn dòng thừa.

Cập nhật hai aggregate trong một transaction có sai không?

Không sai khi chúng cùng một database. Quy tắc một transaction một aggregate sinh ra cho hệ phân tán; với monolith dùng chung database thì nó thường quá chặt. Nhưng thay đổi lan sang aggregate ở service khác thì phải bất đồng bộ qua event.

Bounded context là gì?

Là ranh giới nơi một từ có một nghĩa. Customer trong Sales, Billing và Support là ba khái niệm khác nhau, nên ép vào một bảng sẽ cho bảng rất nhiều cột mà phần lớn luôn null. Mỗi context có model riêng và chỉ dùng chung định danh.

Ba công cụ nào nên áp dụng dù không làm DDD đầy đủ?

Value object cho khái niệm nghiệp vụ, private set cho entity có bất biến, và tham chiếu bằng id giữa các nhóm độc lập. Ba thứ đó cho phần lớn lợi ích với rất ít chi phí; aggregate đầy đủ và bounded context thì thêm khi domain thật sự cần.

Kết luận​

Ba điều đáng nhớ nhất:

  1. Aggregate là ranh giới nhất quán, không phải nhóm entity liên quan.
  2. Tham chiếu bằng id, không bằng navigation. Đó là quy tắc hay bị vi phạm nhất.
  3. DDD dành cho domain phức tạp. Bảng tra cứu và CRUD không cần nó.

Tham khảo​

Điều hướng​