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

16.3 — 2. Layered architecture (cổ điển nhưng vẫn hữu ích)

Tóm tắt

Kiến trúc phân tầng là nền tảng mà mọi thứ khác trong module này xây lên. Bốn tầng, và quy tắc duy nhất: phụ thuộc chỉ hướng vào trong. Điều quyết định nó hoạt động hay không là ranh giới có được ép buộc không: thư mục trong một project là ranh giới do kỷ luật, và kỷ luật thất bại lúc 5 giờ chiều thứ Sáu; project riêng là ranh giới do trình biên dịch ép, và nó không thất bại. Cạm bẫy cố hữu của mô hình này là anemic domain model — entity chỉ có getter/setter còn mọi logic nằm ở service khổng lồ; nó biến bốn tầng thành "ba tầng cộng một thư mục DTO". Nhưng anemic không phải luôn sai: với nghiệp vụ gần như chỉ là CRUD, entity giàu logic là chi phí thừa.

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

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

  • Phân trách nhiệm đúng cho bốn tầng.
  • Nhận ra anemic domain model và biết khi nào nó chấp nhận được.
  • Đặt ranh giới transaction đúng chỗ.
  • Ép ranh giới bằng cấu trúc project.

Nội dung bài học​

16.3.1 — Bốn tầng​

API  ──►  Application  ──►  Domain
│
▼
Infrastructure ──► Domain
TầngTrách nhiệmKhông được chứa
APIHTTP, xác thực, serialize, mã trạng tháiQuy tắc nghiệp vụ
ApplicationĐiều phối use case, ranh giới transactionChi tiết HTTP hay SQL
DomainEntity, value object, bất biến nghiệp vụBất cứ thứ gì của hạ tầng
InfrastructureEF Core, HTTP client, gửi email, hàng đợiQuyết định nghiệp vụ

Cột thứ ba quan trọng hơn cột thứ hai. Dễ nói "Domain chứa nghiệp vụ"; khó hơn là giữ cho nó không chứa gì khác.

Phụ thuộc chỉ hướng vào trong: Domain không biết gì về ba tầng kia. Infrastructure biết Domain, nhưng Domain không biết Infrastructure.

Đó là lý do interface repository nằm ở Application (hoặc Domain), còn implementation nằm ở Infrastructure — mũi tên phụ thuộc bị đảo chiều so với dòng chảy dữ liệu:

// Application/Abstractions/ILeadRepository.cs
public interface ILeadRepository
{
Task<Lead?> GetByIdAsync(LeadId id, CancellationToken ct);
void Add(Lead lead);
}

// Infrastructure/Persistence/EfLeadRepository.cs
internal sealed class EfLeadRepository(CrmDbContext db) : ILeadRepository { ... }

internal ở implementation là chi tiết đáng dùng: không project nào ngoài Infrastructure nhìn thấy lớp đó, nên không ai vô tình phụ thuộc vào nó.

16.3.2 — Ranh giới do trình biên dịch ép​

# Ranh giới đó KỶ LUẬT — thất bại lúc 5 giờ chiều thứ Sáu
src/CrmApi/
├── Controllers/
├── Services/
├── Domain/
└── Data/
# Ranh gioi do TRINH BIEN DICH ep
src/
├── Crm.Domain/ <- KHÔNG reference gì
├── Crm.Application/ <- reference Domain
├── Crm.Infrastructure/ <- reference Application + Domain
└── Crm.Api/ <- reference tat ca (composition root)

Với cấu trúc thứ hai, Crm.Domain không thể using Microsoft.EntityFrameworkCore — project không reference nó, và code không biên dịch được. Đó là loại ranh giới không phụ thuộc vào việc ai đó nhớ hay quên.

Kiểm tra bằng một bước trong CI:

# Domain không được phụ thuộc gì ngoài BCL
dotnet list src/Crm.Domain/Crm.Domain.csproj reference

Hoặc bằng một kiến trúc test:

[Fact]
public void Domain_ShouldNotDependOn_Infrastructure()
{
var result = Types.InAssembly(typeof(Lead).Assembly)
.ShouldNot().HaveDependencyOn("Crm.Infrastructure")
.GetResult();

Assert.True(result.IsSuccessful, string.Join(", ", result.FailingTypeNames ?? []));
}

Thư viện NetArchTest biến quy tắc kiến trúc thành bài test chạy trong CI — mạnh hơn nhiều so với một dòng trong tài liệu mà không ai đọc.

Nhưng đừng tách project quá sớm. Bốn project cho một CRUD 10 bảng là chi phí thuần (bài 16.2). Bắt đầu bằng thư mục, chuyển sang project khi ranh giới đã rõ và kỷ luật đã bắt đầu thất bại.

