Skip to main content

16.14 — Mở rộng và đào sâu

Summary

Những mẫu thiết kế nằm ngoài phạm vi module, kèm điều kiện cụ thể. Mục đáng làm sớm nhất và cũng bị đánh giá thấp nhất là Result thay cho exception cho lỗi nghiệp vụ — không phải vì exception "xấu", mà vì lỗi nghiệp vụ không phải trường hợp ngoại lệ: "số dư không đủ" là một kết quả bình thường và được dự kiến của việc rút tiền, nên biểu diễn nó bằng cơ chế dành cho điều bất thường làm chữ ký phương thức nói dối về những gì nó có thể trả về. Ngược lại, mục cần thận trọng nhất là event sourcing: nó đổi toàn bộ cách đội làm việc với dữ liệu và gần như không quay lại được.

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

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

  • Chọn giữa Result và exception cho từng loại lỗi.
  • Áp dụng Specification pattern khi điều kiện truy vấn bị nhân bản.
  • Thiết kế Value Object đúng cách với EF Core.
  • Đánh giá event sourcing có phù hợp hay không.

Nội dung bài học​

16.14.1 — Result thay cho exception​

Điều kiện kích hoạt: ngay từ đầu, cho mọi lỗi nghiệp vụ dự kiến được.

// Exception cho lỗi NGHIỆP VỤ — chữ ký phương thức nói dối
public async Task<Customer> ConvertLeadAsync(LeadId id, CancellationToken ct)
{
var lead = await _repo.GetByIdAsync(id, ct);
if (lead is null)
throw new NotFoundException("Không tìm thấy lead"); // "ngoai le"?
if (lead.Status == LeadStatus.Lost)
throw new BusinessException("Lead đã mất"); // "ngoai le"?
// ...
}

Chữ ký nói "tôi trả về Customer", nhưng thực tế nó có thể ném hai loại exception mà trình biên dịch không hề nhắc người gọi.

// Result — mọi kết quả có thể có đều nằm trong chữ ký
public async Task<Result<Customer>> ConvertLeadAsync(LeadId id, CancellationToken ct)
{
var lead = await _repo.GetByIdAsync(id, ct);
if (lead is null)
return Result.NotFound<Customer>("Không tìm thấy lead");
if (lead.Status == LeadStatus.Lost)
return Result.Failure<Customer>("Lead đã mất, không thể chuyển đổi");

var customer = lead.Convert(_clock.UtcNow);
return Result.Success(customer);
}

Ba lợi ích cụ thể:

Lợi íchChi tiết
Chữ ký trung thựcNgười gọi thấy ngay có thể thất bại
Hiệu năngNém exception tốn kém hơn trả về giá trị rất nhiều
Ánh xạ HTTP rõ ràngResult mang mã lỗi, controller dịch sang status code
// Controller mong — chi dich Result sang HTTP
return result.IsSuccess
? Ok(result.Value)
: result.Error.ToProblemDetails();

Vẫn dùng exception cho: lỗi lập trình (ArgumentNullException), vi phạm invariant trong domain (DomainException — vì lúc đó dữ liệu đã hỏng và không nên tiếp tục), và lỗi hạ tầng (mất kết nối).

Ranh giới thực dụng: lỗi mà người dùng có thể sửa bằng cách nhập lại thì dùng Result; lỗi mà lập trình viên phải sửa thì dùng exception.

Thư viện Ardalis.Result hoặc FluentResults cung cấp sẵn, hoặc tự viết một struct nhỏ — nó không phức tạp.

16.14.2 — Specification pattern​

Điều kiện kích hoạt: cùng một điều kiện lọc xuất hiện ở ba nơi trở lên.

// Điều kiện "lead đang hoạt động của tenant" lặp ở nhiều chỗ
.Where(l => l.TenantId == tenantId && l.Status != LeadStatus.Lost && !l.IsDeleted)
public sealed class ActiveLeadsSpec : Specification<Lead>
{
public ActiveLeadsSpec(TenantId tenantId)
{
Query.Where(l => l.TenantId == tenantId
&& l.Status != LeadStatus.Lost
&& !l.IsDeleted)
.OrderByDescending(l => l.CreatedAtUtc);
}
}

// Su dung
var leads = await _repo.ListAsync(new ActiveLeadsSpec(tenantId), ct);

Lợi ích: điều kiện nằm một chỗ, test được độc lập, và kết hợp được với nhau.

