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

16.11 — 10. Anti-pattern cần tránh

Tóm tắt

Sáu anti-pattern gặp nhiều nhất khi áp dụng kiến trúc vào backend .NET. Điểm chung của cả sáu: chúng không gây lỗi ngay. Hệ thống vẫn chạy, test vẫn xanh, tính năng vẫn ra — chi phí chỉ hiện ra sau sáu tháng, dưới dạng "sửa một chỗ hỏng ba chỗ". Nguy hiểm nhất không phải God Service (dễ thấy) mà là Anemic Domain Model: nó trông giống Clean Architecture — có đủ Domain, Application, Infrastructure — nhưng quy tắc nghiệp vụ nằm rải trong các service, nên bạn trả toàn bộ chi phí của kiến trúc phân tầng mà không nhận được lợi ích nào. Và đừng bỏ qua anti-pattern cuối: kiến trúc lớn hơn bài toán cũng là một anti-pattern thật sự.

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

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

  • Nhận ra sáu anti-pattern qua dấu hiệu cụ thể trong code.
  • Giải thích vì sao Anemic Domain Model nguy hiểm hơn God Service.
  • Gỡ dần một God Service mà không phải viết lại từ đầu.
  • Dùng kiến trúc test để chặn vi phạm ranh giới ngay tại CI.
  • Biết khi nào kiến trúc là thừa.

Nội dung bài học​

16.11.1 — God Service​

Dấu hiệu: một lớp CrmService hoặc CustomerService dài hàng nghìn dòng, nhận mười mấy dependency trong constructor, và mọi controller đều gọi nó.

// ANTI-PATTERN
public sealed class CrmService(
ILeadRepository leads, ICustomerRepository customers, IOrderRepository orders,
IEmailSender email, ISmsSender sms, IPdfGenerator pdf, IFileStorage storage,
IPaymentGateway payment, ICacheService cache, IBackgroundJobClient jobs,
ILogger<CrmService> logger, IConfiguration config, IMapper mapper)
{
public async Task<LeadDto> CreateLeadAsync(...) { }
public async Task<LeadDto> UpdateLeadAsync(...) { }
public async Task ConvertLeadAsync(...) { }
public async Task<CustomerDto> CreateCustomerAsync(...) { }
public async Task SendMonthlyReportAsync(...) { }
public async Task ExportToExcelAsync(...) { }
// ... 40 phương thức nữa
}

Vì sao tệ:

Hậu quảChi tiết
Không test đượcMỗi test phải dựng 13 dependency dù chỉ kiểm tra một phương thức
Merge conflict liên tụcCả đội sửa cùng một file
Không rõ ranh giớiKhông ai biết đổi CreateLead có ảnh hưởng ExportToExcel không
Tải dependency vô íchMỗi request khởi tạo cả 13 dependency (nếu scoped)

Cách gỡ — không viết lại: tách dần theo use case. Mỗi lần chạm vào một phương thức, kéo nó ra thành một handler riêng (bài 16.7), rồi để CrmService gọi handler đó. Sau vài tháng CrmService rỗng và xoá được.

// Bước trung gian — CrmService uỷ quyền, không còn logic
public sealed class CrmService(ISender sender)
{
public Task<Result<LeadId>> CreateLeadAsync(CreateLeadRequest r, CancellationToken ct)
=> sender.Send(new CreateLeadCommand(r.Name, r.Email, r.Value), ct);
}

Đây là Strangler Fig: thứ mới mọc quanh thứ cũ cho tới khi thứ cũ không còn gì.

16.11.2 — Anemic Domain Model​

Đây là anti-pattern nguy hiểm nhất vì nó giả dạng kiến trúc tốt.

// ANTI-PATTERN — entity chỉ là túi chứa dữ liệu
public class Lead
{
public Guid Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
public string Status { get; set; }
public decimal Value { get; set; }
public DateTime? ConvertedAt { get; set; }
}

// Quy tắc nằm ở đây — và ở ba chỗ khác nữa
public class LeadService
{
public async Task ConvertAsync(Guid id)
{
var lead = await _repo.GetByIdAsync(id);
if (lead.Status == "Lost") throw new Exception("Lead da mat");
if (lead.Value <= 0) throw new Exception("Giá trị không hợp lệ");
lead.Status = "Won";
lead.ConvertedAt = DateTime.Now;
await _repo.SaveAsync(lead);
}
}

Vấn đề thật: quy tắc "lead đã mất thì không convert được" nằm trong LeadService. Ở màn hình import hàng loạt, ai đó gán lead.Status = "Won" trực tiếp — không có gì ngăn được. Entity cho phép mọi setter, nên mọi đoạn code đều có thể phá invariant.

Sáu tháng sau, quy tắc đó xuất hiện ở bốn nơi với ba phiên bản khác nhau, và không ai biết phiên bản nào đúng.

// SỬA — quy tắc sống trong entity, không thể lách
public sealed class Lead
{
public LeadId Id { get; private set; }
public LeadStatus Status { get; private set; }
public Money Value { get; private set; }
public DateTime? ConvertedAt { get; private set; }

private Lead() { } // cho EF Core

public static Lead Create(string name, EmailAddress email, Money value)
{
if (value.Amount <= 0)
throw new DomainException("Giá trị lead phải lớn hơn 0");
return new Lead { Id = LeadId.New(), Status = LeadStatus.New, Value = value, ... };
}

public void Convert(DateTime utcNow)
{
if (Status == LeadStatus.Lost)
throw new DomainException("Lead đã mất, không thể chuyển đổi");
if (Status == LeadStatus.Won)
return; // idempotent

Status = LeadStatus.Won;
ConvertedAt = utcNow;
}
}

