Skip to main content

16.5 — 4. Vertical Slice Architecture (VSA)

Summary

VSA đảo ngược cách chia code: thay vì gom theo loại kỹ thuật (Controllers/, Services/, Repositories/), gom theo tính năng — tất cả những gì "Tạo Lead" cần nằm trong một file. Lợi ích đo được: thay đổi một tính năng đụng một chỗ, và hai người làm hai tính năng không đụng nhau khi merge. Điều khiến nhiều người khó chịu là VSA chấp nhận trùng lặp có kiểm soát: hai slice có thể có hai LeadDto hơi khác nhau, và đó là cố ý — vì gom chúng lại tạo ghép chặt giữa hai tính năng lẽ ra độc lập. Nhưng VSA không thay thế Clean Architecture: nó tổ chức tầng Application, còn Domain vẫn nên tách riêng nếu có bất biến cần bảo vệ.

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

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

  • Tổ chức code theo slice và biết mỗi slice chứa gì.
  • Quyết định khi nào trùng lặp là đúng và khi nào phải gom.
  • Kết hợp VSA với Domain tách riêng.
  • Tránh hai cạm bẫy phổ biến của VSA.

Nội dung bài học​

16.5.1 — Cắt dọc thay vì cắt ngang​

# Cắt NGANG — một tính năng nằm rải 4 thư mục
src/
├── Controllers/LeadsController.cs
├── Services/LeadService.cs
├── Repositories/LeadRepository.cs
└── DTOs/LeadDto.cs
# Cắt DỌC — một tính năng, một chỗ
src/Features/Leads/
├── CreateLead.cs <- request + handler + validator + response
├── GetLeadById.cs
├── SearchLeads.cs
└── ConvertLead.cs
// Features/Leads/CreateLead.cs — TAT CA trong mot file
public static class CreateLead
{
public sealed record Command(string Name, string Email, decimal Value)
: IRequest<Result<Guid>>;

public sealed class Validator : AbstractValidator<Command>
{
public Validator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.Email).NotEmpty().EmailAddress();
RuleFor(x => x.Value).GreaterThan(0);
}
}

public sealed class Handler(CrmDbContext db, TimeProvider clock)
: IRequestHandler<Command, Result<Guid>>
{
public async Task<Result<Guid>> Handle(Command command, CancellationToken ct)
{
if (await db.Leads.AnyAsync(l => l.Email == command.Email, ct))
return Result.Conflict<Guid>("Email đã tồn tại");

var lead = Lead.Create(command.Name, command.Email,
new Money(command.Value, "VND"));

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

return Result.Success(lead.Id.Value);
}
}

public sealed class Endpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder app) =>
app.MapPost("/api/v1/leads", async (Command command, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(command, ct);
return result.IsSuccess
? Results.CreatedAtRoute("GetLead", new { id = result.Value }, result.Value)
: Results.Conflict(result.Error);
}).WithTags("Leads");
}
}

Mọi thứ của tính năng này — hợp đồng, validation, logic, định tuyến — nằm trong một file. Đọc nó là hiểu toàn bộ tính năng.

Hai lợi ích đo được:

Cắt ngangCắt dọc
Sửa một tính năng4 file, 4 thư mục1 file
Hai người, hai tính năngĐụng chung LeadService.csKhông đụng nhau
Xoá một tính năngTìm mảnh ở 4 nơiXoá một file

Hàng cuối đáng chú ý: xoá code dễ là dấu hiệu kiến trúc tốt bị đánh giá thấp. Trong cấu trúc cắt ngang, xoá một tính năng thường để lại rác trong LeadService mà không ai dám dọn.

16.5.2 — Trùng lặp có kiểm soát​

Đây là phần gây tranh cãi nhất, và là phần quan trọng nhất phải hiểu đúng.

// Features/Leads/GetLeadById.cs
public sealed record Response(Guid Id, string Name, string Email, decimal Value, string Status);

// Features/Leads/SearchLeads.cs
public sealed record LeadSummary(Guid Id, string Name, string Status); // KHAC

Phản xạ đầu tiên là gom lại thành một LeadDto chung. Đừng.

Lý do: hai tính năng này thay đổi vì lý do khác nhau. Màn hình chi tiết cần thêm trường Notes; màn hình danh sách thì không. Với DTO chung, thêm Notes làm response của cả hai thay đổi — và bạn vừa ghép hai tính năng lẽ ra độc lập.

Trùng lặp mà VSA chấp nhận là trùng lặp về hình dạng dữ liệu, không phải trùng lặp về quy tắc nghiệp vụ.

