Menu
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Đang tải...

Bài 15 — Onion Architecture & Clean Architecture

Architecture Decision-Making Bài 15/60

Mở đầu — vì sao bài này quan trọng

Bạn đã bao giờ mở một dự án cũ và thấy rằng mỗi lần muốn đổi từ MySQL sang PostgreSQL, hay đổi từ REST sang gRPC, là phải sửa tới hàng trăm file rải rác khắp nơi chưa? Business logic — thứ quý giá nhất của công ty — lại nằm chung một chỗ với đoạn code kết nối database, gọi API bên thứ ba, và cả đoạn parse JSON từ request HTTP. Đó là triệu chứng kinh điển của một kiến trúc không có ranh giới rõ ràng giữa "cái công ty tôi làm" và "công cụ tôi dùng để làm".

Onion Architecture (Kiến trúc củ hành, Jeffrey Palermo, 2008) và Clean Architecture (Robert C. Martin, thường gọi là Uncle Bob, 2012) sinh ra để giải quyết đúng nỗi đau này. Cả hai đều là những biến thể tinh chỉnh của cùng một ý tưởng gốc: đặt business logic vào trung tâm, và đẩy mọi thứ liên quan đến kỹ thuật hạ tầng — database, framework, UI, message broker — ra ngoài rìa.

Với vai trò người ra quyết định kiến trúc, bạn cần hiểu bài này không phải để "vẽ đúng vòng tròn cho đẹp", mà để trả lời được câu hỏi thực tế: khi nào áp dụng những kiểu kiến trúc này thì đáng, khi nào là over-engineering, và làm sao ghi lại quyết định đó một cách thuyết phục. Ở Bài 14 chúng ta đã học Hexagonal (Ports & Adapters) — tư tưởng "core không phụ thuộc ra ngoài". Bài này đi sâu vào hai người anh em kế thừa và mở rộng tư tưởng đó với cấu trúc lớp cụ thể hơn.

Khái niệm cốt lõi

Vấn đề chung: Dependency Rule (Quy tắc phụ thuộc)

Trước khi nói về vòng tròn, hãy nắm nguyên tắc xương sống của cả hai kiến trúc:

> Mọi phụ thuộc source code chỉ được phép trỏ vào trong, hướng về trung tâm. Không bao giờ trỏ ra ngoài.

Nghĩa là lớp Domain (trong cùng) không được phép "biết" gì về database, về framework Spring/Laravel, về Kafka. Ngược lại, lớp Infrastructure (ngoài cùng) được phép biết và phụ thuộc vào Domain. Business logic là "ông chủ", hạ tầng là "người phục vụ" — và ông chủ không cần biết người phục vụ dùng loại xe nào để đi chợ.

Điều này đạt được nhờ Dependency Inversion: Domain định nghĩa các interface (ví dụ OrderRepository), còn Infrastructure viết class cụ thể (PostgresOrderRepository) implement interface đó. Mũi tên phụ thuộc bị "đảo ngược" so với luồng gọi hàm thực tế.

Onion Architecture (Jeffrey Palermo, 2008)

Palermo hình dung hệ thống như những lớp vỏ hành đồng tâm, từ trong ra ngoài:

  • Domain Model (lõi trong cùng): các entity, value object, và những quy tắc nghiệp vụ bất biến. Ví dụ: một DonHang biết cách tự tính tổng tiền, tự kiểm tra "không được đặt hàng khi tài khoản bị khóa". Lớp này không import bất cứ thứ gì từ bên ngoài.
  • Domain Services: logic nghiệp vụ liên quan đến nhiều entity, không thuộc riêng một entity nào. Ví dụ dịch vụ tính phí vận chuyển dựa trên đơn hàng và địa chỉ.
  • Application Services: điều phối các use case (trường hợp sử dụng). Ví dụ "Đặt hàng" gồm: kiểm tra tồn kho → tạo đơn → trừ kho → gửi thông báo. Lớp này gọi Domain, và gọi các interface (như PaymentGateway) mà nó không tự implement.
  • Infrastructure (vỏ ngoài cùng): database, gọi API bên ngoài, gửi email, UI, framework web. Đây là nơi các interface được hiện thực hóa cụ thể.
Điểm mấu chốt của Onion: tất cả các lớp ngoài đều phụ thuộc vào lõi Domain, nhưng lõi không phụ thuộc ngược lại.

Clean Architecture (Robert C. Martin, 2012)