Giờ quy tắc chỉ có một chỗ, và không đường nào lách được vì setter là private.

Cách phân biệt nhanh: nếu entity của bạn chỉ có property { get; set; } và không có phương thức nào ngoài constructor, bạn đang có Anemic Domain Model. Xem Anemic Domain Model (Fowler).

Lưu ý công bằng: anemic model không sai với CRUD thuần — một bảng Country chỉ có mã và tên thì không cần hành vi. Nó sai khi bạn đã trả chi phí của kiến trúc phân tầng mà vẫn để quy tắc ở ngoài.

16.11.3 — Domain phụ thuộc EF Core​

// ANTI-PATTERN — trong Crm.Domain
using Microsoft.EntityFrameworkCore;

public class Lead
{
[Column("lead_name"), MaxLength(200)]
public string Name { get; set; }

public async Task<bool> IsDuplicateAsync(AppDbContext db)
=> await db.Leads.AnyAsync(l => l.Email == Email);
}

Vi phạm trực tiếp dependency rule (bài 16.4). Hậu quả cụ thể:

  • Không unit test được Domain mà không dựng DbContext.
  • Đổi ORM phải sửa Domain.
  • Quy tắc nghiệp vụ trộn với chi tiết lưu trữ.

Sửa: đẩy cấu hình sang IEntityTypeConfiguration trong tầng Infrastructure, và đưa truy vấn ra khỏi entity.

// Crm.Infrastructure/Configurations/LeadConfiguration.cs
public sealed class LeadConfiguration : IEntityTypeConfiguration<Lead>
{
public void Configure(EntityTypeBuilder<Lead> b)
{
b.Property(l => l.Name).HasColumnName("lead_name").HasMaxLength(200);
b.OwnsOne(l => l.Value, v => v.Property(x => x.Amount).HasColumnType("decimal(18,2)"));
}
}

Chặn tự động bằng kiến trúc test — hiệu quả hơn nhắc nhau trong review:

[Fact]
public void Domain_ShouldNotDependOn_EntityFrameworkCore()
{
var result = Types.InAssembly(typeof(Lead).Assembly)
.ShouldNot()
.HaveDependencyOnAny("Microsoft.EntityFrameworkCore", "Microsoft.AspNetCore")
.GetResult();

Assert.True(result.IsSuccessful,
"Domain phụ thuộc hạ tầng: " + string.Join(", ", result.FailingTypeNames ?? []));
}

Gói NetArchTest.Rules. Test này chạy trong CI và chặn PR vi phạm ranh giới.

16.11.4 — Controller điều phối nghiệp vụ​

// ANTI-PATTERN — controller làm việc của use case
[HttpPost("{id}/convert")]
public async Task<IActionResult> Convert(Guid id)
{
var lead = await _leadRepo.GetByIdAsync(id);
if (lead is null) return NotFound();
if (lead.Status == "Lost") return BadRequest("Lead da mat");

var customer = new Customer { Name = lead.Name, Email = lead.Email };
await _customerRepo.AddAsync(customer);

lead.Status = "Won";
await _leadRepo.UpdateAsync(lead);

await _emailSender.SendAsync(lead.Email, "Chào mừng!");
await _auditRepo.AddAsync(new AuditLog(...));

return Ok();
}

Ba vấn đề, vấn đề thứ nhất là nghiêm trọng nhất:

  1. Không có transaction. AddAsync customer thành công, UpdateAsync lead thất bại → dữ liệu mâu thuẫn vĩnh viễn: có customer nhưng lead vẫn "New", và lần convert sau tạo customer trùng.
  2. Không tái sử dụng được. Background job cần convert lead phải chép lại toàn bộ.
  3. Không test được nếu không dựng HTTP.

Còn một vấn đề nữa: SendAsync nằm trong cùng luồng. SMTP chậm hoặc lỗi → request lỗi dù nghiệp vụ đã xong.

Sửa: controller chỉ nhận request, gọi use case, ánh xạ kết quả sang HTTP.

[HttpPost("{id:guid}/convert")]
public async Task<IActionResult> Convert(Guid id, CancellationToken ct)
{
var result = await _sender.Send(new ConvertLeadCommand(new LeadId(id)), ct);
return result.IsSuccess ? NoContent() : result.Error.ToProblemDetails();
}

Transaction nằm trong pipeline behavior (bài 16.7), email đi qua domain event và chạy sau khi commit (bài 16.9).

16.11.5 — MediatR cho mọi thứ​

// ANTI-PATTERN — một lớp query chỉ để gọi một dòng EF
public sealed record GetCountriesQuery : IRequest<List<CountryDto>>;

public sealed class GetCountriesHandler(AppDbContext db)
: IRequestHandler<GetCountriesQuery, List<CountryDto>>
{
public Task<List<CountryDto>> Handle(GetCountriesQuery q, CancellationToken ct)
=> db.Countries.Select(c => new CountryDto(c.Code, c.Name)).ToListAsync(ct);
}

Ba file (query, handler, và đăng ký) cho một truy vấn không có quy tắc, không có transaction, không cần audit. Đây là chi phí thuần.

Quy tắc dùng: MediatR đáng dùng khi bạn cần pipeline — validation, transaction, logging, authorization chạy quanh use case. Truy vấn đọc thuần không cần gì trong số đó thì gọi thẳng.

// Đọc thuần — gọi thẳng, ngắn gọn hơn, và không mất gì
[HttpGet("countries")]
public async Task<IReadOnlyList<CountryDto>> GetCountries(CancellationToken ct)
=> await _db.Countries
.AsNoTracking()
.Select(c => new CountryDto(c.Code, c.Name))
.ToListAsync(ct);

Ranh giới thực dụng: ghi thì qua MediatR (cần transaction và validation), đọc đơn giản thì gọi thẳng, đọc phức tạp có phân quyền theo dữ liệu thì qua MediatR.

16.11.6 — Kiến trúc lớn hơn bài toán​

Anti-pattern cuối cùng ít được nói tới nhưng tốn kém thật:

Crm.Domain/
Crm.Domain.Shared/
Crm.Application/
Crm.Application.Contracts/
Crm.Infrastructure/
Crm.Infrastructure.Persistence/
Crm.Infrastructure.Identity/
Crm.Infrastructure.Messaging/
Crm.Api/
Crm.Api.Contracts/
...cho một ứng dụng 5 màn hình CRUD, đội 2 người

Mỗi ranh giới là chi phí: thêm một lần ánh xạ, thêm một file cấu hình DI, thêm một bước khi đọc code, build chậm hơn.

Khi nào kiến trúc là thừa:

Tình huốngNên dùng
Ứng dụng nội bộ, CRUD thuần, 1–2 ngườiLayered đơn giản, hoặc không phân tầng
Domain phức tạp, nhiều quy tắc, đội nhiều ngườiClean Architecture hoặc VSA
Hệ thống lâu dài, thay đổi liên tụcClean Architecture + DDD chiến thuật
Prototype, MVP cần ra trong 2 tuầnCàng đơn giản càng tốt

Câu hỏi quyết định: "chi phí của ranh giới này có nhỏ hơn chi phí không có nó không?" Nếu không trả lời được cụ thể, ranh giới đó chưa cần.

Kiến trúc là đầu tư, và như mọi đầu tư, nó có thể lỗ khi bài toán không đủ lớn. Bắt đầu đơn giản và tách khi thấy đau — cách này rẻ hơn tách sẵn cho một tương lai có thể không đến.

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

Danh sách rà soát anti-pattern

  • •Không có lớp service nào nhận quá 5–6 dependency.
  • •Entity có phương thức nghiệp vụ, không chỉ property get set.
  • •Setter của entity là private, không thể đổi trạng thái từ bên ngoài.
  • •Assembly Domain không tham chiếu EF Core hay ASP.NET Core.
  • •Có kiến trúc test chặn vi phạm ranh giới ngay trong CI.
  • •Controller không gọi nhiều hơn một use case.
  • •Mọi thao tác ghi nhiều bảng đều nằm trong một transaction.
  • •Tác dụng phụ như gửi email chạy sau khi commit, không trong cùng luồng.
  • •Truy vấn đọc đơn giản không bị bọc trong MediatR một cách máy móc.
  • •Số project trong solution tương xứng với độ phức tạp thật.
  • •Mỗi ranh giới đều trả lời được nó giải quyết vấn đề cụ thể gì.

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

Bài 1 — Phát hiện Anemic Domain Model​

Mở một entity bất kỳ trong dự án của bạn và đếm số phương thức nghiệp vụ. Nếu bằng 0, tìm quy tắc liên quan tới entity đó đang nằm ở đâu và có bao nhiêu bản sao.

Tiêu chí hoàn thành: bạn phân biệt được anemic do thiếu sót với anemic có chủ ý, và biết gỡ theo thứ tự nào.

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

Gợi ý. Không phải mọi entity đều cần phương thức. Câu hỏi là quy tắc đang ở đâu.

Lời giải — đếm tự động:

[Fact]
public void Bao_cao_so_phuong_thuc_nghiep_vu_cua_tung_entity()
{
var bangKe = typeof(Lead).Assembly.GetTypes()
.Where(t => t.IsSubclassOf(typeof(EntityBase)) && !t.IsAbstract)
.Select(t => new
{
Ten = t.Name,
SoPhuongThuc = t.GetMethods(BindingFlags.Public | BindingFlags.Instance
| BindingFlags.DeclaredOnly)
.Count(m => !m.IsSpecialName), // bỏ getter/setter
SoPublicSetter = t.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Count(p => p.SetMethod is { IsPublic: true }),
})
.OrderBy(x => x.SoPhuongThuc)
.ToList();

foreach (var e in bangKe)
_output.WriteLine($"{e.Ten,-24} {e.SoPhuongThuc,3} phương thức, {e.SoPublicSetter,3} public setter");
}
Lead                       0 phương thức,  12 public setter
Order 0 phương thức, 9 public setter
Customer 0 phương thức, 14 public setter
Invoice 1 phương thức, 8 public setter
TinhThanh 0 phương thức, 3 public setter
AuditLog 0 phương thức, 6 public setter

Tìm quy tắc đang nằm ở đâu:

grep -rn "\.Status\s*=\|\.Total\s*=\|\.ClosedUtc\s*=" --include="*.cs" src/ | grep -v "=="
src/Crm.Api/Controllers/LeadsController.cs:94,142
src/Crm.Application/Services/LeadService.cs:211,348,402
src/Crm.Infrastructure/Jobs/ImportLeadJob.cs:122
src/Crm.Infrastructure/Consumers/LeadSyncConsumer.cs:71
src/Crm.Api/Features/Leads/ChotLead.cs:38
grep -rn "500_000_000" --include="*.cs" src/ | wc -l
7

Quy tắc chuyển trạng thái nằm ở 8 chỗ; ngưỡng duyệt nằm ở 7 chỗ.

Phân biệt anemic do thiếu sót với anemic có chủ ý:

Do thiếu sótCó chủ ý
Quy tắc nghiệp vụCó, nhưng nằm ở nơi khácKhông có quy tắc nào
Số bản sao của quy tắcNhiều, thường sai lệchKhông áp dụng
Trạng thái không hợp lệ có tồn tại được khôngCóKhông — vì không có khái niệm "không hợp lệ"
Ví dụLead, Order, InvoiceTinhThanh, AuditLog, bảng đọc

Câu hỏi phân biệt:

"Có tổ hợp giá trị nào của entity này là KHÔNG HỢP LỆ về mặt nghiệp vụ không?"

Lead:      "Status = Won mà ClosedUtc = null" -> KHÔNG HỢP LỆ
-> anemic do thiếu sót -> cần gỡ

TinhThanh: mọi tổ hợp (Id, Ten, Ma) đều hợp lệ
-> anemic có chủ ý -> để nguyên, thêm phương thức là chi phí thừa

Áp vào bảng ở trên:

Lead      -> do thiếu sót, ưu tiên cao  (quy tắc ở 8 chỗ)
Order -> do thiếu sót, ưu tiên cao (tổng tiền phải khớp các dòng)
Customer -> do thiếu sót, ưu tiên vừa
Invoice -> do thiếu sót, ưu tiên cao (hoá đơn đã phát hành không sửa được)
TinhThanh -> CÓ CHỦ Ý, để nguyên
AuditLog -> CÓ CHỦ Ý, để nguyên

Thứ tự gỡ — bốn bước, mỗi bước commit được riêng:

Bước 1 — thêm phương thức domain, GIỮ public setter. Không có gì hỏng, không ai phải sửa gì:

public class Lead
{
public LeadStatus Status { get; set; } // vẫn public
public DateTime? ClosedUtc { get; set; }

// MỚI — chưa ai gọi
public Result ChuyenSangWon(NguoiDung nguoiThucHien, DateTime bayGio)
{
if (Status is LeadStatus.Won or LeadStatus.Lost)
return Result.Loi($"Lead đã ở trạng thái cuối: {Status}");
if (AssignedTo is null)
return Result.Loi("Lead chưa được gán cho ai");
if (Value >= NguongCanDuyet && !nguoiThucHien.LaTruongPhong)
return Result.Loi($"Lead trên {NguongCanDuyet} cần trưởng phòng duyệt");

Status = LeadStatus.Won;
ClosedUtc = bayGio;
Raise(new LeadDaChot(Id, Value));
return Result.ThanhCong();
}
}

Bước 2 — chuyển từng nơi gọi sang dùng phương thức mới. Mỗi nơi là một commit nhỏ, có thể review riêng:

// Trước
lead.Status = "Won";
lead.ClosedUtc = DateTime.UtcNow;

// Sau
var kq = lead.ChuyenSangWon(nguoiDung, _clock.GetUtcNow().UtcDateTime);
if (!kq.ThanhCong) return Results.BadRequest(kq.Loi);

Trong bước này bạn sẽ phát hiện những chỗ không thể chuyển — vì chúng đang cố tình làm điều mà quy tắc cấm. Mỗi chỗ như vậy là một cuộc trò chuyện với nghiệp vụ, không phải một vấn đề kỹ thuật.

Bước 3 — đổi sang private set. Trình biên dịch chỉ ra mọi chỗ còn sót:

public LeadStatus Status { get; private set; }
public DateTime? ClosedUtc { get; private set; }
dotnet build 2>&1 | grep -c CS0272
3

Ba chỗ còn sót mà bước 2 bỏ qua — thường là seeder, test, hoặc một đường code ít dùng.

Bước 4 — thêm ràng buộc database làm lớp cuối:

ALTER TABLE Leads ADD CONSTRAINT CK_Leads_Won_ClosedUtc
CHECK (Status <> 'Won' OR ClosedUtc IS NOT NULL);

Phải dọn dữ liệu cũ trước, và con số dòng phải dọn cho bạn biết quy mô thiệt hại:

SELECT COUNT(*) FROM Leads WHERE Status = 'Won' AND ClosedUtc IS NULL;
1.284

Vì sao thứ tự này quan trọng: mỗi bước không phá vỡ code hiện có, nên bạn dừng lại được ở bất kỳ đâu mà không để lại trạng thái dở dang. Cách ngược lại — đổi private set trước — làm hỏng 8 chỗ cùng lúc và buộc bạn phải sửa hết trong một PR khổng lồ.

Đo tiến độ:

## Gỡ Anemic Domain Model

| Entity | Phương thức | Public setter | Nơi gán trực tiếp | Trạng thái |
|---|---:|---:|---:|---|
| Lead | 0 → 4 | 12 → 0 | 8 → 0 | Xong |
| Order | 0 → 3 | 9 → 1 | 6 → 2 | Đang làm |
| Customer | 0 | 14 | 11 | Chưa |
| Invoice | 1 | 8 | 4 | Chưa |

Cột "nơi gán trực tiếp" là cột quan trọng nhất — nó đo đúng thứ bạn đang cố loại bỏ.

Và một cảnh báo: đừng gỡ hết cùng lúc. Với 40 entity, đây là công việc nhiều tháng. Chọn theo thứ tự:

1. Entity có nhiều quy tắc nhất và nhiều bản sao nhất
2. Entity mà bạn SẮP PHẢI SỬA cho một tính năng mới
3. Phần còn lại, dần dần

Mục 2 đáng ưu tiên hơn vẻ ngoài: gỡ một entity ngay trước khi sửa nó nghĩa là bạn trả chi phí refactor một lần và thu lợi ích ngay lập tức — thay vì làm một dự án refactor riêng mà nghiệp vụ không thấy giá trị.


Bài 2 — Kiến trúc test trên dự án thật​

Thêm NetArchTest.Rules và viết test chặn Domain phụ thuộc Microsoft.EntityFrameworkCore. Chạy trên dự án thật và ghi lại số vi phạm.

Tiêu chí hoàn thành: bạn chạy được trên dự án thật, và có chiến lược xử lý khi số vi phạm lớn.

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

Gợi ý. Test đỏ với 87 vi phạm thì bạn làm gì? Sửa hết, hay tắt test?

Lời giải:

dotnet new xunit -o tests/Crm.ArchitectureTests
dotnet add tests/Crm.ArchitectureTests package NetArchTest.Rules
dotnet add tests/Crm.ArchitectureTests reference src/Crm.Domain src/Crm.Application src/Crm.Infrastructure
public class KienTrucTests
{
private static readonly Assembly Domain = typeof(Lead).Assembly;

[Fact]
public void Domain_khong_duoc_phu_thuoc_EF_Core()
{
var kq = Types.InAssembly(Domain)
.ShouldNot().HaveDependencyOn("Microsoft.EntityFrameworkCore")
.GetResult();

kq.IsSuccessful.Should().BeTrue(
"Domain phụ thuộc EF Core ở {0} kiểu: {1}",
kq.FailingTypeNames?.Count() ?? 0,
string.Join(", ", kq.FailingTypeNames?.Take(10) ?? []));
}
}
Xpect: Domain phụ thuộc EF Core ở 87 kiểu: Lead, Customer, Order, Invoice, ...

87 vi phạm. Đây là tình huống mà phần lớn người gặp khi chạy lần đầu trên một dự án đang chạy — và nó là khoảnh khắc quyết định xem kiến trúc test sẽ sống hay bị tắt.

Ba phản ứng, và chỉ một cái đúng:

1. Sửa hết 87 chỗ ngay      -> PR khổng lồ, rủi ro cao, nhóm phản đối
2. Tắt test, để sau -> "sau" không bao giờ đến
3. Ratchet -> chặn hồi quy ngay, giảm dần <- ĐÚNG

Chiến lược ratchet:

public class KienTrucTests
{
// Ngưỡng hiện tại — CHỈ ĐƯỢC GIẢM.
// Mỗi lần giảm, cập nhật con số này trong cùng PR.
private const int NguongViPhamEfCore = 87;

[Fact]
public void Domain_phu_thuoc_EF_Core_chi_duoc_giam()
{
var kq = Types.InAssembly(Domain)
.ShouldNot().HaveDependencyOn("Microsoft.EntityFrameworkCore")
.GetResult();

var soViPham = kq.FailingTypeNames?.Count() ?? 0;

soViPham.Should().BeLessThanOrEqualTo(NguongViPhamEfCore,
"số vi phạm tăng từ {0} lên {1}. Các kiểu vi phạm: {2}. " +
"Nếu bạn cần thêm phụ thuộc mới, hãy định nghĩa interface trong Domain.",
NguongViPhamEfCore, soViPham,
string.Join(", ", kq.FailingTypeNames ?? []));

// Nhắc cập nhật ngưỡng khi đã giảm được
if (soViPham < NguongViPhamEfCore)
_output.WriteLine(
$"Đã giảm còn {soViPham} vi phạm — hãy cập nhật NguongViPhamEfCore " +
$"từ {NguongViPhamEfCore} xuống {soViPham} trong PR này.");
}
}

Bốn lợi ích của cách này:

  1. Test xanh ngay hôm nay — không ai phải sửa gì để merge.
  2. Hồi quy bị chặn ngay lập tức — thêm một vi phạm mới làm CI đỏ.
  3. Tiến độ hiện rõ — con số trong code, ai cũng thấy.
  4. Không cần dự án refactor riêng — giảm dần trong công việc hằng ngày.

Và quan trọng: thông điệp lỗi phải nói phải làm gì. Người gặp test đỏ thường không phải người viết test, và họ đang vội.

Cách giảm 87 vi phạm — phân loại trước, sửa sau:

[Fact]
public void Bao_cao_loai_vi_pham()
{
var viPham = Domain.GetTypes()
.SelectMany(t => t.GetCustomAttributes(inherit: false)
.Select(a => (Kieu: t.Name, Attribute: a.GetType().Name)))
.Where(x => x.Attribute.StartsWith("Table") || x.Attribute.StartsWith("Column")
|| x.Attribute.StartsWith("Key") || x.Attribute.StartsWith("Index"))
.GroupBy(x => x.Attribute)
.Select(g => $"{g.Key}: {g.Count()} chỗ")
.ToList();

foreach (var v in viPham) _output.WriteLine(v);
}
TableAttribute:             38 chỗ
ColumnAttribute: 24 chỗ
KeyAttribute: 19 chỗ
DatabaseGeneratedAttribute: 6 chỗ

Phân loại này cho thấy phần lớn vi phạm là attribute ánh xạ, không phải code gọi EF Core. Đó là tin tốt — chúng sửa được một cách cơ học:

// Trước — trong Crm.Domain
[Table("Leads")]
public class Lead
{
[Key] public int Id { get; set; }
[Column("lead_name", TypeName = "nvarchar(200)")] public string Name { get; set; } = null!;
}
// Sau — Domain sạch
public class Lead
{
public int Id { get; private set; }
public string Name { get; private set; } = null!;
}
// Crm.Infrastructure/Configurations/LeadConfiguration.cs
public class LeadConfiguration : IEntityTypeConfiguration<Lead>
{
public void Configure(EntityTypeBuilder<Lead> b)
{
b.ToTable("Leads");
b.HasKey(l => l.Id);
b.Property(l => l.Name).HasColumnName("lead_name").HasMaxLength(200);
}
}
protected override void OnModelCreating(ModelBuilder builder)
=> builder.ApplyConfigurationsFromAssembly(typeof(CrmDbContext).Assembly);

Sửa 10–15 entity mỗi sprint, cập nhật ngưỡng, và sau ba sprint con số về 0.

Bảy quy tắc nên có, theo thứ tự thêm vào:

// 1. Dễ nhất, thêm trước
[Fact] public void Domain_khong_phu_thuoc_EF_Core() { }

// 2.
[Fact] public void Domain_khong_phu_thuoc_AspNetCore() { }

// 3.
[Fact] public void Application_khong_phu_thuoc_Infrastructure() { }

// 4.
[Fact] public void Handler_phai_kin_va_dat_ten_ket_thuc_bang_Handler() { }

// 5. Khó hơn — thường nhiều vi phạm
[Fact] public void Entity_khong_co_public_setter() { }

// 6.
[Fact] public void Khong_dung_DateTime_Now_truc_tiep() { }

// 7. Khó nhất — thêm sau cùng
[Fact] public void Domain_chi_phu_thuoc_BCL() { }

Thêm từng quy tắc một, mỗi cái với ngưỡng riêng. Thêm cả bảy cùng lúc cho ra một báo cáo dài đến mức không ai đọc.

Cho phép ngoại lệ có tên, có lý do, có hạn:

private static readonly Dictionary<string, string> NgoaiLe = new()
{
["Crm.Domain.Legacy.BaoCaoCu"] = "sẽ gỡ ở CRM-4821, hạn 2026-12-31",
["Crm.Domain.Import.RowMapper"] = "cần EF Core bulk, đang tìm cách thay",
};

var viPham = (kq.FailingTypeNames ?? []).Except(NgoaiLe.Keys).ToList();

Không có cơ chế này, người ta sẽ tắt hẳn test khi gặp một trường hợp chính đáng — và bạn mất cả bộ quy tắc thay vì một ngoại lệ.

Và một test kiểm tra chính danh sách ngoại lệ:

[Fact]
public void Ngoai_le_kien_truc_khong_duoc_qua_han()
{
var quaHan = NgoaiLe
.Where(kv => Regex.Match(kv.Value, @"hạn (\d{4}-\d{2}-\d{2})") is { Success: true } m
&& DateOnly.Parse(m.Groups[1].Value) < DateOnly.FromDateTime(DateTime.UtcNow))
.Select(kv => kv.Key)
.ToList();

quaHan.Should().BeEmpty("các ngoại lệ sau đã quá hạn, cần xử lý hoặc gia hạn có lý do");
}

Test này biến "sẽ sửa sau" thành một cam kết có thời hạn — và đó là khác biệt giữa một danh sách nợ kỹ thuật được quản lý và một danh sách bị lãng quên.


Bài 3 — Gỡ God Service một bước​

Chọn một phương thức trong service lớn nhất, kéo ra thành handler riêng, để service cũ uỷ quyền. Đo số dependency của service sau khi gỡ.

Tiêu chí hoàn thành: bạn thực hiện được mà không phá vỡ code gọi hiện có, và nêu được vì sao cách "uỷ quyền" tốt hơn cách "sửa hết nơi gọi".

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

Gợi ý. Nếu bạn đổi chữ ký của một phương thức được gọi ở 40 chỗ, PR của bạn có bao nhiêu file?

Lời giải — hiện trạng:

public class LeadService : ILeadService
{
private readonly CrmDbContext _db;
private readonly IEmailSender _email;
private readonly ICacheService _cache;
private readonly IHttpContextAccessor _http;
private readonly IConfiguration _config;
private readonly ILogger<LeadService> _logger;
private readonly IMapper _mapper;
private readonly IPublishEndpoint _bus;
private readonly IFileStorage _storage;
private readonly IPdfGenerator _pdf;
private readonly ISmsSender _sms;
private readonly IExcelExporter _excel;
private readonly ITenantProvider _tenant;
private readonly IBackgroundJobClient _jobs;

// 14 dependency, 687 dòng, 14 phương thức public
}
grep -c "private readonly" src/Crm.Application/Services/LeadService.cs
grep -c "public async Task" src/Crm.Application/Services/LeadService.cs
wc -l src/Crm.Application/Services/LeadService.cs
14
14
687
grep -rn "_leadService\.\|ILeadService" --include="*.cs" src/ | wc -l
43

43 nơi gọi. Đổi chữ ký nghĩa là một PR chạm 43 file.

Ba bước gỡ, không phá vỡ gì:

Bước 1 — tạo handler mới, sao chép logic:

// src/Crm.Application/Leads/ChotLead/ChotLeadCommand.cs
public record ChotLeadCommand(LeadId Id) : IRequest<Result>;

// src/Crm.Application/Leads/ChotLead/ChotLeadHandler.cs
public sealed class ChotLeadHandler : IRequestHandler<ChotLeadCommand, Result>
{
private readonly CrmDbContext _db;
private readonly ICurrentUser _user;
private readonly TimeProvider _clock;
// ĐÚNG 3 dependency — chỉ những gì use case này cần

public ChotLeadHandler(CrmDbContext db, ICurrentUser user, TimeProvider clock)
=> (_db, _user, _clock) = (db, user, clock);

public async Task<Result> Handle(ChotLeadCommand c, CancellationToken ct)
{
var lead = await _db.Leads.FirstOrDefaultAsync(l => l.Id == c.Id, ct);
if (lead is null) return Result.KhongTimThay();

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

await _db.SaveChangesAsync(ct);
return Result.ThanhCong();
}
}

Chú ý: trong lúc sao chép, bạn sẽ thấy logic nào thật sự cần và logic nào là tàn dư. Phương thức cũ có thể gọi _email, _sms, _pdf — và khi tách ra, những việc đó trở thành domain event hoặc bước riêng.

Bước 2 — service cũ uỷ quyền, KHÔNG đổi chữ ký:

public class LeadService : ILeadService
{
private readonly IMediator _mediator;

// Chữ ký GIỮ NGUYÊN — 43 nơi gọi không phải sửa gì
public async Task<Result> ChotLeadAsync(LeadId id, CancellationToken ct)
=> await _mediator.Send(new ChotLeadCommand(id), ct);

// 13 phương thức còn lại giữ nguyên phần cài đặt cũ
}
dotnet build && dotnet test
Build succeeded.
Passed! - Failed: 0, Passed: 412

Không file gọi nào phải sửa. PR này chạm 3 file: hai file mới và LeadService.cs.

Bước 3 — chuyển dần nơi gọi, và xoá dependency thừa:

// Mỗi lần bạn chạm vào một nơi gọi cho việc khác, chuyển luôn
app.MapPost("/leads/{id}/chot", async (LeadId id, IMediator m, CancellationToken ct) =>
{
var kq = await m.Send(new ChotLeadCommand(id), ct);
return kq.ThanhCong ? Results.NoContent() : Results.BadRequest(kq.Loi);
});

Khi không còn ai gọi ChotLeadAsync, xoá nó — và xoá những dependency chỉ nó dùng:

# Dependency nào chỉ còn được dùng ở một chỗ?
grep -c "_pdf\." src/Crm.Application/Services/LeadService.cs

Đo sau khi gỡ 5 phương thức:

              Trước    Sau
Dòng 687 284
Phương thức 14 9
Dependency 14 6
Người chạm/tháng 11 4

Và các handler mới:

ChotLeadHandler        38 dòng, 3 dependency
GanLeadHandler 31 dòng, 2 dependency
HuyLeadHandler 34 dòng, 3 dependency
XuatExcelLeadHandler 52 dòng, 3 dependency
GuiBaoGiaHandler 47 dòng, 4 dependency

Vì sao "uỷ quyền" tốt hơn "sửa hết nơi gọi" — năm lý do:

1. PR nhỏ, review được.

Uỷ quyền:        3 file, khoảng 60 dòng -> review trong 10 phút
Sửa hết nơi gọi: 46 file, khoảng 400 dòng -> không ai review kỹ được

2. Rủi ro thấp và dừng được bất cứ lúc nào. Nếu sau ba phương thức bạn phải chuyển sang việc khác, trạng thái hiện tại vẫn hoàn toàn nhất quán — service cũ vẫn hoạt động, handler mới vẫn hoạt động.

3. Không xung đột merge. PR khổng lồ chạm 46 file gần như chắc chắn xung đột với mọi PR khác đang mở (bài 16.4).

4. Không cần thuyết phục ai. "Tôi cần hai tuần để refactor" là một cuộc trò chuyện khó. "Tôi gỡ phương thức này ra khi sửa nó" thì không cần trò chuyện gì.

5. Hành vi được bảo toàn và kiểm chứng được. Test cũ vẫn chạy qua LeadService và vẫn xanh — chúng trở thành lưới an toàn xác nhận handler mới hành xử giống hệt.

Chọn phương thức nào để gỡ trước:

Ưu tiên 1: phương thức bạn SẮP PHẢI SỬA cho một tính năng
-> trả chi phí refactor một lần, thu lợi ích ngay
Ưu tiên 2: phương thức có nhiều lỗi nhất trong lịch sử
Ưu tiên 3: phương thức dài nhất
Ưu tiên 4: phương thức có nhiều dependency nhất

