Vertical Slice Architecture: Giải pháp thay thế ‘Clean Architecture’ giúp tiết kiệm thời gian thực tế

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Cái giá của Kiến trúc Phân lớp (Layered Architecture)

Hầu hết các lập trình viên bắt đầu bằng việc học mô hình “N-Tier”. Bạn hẳn đã quen thuộc với mô hình này: Controller gọi Service, Service gọi Repository, và Repository làm việc với Cơ sở dữ liệu. Trong nhiều thập kỷ, đây là lựa chọn mặc định để giữ cho mã nguồn luôn “ngăn nắp”.

Tuy nhiên, tôi đã dành nhiều năm đấu tranh với một nút thắt cổ chai cố hữu trong mọi dự án doanh nghiệp. Hãy tưởng tượng bạn cần thêm một trường ‘DiscountCode’ vào đơn hàng. Trong một dự án phân lớp, bạn buộc phải thực hiện cái mà các kỹ sư gọi là “Shotgun Surgery” (Phẫu thuật bằng súng săn – thay đổi rải rác ở nhiều nơi). Bạn mở Order entity, OrderDTO, OrderRepository, IOrderService, phần triển khai (implementation), và cuối cùng là Controller. Tôi đã từng bấm giờ; mất tới 15 phút chỉ để chuyển đổi giữa các tab chỉ để thêm một cột vào cơ sở dữ liệu.

Mã nguồn được tách biệt (decoupled) theo lớp, nhưng lại gắn kết chặt chẽ (tightly coupled) theo tính năng. Sự mâu thuẫn này là lý do khiến việc phát triển trở nên chậm chạp ngay cả trong một Modular Monolith. Bạn dành nhiều thời gian để điều hướng trong cấu trúc thư mục sâu 40 cấp hơn là viết logic nghiệp vụ. Vertical Slice Architecture (VSA) khắc phục điều này bằng cách nhóm mã nguồn xung quanh “những gì nó làm” thay vì “nó là gì về mặt kỹ thuật”.

Chuyển sang mô hình Slice trong chưa đầy 10 phút

Trong một dự án tiêu chuẩn, các thư mục của bạn trông giống như mục lục thư viện, phân tán logic ra toàn bộ giải pháp:

/Controllers
  - ProductController.cs
/Services
  - ProductService.cs
/Repositories
  - ProductRepository.cs
/Models
  - Product.cs
  - ProductDto.cs

Trong Vertical Slice Architecture, chúng ta ngừng nhóm theo vai trò kỹ thuật. Thay vào đó, chúng ta nhóm theo năng lực nghiệp vụ. Một bước tái cấu trúc nhanh sẽ trông như thế này:

/Features
  /Products
    /GetProduct
      - GetProductEndpoint.cs
      - GetProductHandler.cs
      - GetProductResponse.cs
    /CreateProduct
      - CreateProductEndpoint.cs
      - CreateProductCommand.cs
      - CreateProductHandler.cs
      - CreateProductValidator.cs

Giờ đây, mọi thứ cần thiết để tạo một sản phẩm đều nằm ở một nơi duy nhất. Nếu nghiệp vụ thay đổi quy tắc tạo sản phẩm, bạn chỉ cần mở một thư mục. Không còn việc phải lùng sục qua năm lớp khác nhau để tìm xem logic xác thực (validation) đang bị ẩn ở đâu.

Tại sao Slice vượt trội hơn Layer

Kiến trúc Hexagonal và Clean Architecture thường dựa trên các lớp trừu tượng kiểu “phòng xa”. Chúng ta tạo interface cho mọi service vì nghĩ rằng “biết đâu chúng ta sẽ thay đổi cơ sở dữ liệu”. Trong thực tế, tôi đã thấy các nhóm dành hơn 100 giờ để duy trì các interface cho một cuộc di cư database vốn chẳng bao giờ xảy ra trong suốt 5 năm. Trong khi đó, các yêu cầu nghiệp vụ lại thay đổi hàng tuần.

Xây dựng theo slice cho phép mỗi tính năng trở nên duy nhất. Nếu một tính năng chỉ là thao tác CRUD cơ bản, hãy để handler giao tiếp trực tiếp với cơ sở dữ liệu bằng ORM. Nếu một tính năng khác liên quan đến logic tính giá phức tạp, hãy sử dụng mô hình domain phong phú. Bạn không bị ép buộc vào một cái “quan tài” chung cho mọi API endpoint.

Một ví dụ thực tế với Python/FastAPI