Cảnh báo: Specification dễ bị lạm dụng thành một tầng trừu tượng thừa. Với truy vấn chỉ dùng một lần, viết LINQ thẳng ngắn gọn và dễ đọc hơn. Và nếu điều kiện là bảo mật (lọc theo tenant), global query filter chặt chẽ hơn vì nó không thể bị quên (bài 13.11).

Ardalis.Specification là thư viện phổ biến nhất trên .NET.

16.14.3 — Value Object nâng cao​

Điều kiện kích hoạt: một khái niệm nghiệp vụ được biểu diễn bằng kiểu nguyên thuỷ và có quy tắc riêng.

public sealed record Money
{
public decimal Amount { get; }
public string Currency { get; }

public Money(decimal amount, string currency)
{
if (amount < 0) throw new DomainException("Số tiền không được âm");
if (currency.Length != 3) throw new DomainException("Mã tiền tệ phải có 3 ký tự");

Amount = amount;
Currency = 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 hai loại tiền tệ khác nhau");
return new Money(Amount + other.Amount, Currency);
}
}

Quy tắc "không cộng hai loại tiền tệ" giờ không thể vi phạm — trước đó, với hai biến decimal, trình biên dịch vui vẻ cho bạn cộng VND với USD.

Ba cách ánh xạ với EF Core:

// 1. Owned entity — lưu thành nhiều cột
b.OwnsOne(l => l.Value, v =>
{
v.Property(m => m.Amount).HasColumnName("Value").HasColumnType("decimal(18,2)");
v.Property(m => m.Currency).HasColumnName("Currency").HasMaxLength(3);
});

// 2. Value converter — lưu thành MỘT cột
b.Property(l => l.Email)
.HasConversion(e => e.Value, v => new EmailAddress(v))
.HasMaxLength(320);

// 3. Complex type (EF Core 8+) — không bị theo dõi như entity
b.ComplexProperty(l => l.Value);

Cách 3 là lựa chọn tốt nhất khi có sẵn: value object được đối xử đúng như giá trị, không phải entity có định danh, nên tránh được các vấn đề về change tracking mà OwnsOne đôi khi gây ra.

Strongly-typed ID là ứng dụng đáng làm nhất của value object:

public readonly record struct LeadId(Guid Value)
{
public static LeadId New() => new(Guid.NewGuid());
}

// Trình biên dịch chặn lỗi này — với Guid thuần thì không
await _service.ConvertLeadAsync(customerId); // CompileError: sai kieu

Đây là loại bug rất khó tìm khi mọi id đều là Guid — và trình biên dịch bắt được nó miễn phí.

16.14.4 — Event sourcing​

Điều kiện kích hoạt: nghiệp vụ yêu cầu lịch sử đầy đủ vì lý do kiểm toán hoặc pháp lý.

Đã nói kỹ ở bài 17.12. Điểm cần nhắc lại ở đây: đây là quyết định gần như không quay lại được, và nó đổi cách cả đội làm việc với dữ liệu, không chỉ đổi một tầng.

Với phần lớn CRM/ERP, bảng audit log cho lịch sử đầy đủ mà không đổi mô hình lập trình — và đó thường là điểm cân bằng đúng.

16.14.5 — Thứ tự ưu tiên​

Mức độ nên làmMẫu
Làm sớm, rẻ, lợi ích rõResult cho lỗi nghiệp vụ, strongly-typed ID
Làm khi thấy trùng lặpSpecification, Value Object cho khái niệm phức tạp
Cân nhắc kỹ, khó quay lạiEvent sourcing, CQRS tách database

Hai mục hàng đầu nên làm ngay từ dự án mới: chúng rẻ, và sửa lại sau khi codebase đã lớn thì đắt hơn nhiều.

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

Danh sách rà soát mẫu nâng cao

  • •Lỗi nghiệp vụ trả về Result, không ném exception.
  • •Exception chỉ dùng cho lỗi lập trình và vi phạm invariant.
  • •Id của entity dùng kiểu riêng, không dùng Guid thuần.
  • •Khái niệm nghiệp vụ có quy tắc được bọc thành value object.
  • •Value object ánh xạ bằng complex property hoặc converter phù hợp.
  • •Điều kiện lọc lặp từ ba nơi trở lên được gom thành specification.
  • •Lọc theo tenant dùng global query filter, không dùng specification.
  • •Đã cân nhắc bảng audit log trước khi nghĩ tới event sourcing.

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