Được phép trùngKhông được trùng
DTO và response modelQuy tắc nghiệp vụ
Mapping từ entity sang DTOTính toán giá, thuế, chiết khấu
Câu truy vấn tương tựLogic phân quyền
Bước validation về hình dạngBất biến của entity

Cột phải nằm ở Domain, và nó không bao giờ được chép. Đó là ranh giới rõ ràng giữa "trùng lặp có chủ đích" và "code lặp cần sửa".

Quy tắc thực dụng: hai đoạn code giống nhau nhưng sẽ thay đổi vì lý do khác nhau thì giữ riêng. Đây chính là phát biểu gốc của nguyên tắc DRY — nó nói về tri thức, không về ký tự.

16.5.3 — Khi nào tách ra dùng chung​

src/
├── Features/
│ ├── Leads/
│ ├── Customers/
│ └── Invoices/
├── Domain/ <- entity, value object, bat bien
└── SharedKernel/ <- Result, Error, IEndpoint, extension chung

Ba tiêu chí để một thứ được tách ra:

  1. Nó là quy tắc nghiệp vụ, không phải hình dạng dữ liệu.
  2. Nó ổn định — hiếm khi đổi.
  3. Nó được dùng ở nhiều slice và các slice đó phải nhất quán.

Ví dụ đúng: Money, Lead, Result<T>, IEndpoint.

Ví dụ sai: LeadDto dùng chung, LeadService chứa mọi thứ về lead, BaseHandler với logic chung.

SharedKernel phải nhỏ và ổn định. Nếu nó lớn dần, bạn đang tạo lại đúng cái Services/ khổng lồ mà VSA muốn tránh — chỉ khác tên.

Một dấu hiệu cảnh báo: khi SharedKernel bắt đầu có thư mục con theo tính năng, nó đã hỏng.

16.5.4 — Hai cạm bẫy​

1. Slice chép nhau thay vì có quy ước.

Không có template rõ ràng, mỗi người viết slice một kiểu: người dùng minimal API, người dùng controller; người trả Result<T>, người ném exception. Sau sáu tháng, codebase là mười phong cách khác nhau.

Cách chữa: một slice mẫu được review kỹ, và mọi slice mới sao chép từ nó. Kèm một kiến trúc test:

[Fact]
public void EveryCommand_ShouldHave_Validator()
{
var commands = Types.InAssembly(ApplicationAssembly)
.That().ImplementInterface(typeof(IRequest<>))
.GetTypes();

foreach (var c in commands)
Assert.True(HasValidator(c), $"{c.Name} thiếu validator");
}

2. Slice biến thành "monolith nhỏ".

Handler tự truy vấn database, tự gọi API ngoài, tự gửi email, tự ghi audit — và file 600 dòng. Nó lại là cái controller khổng lồ ở bài 16.2, chỉ đổi tên thư mục.

VSA không có nghĩa là không có tầng — nó có nghĩa là tầng được tổ chức theo tính năng. Một slice vẫn nên gọi Domain cho quy tắc nghiệp vụ và gọi interface cho tác dụng phụ bên ngoài.

16.5.5 — Kết hợp VSA và Clean Architecture​

Chúng không loại trừ nhau — chúng giải hai bài toán khác nhau:

Giải quyết
Clean ArchitectureNghiệp vụ không phụ thuộc hạ tầng (chiều dọc)
VSATính năng không phụ thuộc nhau (chiều ngang)

Mẫu kết hợp được dùng nhiều nhất:

src/
├── Crm.Domain/ <- entity, VO, bất biến. KHÔNG reference gì.
├── Crm.Application/
│ └── Features/
│ ├── Leads/CreateLead.cs
│ └── Customers/GetCustomer.cs
├── Crm.Infrastructure/ <- EF Core, email, HTTP client
└── Crm.Api/ <- composition root

Domain tách riêng (từ Clean Architecture), Application tổ chức theo slice (từ VSA).

Và với đường đọc, slice dùng thẳng DbContext — đúng thoả hiệp mức 2 ở bài 16.4.

Chọn thế nào:

Tình huốngNên
CRUD nhiều, nghiệp vụ mỏngVSA thuần, không cần Domain riêng
Nghiệp vụ phức tạp, nhiều bất biếnDomain riêng + slice
Đội lớn làm song song nhiều tính năngVSA giảm xung đột merge
Cần thay hạ tầng trong tương lai gầnClean Architecture đầy đủ

Hàng đầu đáng nói: với một CRM chủ yếu là CRUD, VSA thuần với DbContext trực tiếp thường là lựa chọn đúng — ít file nhất, dễ đọc nhất, và không mất gì vì không có bất biến nào để bảo vệ.

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

