Chuyển tới nội dung chính

16.2 — 1. Vì sao CRM “to ra” là lúc kiến trúc trả giá?

Tóm tắt

Kiến trúc không phải để code "đẹp" — nó để chi phí thay đổi không tăng theo kích thước dự án. Trong một codebase khoẻ, thêm tính năng thứ 100 tốn xấp xỉ bằng thêm tính năng thứ 10. Trong một codebase mục, tính năng thứ 100 tốn gấp mười. Ba dấu hiệu đo được cho biết bạn đang ở đâu: một thay đổi nghiệp vụ nhỏ phải sửa nhiều file ở nhiều tầng, không viết được unit test mà không dựng database, và sửa chỗ A làm hỏng chỗ B không liên quan. Nhưng có một sai lầm ngược lại cũng đắt: áp kiến trúc phức tạp quá sớm. Với một CRUD 10 bảng, bốn project và MediatR là chi phí thuần — và bạn chưa biết ranh giới nghiệp vụ nằm ở đâu để đặt cho đúng.

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

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

  • Nhận ra ba dấu hiệu đo được của code đang mục.
  • Giải thích vì sao chi phí thay đổi tăng.
  • Tránh cả hai sai lầm: quá muộn và quá sớm.
  • Chọn thời điểm tách lớp có cơ sở.

Nội dung bài học​

16.2.1 — Chi phí thay đổi là thước đo duy nhất​

Mọi lập luận kiến trúc cuối cùng quy về một câu hỏi: thay đổi nghiệp vụ tiếp theo tốn bao nhiêu?

Codebase khoe:  tinh nang thu 100 ~ tinh nang thu 10
Codebase mục: tính năng thứ 100 = 10x tính năng thứ 10

Điều làm chi phí tăng không phải số dòng code, mà là số thứ bạn phải hiểu để sửa an toàn. Khi một quy tắc nghiệp vụ nằm rải ở controller, service, trigger database và một job nền, bạn phải biết cả bốn trước khi đổi nó.

Ba dấu hiệu đo được:

1. Một thay đổi nghiệp vụ phải sửa nhiều tầng.

"Khách hàng hạng Bronze không được tạo deal trên 500 triệu" — đếm số file phải sửa. Nếu là một, kiến trúc đang làm việc của nó. Nếu là sáu (controller, service, validator, trigger, báo cáo, job), quy tắc đó không có nhà.

2. Không viết được unit test mà không dựng database.

Nếu để test một quy tắc nghiệp vụ bạn phải dựng DbContext, seed dữ liệu và dọn dẹp, thì quy tắc đó không tách khỏi hạ tầng. Đó là dấu hiệu rõ nhất và dễ kiểm tra nhất.

3. Sửa chỗ A làm hỏng chỗ B không liên quan.

Sửa tính năng Billing làm vỡ màn hình Lead nghĩa là chúng ghép chặt qua một thứ dùng chung — thường là một entity khổng lồ hoặc một service "God" mà mọi thứ đều gọi.

16.2.2 — Nó mục như thế nào​

Không ai cố tình viết code xấu. Nó xảy ra theo từng bước hợp lý:

// Thang 1 — hoan toan on
[HttpPost]
public async Task<IActionResult> Create(CreateLeadRequest request, CancellationToken ct)
{
var lead = new Lead { Name = request.Name, Value = request.Value };
_db.Leads.Add(lead);
await _db.SaveChangesAsync(ct);
return Ok(lead);
}
// Tháng 6 — mỗi dòng thêm vào đều "hợp lý"
[HttpPost]
public async Task<IActionResult> Create(CreateLeadRequest request, CancellationToken ct)
{
if (request.Value > 500_000_000 && _currentUser.Tier == "Bronze")
return BadRequest("Vuot han muc"); // quy tac nghiep vu

var duplicate = await _db.Leads.AnyAsync(l => l.Email == request.Email, ct);
if (duplicate) return Conflict(); // quy tac nghiep vu

var lead = new Lead { ... };
lead.Score = request.Value > 100_000_000 ? 10 : 5; // quy tac nghiep vu

_db.Leads.Add(lead);
await _db.SaveChangesAsync(ct);

await _email.SendAsync(...); // tac dung phu
await _crmSync.PushAsync(lead, ct); // tac dung phu
await _audit.LogAsync(...); // tac dung phu

return Ok(lead);
}