Thay vì một tệp services.py cồng kềnh dài 2.000 dòng, chúng ta tạo một tệp riêng cho tính năng đó. Đây là một slice để cập nhật hồ sơ người dùng:

# features/users/update_profile.py
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.orm import Session
from db import get_db, User

router = APIRouter()

class UpdateProfileRequest(BaseModel):
    display_name: str
    bio: str

@router.put("/users/me")
def handle_update_profile(request: UpdateProfileRequest, db: Session = Depends(get_db)):
    # Logic, xác thực và lưu trữ diễn ra ngay tại đây.
    user = db.query(User).filter(User.id == current_user_id).first()
    user.display_name = request.display_name
    user.bio = request.bio
    db.commit()
    return {"status": "thành công"}

Bằng cách đưa schema và logic vào cùng một tệp để đảm bảo tính Type-safe, tôi đã thấy các nhóm giảm thời gian từ lúc bắt đầu đến lần commit đầu tiên cho các tính năng mới xuống gần 40%. Bạn có thể nắm giữ toàn bộ ngữ cảnh của thay đổi trong đầu mà không phải nhảy qua lại giữa 12 tab đang mở.

Xử lý Shared Logic mà không gây lộn xộn

Câu hỏi đầu tiên tôi luôn nhận được là: “Liệu tôi có bị lặp lại mã nguồn không?” Nếu hai slice đều cần tính toán một mức thuế cụ thể, bạn không nên sao chép-dán mã đó.

Giải pháp là đưa các quy tắc thực sự dùng chung vào thư mục Domain hoặc Shared. Nhưng hãy lưu ý điều này: đừng chia sẻ mã nguồn chỉ vì hiện tại chúng trông giống nhau, nhất là khi áp dụng Lập trình Reactive phức tạp. Hai tính năng có thể trông giống hệt nhau vào thứ Hai nhưng sẽ phát triển thành hai thực thể hoàn toàn khác nhau vào thứ Sáu. Trong VSA, chúng ta ưu tiên một vài dòng code trùng lặp hơn là một lớp trừu tượng “god-object” giòn yếu, có thể làm hỏng mọi thứ khi thay đổi.

Sử dụng Mediator Pattern (C#)

Đối với các dự án lớn hơn, Mediator pattern (như thư viện MediatR) giúp giữ cho các controller mỏng gọn. Controller chỉ đơn giản là phát đi một thông điệp, và handler cụ thể trong slice sẽ tiếp nhận nó.

// Features/Orders/CreateOrder/CreateOrderHandler.cs
public class CreateOrderHandler : IRequestHandler<CreateOrderCommand, Guid>
{
    private readonly AppDbContext _db;
    public CreateOrderHandler(AppDbContext db) => _db = db;

    public async Task<Guid> Handle(CreateOrderCommand request, CancellationToken ct)
    {
        var order = new Order(request.CustomerId);
        _db.Orders.Add(order);
        await _db.SaveChangesAsync(ct);
        return order.Id;
    }
}

Lời khuyên thực tế cho Sprint tiếp theo của bạn

  • Bắt đầu nhỏ: Đừng tái cấu trúc toàn bộ hệ thống nguyên khối (monolith). Hãy tạo một thư mục /Features và xây dựng ticket tiếp theo của bạn dưới dạng một vertical slice.
  • Thực thi ranh giới: Các slice không nên gọi trực tiếp lẫn nhau. Hãy sử dụng các event hoặc shared domain model nếu chúng cần giao tiếp.
  • Xóa bỏ Repository: Nếu một slice chỉ cần một câu truy vấn SQL, hãy viết trực tiếp câu truy vấn đó trong handler. Bạn không cần một lớp trừu tượng ba tầng cho một câu lệnh SELECT đơn giản.
  • Tái cấu trúc bằng cách chia nhỏ: Nếu một thư mục như /Orders trở nên quá đông đúc, hãy chia nhỏ nó. /Orders/Cancel/Orders/Refund dễ quản lý hơn nhiều so với một OrderService khổng lồ.

Chuyển từ phân lớp sang tính năng giúp giảm bớt gánh nặng tư duy cho bạn. Có thể bạn sẽ cảm thấy “lộn xộn” khi thấy một câu truy vấn database nằm ngay cạnh một quy tắc xác thực, nhưng hiệu quả năng suất mang lại là không thể phủ nhận. Bạn không còn là một người điều hướng thư mục nữa; bạn là một người xây dựng tính năng với tốc độ bàn giao nhanh hơn.

Share: