Bạn đã bao giờ tự hỏi: “Làm thế nào để tổ chức code dùng chung trong Vertical Slice Architecture?” Đây là câu hỏi khiến nhiều lập trình viên đau đầu khi áp dụng kiến trúc này.
Mục lục
Giới thiệu
Vertical Slice Architecture (VSA) giống như một luồng gió mới khi bạn lần đầu tiếp cận. Bạn không còn phải nhảy qua bảy tầng lớp chỉ để thêm một trường dữ liệu. Bạn xóa bỏ hàng tá project trong solution. Bạn cảm thấy được giải phóng.
Nhưng khi bắt đầu triển khai những tính năng phức tạp hơn, những vết nứt bắt đầu lộ diện.
Bạn xây dựng slice CreateOrder. Rồi UpdateOrder. Rồi GetOrder. Đột nhiên, bạn nhận thấy sự lặp lại. Logic xác thực địa chỉ xuất hiện ở ba nơi. Thuật toán định giá được cả Cart và Checkout sử dụng.
Bạn cảm thấy thôi thúc tạo một project Common hoặc thư mục SharedServices. Đây là thời điểm quan trọng nhất trong hành trình áp dụng VSA của bạn.
Chọn sai, bạn sẽ reintroduce sự phụ thuộc lẫn nhau mà bạn đang cố thoát khỏi. Chọn đúng, bạn duy trì được tính độc lập làm nên giá trị của VSA.
Đây là cách tôi tiếp cận code dùng chung trong Vertical Slice Architecture.
Lan can vs. Đường mở
Để hiểu tại sao vấn đề này khó, chúng ta cần nhìn lại những gì đã bỏ lại phía sau.
Clean Architecture cung cấp những lan can (guardrails) chặt chẽ. Nó chỉ cho bạn chính xác code sống ở đâu: Entities ở Domain, interfaces ở Application, implementations ở Infrastructure. An toàn. Nó ngăn ngừa sai lầm, nhưng cũng ngăn cản những đường tắt khi cần thiết.
Vertical Slice Architecture loại bỏ những lan can đó. Nó nói: “Hãy tổ chức code theo tính năng, không phải theo mối quan tâm kỹ thuật”. Điều này mang lại tốc độ và sự linh hoạt, nhưng nó chuyển gánh nặng kỷ luật lên bạn.
Vậy bạn có thể làm gì?
Cái bẫy: Ngăn kéo linh tinh “Common”
Con đường ít kháng cự nhất là tạo một project (hoặc thư mục) tên Shared, Common, hoặc Utils.
Đây gần như luôn là một sai lầm.
Hãy tưởng tượng một project Common.Services với class OrderCalculationService. Nó có phương thức tính tổng giỏ hàng (dùng bởi Cart), phương thức khác cho doanh thu lịch sử (dùng bởi Reporting), và một helper định dạng hóa đơn (dùng bởi Invoices). Ba mối quan tâm không liên quan. Ba tần suất thay đổi khác nhau. Một class kết nối tất cả lại với nhau.
Một project Common chắc chắn trở thành ngăn kéo linh tinh cho bất cứ thứ gì bạn không muốn đặt tên đàng hoàng. Nó tạo ra một mạng lưới phụ thuộc chằng chịt nơi các tính năng không liên quan bị ghép lại với nhau chỉ vì chúng tình cờ dùng cùng một phương thức helper.
Bạn đã đưa lại chính sự phụ thuộc mà bạn cố thoát khỏi.
Khung quyết định
Khi gặp tình huống có thể chia sẻ code, tôi đặt ba câu hỏi:
- Đây là infrastructure hay domain?
Infrastructure (database contexts, logging, HTTP clients) hầu như luôn được chia sẻ. Các khái niệm domain cần được xem xét kỹ hơn. - Khái niệm này ổn định đến đâu?
Nếu nó thay đổi mỗi năm một lần, hãy chia sẻ. Nếu nó thay đổi theo mỗi yêu cầu tính năng, hãy giữ nó ở địa phương. - Tôi đã qua “Quy tắc số Ba” chưa?
Lặp cùng một code một lần là ổn. Nhưng tạo ba bản sao sẽ đáng để xem xét. Đừng abstract hóa cho đến khi bạn chạm mốc ba.
Chúng ta giải quyết vấn đề này bằng cách refactor code. Hãy xem một số ví dụ.
Ba cấp độ chia sẻ
Thay vì “Chia sẻ vs. Không chia sẻ” mang tính nhị phân, hãy nghĩ theo ba cấp độ.
Cấp độ 1: Hạ tầng kỹ thuật (Chia sẻ tự do)
Hệ thống ống nước thuần túy ảnh hưởng đến tất cả các slice như nhau: logging adapters, database connection factories, auth middleware, Result pattern, validation pipelines.
Tập trung hóa chúng trong một project Shared.Kernel hoặc Infrastructure. Lưu ý rằng đây cũng có thể là một thư mục trong solution của bạn. Nó hiếm khi thay đổi do yêu cầu kinh doanh.
// ✅ Chia sẻ tốt: Technical Kernel
public readonly record struct Result
{
public bool IsSuccess { get; }
public string Error { get; }
private Result(bool isSuccess, string error)
{
IsSuccess = isSuccess;
Error = error;
}
public static Result Success() => new(true, string.Empty);
public static Result Failure(string error) => new(false, error);
}
Cấp độ 2: Khái niệm Domain (Chia sẻ và đẩy logic xuống)
Đây là một trong những nơi tốt nhất để chia sẻ logic. Thay vì rải rác các quy tắc kinh doanh khắp các slice, hãy đẩy chúng vào entities và value objects.
// ✅ Chia sẻ tốt: Entity với Business Logic
public class Order
{
public Guid Id { get; private set; }
public OrderStatus Status { get; private set; }
public List<OrderLine> Lines { get; private set; }
public bool CanBeCancelled() => Status == OrderStatus.Pending;
public Result Cancel()
{
if (!CanBeCancelled())
{
return Result.Failure("Chỉ có thể hủy đơn hàng đang chờ xử lý.");
}
Status = OrderStatus.Cancelled;
return Result.Success();
}
}
Giờ đây, CancelOrder, GetOrder, và UpdateOrder đều sử dụng cùng một quy tắc kinh doanh. Logic chỉ sống ở một nơi duy nhất.
Điều này ngụ ý một khái niệm quan trọng: các vertical slice khác nhau có thể chia sẻ cùng một domain model.
Cấp độ 3: Logic riêng theo tính năng (Giữ ở địa phương)
Logic chia sẻ giữa các slice liên quan, như CreateOrder và UpdateOrder, không cần phải đi global. Hãy tạo một thư mục Shared (mọi quy tắc đều có ngoại lệ) bên trong feature:
📂 Features
└──📂 Orders
├──📂 CreateOrder
├──📂 UpdateOrder
├──📂 GetOrder
└──📂 Shared
├──📄 OrderValidator.cs
└──📄 OrderPricingService.cs
Điều này cũng có một lợi ích ẩn. Nếu bạn xóa tính năng Orders, logic dùng chung cũng biến mất theo. Không có zombie code bị bỏ lại.
Hãy khám phá một số kịch bản nâng cao mà hầu hết mọi người thường bỏ qua.
Chia sẻ giữa các tính năng
Còn việc chia sẻ code giữa các tính năng không liên quan trong Vertical Slice Architecture thì sao?
Slice CreateOrder cần kiểm tra khách hàng có tồn tại không. GenerateInvoice cần tính thuế. Orders và Customers đều cần định dạng thông báo.
Điều này không nằm gọn trong thư mục Shared của một tính năng. Vậy nó sẽ ở đâu?
Đầu tiên, hãy tự hỏi: bạn có thực sự cần chia sẻ không?
Hầu hết “chia sẻ” giữa các tính năng thực chất chỉ là truy cập dữ liệu được ngụy trang.
Nếu CreateOrder cần dữ liệu khách hàng, nó query database trực tiếp. Nó không gọi vào tính năng Customers. Mỗi slice sở hữu truy cập dữ liệu của riêng nó. Entity Customer được chia sẻ (nó sống trong Domain), nhưng không có service dùng chung nào giữa chúng.
Khi bạn thực sự cần logic dùng chung, hãy xác định nó là gì:
- Domain logic (quy tắc kinh doanh, tính toán) →
Domain/Services - Infrastructure (API bên ngoài, định dạng) →
Infrastructure/Services
// Domain/Services/TaxCalculator.cs
public class TaxCalculator
{
public decimal CalculateTax(Address address, decimal subtotal)
{
var rate = GetTaxRate(address.State, address.Country);
return subtotal * rate;
}
}
Cả CreateOrder và GenerateInvoice đều có thể sử dụng nó mà không bị phụ thuộc lẫn nhau.
Trước khi tạo bất kỳ service xuyên tính năng nào, hãy tự hỏi: logic này có thể sống trên một domain entity không? Hầu hết “business logic dùng chung” thực ra là truy cập dữ liệu, domain logic thuộc về entity, hoặc abstraction vội vàng.
Nếu bạn cần kích hoạt một side effect trong tính năng khác, tôi khuyên dùng messaging và events. Hoặc, tính năng bạn muốn gọi vào có thể expose một facade (public API) cho thao tác đó.
Khi sự trùng lặp là quyết định đúng đắn
Đôi khi code “dùng chung” thực ra không hề dùng chung. Nó chỉ trông giống vậy.
// Features/Orders/GetOrder
public record GetOrderResponse(Guid Id, decimal Total, string Status);
// Features/Orders/CreateOrder
public record CreateOrderResponse(Guid Id, decimal Total, string Status);
Chúng giống hệt nhau. Sự cám dỗ để tạo một SharedOrderDto thật khó cưỡng. Hãy kháng cự nó.
Tuần sau, GetOrder cần thêm tracking URL. Nhưng CreateOrder xảy ra trước khi giao hàng, nên chưa có URL. Nếu bạn đã chia sẻ DTO, giờ bạn sẽ có một thuộc tính nullable gây nhầm lẫn một nửa thời gian.
Trùng lặp rẻ hơn abstraction sai.
Cấu trúc thực tế
Đây là diện mạo của một dự án Vertical Slice Architecture trưởng thành:
📂 src
└──📂 Features
│ ├──📂 Orders
│ │ ├──📂 CreateOrder
│ │ ├──📂 UpdateOrder
│ │ └──📂 Shared # Chia sẻ riêng cho Order
│ ├──📂 Customers
│ │ ├──📂 GetCustomer
│ │ └──📂 Shared # Chia sẻ riêng cho Customer
│ └──📂 Invoices
│ └──📂 GenerateInvoice
└──📂 Domain
│ ├──📂 Entities
│ ├──📂 ValueObjects
│ └──📂 Services # Domain logic xuyên tính năng
└──📂 Infrastructure
│ ├──📂 Persistence
│ └──📂 Services
└──📂 Shared
└──📂 Behaviors
- Features — Các slice độc lập. Mỗi slice sở hữu request/response models của riêng nó.
- Features/[Name]/Shared — Chia sẻ cục bộ giữa các slice liên quan.
- Domain — Entities, value objects, và domain services. Business logic dùng chung sống ở đây.
- Infrastructure — Các mối quan tâm kỹ thuật.
- Shared — Chỉ các hành vi xuyên suốt (cross-cutting behaviors).
Các quy tắc
Sau khi xây dựng nhiều hệ thống theo cách này, đây là những gì tôi đúc kết:
- Features sở hữu request/response models của riêng chúng. Không có ngoại lệ.
- Đẩy business logic vào domain. Entities và value objects là nơi tốt nhất để chia sẻ quy tắc kinh doanh.
- Giữ chia sẻ trong cùng family tính năng ở mức địa phương. Nếu chỉ các slice Order cần nó, hãy giữ nó trong
Features/Orders/Shared. - Infrastructure được chia sẻ mặc định. Database contexts, HTTP clients, logging. Đây là các mối quan tâm kỹ thuật.
- Áp dụng Quy tắc Số Ba. Đừng extract cho đến khi bạn có ba cách sử dụng thực tế với logic giống hệt và ổn định.
Kết luận
Vertical Slice Architecture đặt câu hỏi: “Tính năng này thuộc về cái gì?”
Câu hỏi về code dùng chung thực chất là: “Tôi làm gì khi câu trả lời là nhiều tính năng?”
Hãy thừa nhận rằng một số khái niệm thực sự trải dài qua nhiều tính năng. Hãy đặt chúng vào một ngôi nhà dựa trên bản chất của chúng (domain, infrastructure, hoặc cross-cutting behavior). Hãy kháng cự cám dỗ chia sẻ mọi thứ chỉ vì bạn có thể.
Mục tiêu không phải là zero duplication. Mà là code dễ thay đổi khi requirements thay đổi.
Và requirements luôn thay đổi.
Cảm ơn bạn đã đọc!
Hãy luôn tuyệt vời nhé.