Clean Architecture về bản chất là cùng một triết lý, nhưng Uncle Bob đặt tên lớp rõ ràng hơn và bổ sung khái niệm "ranh giới" và "boundary crossing":

  • Entities: quy tắc nghiệp vụ cốt lõi của toàn doanh nghiệp — thứ không đổi dù ứng dụng này có tồn tại hay không.
  • Use Cases: quy tắc nghiệp vụ riêng của ứng dụng — điều phối luồng dữ liệu để thực hiện một mục tiêu cụ thể.
  • Interface Adapters: chuyển đổi dữ liệu giữa định dạng thuận tiện cho Use Cases và định dạng thuận tiện cho hạ tầng. Đây là nơi có Controllers, Presenters, Gateways.
  • Frameworks & Drivers: vòng ngoài cùng — web framework, database driver, UI. Uncle Bob gọi đây là "chi tiết" (details), và triết lý của ông là "database chỉ là một chi tiết, framework chỉ là một chi tiết — đừng để chúng định hình kiến trúc của bạn".
Điểm Clean bổ sung mạnh so với Onion là quy tắc về luồng điều khiển vượt ranh giới: khi Use Case cần trả kết quả ra ngoài, nó không gọi trực tiếp Presenter mà thông qua một interface (output port), giữ cho mũi tên phụ thuộc luôn hướng vào trong.

So sánh nhanh Onion, Clean và Hexagonal

Nhiều bạn nhầm ba cái này là ba thứ khác nhau hoàn toàn. Thực ra chúng là ba cách diễn đạt cùng một tư tưởng:

  • Hexagonal (Ports & Adapters): nhấn mạnh khái niệm cổng (port) và bộ chuyển đổi (adapter), không quy định số lớp bên trong.
  • Onion: thêm vào cấu trúc lớp đồng tâm rõ ràng (Domain → Domain Services → App Services → Infra).
  • Clean: tổng hợp cả hai, đặt tên lớp chuẩn hóa và thêm quy tắc boundary crossing cùng nguyên tắc SOLID.
Nói cách khác: nếu bạn hiểu Dependency Rule, bạn hiểu cả ba. Sự khác biệt chủ yếu là từ vựng và mức độ chi tiết của hướng dẫn.

Tình huống thực tế

Ví dụ 1 — Sàn thương mại điện tử đổi cổng thanh toán (bối cảnh Việt Nam)

Một startup e-commerce ở TP.HCM, tạm gọi là ShopViet, ban đầu tích hợp duy nhất VNPay. Team lead khi đó viết theo Clean Architecture, định nghĩa một interface CongThanhToan (PaymentGateway) trong lớp Use Case, với các phương thức taoGiaoDich()xacNhanThanhToan(). Lớp Infrastructure có VNPayGateway implement interface này.

Sáu tháng sau, do phí VNPay cao và khách hàng đòi thêm MoMo, ZaloPay, ban giám đốc yêu cầu tích hợp thêm hai cổng nữa trong 3 tuần. Vì business logic "đặt hàng → thanh toán → cập nhật đơn" nằm hoàn toàn ở lớp Use Case và chỉ phụ thuộc vào interface, team chỉ cần viết thêm MoMoGatewayZaloPayGateway ở lớp Infrastructure, cộng một factory chọn cổng theo lựa chọn của khách. Toàn bộ lớp Use Case và Entities không sửa một dòng nào. Việc tích hợp hoàn thành trong 9 ngày.

Bài học: Ranh giới rõ ràng biến một thay đổi lẽ ra "xuyên tâm cả hệ thống" thành một thay đổi "chỉ ở vỏ ngoài". Đó chính là ROI thực tế của Clean/Onion — không phải sự thanh lịch trên giấy, mà là tốc độ phản ứng với thị trường.

Ví dụ 2 — Fintech migrate database dưới áp lực compliance (bối cảnh Đông Nam Á)

Một công ty fintech cho vay tiêu dùng ở Singapore, hoạt động cả ở Việt Nam và Indonesia, ban đầu dùng MongoDB cho toàn bộ dữ liệu khoản vay. Khi mở rộng, cơ quan quản lý yêu cầu dữ liệu giao dịch tài chính phải nằm trên database có bảo đảm ACID mạnh, buộc họ chuyển sang PostgreSQL.

May mắn là kiến trúc theo Onion: các quy tắc nghiệp vụ tính lãi, kiểm tra hạn mức, xác định nợ xấu đều nằm ở Domain Model và Domain Services, chỉ giao tiếp với dữ liệu qua interface KhoanVayRepository. Việc migrate chỉ đụng vào lớp Infrastructure: viết PostgresKhoanVayRepository thay cho MongoKhoanVayRepository. Các test nghiệp vụ (unit test cho Domain) chạy được ngay vì chúng không hề chạm tới database thật — chúng dùng một InMemoryKhoanVayRepository giả lập.