Không có dòng nào sai. Nhưng bây giờ:

  • Quy tắc "Bronze không được vượt 500 triệu" chỉ tồn tại ở đây — import hàng loạt bỏ qua nó hoàn toàn.
  • Test quy tắc đó cần HttpContext, DbContext, và mock ba service.
  • Cùng quy tắc sẽ được chép lại vào job import và vào màn hình admin — rồi ba bản lệch nhau.

Đó là cách một codebase mục: không phải một quyết định tồi, mà 200 quyết định hợp lý không có nơi nào để đặt logic nghiệp vụ.

16.2.3 — Sai lầm ngược: kiến trúc quá sớm​

CRM ban đầu: 10 bảng, 20 endpoint, CRUD thuần
Kiến trúc áp dụng: 4 project, MediatR, repository, specification, domain event

Với dự án đó, bạn vừa thêm:

Chi phíChi tiết
Ba file cho một endpointCommand, Handler, Validator — thay vì một action
Khó lần theo luồng"Ai xử lý command này?" cần tìm kiếm toàn giải pháp
Người mới mất một tuầnChỉ để hiểu cấu trúc trước khi viết dòng đầu tiên
Ranh giới đặt saiVì bạn chưa biết nghiệp vụ đủ rõ

Hàng cuối là chi phí lớn nhất và ít ai nói tới. Kiến trúc là việc vẽ ranh giới, và ranh giới đúng đến từ hiểu nghiệp vụ. Tháng đầu tiên bạn chưa hiểu, nên ranh giới bạn vẽ gần như chắc chắn sai — và sửa ranh giới sai đắt hơn không có ranh giới.

Quy tắc thực dụng:

Quy môNên
CRUD thuần, ít quy tắcMột project, controller gọi service
Bắt đầu có quy tắc nghiệp vụTách Domain khỏi hạ tầng
Nhiều use case, nhiều tác dụng phụThêm tầng Application
Nhiều đội làm song songRanh giới module rõ ràng

Đi theo thứ tự, và chuyển bước khi có dấu hiệu, không theo lịch.

16.2.4 — Dấu hiệu đã tới lúc​

Ba tín hiệu cụ thể, theo thứ tự xuất hiện:

1. Bạn chép một quy tắc nghiệp vụ lần thứ hai. Đó là lúc nó cần một nhà riêng. Không phải lần thứ ba — lần thứ hai, vì lần thứ ba nghĩa là đã có hai bản lệch nhau.

2. Bạn muốn viết một unit test và không viết được. Nếu test quy tắc "Bronze không vượt 500 triệu" đòi dựng HttpContext, quy tắc đó đặt sai chỗ.

3. Một thay đổi làm hỏng thứ không liên quan. Ghép chặt vừa cho bạn biết nó ở đâu.

Ngược lại, chưa cần kiến trúc phức tạp khi: nghiệp vụ gần như chỉ là CRUD, một người hoặc một đội nhỏ, và bạn còn đang tìm hiểu bài toán.

16.2.5 — Nguyên tắc bao trùm​

Mọi thứ trong module này quy về một câu: thứ ít thay đổi nhất không được phụ thuộc vào thứ hay thay đổi nhất.

Quy tac nghiep vu    <- doi theo NAM
Use case <- doi theo THANG
API, UI <- doi theo TUAN
Framework, thu vien <- doi khi nang cap

Nếu quy tắc nghiệp vụ phụ thuộc vào EF Core, thì đổi ORM đụng vào nghiệp vụ. Nếu ngược lại — EF Core phụ thuộc vào nghiệp vụ qua một interface — thì đổi ORM chỉ đụng tầng hạ tầng.

Đó là toàn bộ nội dung của dependency rule ở bài 16.4, và cũng là lý do Module 7 nhấn mạnh việc entity không nên biết gì về EF Core.

Nhưng nhớ: đó là công cụ, không phải mục tiêu. Mục tiêu là chi phí thay đổi thấp. Nếu một cấu trúc làm chi phí tăng — như bốn project cho một CRUD — thì nó đang đi ngược mục tiêu, dù nó "đúng chuẩn".

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

Danh sách rà soát kiến trúc

  • •Đếm được số file phải sửa cho một thay đổi nghiệp vụ điển hình.
  • •Viết được unit test cho quy tắc nghiệp vụ mà không dựng database.
  • •Không có quy tắc nghiệp vụ nào tồn tại ở hai nơi.
  • •Quy tắc nghiệp vụ không nằm trong controller.
  • •Thay đổi ở một tính năng không làm hỏng tính năng khác.
  • •Cấu trúc project phù hợp với quy mô hiện tại, không phải quy mô tưởng tượng.
  • •Chưa vẽ ranh giới module trước khi hiểu đủ nghiệp vụ.
  • •Mỗi lớp trừu tượng đều trả lời được nó giải quyết vấn đề gì.

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