Ưu tiên 1 là chiến lược duy nhất bền vững, vì nó không đòi hỏi thời gian riêng cho refactor.

# Phương thức nào hay bị sửa nhất?
git log --since='1 year ago' -L :ChotLeadAsync:src/Crm.Application/Services/LeadService.cs \
--format='%h %ad' --date=short | grep -c '^commit'

Bẫy cần tránh — đừng chỉ đổi chỗ:

// SAI — handler nhận cả 14 dependency, chỉ là God Service đổi tên
public class ChotLeadHandler : IRequestHandler<ChotLeadCommand, Result>
{
public ChotLeadHandler(CrmDbContext db, IEmailSender email, ICacheService cache,
IHttpContextAccessor http, IConfiguration config, ...)
}

Khi tách, hãy hỏi với từng dependency: "use case này có thật sự cần nó không?"

IPdfGenerator ở ChotLeadHandler?  -> không, chốt lead không sinh PDF
-> nó thuộc về GuiBaoGiaHandler

IEmailSender ở ChotLeadHandler? -> gửi email là phản ứng phụ
-> chuyển thành domain event handler

IConfiguration cho ngưỡng duyệt? -> đó là quy tắc nghiệp vụ
-> chuyển vào Lead (bài 16.1)

Ngưỡng cho một handler khoẻ mạnh:

Dòng:        dưới 60
Dependency: dưới 5
Phương thức public: đúng 1 (Handle)

Nếu một handler vượt các ngưỡng này, nó đang làm nhiều hơn một việc — và câu hỏi đầu tiên nên là liệu một phần của nó có thuộc về entity hay không.

Theo dõi tiến độ:

#!/usr/bin/env bash
for f in $(find src -name "*Service.cs"); do
d=$(grep -c "private readonly" "$f")
m=$(grep -c "public async Task\|public Task" "$f")
l=$(wc -l < "$f")
[ "$d" -gt 6 ] && printf "%-50s %3d dòng, %2d dep, %2d phương thức\n" "$(basename $f)" "$l" "$d" "$m"
done | sort -k2 -rn
LeadService.cs            284 dòng,  6 dep,  9 phương thức
OrderService.cs 512 dòng, 11 dep, 12 phương thức
CustomerService.cs 398 dòng, 9 dep, 10 phương thức

Chạy script này hằng tuần và ghi lại. Đường cong đi xuống là bằng chứng khách quan rằng công việc đang tiến triển — và nó hữu ích hơn nhiều so với cảm giác "code đang tốt dần".

Tự kiểm tra​

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

Vì sao Anemic Domain Model nguy hiểm hơn God Service?

Vì God Service dễ thấy, còn anemic model trông giống kiến trúc tốt. Bạn có đủ Domain, Application, Infrastructure nhưng quy tắc nghiệp vụ nằm trong các service, nên entity cho phép mọi đoạn code phá invariant. Bạn trả toàn bộ chi phí của kiến trúc phân tầng mà không nhận được lợi ích bảo vệ quy tắc.

Anemic Domain Model có bao giờ chấp nhận được không?

Có, với CRUD thuần không có quy tắc nghiệp vụ, ví dụ bảng danh mục chỉ có mã và tên. Nó chỉ sai khi bạn đã trả chi phí của kiến trúc phân tầng mà vẫn để quy tắc ở ngoài entity.

Cách gỡ một God Service ba nghìn dòng mà không viết lại?

Dùng Strangler Fig. Mỗi lần chạm vào một phương thức thì kéo nó ra thành một handler riêng, rồi để service cũ uỷ quyền cho handler đó. Sau vài tháng service cũ rỗng và xoá được, không cần một đợt viết lại lớn.

Vấn đề nghiêm trọng nhất khi controller điều phối nghiệp vụ là gì?

Không có transaction. Nếu thêm customer thành công nhưng cập nhật lead thất bại thì dữ liệu mâu thuẫn vĩnh viễn, và lần convert sau sẽ tạo customer trùng. Hai vấn đề còn lại là không tái sử dụng được cho background job và không test được nếu không dựng HTTP.

Khi nào nên dùng MediatR và khi nào gọi thẳng?

Dùng MediatR khi cần pipeline gồm validation, transaction, logging hoặc authorization chạy quanh use case, tức là hầu hết thao tác ghi. Truy vấn đọc đơn giản không cần gì trong số đó thì gọi thẳng, vì ba file cho một dòng EF Core là chi phí thuần.

Làm sao chặn vi phạm ranh giới một cách tự động?

Viết kiến trúc test bằng NetArchTest.Rules khẳng định assembly Domain không phụ thuộc Microsoft.EntityFrameworkCore hay Microsoft.AspNetCore. Test chạy trong CI và chặn PR vi phạm, hiệu quả hơn nhắc nhau trong code review.

Kết luận​

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

  1. Anemic Domain Model là anti-pattern đắt nhất vì nó giả dạng kiến trúc tốt — bạn trả chi phí mà không có lợi ích.
  2. Kiến trúc test chặn vi phạm ranh giới tốt hơn code review, vì nó không quên và không nể.
  3. Kiến trúc lớn hơn bài toán cũng là anti-pattern. Mỗi ranh giới phải trả lời được nó giải quyết vấn đề gì.

Tham khảo​

Điều hướng​