Nhưng có một cái giá: đội đã phải viết một lớp mapping khá dày để chuyển đổi giữa entity Domain và bản ghi PostgreSQL, vì Domain Model không được "nhiễm" annotation của ORM. Ước tính khoảng 15% effort của dự án migrate là dành cho tầng mapping này.

Bài học: Onion/Clean khiến việc thay database an toàn hơn nhiều, nhưng "cái giá" là bạn phải chấp nhận lớp mapping và không được để entity nghiệp vụ dính chặt vào ORM. Đây là trade-off có ý thức, không phải bug.

Ví dụ 3 — Khi Clean Architecture là over-engineering (bối cảnh thực tế phổ biến)

Một team 3 người xây dựng một công cụ nội bộ CRUD đơn giản để quản lý danh sách nhân viên cho một công ty gia đình. Bạn tech lead trẻ, vừa đọc xong sách Clean Architecture, quyết định áp dụng đầy đủ: Entities, Use Cases, Interface Adapters, mỗi thao tác thêm/sửa/xóa đều có một use case class riêng, DTO riêng, mapper riêng.

Kết quả: một chức năng "sửa số điện thoại nhân viên" — vốn chỉ cần 5 dòng nếu dùng thẳng ORM — nay trải qua 6 lớp file: Controller → Request DTO → Use Case → Domain Entity → Repository Interface → Repository Impl → Response DTO. Team mất gấp 3 thời gian, và người mới vào rất khó tìm chỗ để sửa một lỗi nhỏ.

Bài học: Clean/Onion tỏa sáng khi business logic phức tạp và có khả năng đổi hạ tầng. Với CRUD thuần túy, ít nghiệp vụ, không có ý định đổi database — chi phí abstraction lớn hơn lợi ích. Đây là lý do bạn cần một ADR để tự chất vấn trước khi cam kết.

Hướng dẫn từng bước

Nếu bạn quyết định áp dụng Onion/Clean cho một service, đây là trình tự thực tế:

  • Xác định Domain trước, hạ tầng sau. Ngồi với người hiểu nghiệp vụ, viết ra các entity và quy tắc bất biến (invariant) mà không nhắc gì đến database hay API. Ví dụ: "Đơn hàng đã thanh toán thì không được hủy free". Đây là hạt nhân của bạn.
  • Định nghĩa các interface (port) tại lớp trong. Domain hoặc Use Case cần gì từ thế giới bên ngoài? OrderRepository, PaymentGateway, NotificationSender... Khai báo chúng dưới dạng interface đặt ở lớp trong.
  • Viết Use Case điều phối. Mỗi use case là một luồng nghiệp vụ hoàn chỉnh, chỉ gọi Domain và các interface. Đảm bảo use case không import bất kỳ package hạ tầng nào (không import kafka, không import jdbc).
  • Đặt quy tắc chống rò rỉ phụ thuộc bằng công cụ. Dùng công cụ như ArchUnit (Java), deptrac (PHP), hoặc import-linter (Python) để tự động fail build nếu lớp Domain lỡ import package hạ tầng. Đây là điểm nhiều team quên và kiến trúc "thối" dần.
  • Hiện thực hóa Infrastructure ở vòng ngoài. Viết class cụ thể implement các interface: PostgresOrderRepository, VNPayGateway. Chúng được inject vào use case qua DI container.
  • Viết mapper giữa Domain và Persistence. Chấp nhận việc entity nghiệp vụ và bảng database là hai mô hình khác nhau. Viết mapper rõ ràng thay vì để ORM annotation xâm nhập Domain.
  • Test từ trong ra ngoài. Unit test cho Domain và Use Case dùng fake repository trong bộ nhớ, chạy cực nhanh, không cần database. Integration test riêng cho lớp Infrastructure.

Lỗi thường gặp & mẹo

  • Rò rỉ hạ tầng vào Domain. Đây là lỗi số một: đặt annotation @Entity của JPA, hoặc @Document của MongoDB, ngay lên entity nghiệp vụ. Ban đầu tiện, nhưng bạn vừa đóng đinh Domain vào một công nghệ cụ thể — đúng thứ kiến trúc này muốn tránh. Mẹo: tách entity persistence riêng, dùng mapper.
  • Anemic Domain Model (mô hình thiếu máu). Entity chỉ có getter/setter, còn toàn bộ logic dồn vào Service. Khi đó bạn có "cấu trúc Onion" nhưng không có "linh hồn Domain" — chỉ là thủ tục hóa trá hình. Mẹo: hỏi "quy tắc này có phải bản chất của entity không?" — nếu có, đặt nó vào entity.
  • Bùng nổ số lớp (layer explosion). Như Ví dụ 3, áp dụng máy móc cho mọi thứ. Mẹo: dùng "pragmatic Clean" — cho phép CRUD đơn giản đi tắt, chỉ dựng đầy đủ lớp cho các bounded context có nghiệp vụ phức tạp. Không phải mọi module trong hệ thống đều xứng đáng với chi phí này.
  • DTO ở mọi ranh giới một cách cứng nhắc. Đúng lý thuyết là mỗi ranh giới nên có DTO riêng, nhưng thực tế nếu team nhỏ, việc chia sẻ một model giữa hai lớp gần nhau đôi khi chấp nhận được. Mẹo: cân nhắc chi phí bảo trì thực tế, đừng thờ phụng lý thuyết.
  • Quên bảo vệ bằng công cụ kiểm tra kiến trúc. Kiến trúc không có công cụ kiểm tra tự động sẽ bị bào mòn sau vài sprint dưới áp lực deadline. Mẹo: thêm ArchUnit/deptrac vào CI từ ngày đầu.
  • Nhầm Onion/Clean là bắt buộc phải microservices. Không. Các kiến trúc này áp dụng tuyệt vời bên trong một monolith. Thực ra một Modular Monolith với mỗi module theo Clean là lựa chọn rất mạnh cho nhiều team (chúng ta sẽ bàn kỹ ở Bài 18).

Bài tập thực hành

Bài tập 1 — Nhận diện phụ thuộc sai hướng. Cho một service đặt phòng khách sạn, trong đó class DatPhongService (thuộc lớp Application) có dòng import com.stripe.StripeClient và gọi trực tiếp Stripe. Hãy vẽ lại sơ đồ phụ thuộc hiện tại, chỉ ra mũi tên nào vi phạm Dependency Rule, và đề xuất interface cần tạo để sửa. Viết tên interface và lớp implement tương ứng.

Bài tập 2 — Ra quyết định áp dụng. Bạn có 3 dự án: (a) một blog cá nhân, (b) một hệ thống core banking tính lãi cho vay, (c) một dashboard nội bộ hiển thị số liệu bán hàng từ một database có sẵn. Với mỗi dự án, quyết định: áp dụng Clean/Onion đầy đủ, áp dụng một phần, hay không áp dụng? Viết 2-3 câu lý do cho mỗi lựa chọn, dựa trên độ phức tạp nghiệp vụ và khả năng thay đổi hạ tầng.

Bài tập 3 — Viết mini-ADR. Chọn một service thật bạn đang làm (hoặc dự án giả định). Viết một ADR ngắn (theo cấu trúc đã học ở Bài 4) cho quyết định "Áp dụng Onion Architecture cho service X". Phải nêu rõ: bối cảnh, các phương án thay thế (bao gồm cả "không áp dụng"), quyết định, và hậu quả tích cực lẫn tiêu cực (đặc biệt là chi phí mapping và số lớp tăng thêm).

Tóm tắt

  • Onion Architecture (Palermo, 2008) và Clean Architecture (Uncle Bob, 2012) là hai cách diễn đạt tinh chỉnh của cùng một tư tưởng: đặt business logic vào trung tâm, đẩy hạ tầng ra rìa.
  • Xương sống của cả hai là Dependency Rule: mọi phụ thuộc source code chỉ trỏ vào trong, đạt được nhờ Dependency Inversion (Domain định nghĩa interface, Infrastructure implement).
  • Onion nhấn mạnh các lớp đồng tâm (Domain → Domain Services → App Services → Infrastructure); Clean chuẩn hóa tên lớp (Entities → Use Cases → Interface Adapters → Frameworks) và thêm quy tắc vượt ranh giới. Cùng họ với Hexagonal ở Bài 14.
  • Lợi ích thực tế: đổi cổng thanh toán, thay database, viết test nhanh — vì thay đổi chỉ đụng vào vỏ ngoài. Các ví dụ ShopViet và fintech Đông Nam Á minh họa rõ điều này.
  • Cái giá: lớp mapping dày hơn, số file nhiều hơn, rủi ro over-engineering với các module CRUD đơn giản.
  • Nguyên tắc ra quyết định: áp dụng đầy đủ khi nghiệp vụ phức tạp và hạ tầng có khả năng đổi; đi tắt khi module đơn giản. Luôn ghi lại quyết định bằng ADR, và bảo vệ ranh giới bằng công cụ kiểm tra kiến trúc tự động trong CI.