Bài 1 — Chuyển từ exception sang Result​

Chọn một use case đang ném BusinessException và chuyển sang Result. So sánh code của controller trước và sau.

Tiêu chí hoàn thành: bạn nêu được tiêu chí phân biệt lỗi mong đợi với lỗi bất thường, và biết cái giá của Result.

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

Gợi ý. "Lead chưa được gán cho ai" có phải là một tình huống ngoại lệ không? Nó xảy ra bao nhiêu lần một ngày?

Lời giải — trước, dùng exception:

public async Task ChotLeadAsync(LeadId id, CancellationToken ct)
{
var lead = await _db.Leads.FirstOrDefaultAsync(l => l.Id == id, ct)
?? throw new NotFoundException($"Không tìm thấy lead {id}");

if (lead.Status is LeadStatus.Won or LeadStatus.Lost)
throw new BusinessException("Lead đã ở trạng thái cuối");

if (lead.AssignedTo is null)
throw new BusinessException("Lead chưa được gán cho ai");

lead.ChuyenSangWon(...);
await _db.SaveChangesAsync(ct);
}
app.UseExceptionHandler(a => a.Run(async ctx =>
{
var ex = ctx.Features.Get<IExceptionHandlerFeature>()?.Error;
var (ma, tieuDe) = ex switch
{
NotFoundException => (404, "Không tìm thấy"),
BusinessException => (400, "Vi phạm quy tắc nghiệp vụ"),
ValidationException => (400, "Dữ liệu không hợp lệ"),
UnauthorizedException => (403, "Không có quyền"),
_ => (500, "Lỗi hệ thống"),
};
ctx.Response.StatusCode = ma;
await ctx.Response.WriteAsJsonAsync(new ProblemDetails { Title = tieuDe, Detail = ex?.Message });
}));

Sau, dùng Result:

public async Task<Result> ChotLeadAsync(LeadId id, CancellationToken ct)
{
var lead = await _db.Leads.FirstOrDefaultAsync(l => l.Id == id, ct);
if (lead is null) return Result.KhongTimThay($"Không tìm thấy lead {id}");

var kq = lead.ChuyenSangWon(_user.ToNguoiDung(), _clock.GetUtcNow().UtcDateTime);
if (!kq.ThanhCong) return kq;

await _db.SaveChangesAsync(ct);
return Result.ThanhCong();
}
app.MapPost("/leads/{id}/chot", async (LeadId id, ILeadService svc, CancellationToken ct) =>
{
var kq = await svc.ChotLeadAsync(id, ct);
return kq.ToHttpResult();
});
public static IResult ToHttpResult(this Result kq) => kq.LoaiLoi switch
{
null => Results.NoContent(),
LoaiLoi.KhongTimThay => Results.NotFound(new ProblemDetails { Detail = kq.Loi }),
LoaiLoi.ViPhamQuyTac => Results.BadRequest(new ProblemDetails { Detail = kq.Loi }),
LoaiLoi.KhongCoQuyen => Results.Forbid(),
LoaiLoi.XungDot => Results.Conflict(new ProblemDetails { Detail = kq.Loi }),
_ => Results.Problem(),
};

Bốn khác biệt quan trọng:

ExceptionResult
Xuất hiện trong chữ kýKhông — người gọi không biếtCó — Task<Result>
Trình biên dịch nhắc xử lýKhôngCó, nếu dùng đúng
Chi phí khi xảy raCao — dựng stack trace, unwindGần như không
Luồng điều khiểnNhảy ra chỗ khácTuần tự, đọc được

Cột đầu tiên là khác biệt căn bản: với exception, đọc chữ ký Task ChotLeadAsync(...) không cho biết phương thức này có thể thất bại theo những cách nào. Với Task<Result>, nó hiện rõ.

Tiêu chí phân biệt lỗi mong đợi với lỗi bất thường:

"Tình huống này có nằm trong luồng nghiệp vụ bình thường không?"

Có  -> lỗi MONG ĐỢI -> Result
Không -> lỗi BẤT THƯỜNG -> exception

Áp dụng:

Tình huốngLoạiVì sao
Lead chưa được gánMong đợiXảy ra hàng chục lần mỗi ngày, là một nhánh nghiệp vụ hợp lệ
Lead đã ở trạng thái cuốiMong đợiHai người cùng bấm nút — chuyện bình thường
Không tìm thấy leadMong đợiNgười dùng gõ sai URL, bản ghi vừa bị xoá
Vượt hạn mức công nợMong đợiĐây là một quyết định nghiệp vụ, không phải sự cố
Mất kết nối databaseBất thườngHạ tầng hỏng
Chia cho 0 trong công thứcBất thườngLỗi lập trình
Thiếu cấu hình bắt buộcBất thườngSai cấu hình triển khai
Deserialize thất bạiBất thườngDữ liệu hỏng hoặc hợp đồng sai

Một cách kiểm tra nhanh: nếu bạn sẽ đặt cảnh báo cho nó, đó là exception. Nếu không, đó là Result.

Không ai muốn nhận cảnh báo lúc 2 giờ sáng vì "có người cố chốt một lead đã chốt".

Ba cái giá của Result:

1. Phải kiểm tra ở mọi tầng, và quên là lỗi im lặng:

var kq = lead.ChuyenSangWon(...);
// Quên kiểm tra kq.ThanhCong
await _db.SaveChangesAsync(ct); // lưu dù thao tác thất bại
return Result.ThanhCong(); // báo thành công

Exception không thể bị quên — nó tự nhảy ra. Result thì có thể.

Giảm thiểu bằng analyzer:

[MustUseReturnValue]
public Result ChuyenSangWon(...) { }
<PackageReference Include="ErrorProne.NET.CoreAnalyzers" Version="..." PrivateAssets="all" />
dotnet_diagnostic.EPC12.severity = error    # bỏ qua giá trị trả về

2. Code dài hơn khi có nhiều bước:

var kq1 = lead.KiemTraQuyen(nguoiDung);
if (!kq1.ThanhCong) return kq1;

var kq2 = lead.ChuyenSangWon(nguoiDung, bayGio);
if (!kq2.ThanhCong) return kq2;

var kq3 = don.TaoTuLead(lead);
if (!kq3.ThanhCong) return kq3;

Giảm bằng railway-oriented programming:

return lead.KiemTraQuyen(nguoiDung)
.Bind(() => lead.ChuyenSangWon(nguoiDung, bayGio))
.Bind(() => don.TaoTuLead(lead))
.Tap(() => _logger.LogInformation("Đã chốt lead {Id}", lead.Id));

Thư viện CSharpFunctionalExtensions hoặc FluentResults cung cấp sẵn. Nhưng cân nhắc: cú pháp này lạ với nhiều người trong nhóm, và chi phí học không nhỏ.

3. Không dùng được trong constructor và property:

// Constructor không trả về Result được
public Money(decimal amount, string currency)
{
if (amount < 0) throw new ArgumentException(...); // buộc phải exception
}

// Dùng factory method thay thế
public static Result<Money> Tao(decimal amount, string currency)
{
if (amount < 0) return Result<Money>.Loi("Số tiền không được âm");
return Result<Money>.ThanhCong(new Money(amount, currency));
}

Cách dùng thực dụng — kết hợp cả hai:

Result:     lỗi nghiệp vụ mong đợi
-> tầng domain và application

Exception: lỗi hạ tầng, lỗi lập trình, vi phạm điều kiện tiên quyết
-> ArgumentNullException, InvalidOperationException, SqlException
public Result ChuyenSangWon(NguoiDung nguoiThucHien, DateTime bayGio)
{
// Điều kiện tiên quyết — vi phạm là LỖI LẬP TRÌNH
ArgumentNullException.ThrowIfNull(nguoiThucHien);

// Quy tắc nghiệp vụ — Result
if (Status is LeadStatus.Won or LeadStatus.Lost)
return Result.Loi("Lead đã ở trạng thái cuối");

Status = LeadStatus.Won;
ClosedUtc = bayGio;
return Result.ThanhCong();
}

nguoiThucHien bằng null không phải tình huống nghiệp vụ — đó là bug ở nơi gọi, và nó phải nổ to để được sửa.

Và một lợi ích của Result ít được nhắc: hiệu năng ở đường lỗi.

Ném và bắt một exception:  ~5–50 micro giây (dựng stack trace, unwind)
Trả về một Result: ~10 nano giây