Bài 1 — Đo chi phí thay đổi​

Chọn một quy tắc nghiệp vụ trong dự án và đếm số file phải sửa nếu quy tắc đó đổi. Ghi lại con số.

Tiêu chí hoàn thành: bạn giải thích được vì sao con số đó là chỉ số kiến trúc tốt hơn số dòng code hay coverage, và biết ngưỡng nào là dấu hiệu cần hành động.

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

Gợi ý. Kiến trúc tồn tại để làm gì? Hãy đo đúng thứ đó.

Lời giải — chọn một quy tắc cụ thể:

"Lead có giá trị trên 500 triệu phải được trưởng phòng duyệt trước khi chuyển sang trạng thái Won."

grep -rn "500_000_000\|500000000\|NguongDuyet\|ApprovalThreshold" --include="*.cs" src/
src/Crm.Api/Controllers/LeadsController.cs:87
src/Crm.Api/Controllers/LeadsController.cs:142
src/Crm.Application/Services/LeadService.cs:203
src/Crm.Application/Services/LeadService.cs:341
src/Crm.Application/Validators/CapNhatLeadValidator.cs:34
src/Crm.Infrastructure/Jobs/ImportLeadJob.cs:118
src/Crm.Infrastructure/Consumers/LeadSyncConsumer.cs:64
src/Crm.Api/Endpoints/BulkUpdateEndpoint.cs:45
tests/Crm.Tests/LeadServiceTests.cs:212

9 chỗ, 8 file production. Và ba phiên bản khác nhau của cùng một quy tắc:

// LeadsController.cs:87
if (request.Value > 500_000_000 && !User.IsInRole("Manager"))
return Forbid();

// LeadService.cs:203 — dùng >= thay vì >
if (lead.Value >= 500_000_000 && !nguoiDung.LaTruongPhong)
throw new BusinessException("Cần trưởng phòng duyệt");

// ImportLeadJob.cs:118 — KHÔNG kiểm tra gì
lead.Status = "Won";

Ba phiên bản nghĩa là ba hành vi khác nhau tuỳ đường đi của dữ liệu — và đường thứ ba không kiểm tra gì cả.

Vì sao con số này là chỉ số kiến trúc tốt hơn số dòng code hay coverage:

Chỉ sốĐo gìVấn đề
Số dòng codeKích thướcKhông nói gì về việc sửa có dễ không
Test coverageBao nhiêu dòng được chạy trong testKhông nói gì về việc test có ý nghĩa không
Số lớp, số interfaceMức độ phân táchNhiều lớp có thể là tốt hoặc là kiến trúc thừa
Số file phải sửa cho một thay đổi nghiệp vụĐúng thứ kiến trúc tồn tại để tối ưu—

Kiến trúc không tồn tại để code đẹp. Nó tồn tại để thay đổi rẻ. Và cách duy nhất đo được điều đó là hỏi: "nếu nghiệp vụ đổi, tôi phải chạm vào bao nhiêu chỗ?"

Ba tính chất làm chỉ số này tốt:

  1. Không đánh lừa được. Bạn có thể tăng coverage bằng test vô nghĩa; bạn không thể giảm số file phải sửa bằng mẹo nào ngoài việc thật sự gom quy tắc lại.
  2. Nó đo cái người ta thật sự đau. "Sửa một quy tắc mất ba ngày" là thứ nhóm cảm nhận được, khác với "coverage 73%".
  3. Nó chỉ thẳng vào chỗ cần sửa. Danh sách grep chính là danh sách việc phải làm.

Ngưỡng và hành động:

1 file:      Lý tưởng — quy tắc nằm ở một chỗ, mọi đường ghi đi qua đó
2–3 file: Chấp nhận được — thường là entity, cấu hình, và một test
4–8 file: Dấu hiệu cảnh báo — quy tắc đã bị nhân bản
9+ file: Có vấn đề — gần như chắc chắn có bản sao SAI LỆCH

Con số chỉ là một nửa; nửa còn lại là các bản sao có giống nhau không. Chín chỗ giống hệt nhau là vấn đề bảo trì. Chín chỗ với ba hành vi khác nhau là lỗi đang chạy trên production.