16.3.3 — Anemic domain model​

// ANEMIC — entity chỉ là túi dữ liệu
public class Lead
{
public int Id { get; set; }
public string Status { get; set; } = "";
public decimal Value { get; set; }
public DateTime? ConvertedAt { get; set; }
}

// Moi logic o service
public class LeadService
{
public async Task ConvertAsync(int leadId, CancellationToken ct)
{
var lead = await _repo.GetAsync(leadId, ct);

if (lead.Status == "Lost") throw new InvalidOperationException("Da mat");
if (lead.ConvertedAt is not null) throw new InvalidOperationException("Da convert");
if (lead.Value <= 0) throw new InvalidOperationException("Giá trị không hợp lệ");

lead.Status = "Won";
lead.ConvertedAt = DateTime.UtcNow;

await _repo.SaveAsync(ct);
}
}

Vấn đề: không gì ngăn ai đó viết lead.Status = "Won" ở một chỗ khác mà bỏ qua ba kiểm tra. Và chắc chắn sẽ có — trong job import, trong màn hình admin, trong một script sửa dữ liệu.

// RICH — bat bien nam TRONG entity
public sealed class Lead
{
public LeadId Id { get; private set; }
public LeadStatus Status { get; private set; } // 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, Money value)
{
if (value.Amount <= 0) throw new DomainException("Giá trị phải lớn hơn 0");

return new Lead { Id = LeadId.New(), Status = LeadStatus.New, Value = value };
}

public void Convert(DateTime now)
{
if (Status == LeadStatus.Lost) throw new DomainException("Lead đã mất");
if (ConvertedAt is not null) throw new DomainException("Lead đã được chuyển đổi");

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

private set là điểm mấu chốt. Không ai đổi được Status ngoài các phương thức của chính Lead — nên bất biến được ép buộc, không phải "được khuyến nghị".

Kết quả: quy tắc có một nhà duy nhất, test được mà không cần database, và mọi đường ghi đều đi qua nó.

Khi nào anemic chấp nhận được:

Tình huốngAnemic ổn?
CRUD thuần, không bất biếnCó
Bảng tra cứu, dữ liệu tham chiếuCó
Import/export dữ liệuCó
Entity có quy tắc chuyển trạng tháiKhông
Entity liên quan tiền hoặc tồn khoKhông

Ép entity giàu logic cho một bảng Country chỉ có Code và Name là chi phí thừa. Anemic là mặc định hợp lý; entity giàu logic là thứ bạn thêm khi có bất biến cần bảo vệ.

16.3.4 — Ranh giới transaction​

// Application — đây là nơi transaction bắt đầu và kết thúc
public sealed class ConvertLeadHandler(
ILeadRepository leads, ICustomerRepository customers,
IUnitOfWork uow, TimeProvider clock)
{
public async Task<Result> HandleAsync(ConvertLeadCommand command, CancellationToken ct)
{
var lead = await leads.GetByIdAsync(command.LeadId, ct);
if (lead is null) return Result.NotFound();

lead.Convert(clock.GetUtcNow().UtcDateTime); // DOMAIN quyết định

var customer = Customer.CreateFromLead(lead); // DOMAIN quyết định
customers.Add(customer);

await uow.SaveChangesAsync(ct); // MOT transaction

return Result.Success();
}
}

Application quyết định ranh giới transaction, không phải Domain và không phải API.

Lý do: Domain không được biết về database (bài 16.4), còn API không biết use case nào cần nguyên tử với nhau.

Và như bài 13.10 đã nói: DbContext đã là Unit of Work, nên IUnitOfWork ở đây chỉ đáng có nếu Application không được biết về EF Core. Nếu Application tham chiếu EF Core (điều nhiều dự án chấp nhận), gọi thẳng DbContext.SaveChangesAsync đơn giản hơn.

Tác dụng phụ bên ngoài nằm ngoài transaction:

await uow.SaveChangesAsync(ct);                  // commit TRƯỚC

await _notifications.PublishAsync(...); // rồi mới gửi

Đúng nguyên tắc ở bài 11.8 và bài 12.7.

16.3.5 — Hạn chế của mô hình phân tầng​

Ba hạn chế thật, và chúng dẫn tới bài 16.5:

1. Một tính năng nằm rải khắp bốn project. Thêm "xuất danh sách lead" cần sửa file ở API, Application, Domain và Infrastructure — bốn thư mục khác nhau trong bốn project khác nhau.

2. Tầng trở thành "nơi để mọi thứ". Application sau hai năm có 200 service không liên quan gì nhau, chỉ chung nhau ở chỗ "không phải HTTP và không phải database".

3. Ghép chặt theo chiều ngang. Mọi tính năng dùng chung ILeadRepository, nên thêm một phương thức cho tính năng A ảnh hưởng mọi tính năng khác dùng interface đó.

Điểm 3 là cái ngược đời: kiến trúc phân tầng tách theo loại kỹ thuật nhưng ghép theo tính năng — trong khi thứ bạn muốn thay đổi độc lập chính là tính năng.

Đó là lý do Vertical Slice tồn tại, và vì sao nhiều dự án dùng kết hợp: giữ Domain tách bạch, nhưng tổ chức Application theo tính năng thay vì theo loại kỹ thuật.

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

Danh sách rà soát phân tầng

  • •Domain không reference EF Core, ASP.NET Core hay bất kỳ hạ tầng nào.
  • •Ranh giới được ép bằng project hoặc bằng kiến trúc test, không chỉ bằng thư mục.
  • •Interface repository nằm ở Application; implementation nằm ở Infrastructure.
  • •Implementation hạ tầng khai internal để không ai phụ thuộc trực tiếp.
  • •Entity có bất biến dùng private set, không phải public setter.
  • •Anemic chỉ dùng cho entity thật sự không có bất biến.
  • •Ranh giới transaction nằm ở Application, không ở Domain hay API.
  • •Tác dụng phụ bên ngoài xảy ra sau khi commit.
  • •Số project phù hợp với quy mô, không tách sớm hơn cần thiết.

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

Bài 1 — Kiến trúc test chặn phụ thuộc sai chiều​

Thêm NetArchTest và viết một test khẳng định assembly Domain không phụ thuộc Infrastructure. Cố tình thêm một using vi phạm và xác nhận test đỏ.

Tiêu chí hoàn thành: bạn nêu được vì sao kiến trúc test cần thiết dù đã tách project, và viết được ít nhất ba quy tắc khác.

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

Gợi ý. Tách project ngăn được phụ thuộc trực tiếp. Còn phụ thuộc gián tiếp thì sao?

Lời giải:

dotnet add tests/Crm.ArchitectureTests package NetArchTest.Rules
public class KienTrucTests
{
private static readonly Assembly Domain = typeof(Lead).Assembly;
private static readonly Assembly Application = typeof(ChotLeadHandler).Assembly;
private static readonly Assembly Infrastructure = typeof(CrmDbContext).Assembly;

[Fact]
public void Domain_khong_duoc_phu_thuoc_Infrastructure()
{
var kq = Types.InAssembly(Domain)
.ShouldNot()
.HaveDependencyOnAny("Crm.Infrastructure", "Microsoft.EntityFrameworkCore",
"Microsoft.AspNetCore", "Dapper", "StackExchange.Redis")
.GetResult();

kq.IsSuccessful.Should().BeTrue(
"Domain phụ thuộc hạ tầng: " +
string.Join(", ", kq.FailingTypeNames ?? []));
}
}
// Cố tình vi phạm
using Microsoft.EntityFrameworkCore; // trong Crm.Domain/Entities/Lead.cs
Xpect: Domain phụ thuộc hạ tầng: Crm.Domain.Entities.Lead

Vì sao kiến trúc test cần thiết dù đã tách project — bốn lý do:

1. Tham chiếu gián tiếp đi vòng qua. Project reference là bắc cầu:

Crm.Domain -> Crm.SharedKernel -> Newtonsoft.Json -> ...

Crm.Domain KHÔNG tham chiếu trực tiếp EF Core,
nhưng nếu SharedKernel tham chiếu nó, Domain vẫn dùng được:
using Microsoft.EntityFrameworkCore; // BIÊN DỊCH ĐƯỢC

Trình biên dịch không ngăn điều này, và không ai nhận ra cho tới khi đọc lại using của từng file.

2. Ai đó sẽ thêm tham chiếu. Khi cần một thuộc tính EF Core như [Owned] hay [NotMapped], cách nhanh nhất là thêm reference. Một dòng trong .csproj, và trong code review nó trông vô hại:

<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.0" />

Kiến trúc test làm điều đó thất bại ngay, kèm thông điệp giải thích vì sao.

3. Nhiều quy tắc kiến trúc không biểu diễn được bằng project. Tách project chỉ kiểm soát được phụ thuộc giữa các assembly. Nó không ngăn được:

- Entity có public setter
- Handler gọi thẳng handler khác
- Controller gọi thẳng DbContext (nếu cùng project)
- Lớp trong namespace Domain phụ thuộc lớp trong namespace Application

4. Nó là tài liệu chạy được. Một file test 60 dòng mô tả toàn bộ quy tắc kiến trúc, và nó không bao giờ lỗi thời — khác với sơ đồ trong wiki.

Bảy quy tắc đáng có:

[Fact]
public void Domain_chi_duoc_phu_thuoc_BCL()
{
var choPhep = new[] { "System", "Crm.Domain", "Crm.SharedKernel" };

var viPham = Types.InAssembly(Domain).GetTypes()
.SelectMany(t => t.Assembly.GetReferencedAssemblies())
.Select(a => a.Name!)
.Distinct()
.Where(n => !choPhep.Any(p => n.StartsWith(p)) && n != "netstandard")
.ToList();

viPham.Should().BeEmpty();
}

[Fact]
public void Application_khong_duoc_phu_thuoc_Infrastructure()
=> Types.InAssembly(Application)
.ShouldNot().HaveDependencyOn("Crm.Infrastructure")
.GetResult().IsSuccessful.Should().BeTrue();

[Fact]
public void Application_khong_duoc_biet_ve_HTTP()
=> Types.InAssembly(Application)
.ShouldNot().HaveDependencyOnAny("Microsoft.AspNetCore", "System.Web")
.GetResult().IsSuccessful.Should().BeTrue();

[Fact]
public void Handler_phai_kin_va_ket_thuc_bang_Handler()
=> Types.InAssembly(Application)
.That().ImplementInterface(typeof(IRequestHandler<,>))
.Should().BeSealed().And().HaveNameEndingWith("Handler")
.GetResult().IsSuccessful.Should().BeTrue();

[Fact]
public void Entity_khong_duoc_co_public_setter()
{
var viPham = Domain.GetTypes()
.Where(t => t.IsSubclassOf(typeof(EntityBase)) && !t.IsAbstract)
.SelectMany(t => t.GetProperties(BindingFlags.Public | BindingFlags.Instance))
.Where(p => p.SetMethod is { IsPublic: true })
.Select(p => $"{p.DeclaringType!.Name}.{p.Name}")
.ToList();

viPham.Should().BeEmpty("public setter cho phép đi vòng qua bất biến của entity");
}

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

var viPham = files
.Where(f => Regex.IsMatch(File.ReadAllText(f),
@"DateTime\.(Now|Today|UtcNow)|DateTimeOffset\.(Now|UtcNow)"))
.Select(Path.GetFileName)
.ToList();

viPham.Should().BeEmpty("dùng TimeProvider để code test được");
}

[Fact]
public void Khong_duoc_phu_thuoc_vong_tron()
=> Types.InAssembly(Application)
.Should().NotHaveDependencyOn("Crm.Api")
.GetResult().IsSuccessful.Should().BeTrue();

Ba điều làm kiến trúc test hữu ích trên thực tế:

1. Thông điệp lỗi phải nói phải làm gì:

kq.IsSuccessful.Should().BeTrue(
"Domain không được phụ thuộc hạ tầng. Các kiểu vi phạm: {0}. " +
"Nếu cần một khái niệm từ hạ tầng, hãy định nghĩa interface trong Domain " +
"và cài đặt nó ở Infrastructure.",
string.Join(", ", kq.FailingTypeNames ?? []));

Người gặp test đỏ thường không phải người viết test. Thông điệp phải tự giải thích.

2. Cho phép ngoại lệ có tên và có lý do:

private static readonly string[] NgoaiLeDaDuyet =
[
"Crm.Domain.Legacy.BaoCaoCu", // sẽ gỡ ở ticket CRM-4821
];

var viPham = kq.FailingTypeNames?.Except(NgoaiLeDaDuyet).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ệ.

3. Thêm quy tắc dần, đừng thêm hết một lúc. Chạy lần đầu trên một dự án đang chạy sẽ cho hàng trăm vi phạm. Cách làm được:

[Fact]
public void Domain_khong_phu_thuoc_EF_Core()
{
var kq = /* ... */;
var soViPham = kq.FailingTypeNames?.Count() ?? 0;

// Ngưỡng hiện tại — chỉ được GIẢM, không được tăng
soViPham.Should().BeLessThanOrEqualTo(12,
"số vi phạm chỉ được giảm; nếu bạn cần tăng, hãy sửa code thay vì sửa ngưỡng");
}

Đây là mẫu "ratchet": nó chặn hồi quy ngay lập tức mà không buộc bạn phải sửa hết trước khi có được lợi ích. Giảm ngưỡng dần theo mỗi sprint.

Và chạy kiến trúc test trong CI như một test bình thường — chúng chạy trong vài trăm mili giây và không cần hạ tầng gì.


Bài 2 — Anemic thành rich​

Chọn một entity có quy tắc chuyển trạng thái, chuyển public set thành private set và đưa quy tắc vào phương thức. Đếm số chỗ trong code phải sửa — đó là số chỗ trước đây có thể phá bất biến.

Tiêu chí hoàn thành: bạn giải thích được vì sao số chỗ phải sửa chính là số lỗ hổng, và nêu được khi nào anemic model là lựa chọn đúng.

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

Gợi ý. Nếu trình biên dịch báo lỗi ở 14 chỗ, thì trước đó 14 chỗ đó đang làm gì?

Lời giải — trước khi đổi:

public class Lead
{
public int Id { get; set; }
public string Name { get; set; } = null!;
public decimal Value { get; set; }
public string Status { get; set; } = null!;
public DateTime? ClosedUtc { get; set; }
public string? AssignedTo { get; set; }
}
grep -rn "\.Status = " --include="*.cs" src/ | wc -l
14

Sau khi đổi:

public class Lead
{
public int Id { get; private set; }
public string Name { get; private set; } = null!;
public Money Value { get; private set; } = null!;
public LeadStatus Status { get; private set; }
public DateTime? ClosedUtc { get; private set; }
public string? AssignedTo { get; private set; }

private Lead() { } // cho EF Core

public static Lead Tao(string ten, Money giaTri)
{
if (string.IsNullOrWhiteSpace(ten))
throw new ArgumentException("Tên lead là bắt buộc", nameof(ten));
if (giaTri.Amount <= 0)
throw new ArgumentException("Giá trị phải dương", nameof(giaTri));

return new Lead { Name = ten, Value = giaTri, Status = LeadStatus.New };
}

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, bayGio));
return Result.ThanhCong();
}