Danh sách rà soát VSA

  • •Một tính năng nằm gọn trong một file hoặc một thư mục.
  • •Hai người làm hai tính năng không sửa chung file nào.
  • •Xoá một tính năng chỉ cần xoá slice của nó.
  • •DTO không bị gom lại chỉ vì chúng trông giống nhau.
  • •Quy tắc nghiệp vụ KHÔNG bị chép giữa các slice.
  • •SharedKernel nhỏ, ổn định, và không có thư mục con theo tính năng.
  • •Có một slice mẫu được review kỹ làm chuẩn.
  • •Có kiến trúc test ép quy ước chung giữa các slice.
  • •Handler không tự làm mọi thứ; tác dụng phụ đi qua interface.
  • •Đã chọn có ý thức giữa VSA thuần và VSA kết hợp Domain riêng.

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

Bài 1 — Chuyển một tính năng thành slice​

Lấy một tính năng đang nằm rải ở controller, service, repository và gom thành một slice. So sánh số file và số dòng.

Tiêu chí hoàn thành: bạn đo được cả hai con số, và nêu được thứ VSA đánh đổi để có được sự gọn gàng đó.

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

Gợi ý. Nếu mọi tính năng đều tự chứa, thì code dùng chung nằm ở đâu?

Lời giải — trước, cắt theo tầng:

src/Crm.Api/Controllers/LeadsController.cs           412 dòng (18 action)
src/Crm.Api/Contracts/ChotLeadRequest.cs 8 dòng
src/Crm.Api/Contracts/ChotLeadResponse.cs 6 dòng
src/Crm.Application/Services/ILeadService.cs 34 dòng (14 phương thức)
src/Crm.Application/Services/LeadService.cs 687 dòng (14 phương thức)
src/Crm.Application/Validators/ChotLeadValidator.cs 18 dòng
src/Crm.Application/Mapping/LeadProfile.cs 42 dòng
src/Crm.Domain/Repositories/ILeadRepository.cs 28 dòng
src/Crm.Infrastructure/Repositories/LeadRepository.cs 156 dòng

Để hiểu tính năng "chốt lead", bạn phải mở 9 file và tìm đúng phần liên quan trong mỗi file — khoảng 80 dòng nằm rải trong 1.391 dòng.

Sau, cắt theo tính năng:

src/Crm.Api/Features/Leads/ChotLead/
ChotLeadEndpoint.cs 22 dòng
ChotLeadCommand.cs 4 dòng
ChotLeadHandler.cs 38 dòng
ChotLeadValidator.cs 14 dòng

Hoặc gộp vào một file — mẫu thường thấy trong VSA:

// src/Crm.Api/Features/Leads/ChotLead.cs — 78 dòng, TẤT CẢ
namespace Crm.Api.Features.Leads;