Chênh lệch ba bậc độ lớn. Với một endpoint mà 20% request vi phạm quy tắc nghiệp vụ — chuyện bình thường với API công khai — exception trở thành chi phí đáng kể. Và nó còn làm nhiễu profiler cùng hệ thống theo dõi lỗi, vì mọi exception đều được ghi nhận.

Khi nào KHÔNG cần chuyển sang Result:

- Dự án nhỏ, ít quy tắc nghiệp vụ
- Nhóm đã quen với exception và có middleware xử lý tốt
- Tỷ lệ lỗi nghiệp vụ thấp (dưới 1% request)

Chuyển đổi là công việc nhiều tháng trên một codebase lớn. Nếu middleware xử lý exception đang hoạt động tốt và không ai phàn nàn, lợi ích có thể không xứng với chi phí — hãy áp dụng Result cho code mới và để code cũ yên.


Bài 2 — Strongly-typed ID​

Thêm kiểu id riêng cho một entity và xem trình biên dịch bắt được bao nhiêu chỗ truyền nhầm.

Tiêu chí hoàn thành: bạn đếm được số lỗi biên dịch, và mỗi lỗi bạn xác định được đó là lỗi thật hay chỉ cần đổi kiểu.

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

Gợi ý. Không phải mọi lỗi biên dịch đều là bug đang tồn tại. Nhưng một số thì đúng là.

Lời giải — phần kỹ thuật đã có ở bài 16.5. Bài này tập trung vào việc đọc kết quả.

public readonly record struct LeadId(Guid Value)
{
public static LeadId Moi() => new(Guid.CreateVersion7());
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;
}
public override string ToString() => Value.ToString();
}
public class Lead
{
public LeadId Id { get; private set; } // từ Guid
public CustomerId CustomerId { get; private set; }
}
dotnet build 2>&1 | grep -E "error CS" | wc -l
142

Phân loại 142 lỗi:

dotnet build 2>&1 | grep -oP "error \K CS\d+" | sort | uniq -c | sort -rn
   98 CS1503     Argument type mismatch
31 CS0029 Cannot implicitly convert
9 CS0019 Operator cannot be applied
4 CS1061 Does not contain a definition

Loại 1 — chỉ cần đổi kiểu (khoảng 90%):

// Trước
public async Task<Lead?> LayAsync(Guid id, CancellationToken ct)

// Sau
public async Task<Lead?> LayAsync(LeadId id, CancellationToken ct)
// Trước
var lead = await _repo.LayAsync(request.LeadId, ct); // request.LeadId là Guid

// Sau — đổi kiểu trong DTO luôn
public record ChotLeadRequest(LeadId LeadId);

Những lỗi này là chi phí chuyển đổi, không phải bug. Sửa cơ học, từ ngoài vào trong: đổi kiểu ở entity trước, rồi repository, rồi handler, rồi DTO.

Loại 2 — lỗi thật (khoảng 5–10%):

// LỖI THẬT — truyền nhầm thứ tự
await _svc.GanLeadAsync(nguoiDungId, leadId, ct);
// ^^^^^^^^^^^ ^^^^^^ đảo ngược
error CS1503: Argument 1: cannot convert from 'NguoiDungId' to 'LeadId'
error CS1503: Argument 2: cannot convert from 'LeadId' to 'NguoiDungId'

Hai lỗi cùng một dòng, hai chiều ngược nhau — đây là chữ ký rõ nhất của một bug truyền nhầm thứ tự.

// LỖI THẬT — so sánh hai loại id khác nhau
if (lead.Id == customer.Id) // trước đây biên dịch được!
error CS0019: Operator '==' cannot be applied to operands of type 'LeadId' and 'CustomerId'

Đoạn này trước đây luôn trả về false (hai Guid khác nhau), nên nhánh if không bao giờ chạy. Một nhánh code chết mà không ai biết.

// LỖI THẬT — dùng id của aggregate khác làm khoá cache
_cache.Set($"lead:{customerId}", lead, ttl);

Cách tìm lỗi thật giữa 142 lỗi:

# Dòng nào có HAI lỗi CS1503 -> gần như chắc chắn truyền nhầm thứ tự
dotnet build 2>&1 | grep "error CS1503" \
| grep -oP "^[^(]+\(\d+" | sort | uniq -c | awk '$1 > 1'
   2 src/Crm.Api/Controllers/LeadsController.cs(87
2 src/Crm.Application/Services/LeadService.cs(214
2 src/Crm.Infrastructure/Jobs/AssignLeadJob.cs(56

Ba chỗ truyền nhầm thứ tự — ba bug đã ở production.

# CS0019 trên toán tử so sánh -> so sánh hai loại id khác nhau
dotnet build 2>&1 | grep "error CS0019"

Ghi lại kết quả:

## Strongly-typed ID — kết quả

Áp dụng cho: LeadId, CustomerId, OrderId, NguoiDungId
Tổng lỗi biên dịch: 142

| Loại | Số lượng | Ghi chú |
|---|---:|---|
| Đổi kiểu cơ học | 129 | chi phí chuyển đổi |
| **Truyền nhầm thứ tự** | **3** | bug đang ở production |
| **So sánh id khác loại** | **2** | nhánh code chết |
| Khác | 8 | mapping, serialize |

### Ba bug tìm được
1. `LeadsController.cs:87` — `GanLeadAsync(nguoiDungId, leadId)` đảo thứ tự
-> gán sai lead cho sai người, không có lỗi nào
2. `LeadService.cs:214` — tương tự
3. `AssignLeadJob.cs:56` — tương tự

### Hai nhánh code chết
1. `LeadService.cs:302` — `if (lead.Id == customer.Id)` luôn false
2. `BaoCaoService.cs:88` — tương tự

Chiến lược áp dụng dần — đừng làm hết một lúc:

Sprint 1: LeadId          -> khoảng 40 lỗi, sửa trong một buổi
Sprint 2: CustomerId -> khoảng 35 lỗi
Sprint 3: OrderId, NguoiDungId

Làm từng kiểu một nghĩa là mỗi PR có kích thước review được, và bạn dừng lại được bất cứ lúc nào.

Và dùng source generator để không phải viết tay:

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

Một attribute sinh ra struct, converter cho EF Core, JSON, Dapper, TryParse và TypeConverter — khoảng 120 dòng mà bạn không phải viết và không phải bảo trì.

Kiểm chứng hiệu quả sau khi áp dụng:

# Trước: bao nhiêu phương thức nhận từ hai Guid trở lên?
git stash && grep -rnE "\(([^)]*Guid [a-zA-Z]+,\s*){1,}[^)]*Guid" --include="*.cs" src/ | wc -l
git stash pop
Trước: 34 phương thức có thể truyền nhầm
Sau: 0

34 chỗ mà trình biên dịch trước đây không kiểm tra được, giờ được kiểm tra. Và ba trong số đó đang sai.


Bài 3 — Value Object cho khái niệm nghiệp vụ​

Bọc một khái niệm nghiệp vụ đang dùng kiểu nguyên thuỷ và ánh xạ nó với EF Core.

Tiêu chí hoàn thành: bạn ánh xạ được với EF Core, và nêu được ba dấu hiệu một kiểu nguyên thuỷ nên trở thành value object.

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

Gợi ý. decimal Value — đơn vị là gì? Âm có hợp lệ không? Cộng hai giá trị khác tiền tệ thì sao?

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

public class Lead
{
public decimal Value { get; private set; }
public string Currency { get; private set; } = "VND";
}
var tong = lead1.Value + lead2.Value;      // cộng VND với USD? Không ai biết
lead.Value = -1000; // âm? Không ai chặn

Sau — value object:

public readonly record struct Money : IComparable<Money>
{
public decimal Amount { get; }
public string Currency { get; }

private Money(decimal amount, string currency)
=> (Amount, Currency) = (amount, currency);

public static Result<Money> Tao(decimal amount, string currency)
{
if (amount < 0)
return Result<Money>.Loi("Số tiền không được âm");
if (string.IsNullOrWhiteSpace(currency) || currency.Length != 3)
return Result<Money>.Loi("Mã tiền tệ phải có 3 ký tự");

return Result<Money>.ThanhCong(new Money(amount, currency.ToUpperInvariant()));
}

public static Money VND(decimal amount) => new(amount, "VND");
public static Money Zero => new(0, "VND");

public static Money operator +(Money a, Money b)
{
if (a.Currency != b.Currency)
throw new InvalidOperationException(
$"Không cộng được {a.Currency} với {b.Currency}");
return new Money(a.Amount + b.Amount, a.Currency);
}

public static Money operator *(Money m, int soLuong) => new(m.Amount * soLuong, m.Currency);

public static bool operator >(Money a, Money b) => a.CompareTo(b) > 0;
public static bool operator <(Money a, Money b) => a.CompareTo(b) < 0;
public static bool operator >=(Money a, Money b) => a.CompareTo(b) >= 0;
public static bool operator <=(Money a, Money b) => a.CompareTo(b) <= 0;

public int CompareTo(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException(
$"Không so sánh được {Currency} với {other.Currency}");
return Amount.CompareTo(other.Amount);
}

public override string ToString() => $"{Amount:N0} {Currency}";
}
var tong = lead1.Value + lead2.Value;      // ném exception nếu khác tiền tệ
var m = Money.Tao(-1000, "VND"); // trả Result thất bại

Chú ý dùng exception cho phép cộng khác tiền tệ, và Result cho việc tạo giá trị âm. Lý do: cộng VND với USD là lỗi lập trình — không có luồng nghiệp vụ nào hợp lệ dẫn tới đó. Còn "người dùng nhập số âm" là tình huống mong đợi (bài 1 ở trên).

Ánh xạ với EF Core — ba cách:

Cách 1 — OwnsOne, nên dùng cho value object nhiều thuộc tính:

builder.Entity<Lead>(b =>
{
b.OwnsOne(l => l.Value, m =>
{
m.Property(x => x.Amount).HasColumnName("Value").HasPrecision(18, 2).IsRequired();
m.Property(x => x.Currency).HasColumnName("Currency").HasMaxLength(3).IsRequired();
});
});
CREATE TABLE Leads (
Id uniqueidentifier NOT NULL,
Value decimal(18,2) NOT NULL,
Currency nvarchar(3) NOT NULL
);

Không đổi schema — hai cột vẫn như cũ, chỉ mô hình C# đổi.

Cách 2 — ComplexProperty (EF Core 8+), cho value object là struct:

builder.Entity<Lead>()
.ComplexProperty(l => l.Value, m =>
{
m.Property(x => x.Amount).HasColumnName("Value").HasPrecision(18, 2);
m.Property(x => x.Currency).HasColumnName("Currency").HasMaxLength(3);
});

Khác biệt quan trọng: OwnsOne coi value object là một entity sở hữu với identity riêng, nên nó tham gia change tracking như một thực thể. ComplexProperty coi nó là giá trị thuần tuý — đúng ngữ nghĩa hơn cho value object, và nhẹ hơn.

Với readonly record struct như Money, ComplexProperty là lựa chọn đúng.

Cách 3 — ValueConverter, cho value object bọc một giá trị duy nhất:

public readonly record struct Email
{
public string Value { get; }
private Email(string value) => Value = value;
public static Result<Email> Tao(string? v) { /* ... */ }
}
public class EmailConverter : ValueConverter<Email, string>
{
public EmailConverter() : base(
e => e.Value,
s => Email.Tao(s).Value) { }
}
protected override void ConfigureConventions(ModelConfigurationBuilder builder)
=> builder.Properties<Email>().HaveConversion<EmailConverter>().HaveMaxLength(320);

Cảnh báo về ValueConverter và truy vấn:

// KHÔNG dịch được sang SQL — EF Core không biết cách so sánh phần bên trong
var leads = await _db.Leads.Where(l => l.Email.Value.StartsWith("an")).ToListAsync(ct);
System.InvalidOperationException: The LINQ expression could not be translated.

Converter chỉ dịch được phép so sánh bằng trên toàn bộ giá trị:

var email = Email.Tao("an@abc.com").Value;
var lead = await _db.Leads.FirstOrDefaultAsync(l => l.Email == email, ct); // OK

Với truy vấn phức tạp hơn, dùng EF.Property hoặc thêm một cột phụ.

Và nhớ ValueComparer cho value object là kiểu tham chiếu — nếu không, thay đổi sẽ không được phát hiện (bài 13.2). Với readonly record struct, vấn đề này không tồn tại vì struct được so sánh theo giá trị.

Ba dấu hiệu một kiểu nguyên thuỷ nên trở thành value object:

Dấu hiệu 1 — nó luôn đi cùng một giá trị khác.

public decimal Value { get; set; }
public string Currency { get; set; } // hai cái này LUÔN đi cùng nhau
public DateTime StartDate { get; set; }
public DateTime EndDate { get; set; } // -> DateRange
public decimal Latitude { get; set; }
public decimal Longitude { get; set; } // -> Coordinates

Hai thuộc tính luôn đi cùng nhau, luôn được đọc cùng nhau, và có bất biến giữa chúng (EndDate >= StartDate) — đó là một khái niệm đang bị tách làm đôi.

Dấu hiệu 2 — có validate lặp lại ở nhiều chỗ.

grep -rn "EmailAddress()\|IsValidEmail\|MailAddress.TryCreate" --include="*.cs" src/ | wc -l
14

14 chỗ validate email. Với value object, việc validate xảy ra một lần ở factory method, và sau đó mọi Email trong hệ thống đều hợp lệ theo định nghĩa.

Dấu hiệu 3 — có phép toán hoặc quy tắc riêng.

// Logic này nằm rải khắp nơi
var thanhTien = donGia * soLuong;
var sauThue = thanhTien * 1.1m;
var lamTron = Math.Round(sauThue, 0, MidpointRounding.AwayFromZero);
// Thuộc về Money
public Money ThemThue(decimal tyLe) => new(Math.Round(Amount * (1 + tyLe), 0,
MidpointRounding.AwayFromZero), Currency);

Quy tắc làm tròn tiền tệ là chỗ đặc biệt hay sai: hai chỗ khác nhau dùng MidpointRounding khác nhau sẽ cho kết quả lệch một đồng — và trong kế toán, lệch một đồng là một vấn đề thật.

Những khái niệm thường nên là value object trong một CRM:

Money           số tiền + tiền tệ
Email chuỗi có định dạng
PhoneNumber chuỗi có định dạng, có mã quốc gia
Address nhiều trường đi cùng nhau
DateRange hai mốc thời gian có ràng buộc
Percentage số trong khoảng 0–100
TaxCode mã số thuế có quy tắc kiểm tra
Quantity số nguyên không âm + đơn vị

Và một lưu ý về hiệu năng: readonly record struct không cấp phát trên heap, nên value object nhỏ gần như miễn phí. Nhưng struct lớn hơn 16 byte bị sao chép mỗi lần truyền — với Address có năm trường chuỗi, dùng sealed record (class) thay vì struct.

public readonly record struct Money(decimal Amount, string Currency);   // 24 byte, struct OK
public sealed record Address(string Street, string Ward, string District,
string City, string Country); // dùng class

Tự kiểm tra​

Frequently asked questions

Vì sao lỗi nghiệp vụ không nên dùng exception?

Vì lỗi nghiệp vụ không phải trường hợp ngoại lệ. Số dư không đủ là kết quả bình thường và dự kiến được của việc rút tiền. Dùng exception làm chữ ký phương thức nói dối về những gì nó có thể trả về, và ném exception cũng tốn kém hơn trả về giá trị.

Ranh giới giữa Result và exception là gì?

Lỗi mà người dùng có thể sửa bằng cách nhập lại thì dùng Result. Lỗi mà lập trình viên phải sửa, như tham số null hay vi phạm invariant trong domain, thì dùng exception.

Khi nào Specification pattern bị lạm dụng?

Khi dùng cho truy vấn chỉ xuất hiện một lần, vì khi đó LINQ thẳng ngắn gọn và dễ đọc hơn. Và nếu điều kiện là bảo mật như lọc theo tenant thì global query filter chặt chẽ hơn vì nó không thể bị quên.

Vì sao complex property tốt hơn owned entity cho value object?

Vì value object được đối xử đúng như giá trị chứ không phải entity có định danh, nên tránh được các vấn đề về change tracking mà owned entity đôi khi gây ra.

Strongly-typed ID giải quyết bug gì?

Truyền nhầm id giữa các loại entity. Khi mọi id đều là Guid, trình biên dịch không phát hiện được việc truyền customerId vào chỗ cần leadId, và bug đó rất khó tìm lúc chạy.

Hai mẫu nào nên làm ngay từ dự án mới?

Result cho lỗi nghiệp vụ và strongly-typed ID. Cả hai đều rẻ để áp dụng từ đầu, trong khi sửa lại sau khi codebase đã lớn thì đắt hơn nhiều.

Kết luận​

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

  1. Lỗi nghiệp vụ là kết quả, không phải ngoại lệ. Result làm chữ ký trung thực.
  2. Strongly-typed ID là lợi ích miễn phí từ trình biên dịch.
  3. Event sourcing gần như không quay lại được. Bảng audit log thường đúng hơn.

Tham khảo​

Điều hướng​