Cách đưa về 1 file:

public class Lead
{
private static readonly Money NguongCanDuyet = Money.VND(500_000_000);

public LeadStatus Status { get; private set; }
public Money Value { get; private set; }

public Result ChuyenSangWon(NguoiDung nguoiThucHien)
{
if (Status == LeadStatus.Won)
return Result.Loi("Lead đã ở trạng thái Won");

if (Value >= NguongCanDuyet && !nguoiThucHien.LaTruongPhong)
return Result.Loi($"Lead trên {NguongCanDuyet} cần trưởng phòng duyệt");

Status = LeadStatus.Won;
ClosedUtc = DateTime.UtcNow;
Raise(new LeadDaChot(Id, Value));
return Result.ThanhCong();
}
}
public LeadStatus Status { get; private set; }      // private set — không ai gán trực tiếp được

private set là thứ làm cho việc gom có hiệu lực. Không có nó, quy tắc nằm trong ChuyenSangWon nhưng vẫn có tám chỗ khác gán lead.Status = "Won" và đi vòng qua nó.

grep -rn "500_000_000" --include="*.cs" src/
src/Crm.Domain/Entities/Lead.cs:12

Đo lại sau khi gom, và ghi cả hai con số:

## Chi phí thay đổi — quy tắc ngưỡng duyệt

| Ngày | Số file | Số phiên bản khác nhau |
|---|---:|---:|
| 2026-03-15 | 8 | 3 |
| 2026-09-25 | 1 | 1 |

Mở rộng — đo cho năm quy tắc quan trọng nhất:

| Quy tắc nghiệp vụ | Số file | Ghi chú |
|---|---:|---|
| Ngưỡng duyệt lead | 8 | 3 phiên bản khác nhau |
| Tính chiết khấu theo hạng khách | 5 | 2 phiên bản |
| Quy tắc chuyển trạng thái đơn hàng | 12 | không có chỗ nào đầy đủ |
| Giới hạn số lead mỗi nhân viên | 3 | nhất quán |
| Định dạng mã khách hàng | 6 | 4 phiên bản |

Bảng này là lập luận kiến trúc tốt nhất bạn có thể đưa ra khi đề xuất refactor. Nó không nói "code xấu" — một nhận định chủ quan — mà nói "sửa một quy tắc phải chạm 12 file và hiện có 12 hành vi khác nhau", một sự thật kiểm chứng được.

Và một cách đo bổ sung, từ lịch sử Git:

# File nào hay bị sửa cùng nhau? -> chúng có khớp nối ngầm
git log --format='%H' --since='6 months ago' | while read c; do
git diff-tree --no-commit-id --name-only -r "$c" | grep '\.cs$' | sort | paste -sd,
done | sort | uniq -c | sort -rn | head -10
  42 src/Crm.Api/Controllers/LeadsController.cs,src/Crm.Application/Services/LeadService.cs
31 src/Crm.Application/Services/LeadService.cs,src/Crm.Infrastructure/Jobs/ImportLeadJob.cs

Hai file luôn thay đổi cùng nhau nghĩa là chúng chia sẻ một khái niệm — và khái niệm đó đang nằm ở cả hai chỗ thay vì một chỗ. Đây là cách tìm ra những quy tắc bị nhân bản mà bạn chưa biết là tồn tại.


Bài 2 — Thử viết unit test không dùng database​

Chọn một quy tắc nghiệp vụ và viết test không dùng database. Nếu không được, ghi lại chính xác thứ gì chặn bạn.

Tiêu chí hoàn thành: bạn liệt kê được các thứ chặn và phân loại chúng, và hiểu vì sao "khó test" là triệu chứng chứ không phải vấn đề.

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

Gợi ý. Nếu để test một quy tắc bạn phải dựng cả một hệ thống, thì quy tắc đó đang phụ thuộc vào cả hệ thống.

Lời giải — thử với code điển hình:

public class LeadService
{
private readonly CrmDbContext _db;
private readonly IHttpContextAccessor _http;
private readonly IEmailSender _email;
private readonly IConfiguration _config;

public async Task<IActionResult> ChotLeadAsync(int id)
{
var lead = await _db.Leads.FirstAsync(l => l.Id == id);
var nguoiDung = _http.HttpContext!.User;
var nguong = _config.GetValue<decimal>("Lead:NguongDuyet");

if (lead.Value >= nguong && !nguoiDung.IsInRole("Manager"))
return new ForbidResult();

lead.Status = "Won";
lead.ClosedUtc = DateTime.UtcNow;
await _db.SaveChangesAsync();

await _email.GuiAsync(lead.Email, "Chúc mừng", "...");
return new OkResult();
}
}