public static class ChotLead
{
public record Command(int LeadId) : IRequest<Result>;

public class Validator : AbstractValidator<Command>
{
public Validator() => RuleFor(x => x.LeadId).GreaterThan(0);
}

public class Handler : IRequestHandler<Command, Result>
{
private readonly CrmDbContext _db;
private readonly ICurrentUser _user;
private readonly TimeProvider _clock;

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

public async Task<Result> Handle(Command c, CancellationToken ct)
{
var lead = await _db.Leads.FirstOrDefaultAsync(l => l.Id == c.LeadId, 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();
}
}

public static void DangKy(IEndpointRouteBuilder app) =>
app.MapPost("/leads/{id:int}/chot", async (int id, IMediator m, CancellationToken ct) =>
{
var kq = await m.Send(new Command(id), ct);
return kq.ThanhCong ? Results.NoContent() : Results.BadRequest(kq.Loi);
})
.WithName("ChotLead")
.WithTags("Leads");
}
Cắt theo tầngCắt theo tính năng
Số file phải mở để hiểu91
Tổng dòng trong các file đó1.39178
Dòng thật sự liên quan~8078
Số file phải sửa khi đổi tính năng4–61
Xoá tính năngPhải tìm và gỡ ở 6 fileXoá 1 file

Dòng cuối là lợi ích ít được nhắc nhưng rất thực: trong kiến trúc theo tầng, tính năng bị bỏ thường để lại "xác" — một phương thức trong LeadService không ai gọi, một mục trong ILeadRepository, một profile mapping. Với VSA, xoá là xoá thật.

Thứ VSA đánh đổi — bốn thứ:

1. Trùng lặp code. Đây là đánh đổi chính, và nó có chủ ý:

// Features/Leads/ChotLead.cs
var lead = await _db.Leads.FirstOrDefaultAsync(l => l.Id == c.LeadId, ct);
if (lead is null) return Result.KhongTimThay();

// Features/Leads/GanLead.cs — đoạn giống hệt
var lead = await _db.Leads.FirstOrDefaultAsync(l => l.Id == c.LeadId, ct);
if (lead is null) return Result.KhongTimThay();

// Features/Leads/HuyLead.cs — lại giống

VSA chấp nhận điều này vì lập luận: khớp nối sai đắt hơn trùng lặp. Ba đoạn giống nhau hôm nay có thể cần rẽ ba hướng khác nhau ngày mai, và khi đó việc tách một phương thức dùng chung ra thành ba lại tốn hơn.

Nhưng lập luận này có giới hạn, và giới hạn nằm ở bài 3.

2. Khó thấy bức tranh tổng thể. Với 80 slice, không có chỗ nào cho bạn thấy "tất cả những gì làm được với Lead". Bù lại bằng quy ước đặt tên nhất quán và cấu trúc thư mục rõ:

Features/Leads/
ChotLead.cs
GanLead.cs
HuyLead.cs
LayDanhSachLead.cs
LayChiTietLead.cs
TaoLead.cs

3. Dễ trượt vào việc bỏ qua domain. Handler truy cập DbContext trực tiếp, nên không có gì bắt buộc nó phải đi qua entity:

// Dễ viết, và nó bỏ qua mọi quy tắc
lead.Status = LeadStatus.Won;
await _db.SaveChangesAsync(ct);

Đây là rủi ro lớn nhất của VSA, và cách chặn là private set cộng kiến trúc test (bài 16.2) — VSA không thay thế được domain model phong phú.

4. Mỗi slice tự quyết định cách làm. Tự do này là điểm mạnh (slice đơn giản dùng cách đơn giản) và cũng là rủi ro (mười người viết mười kiểu). Cần một CONVENTIONS.md và review có kỷ luật.

Kết hợp VSA với Clean Architecture — cách phần lớn dự án thật nên dùng:

src/
Crm.Domain/ <- CHUNG: entity, value object, quy tắc nghiệp vụ
Entities/Lead.cs
ValueObjects/Money.cs
Events/LeadDaChot.cs

Crm.Api/Features/ <- RIÊNG: mỗi tính năng một slice
Leads/
ChotLead.cs
GanLead.cs
LayDanhSachLead.cs
Domain    -> chia sẻ, vì quy tắc nghiệp vụ PHẢI có một nguồn sự thật
Slice -> riêng, vì điều phối của mỗi tính năng là riêng của nó

Ranh giới này trả lời được câu hỏi "cái gì được chép, cái gì không":

Loại codeChia sẻ hay chép
Quy tắc nghiệp vụ, bất biếnLuôn chia sẻ — Domain
Value objectLuôn chia sẻ — Domain
Điều phối use caseRiêng mỗi slice
DTO của request/responseRiêng mỗi slice
Validator đầu vàoRiêng mỗi slice
Truy vấn đọcRiêng mỗi slice
Hạ tầng dùng chung (email, cache)Chia sẻ — interface trong Domain hoặc SharedKernel

Và một mẹo thực tế khi chuyển dần: đừng chuyển cả LeadService 687 dòng một lúc. Chuyển một phương thức mỗi lần bạn phải sửa nó:

// LeadService giữ nguyên, nhưng uỷ quyền
public async Task<Result> ChotLeadAsync(int id, CancellationToken ct)
=> await _mediator.Send(new ChotLead.Command(id), ct);

Sau vài tháng, LeadService chỉ còn là một lớp chuyển tiếp mỏng và bạn xoá nó. Cách này không bao giờ có một PR khổng lồ, và mỗi bước đều có thể dừng lại mà không để lại trạng thái dở dang.


Bài 2 — Đo xung đột merge​

Xem lịch sử Git của LeadService.cs (hoặc file service lớn nhất) và đếm số lần có merge conflict trong sáu tháng qua.

Tiêu chí hoàn thành: bạn đo được bằng lệnh Git, và giải thích được vì sao file lớn gây xung đột không tỉ lệ với kích thước của nó.

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

Gợi ý. Xung đột xảy ra khi hai nhánh sửa cùng một file. Xác suất đó phụ thuộc vào gì?

Lời giải — đo số người và số commit chạm vào mỗi file:

git log --since='6 months ago' --name-only --format='%an' -- '*.cs' \
| awk '
/^[A-Za-z]/ && !/\.cs$/ { tacGia = $0; next }
/\.cs$/ { key = $0; nguoi[key "|" tacGia] = 1; commit[key]++ }
END {
for (k in nguoi) { split(k, p, "|"); soNguoi[p[1]]++ }
for (f in commit) printf "%4d commit %2d người %s\n", commit[f], soNguoi[f], f
}' \
| sort -rn | head -15
 284 commit  11 người  src/Crm.Application/Services/LeadService.cs
198 commit 9 người src/Crm.Api/Controllers/LeadsController.cs
142 commit 8 người src/Crm.Infrastructure/Repositories/LeadRepository.cs
87 commit 6 người src/Crm.Application/Mapping/LeadProfile.cs
24 commit 3 người src/Crm.Domain/Entities/Lead.cs
18 commit 4 người src/Crm.Api/Features/Leads/ChotLead.cs

Đếm merge conflict thật:

git log --since='6 months ago' --format='%H %s' --merges | while read -r sha msg; do
cha=$(git rev-list --parents -n1 "$sha" | cut -d' ' -f2-)
base=$(git merge-base $cha 2>/dev/null) || continue
xungdot=$(git merge-tree "$base" $cha 2>/dev/null | grep -c '^<<<<<<<')
[ "$xungdot" -gt 0 ] && echo "$xungdot $sha $msg"
done | sort -rn | head

Cách đơn giản hơn, dùng reflog của các lần merge đã giải quyết:

git log --since='6 months ago' --format='%s' | grep -ci "conflict\|resolve merge"

Trên một dự án điển hình:

LeadService.cs:       31 lần xung đột trong 6 tháng
LeadsController.cs: 18 lần
Lead.cs (Domain): 2 lần
Features/*.cs: 0 lần

Vì sao file lớn gây xung đột không tỉ lệ với kích thước — ba cơ chế cộng dồn:

Cơ chế 1 — xác suất va chạm tăng theo bình phương số người. Với n nhánh đang sửa cùng một file, số cặp có thể xung đột là n(n-1)/2:

2 nhánh:  1 cặp
5 nhánh: 10 cặp
11 nhánh: 55 cặp

Số cặp tăng theo O(n²), không theo O(n).

Cơ chế 2 — Git merge theo hunk, không theo ngữ nghĩa. Hai thay đổi ở hai phương thức khác nhau vẫn xung đột nếu chúng gần nhau trong file:

public async Task ChotLeadAsync(...) { ... }      // An sửa

public async Task GanLeadAsync(...) { ... } // Bình sửa

Nếu hai phương thức cách nhau dưới ba dòng, Git coi đó là một hunk và báo xung đột — dù hai thay đổi hoàn toàn độc lập về mặt logic.

Cơ chế 3 — vùng "nóng" tập trung. Trong một service lớn, hai vùng bị mọi người chạm tới:

public class LeadService
{
private readonly CrmDbContext _db;
private readonly IEmailSender _email;
private readonly ICacheService _cache; // <- vùng nóng 1: danh sách dependency
// ... 12 dependency nữa

public LeadService(CrmDbContext db, IEmailSender email, ICacheService cache, ...)
=> ...; // <- vùng nóng 2: constructor
}

Mọi tính năng mới thêm một dependency đều chạm vào đúng hai chỗ này. Xung đột ở đây gần như chắc chắn khi có hai nhánh song song — và nó xảy ra ngay cả khi hai tính năng không liên quan gì tới nhau.

Với VSA, mỗi handler có constructor riêng với 2–3 dependency, nên vùng nóng biến mất.

Chi phí thật của xung đột:

Mỗi lần giải quyết:     15–45 phút
Rủi ro giải sai: mất code, hoặc gộp hai thay đổi thành một hành vi sai
Rủi ro test bỏ sót: code sau merge chưa từng được chạy ở dạng đó

31 lần × 30 phút = 15,5 giờ trong 6 tháng, chỉ cho MỘT file

Và rủi ro giải sai là chi phí lớn hơn thời gian: một merge conflict giải sai trong một service 687 dòng rất khó phát hiện khi review, vì diff của lần merge trông như một đống thay đổi không liên quan.

Sau khi chuyển sang VSA:

Features/Leads/ChotLead.cs:       0 xung đột (chỉ 1–2 người chạm)
Features/Leads/GanLead.cs: 0
Features/Leads/LayDanhSach.cs: 0
Domain/Entities/Lead.cs: 2 (ít người chạm, và chạm có chủ đích)

Lý do: mỗi slice thường chỉ có một người làm tại một thời điểm, vì một tính năng thường được giao cho một người.

Nhưng đừng nhầm nguyên nhân. Không phải "VSA giảm xung đột" mà là:

File nhỏ, mỗi file một mối quan tâm, thì ít người chạm cùng lúc.

VSA là một cách đạt được điều đó, nhưng không phải cách duy nhất. Tách LeadService 687 dòng thành LeadChotService, LeadGanService, LeadTruyVanService cũng cho kết quả tương tự.

Đưa phép đo vào quy trình:

#!/usr/bin/env bash
# canh-bao-file-nong.sh — chạy hằng tuần
git log --since='3 months ago' --name-only --format='%an' -- '*.cs' \
| awk '
/^[A-Za-z]/ && !/\.cs$/ { a = $0; next }
/\.cs$/ { n[$0 "|" a] = 1; c[$0]++ }
END { for (k in n) { split(k, p, "|"); s[p[1]]++ }
for (f in c) if (s[f] >= 5 && c[f] >= 50) printf "%s: %d commit, %d người\n", f, c[f], s[f] }'
src/Crm.Application/Services/LeadService.cs: 284 commit, 11 người

Ngưỡng "từ 5 người và 50 commit trong 3 tháng" là một heuristic tốt cho "file này cần được tách". Đưa kết quả vào cuộc họp sprint, và nó trở thành một tín hiệu khách quan thay vì một ý kiến.

Và một chỉ số bổ sung — số dòng thay đổi trung bình mỗi commit:

git log --since='6 months ago' --format='' --numstat -- src/Crm.Application/Services/LeadService.cs \
| awk '{ them += $1; xoa += $2; n++ } END { printf "%d commit, trung bình %.0f dòng thêm, %.0f dòng xoá\n", n, them/n, xoa/n }'
284 commit, trung bình 18 dòng thêm, 6 dòng xoá

18 dòng mỗi commit trên một file 687 dòng nghĩa là mỗi người chỉ chạm một phần rất nhỏ — bằng chứng rõ ràng rằng file đang chứa nhiều mối quan tâm độc lập và nên được tách.


Bài 3 — Tìm quy tắc bị chép giữa các slice​

Trong một codebase VSA, grep một hằng số nghiệp vụ qua mọi slice. Mỗi lần xuất hiện ngoài Domain là một vi phạm.

Tiêu chí hoàn thành: bạn phân biệt được trùng lặp chấp nhận được với trùng lặp nguy hiểm, và biết tiêu chí để tách vào SharedKernel.

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

Gợi ý. VSA chấp nhận trùng lặp. Nhưng chấp nhận trùng lặp cái gì?

Lời giải:

grep -rn "500_000_000\|NguongDuyet" --include="*.cs" src/Crm.Api/Features/
src/Crm.Api/Features/Leads/ChotLead.cs:42
src/Crm.Api/Features/Leads/CapNhatLead.cs:38
src/Crm.Api/Features/Import/ImportLeadHangLoat.cs:87
src/Crm.Api/Features/Reports/BaoCaoDuyet.cs:24

Bốn chỗ. Đây là trùng lặp nguy hiểm, và nó là thất bại điển hình của VSA khi áp dụng sai.

Phân biệt hai loại trùng lặp:

Trùng lặp chấp nhận đượcTrùng lặp nguy hiểm
Nội dungĐiều phối, mã hạ tầngQuy tắc nghiệp vụ
Nếu hai bản sai lệchChỉ là khác cách làmHệ thống hành xử không nhất quán
Khi nghiệp vụ đổiKhông cần đổiPhải đổi MỌI bản, nếu sót là lỗi
Ví dụNạp entity, trả NotFound, map DTONgưỡng duyệt, công thức tính, điều kiện chuyển trạng thái

Trùng lặp chấp nhận được:

// ChotLead.cs
var lead = await _db.Leads.FirstOrDefaultAsync(l => l.Id == c.LeadId, ct);
if (lead is null) return Result.KhongTimThay();

// GanLead.cs — giống hệt, và KHÔNG SAO
var lead = await _db.Leads.FirstOrDefaultAsync(l => l.Id == c.LeadId, ct);
if (lead is null) return Result.KhongTimThay();

Nếu một ngày ChotLead cần Include(l => l.Approvals) mà GanLead thì không, hai bản rẽ hướng — và đó là điều đúng. Gom chúng vào một LayLeadAsync() dùng chung sẽ buộc bạn thêm tham số, rồi thêm tham số nữa, cho tới khi có một phương thức với sáu tham số optional phục vụ mọi người kém.

Trùng lặp nguy hiểm:

// ChotLead.cs:42
if (lead.Value >= 500_000_000 && !nguoiDung.LaTruongPhong)
return Result.Loi("Cần trưởng phòng duyệt");

// ImportLeadHangLoat.cs:87 — dùng > thay vì >=
if (lead.Value > 500_000_000 && !nguoiDung.LaTruongPhong)
continue;

// BaoCaoDuyet.cs:24 — ngưỡng khác
var canDuyet = leads.Where(l => l.Value >= 300_000_000);

Ba phiên bản, ba hành vi. Và bản thứ ba nghĩa là báo cáo hiển thị một tập lead khác với tập thật sự cần duyệt — một lỗi nghiệp vụ mà không test nào bắt được, vì mỗi slice có test riêng và mỗi test đều xanh.

Quy tắc để phân biệt — một câu hỏi:

"Nếu hai bản sao này khác nhau, hệ thống có hành xử KHÔNG NHẤT QUÁN về mặt nghiệp vụ không?"

Có  -> quy tắc nghiệp vụ -> PHẢI ở Domain, không được chép
Không -> điều phối -> chép được, và thường nên chép

Áp dụng:

"Lead trên 500 triệu cần duyệt"
-> hai bản khác nhau = một đường cho qua, một đường chặn
-> KHÔNG NHẤT QUÁN -> Domain

"Nạp lead bằng FirstOrDefaultAsync"
-> hai bản khác nhau = một bản có Include, một bản không
-> vẫn nhất quán về nghiệp vụ -> chép được

Gom về Domain:

// src/Crm.Domain/Entities/Lead.cs
public class Lead
{
public static readonly Money NguongCanDuyet = Money.VND(500_000_000);

public bool CanTruongPhongDuyet() => Value >= NguongCanDuyet;

public Result ChuyenSangWon(NguoiDung nguoiThucHien, DateTime bayGio)
{
if (CanTruongPhongDuyet() && !nguoiThucHien.LaTruongPhong)
return Result.Loi($"Lead trên {NguongCanDuyet} cần trưởng phòng duyệt");
// ...
}
}
// Mọi slice gọi lại cùng một chỗ
var kq = lead.ChuyenSangWon(nguoiDung, bayGio); // ChotLead.cs
var canDuyet = leads.Where(l => l.CanTruongPhongDuyet()); // BaoCaoDuyet.cs
grep -rn "500_000_000" --include="*.cs" src/
src/Crm.Domain/Entities/Lead.cs:14

Tiêu chí tách vào SharedKernel — và đây là phần dễ sai nhất của VSA, vì SharedKernel có xu hướng phình ra:

Tách khi có ĐỦ ba điều kiện:

1. Là quy tắc nghiệp vụ hoặc khái niệm nghiệp vụ, không phải tiện ích
2. Được dùng ở ba slice trở lên (quy tắc "ba lần")
3. Nếu các bản sao khác nhau thì hệ thống sai về nghiệp vụ

ĐỪNG tách khi:

- Chỉ dùng ở hai chỗ (chờ lần thứ ba)
- Chỉ giống nhau về hình dạng, không giống về ý nghĩa
- Là mã điều phối
- Bạn phải thêm tham số để nó phục vụ được cả hai chỗ <- dấu hiệu rõ nhất

Điều kiện cuối đáng nhớ: nếu để dùng chung bạn phải thêm một bool hay một enum để phân biệt hai trường hợp, thì hai chỗ đó không cùng một thứ — và bạn đang tạo khớp nối sai.

Cấu trúc SharedKernel nên có:

src/Crm.Domain/                    <- quy tắc nghiệp vụ, luôn chia sẻ
Entities/ Lead, Customer, Order
ValueObjects/ Money, Email, PhoneNumber, TenantId
Events/ LeadDaChot, DonHangDaTao
Exceptions/ BusinessRuleViolation

src/Crm.SharedKernel/ <- khái niệm CHUNG cho mọi bounded context
Result.cs
EntityBase.cs
IDomainEvent.cs
ValueObject.cs

src/Crm.Api/Features/ <- điều phối, RIÊNG mỗi slice

Ranh giới giữa Domain và SharedKernel: Domain chứa khái niệm của nghiệp vụ này; SharedKernel chứa khái niệm của cách xây dựng phần mềm — Result, EntityBase, ValueObject không nói gì về CRM.

Kiểm tra tự động, chạy trong CI:

[Fact]
public void Hang_so_nghiep_vu_chi_duoc_khai_bao_trong_Domain()
{
var mauSo = new Regex(@"\b\d{1,3}(_\d{3})+\b|\b\d{7,}\b");

var viPham = Directory
.GetFiles(ThuMucSrc("Crm.Api/Features"), "*.cs", SearchOption.AllDirectories)
.Select(f => (File: f, Dong: File.ReadAllLines(f)
.Select((noiDung, i) => (noiDung, so: i + 1))
.Where(x => mauSo.IsMatch(x.noiDung)
&& !x.noiDung.TrimStart().StartsWith("//"))
.ToList()))
.Where(x => x.Dong.Count > 0)
.SelectMany(x => x.Dong.Select(d => $"{Path.GetFileName(x.File)}:{d.so}"))
.ToList();

viPham.Should().BeEmpty(
"hằng số nghiệp vụ phải khai báo trong Domain, không nằm trong slice");
}
[Fact]
public void Slice_khong_duoc_phu_thuoc_slice_khac()
{
var kq = Types.InAssembly(typeof(ChotLead).Assembly)
.That().ResideInNamespaceMatching(@"Crm\.Api\.Features\.Leads")
.ShouldNot().HaveDependencyOnAny(
"Crm.Api.Features.Import", "Crm.Api.Features.Reports")
.GetResult();

kq.IsSuccessful.Should().BeTrue(
"slice phụ thuộc slice khác là dấu hiệu code chung nên ở Domain");
}

Test thứ hai bắt được một sai lầm tinh vi: khi hai slice cần cùng một logic, cách dễ nhất là slice này gọi handler của slice kia. Điều đó phá vỡ tính độc lập của slice — và thứ cần chia sẻ lẽ ra phải nằm ở Domain.

Và rà định kỳ, vì trùng lặp nguy hiểm tích tụ dần:

# Điều kiện so sánh trạng thái nằm ngoài Domain
grep -rn "Status ==\|Status !=" --include="*.cs" src/Crm.Api/Features/ | wc -l

# Kiểm tra vai trò nằm ngoài Domain
grep -rn "IsInRole\|LaTruongPhong\|HasClaim" --include="*.cs" src/Crm.Api/Features/ | wc -l

Hai con số này nên tiến về 0 theo thời gian. Nếu chúng tăng, quy tắc nghiệp vụ đang rò rỉ ra khỏi Domain — và mỗi lần rò rỉ là một bản sao sẽ sai lệch trong tương lai.

Tự kiểm tra​

Frequently asked questions

VSA khác kiến trúc phân tầng ở đâu?

Phân tầng gom theo loại kỹ thuật như controller, service, repository; VSA gom theo tính năng, tất cả những gì một tính năng cần nằm trong một file hoặc một thư mục. Kết quả là sửa một tính năng đụng một chỗ, và hai người làm hai tính năng không đụng nhau khi merge.

Vì sao VSA chấp nhận trùng lặp DTO?

Vì hai tính năng thay đổi vì lý do khác nhau. Gom chúng vào một DTO chung nghĩa là thêm trường cho màn hình chi tiết sẽ đổi luôn response của màn hình danh sách, tức bạn vừa ghép hai tính năng lẽ ra độc lập.

Thứ gì được phép trùng và thứ gì không?

Được phép trùng là hình dạng dữ liệu: DTO, mapping, truy vấn tương tự, validation về hình dạng. Không được trùng là quy tắc nghiệp vụ, tính toán giá và thuế, logic phân quyền, bất biến của entity. Nhóm sau nằm ở Domain và không bao giờ được chép.

Khi nào một thứ nên tách ra SharedKernel?

Khi nó là quy tắc nghiệp vụ chứ không phải hình dạng dữ liệu, nó ổn định hiếm khi đổi, và nhiều slice dùng nó và bắt buộc phải nhất quán. SharedKernel phải nhỏ; khi nó bắt đầu có thư mục con theo tính năng thì nó đã hỏng.

Hai cạm bẫy của VSA là gì?

Slice chép nhau thay vì có quy ước chung, dẫn tới mười phong cách khác nhau sau sáu tháng; chữa bằng một slice mẫu và kiến trúc test. Và slice biến thành monolith nhỏ khi handler tự làm mọi thứ, lúc đó nó lại là cái controller khổng lồ chỉ đổi tên thư mục.

VSA và Clean Architecture có loại trừ nhau không?

Không, chúng giải hai bài toán khác nhau: Clean Architecture lo nghiệp vụ không phụ thuộc hạ tầng theo chiều dọc, VSA lo tính năng không phụ thuộc nhau theo chiều ngang. Mẫu kết hợp phổ biến là Domain tách riêng còn Application tổ chức theo slice.

Kết luận​

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

  1. Cắt theo tính năng, không theo loại kỹ thuật. Xoá một tính năng nên là xoá một file.
  2. Trùng DTO là cố ý; trùng quy tắc nghiệp vụ là lỗi.
  3. VSA tổ chức Application; Domain vẫn nên tách riêng khi có bất biến.

Tham khảo​

Điều hướng​