16.13 — Liên hệ CRM
Áp dụng kiến trúc vào một CRM cụ thể, với bốn bounded context và ranh giới aggregate rõ ràng. Nguyên tắc chi phối mọi quyết định ở đây: aggregate là ranh giới của tính nhất quán tức thì — thứ gì phải đúng ngay trong cùng một transaction thì nằm trong cùng aggregate; thứ gì chấp nhận đúng sau vài giây thì nằm ngoài. Áp dụng nguyên tắc đó cho ra một kết luận mà nhiều người thấy phản trực giác: Lead và Activity không nên chung aggregate, dù trên sơ đồ ERD chúng có quan hệ cha-con rõ ràng. B ài này cũng đi qua ba quyết định gây tranh cãi nhất trong dự án CRM thật và cách trả lời chúng.
Mục tiêu bài học
Sau bài này bạn có thể:
- Xác định bốn bounded context của một CRM điển hình.
- Chọn ranh giới aggregate theo nhu cầu nhất quán.
- Trả lời ba câu hỏi kiến trúc hay gây tranh cãi.
- Liên kết các context mà không chia sẻ entity.
Nội dung bài học
16.13.1 — Bốn bounded context
| Context | Khái niệm cốt lõi | "Customer" nghĩa là gì ở đây |
|---|---|---|
| Sales | Lead, Opportunity, Quote | Người có thể mua — quan tâm ngân sách, nhu cầu |
| Billing | Invoice, Payment, Subscription | Đơn vị thanh toán — mã số thuế, hạn mức |
| Support | Ticket, SLA, Knowledge base | Người cần hỗ trợ — lịch sử ticket, mức ưu tiên |
| Marketing | Campaign, Segment, Email | Đối tượng tiếp cận — nguồn, hành vi, trạng thái đăng ký |
Cùng một người thật, bốn mô hình khác nhau — và đó là đúng, không phải thiếu chuẩn hoá (bài 16.6).
// Sales
public sealed class Customer
{
public CustomerId Id { get; private set; }
public string CompanyName { get; private set; }
public Money EstimatedBudget { get; private set; }
public ContactPerson DecisionMaker { get; private set; }
}
// Billing — CÙNG một khách hàng thực tế
public sealed class Customer
{
public CustomerId Id { get; private set; }
public string LegalName { get; private set; }
public TaxCode TaxCode { get; private set; }
public Money CreditLimit { get; private set; }
}
Liên kết giữa các context qua CustomerId, không qua tham chiếu entity. Ép chúng thành một lớp dùng chung tạo ra một entity khổng lồ mà không context nào dùng hết, và mọi thay đổi ở một bên đều ảnh hưởng ba bên còn lại.
16.13.2 — Ranh giới aggregate
Cùng aggregate — phải nhất quán ngay:
public sealed class Deal // aggregate root
{
private readonly List<DealLineItem> _lineItems = [];
public IReadOnlyList<DealLineItem> LineItems => _lineItems;
public Money TotalValue { get; private set; }
public void AddLineItem(ProductId productId, int quantity, Money unitPrice)
{
if (Status == DealStatus.Won)
throw new DomainException("Deal đã chốt, không thêm được sản phẩm");
_lineItems.Add(new DealLineItem(productId, quantity, unitPrice));
RecalculateTotal(); // INVARIANT: tong luon khop voi dong
}
}
DealLineItem thuộc aggregate Deal vì có một invariant thật: tổng giá trị deal phải luôn khớp với tổng các dòng. Nếu thêm dòng mà không cập nhật tổng trong cùng transaction, dữ liệu sai ngay lập tức.
Khác aggregate — chỉ cần nhất quán cuối cùng:
public sealed class Lead
{
public LeadId Id { get; private set; }
public LeadStatus Status { get; private set; }
// KHÔNG có List<Activity>
}
public sealed class Activity // aggregate RIENG
{
public ActivityId Id { get; private set; }
public LeadId LeadId { get; private set; } // tham chiếu bằng ID
public string Note { get; private set; }
}
Đây là điểm ph ản trực giác. Trên ERD, Lead và Activity là quan hệ cha-con. Nhưng hỏi: có invariant nào nối chúng không? Một lead có 0 hay 500 activity đều hợp lệ; thêm một activity không làm lead sai.
Hậu quả của việc gộp chúng lại rất cụ thể:
// Neu Activity nam trong aggregate Lead:
var lead = await _repo.GetByIdAsync(leadId, ct); // nap CA 500 activity
lead.AddActivity("Đã gọi điện"); // chi de them MOT dong
await _repo.SaveAsync(lead, ct);
Nạp 500 bản ghi để thêm một. Và với aggregate lớn, tranh chấp đồng thời tăng mạnh — hai người thêm activity cùng lúc sẽ va vào nhau qua concurrency token của Lead.
Quy tắc kiểm tra: "nếu hai thứ này lệch nhau trong 5 giây, có ai bị thiệt hại không?" Không → khác aggregate.
16.13.3 — Ba quyết định gây tranh cãi
Câu hỏi 1: Lead và Customer là một hay hai entity?
Thường gặp đề xuất gộp: "khách hàng cũng chỉ là lead đã chuyển đổi, thêm cột IsConverted là xong".
Vấn đề: hai thứ có vòng đời và quy tắc khác nhau. Lead có thể bị xoá, gộp, đánh dấu spam. Customer có ràng buộc pháp lý, không xoá được khi còn hoá đơn chưa thanh toán. Gộp lại nghĩa là entity phải mang mọi quy tắc của cả hai, và mỗi phương thức phải bắt đầu bằng "nếu là lead thì...".
Trả lời: hai entity riêng. Lead.Convert() tạo ra một Customer mới và đánh dấu lead là đã chuyển đổi.
Câu hỏi 2: Có nên có IRepository<T> chung không?
// Hấp dẫn — viết một lần dùng cho mọi entity
public interface IRepository<T> where T : class
{
Task<T?> GetByIdAsync(Guid id, CancellationToken ct);
Task AddAsync(T entity, CancellationToken ct);
Task DeleteAsync(T entity, CancellationToken ct);
}
Hai vấn đề: nó cho phép truy cập mọi entity kể cả những entity không phải aggregate root — phá vỡ ranh giới aggregate; và nó thường là một lớp mỏng trên DbContext, vốn đã là repository và unit of work (bài 13.10).
Trả lời: repository riêng cho từng aggregate root, với phương thức mang ý nghĩa nghiệp vụ.
public interface ILeadRepository
{
Task<Lead?> GetByIdAsync(LeadId id, CancellationToken ct);
Task<IReadOnlyList<Lead>> GetActiveByOwnerAsync(UserId ownerId, CancellationToken ct);
void Add(Lead lead);
}
Không có Delete nếu nghiệp vụ không cho xoá lead — interface chỉ nên phơi ra những gì hợp lệ.
Câu hỏi 3: Truy vấn đọc có đi qua repository không?
Trả lời: không. Truy vấn đọc cho màn hình danh sách nên đi thẳng, dùng projection (bài 16.5):
// Đọc — không qua repository, không nạp entity
var leads = await _db.Leads
.AsNoTracking()
.Where(l => l.OwnerId == ownerId)
.Select(l => new LeadListItemDto(l.Id, l.Name, l.Status, l.Value.Amount))
.ToListAsync(ct);
Repository phục vụ đường ghi, nơi cần aggregate đầy đủ để áp dụng quy tắc. Đường đọc chỉ cần dữ liệu, và bắt nó đi qua repository buộc phải nạp entity đầy đủ rồi vứt đi phần lớn.
16.13.4 — Liên kết giữa các context
// Sales phát event khi lead chuyển đổi
public void Convert(DateTime utcNow)
{
Status = LeadStatus.Won;
ConvertedAtUtc = utcNow;
Raise(new LeadConvertedDomainEvent(Id, CustomerId));
}
// Handler noi bo dich sang integration event, dua vao outbox
public sealed class PublishLeadConvertedHandler(AppDbContext db)
: INotificationHandler<LeadConvertedDomainEvent>
{
public Task Handle(LeadConvertedDomainEvent e, CancellationToken ct)
{
db.OutboxMessages.Add(OutboxMessage.From(
new LeadConvertedIntegrationEvent(
EventId: Guid.NewGuid(),
CustomerId: e.CustomerId,
CustomerEmail: ...,
OccurredAtUtc: DateTime.UtcNow)));
return Task.CompletedTask;
}
}
Billing và Support nghe integration event và tạo dữ liệu của mình. Không context nào truy cập database của context khác (bài 17.11).
Điểm quan trọng: domain event nằm trong Sales và có thể refactor tự do; integration event là hợp đồng công khai với Billing và Support, đổi là breaking change (bài 17.2).
16.13.5 — Rà lại code của bạn
Danh sách rà soát kiến trúc CRM
- •Mỗi bounded context có mô hình riêng, không dùng chung entity.
- •Các context liên kết qua ID, không qua tham chiếu entity.
- •Aggregate chỉ gộp những thứ có invariant chung thật sự.
- •Lead và Activity là hai aggregate riêng.
- •Lead và Customer là hai entity riêng với vòng đời riêng.
- •Repository riêng cho từng aggregate root, không dùng IRepository chung.
- •Interface repository chỉ phơi ra thao tác hợp lệ về nghiệp vụ.
- •Truy vấn đọc dùng projection, không đi qua repository.
- •Domain event được dịch sang integration event trước khi ra ngoài.
Bài tập áp dụng
Bài 1 — Vẽ bounded context
Với hệ thống của bạn, liệt kê các bounded context và tìm một từ có nghĩa khác nhau ở hai context.
Tiêu chí hoàn thành: bạn tìm được ít nhất một từ như vậy, và giải thích được vì sao gộp hai nghĩa vào một lớp là nguồn của nợ kỹ thuật.
Gợi ý và lời giải — Bài 1
Gợi ý. Hỏi bộ phận bán hàng và bộ phận kế toán cùng một câu: "khách hàng là gì?"
Lời giải — bốn context điển hình của một CRM:
Sales quản lý cơ hội bán hàng
Customer Care chăm sóc sau bán
Billing hoá đơn và thanh toán
Identity tài khoản và phân quyền
Từ "Khách hàng" ở bốn context:
| Context | "Khách hàng" nghĩa là | Thuộc tính quan tâm |
|---|---|---|
| Sales | Một cơ hội, có thể chưa mua gì | Nguồn, giai đoạn, giá trị dự kiến, người phụ trách |
| Customer Care | Người đang dùng sản phẩm | Ticket, hợp đồng dịch vụ, mức độ hài lòng |
| Billing | Pháp nhân trả tiền | Mã số thuế, địa chỉ xuất hoá đơn, hạn mức công nợ |
| Identity | Người đăng nhập | Email, vai trò, lần đăng nhập cuối |
Bốn khái niệm khác nhau, cùng một từ. Và chúng không trùng nhau về phạm vi:
Một cơ hội chưa chốt -> có ở Sales, KHÔNG có ở Billing
Một pháp nhân trả tiền cho -> một pháp nhân ở Billing có thể ứng với
ba công ty con ba khách hàng ở Sales
Một người dùng -> có ở Identity, có thể không phải khách hàng nào
Các từ khác thường có nhiều nghĩa:
| Từ | Nghĩa ở context A | Nghĩa ở context B |
|---|---|---|
| Sản phẩm | Catalog: mô tả, ảnh, giá niêm yết | Kho: SKU, vị trí, tồn kho |
| Đơn hàng | Sales: cơ hội đã chốt | Giao hàng: kiện hàng cần vận chuyển |
| Người dùng | Identity: tài khoản đăng nhập | HR: nhân viên có lương và chức danh |
| Giá | Catalog: giá niêm yết | Sales: giá sau chiết khấu cho từng khách |
| Trạng thái | Sales: giai đoạn bán hàng | Billing: tình trạng thanh toán |
Vì sao gộp hai nghĩa vào một lớp là nguồn của nợ kỹ thuật — đây là phần chính của bài.
// Lớp gộp mọi nghĩa
public class Customer
{
public int Id { get; set; }
public string Name { get; set; }
// Sales
public string? Source { get; set; }
public string? SalesStage { get; set; }
public decimal? DealValue { get; set; }
public string? AssignedSalesRep { get; set; }
// Customer Care
public List<Ticket> Tickets { get; set; }
public string? ServiceLevel { get; set; }
public int? SatisfactionScore { get; set; }
// Billing
public string? TaxCode { get; set; }
public string? BillingAddress { get; set; }
public decimal? CreditLimit { get; set; }
public PaymentTerms? PaymentTerms { get; set; }
// Identity
public string? Email { get; set; }
public string[]? Roles { get; set; }
public DateTime? LastLoginUtc { get; set; }
}
Bốn hậu quả, và chúng tích tụ theo thời gian:
1. Hầu hết thuộc tính là nullable, và không ai biết khi nào cái nào bắt buộc.
TaxCode bắt buộc khi nào? -> khi xuất hoá đơn, nhưng lớp không nói được điều đó
SalesStage có nghĩa gì với một khách đã mua 3 năm? -> không có nghĩa
Không có bất biến nào có thể viết ra, vì mỗi bất biến chỉ đúng trong một context. Đây là lý do một lớp như vậy luôn là anemic domain model — không phải do lười, mà do không thể viết được phương thức nghiệp vụ nào đúng cho mọi trường hợp.
2. Mọi context phải thay đổi khi một context đổi.
Billing cần thêm trường "hình thức thanh toán ưu tiên"
-> sửa lớp Customer
-> Sales, Customer Care, Identity đều build lại, test lại, deploy lại
3. Truy vấn nạp thứ không cần.
SELECT Id, Name, Source, SalesStage, DealValue, AssignedSalesRep,
ServiceLevel, SatisfactionScore, TaxCode, BillingAddress,
CreditLimit, PaymentTerms, Email, Roles, LastLoginUtc, ...
FROM Customers
-- Màn hình bán hàng cần 4 cột, nạp về 24
4. Không tách dịch vụ được. Nếu một ngày module Billing cần thành dịch vụ riêng, nó phụ thuộc vào một lớp mà ba context khác cũng phụ thuộc — và bạn phải gỡ ranh giới trước khi tách.
Tách theo context:
namespace Crm.Sales.Domain;
public class KhachHangTiemNang // "Customer" của Sales
{
public KhachHangTiemNangId Id { get; private set; }
public string Ten { get; private set; }
public NguonKhach Nguon { get; private set; }
public GiaiDoan GiaiDoan { get; private set; }
public Money GiaTriDuKien { get; private set; }
public NhanVienId NguoiPhuTrach { get; private set; }
public Result ChuyenGiaiDoan(GiaiDoan moi) { /* quy tắc của Sales */ }
}
namespace Crm.Billing.Domain;
public class PhapNhanThanhToan // "Customer" của Billing
{
public PhapNhanId Id { get; private set; }
public string TenPhapLy { get; private set; }
public MaSoThue MaSoThue { get; private set; }
public DiaChi DiaChiXuatHoaDon { get; private set; }
public Money HanMucCongNo { get; private set; }
public Result KiemTraHanMuc(Money soTien) { /* quy tắc của Billing */ }
}
Giờ mỗi lớp có bất biến viết ra được:
KhachHangTiemNang: "GiaTriDuKien phải dương"
"chỉ chuyển giai đoạn theo thứ tự đã định"
-> hai quy tắc luôn đúng, không có ngoại lệ
PhapNhanThanhToan: "MaSoThue bắt buộc và đúng định dạng"
"không xuất hoá đơn vượt hạn mức công nợ"
Không thuộc tính nào nullable một cách vô nghĩa, vì mỗi lớp chỉ chứa thứ luôn có ý nghĩa trong context của nó.
Liên kết giữa các context — bằng id, qua một bảng ánh xạ:
public class AnhXaKhachHang
{
public KhachHangTiemNangId SalesId { get; private set; }
public PhapNhanId? BillingId { get; private set; } // null cho tới khi chốt
public NguoiDungId? IdentityId { get; private set; }
}
BillingId nullable ở đây là có nghĩa: một cơ hội chưa chốt thì chưa có pháp nhân thanh toán. Khác hẳn với TaxCode nullable trong lớp gộp, nơi null không nói lên điều gì.
Ba cách tìm bounded context trong hệ thống của bạn:
1. Hỏi các bộ phận cùng một câu hỏi. Nếu bộ phận bán hàng và kế toán mô tả "khách hàng" khác nhau, đó là ranh giới.
2. Tìm lớp có nhiều thuộc tính nullable nhất:
[Fact]
public void Bao_cao_thuoc_tinh_nullable()
{
var bangKe = typeof(Customer).Assembly.GetTypes()
.Where(t => t.IsSubclassOf(typeof(EntityBase)))
.Select(t => new
{
t.Name,
Tong = t.GetProperties().Length,
Nullable = t.GetProperties().Count(p =>
Nullable.GetUnderlyingType(p.PropertyType) is not null
|| new NullabilityInfoContext().Create(p).WriteState == NullabilityState.Nullable),
})
.OrderByDescending(x => (double)x.Nullable / x.Tong)
.ToList();
foreach (var e in bangKe)
_output.WriteLine($"{e.Name,-24} {e.Nullable}/{e.Tong} nullable " +
$"({100.0 * e.Nullable / e.Tong:F0}%)");
}
Customer 18/24 nullable (75%) <- ứng viên rõ ràng
Order 9/19 nullable (47%)
Lead 4/14 nullable (29%)
Tỷ lệ nullable trên 60% gần như luôn nghĩa là lớp đang phục vụ nhiều context.
3. Nhìn vào ai sửa gì trong lịch sử Git:
git log --since='1 year ago' --format='%an' -- src/Crm.Domain/Entities/Customer.cs \
| sort | uniq -c | sort -rn
42 an-sales
38 binh-billing
21 cuong-support
Ba nhóm khác nhau cùng sửa một file là dấu hiệu file đó đang chứa ba mối quan tâm — và đó cũng chính là nguồn của merge conflict ở bài 16.4.
Bài 2 — Kiểm tra ranh giới aggregate
Với mỗi quan hệ cha-con trong ERD, hỏi "lệch nhau 5 giây có ai thiệt hại không" và quyết định gộp hay tách.
Tiêu chí hoàn thành: bạn áp dụng câu hỏi cho ít nhất năm quan hệ, và nêu được hai dấu hiệu bổ sung khi câu trả lời không rõ ràng.
Gợi ý và lời giải — Bài 2
Gợi ý. "Thiệt hại" nghĩa là gì? Ai thiệt hại, và thiệt hại bao nhiêu?
Lời giải — bảng đánh giá:
| Quan hệ | Lệch 5 giây thì | Ai thiệt hại | Quyết định |
|---|---|---|---|
| Order ↔ OrderItem | Tổng tiền khác tổng các dòng | Khách trả sai tiền | Gộp |
| Order ↔ Discount | Giảm giá áp sai | Khách trả sai tiền | Gộp |
| Lead ↔ LeadNote | Ghi chú hiện chậm | Không ai | Tách |
| Lead ↔ LeadActivity | Nhật ký chậm | Không ai | Tách |
| Customer ↔ Address | Địa chỉ mới hiện chậm | Không ai — trừ khi đang xuất hoá đơn | Tách |
| Invoice ↔ InvoiceLine | Tổng hoá đơn sai | Sai sổ sách, sai thuế | Gộp |
| Product ↔ Inventory | Tồn kho hiển thị sai | Có thể bán quá số lượng | Xem bên dưới |
| Order ↔ Payment | Trạng thái thanh toán chậm | Không ai | Tách |
| Order ↔ Shipment | Trạng thái giao hàng chậm | Không ai | Tách |
Dòng Product ↔ Inventory đáng phân tích kỹ, vì nó là trường hợp mà câu trả lời phụ thuộc vào nghiệp vụ:
Cửa hàng cho phép đặt trước, có thể huỷ nếu hết hàng
-> lệch 5 giây = khách đặt rồi nhận thông báo huỷ
-> khó chịu nhưng không mất tiền
-> TÁCH được, dùng nhất quán cuối cùng
Bán vé sự kiện, không được bán quá số ghế
-> lệch 5 giây = bán 2 vé cho 1 ghế
-> KHÔNG chấp nhận được
-> GỘP, hoặc dùng cập nhật nguyên tử (bài 13.8)
Cùng một quan hệ trong ERD, hai câu trả lời khác nhau — vì "thiệt hại" được định nghĩa bởi nghiệp vụ, không bởi cấu trúc dữ liệu.
Hai dấu hiệu bổ sung khi câu trả lời không rõ ràng:
Dấu hiệu 1 — collection có giới hạn tự nhiên không?
Order -> OrderItem: một đơn có tối đa vài chục mặt hàng -> CÓ giới hạn -> gộp được
Lead -> LeadActivity: một lead có thể có hàng nghìn hoạt động -> KHÔNG giới hạn -> tách
Customer -> Address: một khách có vài địa chỉ -> có giới hạn
Product -> Review: một sản phẩm có thể có hàng chục nghìn -> tách
Đây là dấu hiệu kỹ thuật mạnh nhất, vì aggregate được nạp toàn bộ mỗi lần. Một collection không giới hạn nghĩa là thời gian nạp tăng vô hạn theo tuổi của bản ghi.
Khi hai dấu hiệu mâu thuẫn — nghiệp vụ nói gộp nhưng collection không giới hạn — hãy tách theo thời gian:
public class Order
{
private readonly List<OrderItem> _items = new(); // trong aggregate
public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
}
public class OrderAuditLog // aggregate riêng
{
public OrderId OrderId { get; private set; }
}
Dấu hiệu 2 — ai là người thay đổi, và bao nhiêu lần một ngày?
Order và OrderItem: cùng một người (nhân viên bán hàng), cùng một thao tác
-> gộp, vì họ luôn sửa cùng lúc
Order và Shipment: hai người khác nhau (bán hàng và kho), hai thời điểm
-> tách, vì gộp sẽ gây xung đột concurrency giả
Dấu hiệu này quan trọng vì RowVersion đặt trên aggregate root: gộp Shipment vào Order nghĩa là nhân viên kho cập nhật trạng thái giao hàng sẽ làm nhân viên bán hàng đang sửa đơn nhận DbUpdateConcurrencyException — một xung đột hoàn toàn giả (bài 16.5).
Kiểm chứng bằng số liệu thật:
-- Số phần tử con lớn nhất của mỗi quan hệ
SELECT 'OrderItem' AS Quan_He, MAX(c) AS Lon_Nhat, AVG(c * 1.0) AS Trung_Binh
FROM (SELECT OrderId, COUNT(*) c FROM OrderItems GROUP BY OrderId) x
UNION ALL
SELECT 'LeadActivity', MAX(c), AVG(c * 1.0)
FROM (SELECT LeadId, COUNT(*) c FROM LeadActivities GROUP BY LeadId) x;
Quan_He Lon_Nhat Trung_Binh
OrderItem 47 4,2 <- có giới hạn, gộp được
LeadActivity 3.842 184,0 <- không giới hạn, phải tách
-- Hai bảng có được sửa cùng lúc không?
SELECT
SUM(CASE WHEN ABS(DATEDIFF(SECOND, o.UpdatedUtc, s.UpdatedUtc)) < 5
THEN 1 ELSE 0 END) AS Cung_Luc,
COUNT(*) AS Tong
FROM Orders o JOIN Shipments s ON s.OrderId = o.Id;
Cung_Luc Tong
12 8421 <- 0,14% — hai bảng gần như KHÔNG BAO GIỜ sửa cùng lúc
Con số này là bằng chứng mạnh cho việc tách: nếu hai bảng chỉ được sửa cùng lúc trong 0,14% trường hợp, gộp chúng vào một aggregate là trả chi phí cho 99,86% trường hợp còn lại.
Kiểm tra aggregate hiện tại có quá lớn không:
[Fact]
public void Bao_cao_so_bang_moi_aggregate_cham_toi()
{
var don = _db.Orders
.Include(o => o.Items)
.Include(o => o.Payments)
.Include(o => o.Shipments)
.First();
don.ThemMatHang(sanPhamId, 1, donGia);
var bang = _db.ChangeTracker.Entries()
.GroupBy(e => e.Metadata.GetTableName())
.Select(g => $"{g.Key}: {g.Count()} entity")
.ToList();
_output.WriteLine($"Chạm {bang.Count} bảng:");
foreach (var b in bang) _output.WriteLine(" " + b);
}
Chạm 6 bảng:
Orders: 1 entity
OrderItems: 23 entity
Payments: 4 entity
Shipments: 2 entity
ShipmentTrackingEvents: 187 entity
Customers: 1 entity
187 entity của ShipmentTrackingEvents nạp về chỉ để thêm một mặt hàng — đây là dấu hiệu rõ nhất rằng aggregate đang quá lớn.
Ba ngưỡng để đánh giá:
Aggregate khoẻ mạnh:
- dưới 3 bảng mỗi SaveChanges
- dưới 50 entity nạp về cho một thao tác thông thường
- mọi collection có giới hạn tự nhiên biết trước
Và một lời nhắc từ bài 16.5: tách quá nhỏ cũng sai. Tách OrderItem thành aggregate riêng nghĩa là tổng tiền đơn hàng không còn được bảo vệ bởi transaction — và với tiền, một khoảnh khắc sai là không chấp nhận được.
Câu hỏi "lệch 5 giây có ai thiệt hại không" cắt cả hai chiều: nó nói cái gì phải tách, và cũng nói cái gì phải giữ lại.
Bài 3 — Rà repository generic
Nếu đang dùng IRepository<T> chung, liệt kê mọi entity đang được truy cập qua nó và đánh dấu cái nào không phải aggregate root.
Tiêu chí hoàn thành: bạn nêu được vì sao truy cập entity con trực tiếp phá vỡ bất biến của aggregate, và biết cách chặn.
Gợi ý và lời giải — Bài 3
Gợi ý. Nếu bạn nạp một OrderItem rời và sửa số lượng, ai cập nhật tổng tiền của đơn hàng?
Lời giải — liệt kê:
grep -rn "IRepository<" --include="*.cs" src/ \
| grep -oP "IRepository<\K[A-Za-z]+" | sort | uniq -c | sort -rn
34 Lead
28 Order
19 Customer
14 OrderItem <- entity CON
11 Invoice
9 InvoiceLine <- entity CON
7 LeadNote <- entity CON
6 Address <- entity CON
Bốn entity con đang được truy cập trực tiếp, ở tổng cộng 36 chỗ.
Vì sao truy cập entity con trực tiếp phá vỡ bất biến:
// Nạp OrderItem RỜI, không qua Order
var item = await _itemRepo.GetByIdAsync(itemId, ct);
item.Quantity = 10; // từ 2 lên 10
await _itemRepo.SaveChangesAsync(ct);
SELECT Id, OrderId, Total FROM Orders WHERE Id = 42;
SELECT SUM(Quantity * UnitPrice) FROM OrderItems WHERE OrderId = 42;
Total: 2.400.000 <- tổng cũ
SUM: 12.000.000 <- tổng thật sau khi sửa
Tổng tiền đơn hàng sai. Và không có gì báo lỗi, vì:
1. OrderItem không biết nó thuộc về Order nào về mặt nghiệp vụ
-> nó không có cách nào gọi TinhLaiTong()
2. Order không được nạp
-> phương thức domain của nó không chạy
3. RowVersion của Order không đổi
-> lần sửa tiếp theo của Order không phát hiện xung đột
Đây chính xác là thứ mà private set và IReadOnlyList ở bài 16.5 ngăn chặn — nhưng IRepository<OrderItem> mở lại cửa hậu đó, vì nó cho phép nạp và lưu entity con mà không qua root.
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
Cách đúng — chỉ aggregate root có repository:
public interface IOrderRepository
{
Task<Order?> LayAsync(OrderId id, CancellationToken ct);
Task ThemAsync(Order don, CancellationToken ct);
// KHÔNG có phương thức nào cho OrderItem
}
var don = await _orderRepo.LayAsync(orderId, ct);
if (don is null) return Result.KhongTimThay();
var kq = don.DoiSoLuong(sanPhamId, 10); // qua root -> tổng được tính lại
if (!kq.ThanhCong) return kq;
await _db.SaveChangesAsync(ct);
public Result DoiSoLuong(ProductId sanPhamId, int soLuongMoi)
{
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");
item.DoiSoLuong(soLuongMoi);
TinhLaiTong(); // bất biến LUÔN được giữ
return Result.ThanhCong();
}
Chặn bằng ba lớp:
Lớp 1 — marker interface cho aggregate root:
public interface IAggregateRoot { }
public class Order : EntityBase, IAggregateRoot { }
public class Lead : EntityBase, IAggregateRoot { }
public class OrderItem : EntityBase { } // KHÔNG có marker
public interface IRepository<T> where T : class, IAggregateRoot
{
Task<T?> LayAsync(Guid id, CancellationToken ct);
Task ThemAsync(T entity, CancellationToken ct);
}
services.AddScoped<IRepository<OrderItem>, Repository<OrderItem>>();
error CS0311: The type 'OrderItem' cannot be used as type parameter 'T'.
There is no implicit reference conversion from 'OrderItem' to 'IAggregateRoot'.
Trình biên dịch chặn, không cần ai nhớ.
Lớp 2 — kiến trúc test cho những đường vòng khác:
[Fact]
public void Chi_aggregate_root_moi_duoc_lam_DbSet()
{
var dbSetTypes = typeof(CrmDbContext).GetProperties()
.Where(p => p.PropertyType.IsGenericType
&& p.PropertyType.GetGenericTypeDefinition() == typeof(DbSet<>))
.Select(p => p.PropertyType.GetGenericArguments()[0])
.ToList();
var khongPhaiRoot = dbSetTypes
.Where(t => !typeof(IAggregateRoot).IsAssignableFrom(t))
.Select(t => t.Name)
.ToList();
khongPhaiRoot.Should().BeEmpty(
"entity con không nên có DbSet riêng; truy cập chúng qua aggregate root");
}
// Entity con vẫn được ánh xạ, nhưng KHÔNG có DbSet
public DbSet<Order> Orders => Set<Order>();
// KHÔNG có: public DbSet<OrderItem> OrderItems
EF Core vẫn ánh xạ OrderItem qua navigation của Order, nên truy vấn Include và Any vẫn hoạt động bình thường:
var co = await _db.Orders.AnyAsync(o => o.Items.Any(i => i.ProductId == sp), ct);
Lớp 3 — ràng buộc database làm lớp cuối:
ALTER TABLE Orders ADD CONSTRAINT CK_Orders_Total
CHECK (Total >= 0);
Ràng buộc "tổng khớp với các dòng" không viết được bằng CHECK đơn giản, nhưng một job kiểm tra định kỳ thì được:
public class KiemTraToanVenJob : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromHours(6));
while (await timer.WaitForNextTickAsync(ct))
{
var lech = await _db.Database.SqlQuery<int>($@"
SELECT COUNT(*) FROM Orders o
WHERE o.Total <> (SELECT ISNULL(SUM(i.Quantity * i.UnitPrice), 0)
FROM OrderItems i WHERE i.OrderId = o.Id)")
.FirstAsync(ct);
if (lech > 0)
_logger.LogError("Có {SoDon} đơn hàng lệch tổng tiền — " +
"có đường ghi chưa được kiểm soát", lech);
}
}
}
Và câu hỏi lớn hơn: có nên dùng IRepository<T> generic không?
Ngay cả khi đã giới hạn cho aggregate root, repository generic vẫn có vấn đề đã mổ xẻ ở bài 13.9: nó chặn AsNoTracking, Include, ExecuteUpdate, projection, và số phương thức cần có tăng theo tổ hợp điều kiện.
Cách thực dụng hơn — repository riêng cho từng aggregate, và chỉ cho đường ghi:
public interface IOrderRepository
{
Task<Order?> LayAsync(OrderId id, CancellationToken ct);
Task<Order?> LayKemMatHangAsync(OrderId id, CancellationToken ct);
Task ThemAsync(Order don, CancellationToken ct);
}
// Đường ĐỌC không qua repository
public interface IOrderQueries
{
Task<PagedResult<OrderDto>> TimAsync(string tenantId, int trang, int coTrang, CancellationToken ct);
Task<OrderChiTietDto?> LayChiTietAsync(OrderId id, CancellationToken ct);
}
Cách này giữ được lợi ích thật — mọi đường ghi đều đi qua aggregate root — mà không trả cái giá của lớp trừu tượng ở đường đọc.
Rà soát bằng ba lệnh:
# 1. DbSet cho entity con
grep -n "DbSet<" src/Crm.Infrastructure/CrmDbContext.cs
# 2. Repository cho entity con
grep -rn "IRepository<OrderItem>\|IRepository<InvoiceLine>" --include="*.cs" src/
# 3. Gán trực tiếp thuộc tính của entity con
grep -rn "\.Quantity\s*=\|\.UnitPrice\s*=" --include="*.cs" src/ | grep -v "=="
Mỗi kết quả là một đường ghi có thể phá vỡ bất biến của aggregate — và mỗi đường như vậy là nguồn của những dòng dữ liệu lệch mà bạn sẽ tìm thấy khi chạy câu truy vấn đối chiếu ở đầu bài.
Tự kiểm tra
Frequently asked questions
Vì sao bốn context có bốn mô hình Customer khác nhau lại đúng?
Vì mỗi context quan tâm khía cạnh khác nhau của cùng một người. Sales cần ngân sách và người ra quyết định, Billing cần mã số thuế và hạn mức. Ép thành một lớp dùng chung tạo entity khổng lồ mà không context nào dùng hết.
Quy tắc xác định ranh giới aggregate là gì?
Aggregate là ranh giới của tính nhất quán tức thì. Câu hỏi kiểm tra là nếu hai thứ này lệch nhau trong năm giây thì có ai bị thiệt hại không. Nếu không thì chúng thuộc hai aggregate khác nhau.
Vì sao Lead và Activity không nên chung aggregate?
Vì không có invariant nào nối chúng: một lead có 0 hay 500 activity đều hợp lệ. Gộp lại buộc phải nạp toàn bộ activity chỉ để thêm một dòng, và làm tăng tranh chấp đồng thời qua concurrency token của lead.
Vì sao Lead và Customer nên là hai entity riêng?
Vì chúng có vòng đời và quy tắc khác nhau. Lead xoá được, gộp được, đánh dấu spam được. Customer có ràng buộc pháp lý và không xoá được khi còn hoá đơn. Gộp lại khiến mọi phương thức phải bắt đầu bằng nếu là lead thì.
Vì sao không nên dùng IRepository chung?
Vì nó cho truy cập mọi entity kể cả những cái không phải aggregate root, phá vỡ ranh giới aggregate. Và nó thường chỉ là lớp mỏng trên DbContext, vốn đã là repository và unit of work rồi.
Vì sao truy vấn đọc không nên đi qua repository?
Vì repository phục vụ đường ghi, nơi cần aggregate đầy đủ để áp quy tắc. Đường đọc chỉ cần dữ liệu, và bắt nó qua repository buộc phải nạp entity đầy đủ rồi vứt đi phần lớn.
Kết luận
Ba điều đáng nhớ nhất:
- Aggregate là ranh giới của nhất quán tức thì, không phải ranh giới của ERD.
- Repository riêng cho từng aggregate root, chỉ phơi ra thao tác hợp lệ.
- Đường đọc không đi qua repository. Projection là đủ và nhanh hơn nhiều.
Tham khảo
- Design a DDD-oriented microservice
- Domain-Driven Design (Martin Fowler)
- Domain events design and implementation
- Identify microservice domain model boundaries
Điều hướng
- Bài trước: 16.11 — Bổ sung (đối chiếu roadmap.sh)
- Bài tiếp theo: 16.13 — Mở rộng và đào sâu
- Về module: Trang mục lục