Để test một dòng logic — lead.Value >= nguong && !IsInRole — bạn cần:

[Fact]
public async Task Lead_tren_nguong_can_truong_phong_duyet()
{
// 1. Database
var options = new DbContextOptionsBuilder<CrmDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;
await using var db = new CrmDbContext(options);
db.Leads.Add(new Lead { Id = 1, Value = 600_000_000, Status = "New" });
await db.SaveChangesAsync();

// 2. HttpContext với claim
var http = Substitute.For<IHttpContextAccessor>();
http.HttpContext.Returns(new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(
[new Claim(ClaimTypes.Role, "Sales")], "test")),
});

// 3. Cấu hình
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{ ["Lead:NguongDuyet"] = "500000000" })
.Build();

// 4. Email
var email = Substitute.For<IEmailSender>();

var svc = new LeadService(db, http, email, config);
var kq = await svc.ChotLeadAsync(1);

kq.Should().BeOfType<ForbidResult>();
}

33 dòng dựng cảnh cho 1 dòng logic. Và nó còn phụ thuộc vào DateTime.UtcNow, nên không test được gì liên quan tới thời gian.

Phân loại những thứ chặn bạn:

Thứ chặnLoạiMức nghiêm trọng
DbContext trong lớp nghiệp vụPhụ thuộc hạ tầngCao
IHttpContextAccessorPhụ thuộc giao thứcCao
DateTime.UtcNow gọi trực tiếpPhụ thuộc ẩnCao
Trả về IActionResultPhụ thuộc frameworkTrung bình
IConfiguration cho giá trị nghiệp vụPhụ thuộc hạ tầngTrung bình
IEmailSenderPhụ thuộc ngoài — đây là phụ thuộc hợp lệThấp

Ba loại đầu là vấn đề thật. Loại cuối thì không: gửi email là một tác dụng phụ ra ngoài, và mock nó là điều đúng đắn.

Ba dấu hiệu rõ ràng nhất, và ý nghĩa của từng cái:

1. DateTime.UtcNow gọi trực tiếp — phụ thuộc ẩn không xuất hiện trong constructor:

// Không test được thời gian
lead.ClosedUtc = DateTime.UtcNow;

// Test được
public LeadService(TimeProvider clock) => _clock = clock;
lead.ClosedUtc = _clock.GetUtcNow().UtcDateTime;
var clock = new FakeTimeProvider(new DateTimeOffset(2026, 9, 25, 8, 0, 0, TimeSpan.Zero));

TimeProvider có sẵn từ .NET 8, và Microsoft.Extensions.TimeProvider.Testing cho FakeTimeProvider. Đây là thay đổi rẻ nhất trong ba cái và nó mở khoá cả một nhóm test.

2. IHttpContextAccessor trong lớp nghiệp vụ — quy tắc nghiệp vụ biết về HTTP:

// Tầng nghiệp vụ không nên biết HTTP tồn tại
var nguoiDung = _http.HttpContext!.User;

// Nhận thứ nó thật sự cần
public Result ChotLead(Lead lead, NguoiDung nguoiThucHien)

Hệ quả thực tế của việc này lớn hơn chuyện test: một job import hay một message consumer không có HttpContext, nên chúng không gọi được phương thức này — và người ta sẽ viết một bản sao của quy tắc cho chúng, đúng như đã thấy ở bài 1.

3. Trả về IActionResult — tầng nghiệp vụ nói bằng ngôn ngữ HTTP:

return new ForbidResult();          // nghĩa là gì với một job nền?

return Result.Loi("Cần trưởng phòng duyệt"); // không phụ thuộc giao thức

Sau khi tách:

public class Lead
{
private static readonly decimal NguongCanDuyet = 500_000_000m;

public Result ChuyenSangWon(NguoiDung nguoiThucHien, DateTime bayGio)
{
if (Value >= NguongCanDuyet && !nguoiThucHien.LaTruongPhong)
return Result.Loi("Cần trưởng phòng duyệt");

Status = LeadStatus.Won;
ClosedUtc = bayGio;
return Result.ThanhCong();
}
}
[Fact]
public void Lead_tren_nguong_can_truong_phong_duyet()
{
var lead = Lead.Tao("Công ty ABC", 600_000_000m);
var nhanVien = NguoiDung.NhanVien("an");

var kq = lead.ChuyenSangWon(nhanVien, DateTime.UtcNow);

kq.ThanhCong.Should().BeFalse();
kq.Loi.Should().Contain("trưởng phòng");
lead.Status.Should().Be(LeadStatus.New); // trạng thái KHÔNG đổi
}