public Result Gan(string nguoiDungId)
{
if (Status is LeadStatus.Won or LeadStatus.Lost)
return Result.Loi("Không gán được lead đã đóng");

AssignedTo = nguoiDungId;
return Result.ThanhCong();
}
}
dotnet build 2>&1 | grep -c "CS0272"
14

Vì sao 14 chỗ phải sửa chính là 14 lỗ hổng — đây là phần chính của bài.

Mỗi chỗ gán lead.Status = "Won" là một chỗ bỏ qua toàn bộ quy tắc:

// Đường 1 — Controller, có kiểm tra
if (lead.Value < nguong || User.IsInRole("Manager"))
lead.Status = "Won";

// Đường 2 — Job import, KHÔNG kiểm tra gì
foreach (var row in csv) lead.Status = row.Status;

// Đường 3 — Consumer đồng bộ, không kiểm tra trạng thái hiện tại
lead.Status = message.NewStatus; // Won -> New cũng được

// Đường 4 — Endpoint cập nhật hàng loạt, không đặt ClosedUtc
await _db.Leads.Where(...).ExecuteUpdateAsync(s => s.SetProperty(l => l.Status, "Won"));

Bốn đường, bốn hành vi. Và ba trong bốn đường có thể tạo ra dữ liệu không hợp lệ theo quy tắc nghiệp vụ:

-- Lead ở trạng thái Won mà không có ClosedUtc
SELECT COUNT(*) FROM Leads WHERE Status = 'Won' AND ClosedUtc IS NULL;
-- 1.284

-- Lead Won mà chưa gán cho ai
SELECT COUNT(*) FROM Leads WHERE Status = 'Won' AND AssignedTo IS NULL;
-- 312

-- Lead giá trị lớn được Won mà không có bản ghi duyệt
SELECT COUNT(*) FROM Leads l WHERE l.Status = 'Won' AND l.Value >= 500000000
AND NOT EXISTS (SELECT 1 FROM Approvals a WHERE a.LeadId = l.Id);
-- 47

Đây là dữ liệu đã có sẵn trên production, sinh ra từ những đường code đi vòng qua quy tắc. Không ai báo lỗi vì hệ thống không bao giờ phàn nàn — và một báo cáo doanh số dựa trên những dòng này sẽ cho con số sai.

private set biến bốn đường thành một:

Trước:  4 đường ghi, 4 hành vi, 3 đường tạo được dữ liệu không hợp lệ
Sau: 1 đường ghi (ChuyenSangWon), 1 hành vi, 0 lỗ hổng

Và quan trọng: trình biên dịch chỉ cho bạn danh sách đầy đủ. Không cần đoán, không cần review thủ công — mỗi lỗi CS0272 là một chỗ cần xem.

Sửa từng chỗ:

// Trước
lead.Status = "Won";
lead.ClosedUtc = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);

// Sau
var kq = lead.ChuyenSangWon(nguoiDung, _clock.GetUtcNow().UtcDateTime);
if (!kq.ThanhCong) return Results.BadRequest(kq.Loi);
await _db.SaveChangesAsync(ct);
// Job import — trước đây bỏ qua mọi quy tắc, giờ buộc phải xử lý
foreach (var row in csv)
{
var kq = lead.ChuyenSangWon(NguoiDung.HeThong, bayGio);
if (!kq.ThanhCong)
{
_logger.LogWarning("Bỏ qua lead {Id}: {Loi}", row.Id, kq.Loi);
soLoi++;
continue;
}
}

Đoạn thứ hai đáng chú ý: trước đây job im lặng tạo dữ liệu sai; giờ nó ghi log và đếm. Bạn có thể phát hiện rằng 15% dữ liệu import vốn đã không hợp lệ — một thông tin nghiệp vụ quan trọng mà trước đó không ai biết.

Cấu hình EF Core cho private set:

builder.Entity<Lead>(b =>
{
b.Property(l => l.Status).HasConversion<string>();
b.Property(l => l.Name).HasMaxLength(200).IsRequired();

// Backing field cho collection
b.Metadata.FindNavigation(nameof(Lead.Contacts))!
.SetPropertyAccessMode(PropertyAccessMode.Field);
});

EF Core đọc và ghi được private set qua reflection, nên không cần đổi gì thêm. Constructor private Lead() { } là bắt buộc để EF Core vật chất hoá entity.

Khi nào anemic model là lựa chọn đúng — bốn trường hợp, và chúng có thật:

1. CRUD thuần tuý, không có quy tắc. Một bảng danh mục — tỉnh thành, ngành nghề, đơn vị tính — không có bất biến nào để bảo vệ:

public class TinhThanh
{
public int Id { get; set; }
public string Ten { get; set; } = null!;
public string Ma { get; set; } = null!;
}

Thêm phương thức và private set ở đây là chi phí không đổi lấy gì.

2. DTO và view model. Chúng phải anemic — đó là mục đích của chúng:

public record LeadDto(int Id, string Name, decimal Value, string Status);

3. Bảng báo cáo và dữ liệu đọc. Chúng được sinh ra từ nơi khác và không bao giờ được sửa qua code:

public class BaoCaoDoanhSoThang
{
public int Thang { get; set; }
public decimal TongDoanhSo { get; set; }
}

4. Giai đoạn rất sớm của dự án, khi quy tắc chưa rõ. Áp domain model phong phú lên một nghiệp vụ bạn chưa hiểu sẽ tạo ra những bất biến sai — và sửa chúng đắt hơn là thêm chúng sau. Đây là điểm ở bài 16.1 về kiến trúc sớm cũng sai.

Câu hỏi để quyết định:

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

Có  -> cần rich model để ngăn trạng thái đó tồn tại
Không -> anemic là đủ, và rẻ hơn

Với Lead: "Won mà chưa gán cho ai" là không hợp lệ → cần rich model. Với TinhThanh: không có tổ hợp nào không hợp lệ → anemic là đúng.

Và đừng áp một kiểu cho cả dự án. Một codebase khoẻ mạnh thường có cả hai: rich model cho vài aggregate cốt lõi nơi quy tắc tập trung, và anemic cho hàng chục bảng danh mục và bảng đọc xung quanh.


Bài 3 — Đếm đường ghi​

grep mọi chỗ gán trực tiếp Status của một entity. Mỗi chỗ là một nơi quy tắc có thể bị bỏ qua.

Tiêu chí hoàn thành: bạn tìm được cả những đường ghi không xuất hiện trong grep, và biết cách xác minh dữ liệu hiện có.

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

Gợi ý. grep tìm được lead.Status = "Won". Nó có tìm được ExecuteUpdate không? Hay một trigger?

Lời giải — bắt đầu bằng grep:

grep -rn "\.Status\s*=" --include="*.cs" src/ | grep -v "=="
src/Crm.Api/Controllers/LeadsController.cs:94
src/Crm.Application/Services/LeadService.cs:211
src/Crm.Application/Services/LeadService.cs:348
src/Crm.Infrastructure/Jobs/ImportLeadJob.cs:122
src/Crm.Infrastructure/Consumers/LeadSyncConsumer.cs:71
... (14 chỗ)

Nhưng grep bỏ sót ít nhất bảy dạng đường ghi:

1. ExecuteUpdateAsync — không có dấu = theo cú pháp thông thường:

grep -rn "ExecuteUpdate\|ExecuteDelete" --include="*.cs" src/
await _db.Leads.Where(l => l.UpdatedUtc < mocCu)
.ExecuteUpdateAsync(s => s.SetProperty(l => l.Status, "Archived"), ct);

Nó bỏ qua change tracker, interceptor, và mọi quy tắc trong entity (bài 13.7).

2. SQL thô:

grep -rn "FromSql\|ExecuteSqlRaw\|ExecuteSqlInterpolated" --include="*.cs" src/
await _db.Database.ExecuteSqlRawAsync("UPDATE Leads SET Status = 'Won' WHERE Id = {0}", id);

3. Bulk extension:

grep -rn "BulkUpdate\|BulkInsert\|BulkMerge" --include="*.cs" src/

4. AutoMapper ánh xạ ngược vào entity — đây là đường khó thấy nhất:

_mapper.Map(request, lead);        // gán MỌI thuộc tính khớp tên, kể cả Status
await _db.SaveChangesAsync(ct);
grep -rn "_mapper.Map(\|Mapper.Map(" --include="*.cs" src/ | grep -v "Map<"

Dấu hiệu nhận biết: Map với hai tham số (nguồn và đích) thay vì Map<T> với một tham số. Bản hai tham số ghi đè lên object đã có.

Và nó vẫn hoạt động với private set — AutoMapper dùng reflection. Đây là lý do private set một mình không đủ:

// Chặn ở cấu hình mapping
CreateMap<CapNhatLeadRequest, Lead>()
.ForMember(d => d.Status, o => o.Ignore())
.ForMember(d => d.ClosedUtc, o => o.Ignore());

5. JSON patch:

patchDoc.ApplyTo(lead);            // sửa bất kỳ thuộc tính nào client chỉ định

6. Migration có Sql():