Từ 33 dòng xuống 8 dòng, không database, không mock nào, chạy trong vài mili giây.

Vì sao "khó test" là triệu chứng chứ không phải vấn đề — đây là phần chính của bài.

Khó test là hệ quả quan sát được của việc code có quá nhiều khớp nối. Và khớp nối đó gây ra những hậu quả khác, nghiêm trọng hơn, mà bạn chưa nhìn thấy:

Khó test             <- triệu chứng bạn thấy đầu tiên, vì test là thứ đầu tiên chạm vào

Cùng nguyên nhân gây ra:
Khó dùng lại -> job import không gọi được -> viết bản sao -> quy tắc lệch nhau
Khó đổi -> sửa một quy tắc phải chạm 8 file (bài 1)
Khó đọc -> phải hiểu cả hạ tầng mới hiểu được nghiệp vụ
Khó gỡ lỗi -> lỗi nghiệp vụ và lỗi hạ tầng lẫn vào nhau
Khó đưa lên nền tảng khác -> logic gắn chặt với ASP.NET Core

Đây là lý do cách sửa đúng không phải là làm cho test dễ hơn, mà là gỡ khớp nối:

Sai:  viết helper dựng DbContext + HttpContext để test bớt dài
-> triệu chứng biến mất, nguyên nhân còn nguyên, và giờ có thêm helper phải bảo trì

Đúng: tách logic ra khỏi hạ tầng
-> test dễ là MỘT trong nhiều lợi ích, không phải mục tiêu

Nói cách khác: đừng thiết kế để dễ test; thiết kế cho đúng, rồi dễ test là hệ quả. Nếu bạn thấy mình đang thêm interface chỉ để mock được, hãy dừng lại và hỏi lớp đó có thật sự cần phụ thuộc đó không.

Bài tập ba câu hỏi để tự đánh giá một lớp:

1. Constructor có bao nhiêu tham số? Bao nhiêu trong số đó là hạ tầng?
2. Để test một quy tắc, tôi phải dựng bao nhiêu thứ không liên quan tới quy tắc đó?
3. Nếu một job nền cần áp dụng cùng quy tắc, nó gọi được phương thức này không?

Câu 3 là câu quan trọng nhất, và nó không nhắc gì tới test — nhưng câu trả lời "không" gần như luôn đi cùng với "khó test".


Bài 3 — Tìm quy tắc trùng lặp​

grep một hằng số nghiệp vụ (ví dụ ngưỡng 500_000_000) trong toàn bộ mã nguồn. Đếm số chỗ xuất hiện.

Tiêu chí hoàn thành: bạn tìm được ít nhất một bản sao sai lệch so với các bản khác, và biết cách ngăn chúng xuất hiện lại.

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

Gợi ý. Đừng chỉ tìm con số. Tìm cả tên biến, tên hằng, và giá trị trong cấu hình.

Lời giải — tìm rộng:

# Con số ở mọi dạng viết
grep -rn "500_000_000\|500000000\|500\.000\.000\|5e8" --include="*.cs" src/

# Tên khái niệm
grep -rni "nguongduyet\|approvalthreshold\|canduyet\|requiresapproval" --include="*.cs" src/

# Trong cấu hình
grep -rn "NguongDuyet\|ApprovalThreshold" --include="*.json" --include="*.yml" .

# Trong migration và SQL
grep -rn "500000000" --include="*.sql" --include="*.cs" src/*/Migrations/
src/Crm.Api/Controllers/LeadsController.cs:87       if (request.Value > 500_000_000
src/Crm.Application/Services/LeadService.cs:203 if (lead.Value >= 500_000_000
src/Crm.Application/Validators/LeadValidator.cs:34 .GreaterThan(500_000_000)
src/Crm.Infrastructure/Jobs/ImportLeadJob.cs:118 // không kiểm tra
src/Crm.Api/appsettings.json:24 "NguongDuyet": 500000000
src/Crm.Api/appsettings.Production.json:11 "NguongDuyet": 300000000
src/Crm.Web/wwwroot/js/lead-form.js:56 if (value > 500000000)

Bảy chỗ, và hai bản sao sai lệch nghiêm trọng:

1. Controller dùng >   ; Service dùng >=
-> lead đúng 500.000.000 được duyệt ở một đường, bị chặn ở đường kia

2. appsettings.Production.json có ngưỡng 300.000.000
-> production hành xử KHÁC dev và staging
-> và không code nào đọc giá trị đó, vì Controller và Service đều hardcode
-> giá trị trong file cấu hình hoàn toàn VÔ NGHĨA, nhưng trông như đang có hiệu lực

Bản sao thứ hai là loại tệ nhất: nó không chỉ sai, nó còn đánh lừa người đọc. Ai đó muốn đổi ngưỡng sẽ sửa file cấu hình, deploy, và không thấy gì thay đổi.

Ba cách tìm thêm những quy tắc bị nhân bản mà bạn chưa biết:

1. Tìm số ma (magic number) trong lớp nghiệp vụ:

grep -rnE "[^a-zA-Z0-9_.]([0-9]{4,}|[0-9]+_[0-9]{3})" --include="*.cs" \
src/Crm.Application src/Crm.Domain \
| grep -vE "Migrations|//|///|Assert|Should|\[InlineData"

2. Tìm mẫu điều kiện lặp lại:

grep -rn "IsInRole\|HasClaim\|LaTruongPhong" --include="*.cs" src/ | wc -l
grep -rn "Status ==\|Status !=" --include="*.cs" src/ | wc -l
23        <- 23 chỗ kiểm tra vai trò
47 <- 47 chỗ so sánh trạng thái

47 chỗ so sánh trạng thái nghĩa là logic chuyển trạng thái đang nằm rải rác thay vì tập trung trong entity.

3. Tìm file hay thay đổi cùng nhau — cách tìm ra khớp nối ngầm:

git log --format='%H' --since='1 year ago' | while read c; do
git diff-tree --no-commit-id --name-only -r "$c" | grep '\.cs$' | sort | paste -sd,
done | sort | uniq -c | sort -rn | head -10

Cách ngăn chúng xuất hiện lại — bốn lớp:

Lớp 1 — một nơi duy nhất, với private set:

public class Lead
{
public static readonly Money NguongCanDuyet = Money.VND(500_000_000);

public LeadStatus Status { get; private set; } // không ai gán trực tiếp được
public Money Value { get; private set; }

public Result ChuyenSangWon(NguoiDung nguoiThucHien) { /* quy tắc ở đây */ }
}

private set là thứ làm cho lớp này có hiệu lực. Không có nó, quy tắc tồn tại nhưng có thể đi vòng qua.

Lớp 2 — kiến trúc test chặn đường vòng:

[Fact]
public void Khong_duoc_gan_truc_tiep_LeadStatus_ngoai_Domain()
{
var ketQua = Types.InAssembly(typeof(LeadService).Assembly)
.That().ResideInNamespace("Crm.Application")
.ShouldNot().HaveDependencyOn("Crm.Domain.Entities.LeadStatus")
.GetResult();

// hoặc kiểm tra bằng Roslyn analyzer cho phép gán thuộc tính
}

Lớp 3 — analyzer cấm số ma trong lớp nghiệp vụ:

<PackageReference Include="SonarAnalyzer.CSharp" Version="..." PrivateAssets="all" />
# .editorconfig — trong project Domain và Application
[src/Crm.Domain/**.cs]
dotnet_diagnostic.S109.severity = error # Magic number

Lớp 4 — test tự tìm bản sao, chạy trong CI:

[Fact]
public void Nguong_duyet_chi_duoc_khai_bao_MOT_lan()
{
var files = Directory.GetFiles("../../../../../src", "*.cs", SearchOption.AllDirectories)
.Where(f => !f.Contains("Migrations") && !f.Contains("obj"));

var viPham = files
.Where(f => Regex.IsMatch(File.ReadAllText(f), @"500[_.]?000[_.]?000"))
.Where(f => !f.EndsWith("Lead.cs"))
.ToList();

viPham.Should().BeEmpty(
"ngưỡng duyệt chỉ được khai báo trong Lead.cs; " +
"tìm thấy ở: " + string.Join(", ", viPham.Select(Path.GetFileName)));
}

Test này trông thô sơ, nhưng nó hiệu quả một cách bất ngờ: nó bắt được đúng lúc ai đó copy một dòng điều kiện sang chỗ mới, và thông điệp lỗi nói rõ phải làm gì.

Và một điểm về giá trị trong frontend:

// wwwroot/js/lead-form.js
if (value > 500000000) { hienThiCanhBaoDuyet(); }

Đây là trùng lặp hợp lệ — frontend cần biết ngưỡng để cải thiện trải nghiệm, và nó không thể gọi domain của backend. Nhưng nó phải:

  1. Không phải nguồn sự thật. Backend vẫn kiểm tra đầy đủ, luôn luôn.
  2. Lấy từ backend, đừng hardcode:
app.MapGet("/api/cau-hinh-nghiep-vu", () => new
{
NguongDuyetLead = Lead.NguongCanDuyet.Amount,
});
const cauHinh = await fetch('/api/cau-hinh-nghiep-vu').then(r => r.json());
if (value > cauHinh.nguongDuyetLead) { hienThiCanhBaoDuyet(); }

Với cách này, ngưỡng vẫn chỉ có một nguồn sự thật, và frontend đọc từ đó.

Ghi lại kết quả để theo dõi theo thời gian:

## Rà quy tắc bị nhân bản — 2026-09-25

| Quy tắc | Số chỗ | Bản sao sai lệch | Trạng thái |
|---|---:|---|---|
| Ngưỡng duyệt lead | 7 | `>` vs `>=`; cấu hình production 300tr không có hiệu lực | Đã gom |
| Tính chiết khấu | 5 | làm tròn khác nhau ở 2 chỗ | Đang xử lý |
| Mã khách hàng | 6 | 4 định dạng khác nhau | Chưa |

Cột "bản sao sai lệch" là cột quan trọng nhất — nó biến một nhiệm vụ refactor thành một danh sách lỗi đã được xác định.

Tự kiểm tra​

Câu hỏi thường gặp

Thước đo duy nhất của kiến trúc là gì?

Chi phí của thay đổi nghiệp vụ tiếp theo. Trong codebase khoẻ, tính năng thứ 100 tốn xấp xỉ tính năng thứ 10; trong codebase mục thì tốn gấp mười. Điều làm chi phí tăng không phải số dòng code mà là số thứ bạn phải hiểu để sửa an toàn.

Ba dấu hiệu code đang mục là gì?

Một thay đổi nghiệp vụ nhỏ phải sửa nhiều file ở nhiều tầng, không viết được unit test mà không dựng database, và sửa một chỗ làm hỏng chỗ khác không liên quan. Dấu hiệu thứ hai là rõ nhất và dễ kiểm tra nhất.

Vì sao codebase mục đi mà không ai viết code xấu?

Vì nó xảy ra qua hàng trăm quyết định hợp lý. Mỗi dòng thêm vào controller đều có lý do chính đáng, nhưng kết quả là quy tắc nghiệp vụ không có nơi nào để ở, nên nó bị chép lại ở job import và màn hình admin rồi ba bản lệch nhau.

Chi phí lớn nhất của kiến trúc quá sớm là gì?

Ranh giới bị đặt sai. Kiến trúc là việc vẽ ranh giới, mà ranh giới đúng đến từ hiểu nghiệp vụ; tháng đầu tiên bạn chưa hiểu nên ranh giới vẽ ra gần như chắc chắn sai. Sửa ranh giới sai đắt hơn không có ranh giới.

Khi nào nên tách một quy tắc nghiệp vụ ra?

Khi bạn chép nó lần thứ hai, không phải lần thứ ba, vì lần thứ ba nghĩa là đã có hai bản lệch nhau. Hai tín hiệu khác là muốn viết unit test mà không viết được, và một thay đổi làm hỏng thứ không liên quan.

Nguyên tắc bao trùm của toàn bộ module là gì?

Thứ ít thay đổi nhất không được phụ thuộc vào thứ hay thay đổi nhất. Quy tắc nghiệp vụ đổi theo năm, framework đổi khi nâng cấp, nên nghiệp vụ không được phụ thuộc framework. Nhưng đó là công cụ chứ không phải mục tiêu; mục tiêu vẫn là chi phí thay đổi thấp.

Kết luận​

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

  1. Thước đo là chi phí thay đổi tiếp theo, không phải code có "đẹp" hay không.
  2. Tách khi chép lần thứ hai, không sớm hơn và không muộn hơn.
  3. Kiến trúc quá sớm đặt ranh giới sai — và sửa ranh giới sai đắt hơn không có.

Tham khảo​

Điều hướng​