grep -rn "migrationBuilder.Sql" --include="*.cs" src/*/Migrations/

7. Ngoài code C# hoàn toàn:

-- Trigger
SELECT name, OBJECT_DEFINITION(object_id) FROM sys.triggers
WHERE OBJECT_NAME(parent_id) = 'Leads';

-- Stored procedure
SELECT name FROM sys.procedures WHERE OBJECT_DEFINITION(object_id) LIKE '%Leads%Status%';

-- SQL Agent job
SELECT j.name, st.command FROM msdb.dbo.sysjobs j
JOIN msdb.dbo.sysjobsteps st ON st.job_id = j.job_id
WHERE st.command LIKE '%Leads%';
-- Ai đã ghi vào bảng này, và lần cuối khi nào?
SELECT t.name, s.last_user_update, s.user_updates
FROM sys.dm_db_index_usage_stats s
JOIN sys.tables t ON t.object_id = s.object_id
WHERE s.database_id = DB_ID() AND t.name = 'Leads';

Đây là nhóm đáng sợ nhất, vì không có cách nào tìm ra chúng bằng cách đọc code C#. Một job SQL Agent cập nhật trạng thái lúc 2 giờ sáng có thể đã chạy nhiều năm mà không ai trong nhóm phát triển biết.

Xác minh dữ liệu hiện có — đây là bước cho biết đường ghi nào đã gây hại:

-- 1. Trạng thái không hợp lệ về nghiệp vụ
SELECT 'Won không có ClosedUtc' AS ViPham, COUNT(*) AS SoDong
FROM Leads WHERE Status = 'Won' AND ClosedUtc IS NULL
UNION ALL
SELECT 'Won chưa gán cho ai', COUNT(*)
FROM Leads WHERE Status = 'Won' AND AssignedTo IS NULL
UNION ALL
SELECT 'Giá trị lớn Won mà không có duyệt', COUNT(*)
FROM Leads l WHERE l.Status = 'Won' AND l.Value >= 500000000
AND NOT EXISTS (SELECT 1 FROM Approvals a WHERE a.LeadId = l.Id)
UNION ALL
SELECT 'Trạng thái không nằm trong danh sách hợp lệ', COUNT(*)
FROM Leads WHERE Status NOT IN ('New','Contacted','Qualified','Won','Lost');
ViPham                                     SoDong
Won không có ClosedUtc 1284
Won chưa gán cho ai 312
Giá trị lớn Won mà không có duyệt 47
Trạng thái không nằm trong danh sách hợp lệ 8

Dòng cuối đáng chú ý: tám dòng có trạng thái mà code không biết tới:

SELECT DISTINCT Status, COUNT(*) FROM Leads GROUP BY Status;
New         12840
Contacted 8421
Qualified 3102
Won 4218
Lost 2891
won 5 <- chữ thường
WON 2 <- chữ hoa
Closed 1 <- không tồn tại trong enum

Năm dòng won chữ thường sẽ không xuất hiện trong báo cáo lọc Status = 'Won'. Đây là dữ liệu sai đang âm thầm làm sai mọi con số — và nó chỉ có thể đến từ một đường ghi không đi qua enum của C#.

Sau khi gom, chặn từ phía database:

ALTER TABLE Leads ADD CONSTRAINT CK_Leads_Status
CHECK (Status IN ('New','Contacted','Qualified','Won','Lost'));

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

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

Ràng buộc database là lớp cuối cùng, và là lớp duy nhất mọi đường ghi đều phải đi qua — kể cả trigger, job SQL Agent, và người chạy UPDATE bằng tay trong SSMS.

Trước khi thêm, phải dọn dữ liệu cũ (nếu không ALTER TABLE thất bại):

UPDATE Leads SET ClosedUtc = UpdatedUtc
WHERE Status = 'Won' AND ClosedUtc IS NULL;

UPDATE Leads SET Status = 'Won' WHERE Status IN ('won', 'WON');
UPDATE Leads SET Status = 'Lost' WHERE Status = 'Closed';

Ba lớp bảo vệ, và vì sao cần cả ba:

LớpBắt đượcKhông bắt được
private set + phương thức domainCode C# đi qua entityExecuteUpdate, SQL thô, AutoMapper
Kiến trúc testPhụ thuộc sai chiều, public setterĐường ghi ngoài code
Ràng buộc databaseMọi đường ghiQuy tắc quá phức tạp cho CHECK

Ba lớp này không thừa nhau — chúng bắt được ba tập vấn đề khác nhau. Lớp đầu cho thông điệp lỗi rõ ràng nhất và bắt sớm nhất; lớp cuối là thứ duy nhất không thể đi vòng qua.

Và theo dõi định kỳ:

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 viPham = await _db.Database
.SqlQuery<int>($"SELECT COUNT(*) FROM Leads WHERE Status='Won' AND ClosedUtc IS NULL")
.FirstAsync(ct);

if (viPham > 0)
_logger.LogError("Có {SoDong} lead vi phạm bất biến — có đường ghi chưa được kiểm soát",
viPham);
}
}
}

Con số này nên luôn bằng 0. Nếu nó khác 0 sau khi bạn đã thêm ràng buộc, nghĩa là ràng buộc bị tắt hoặc có một đường ghi mới — và bạn biết trong vòng sáu giờ thay vì sáu tháng.

Tự kiểm tra​

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

Quy tắc duy nhất của kiến trúc phân tầng là gì?

Phụ thuộc chỉ hướng vào trong. Domain không biết gì về Application, Infrastructure hay API; Infrastructure biết Domain nhưng Domain không biết Infrastructure. Đó là lý do interface repository nằm ở Application còn implementation nằm ở Infrastructure.

Vì sao ranh giới bằng project mạnh hơn ranh giới bằng thư mục?

Thư mục là ranh giới do kỷ luật, và kỷ luật thất bại lúc gấp gáp. Project là ranh giới do trình biên dịch ép: nếu Domain không reference EF Core thì code vi phạm đơn giản là không biên dịch được. Có thể thay bằng kiến trúc test chạy trong CI.

Anemic domain model là gì và vấn đề của nó?

Entity chỉ có getter và setter còn mọi logic nằm ở service. Vấn đề là không gì ngăn ai đó gán thẳng trạng thái ở chỗ khác mà bỏ qua các kiểm tra, và điều đó chắc chắn xảy ra trong job import hay script sửa dữ liệu.

Khi nào anemic chấp nhận được?

Với CRUD thuần không có bất biến, bảng tra cứu và dữ liệu tham chiếu, và luồng import export. Ép entity giàu logic cho một bảng chỉ có mã và tên là chi phí thừa. Anemic là mặc định hợp lý; entity giàu logic là thứ thêm vào khi có bất biến cần bảo vệ.

Ranh giới transaction nên nằm ở tầng nào?

Application. Domain không được biết về database, còn API không biết use case nào cần nguyên tử với nhau. Và tác dụng phụ bên ngoài như gửi email hay publish message phải nằm sau khi commit.

Hạn chế lớn nhất của kiến trúc phân tầng là gì?

Nó tách theo loại kỹ thuật nhưng ghép theo tính năng: một tính năng nằm rải khắp bốn project, và mọi tính năng dùng chung một repository nên sửa cho tính năng này ảnh hưởng tính năng khác. Trong khi thứ bạn muốn thay đổi độc lập lại chính là tính năng.

Kết luận​

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

  1. Ranh giới phải do trình biên dịch ép, không do kỷ luật con người.
  2. private set biến bất biến từ khuyến nghị thành ép buộc.
  3. Phân tầng ghép theo tính năng — đó là hạn chế mà Vertical Slice giải quyết.

Tham khảo​

Điều hướng​