Clean Architecture: Tại sao tầng dữ liệu không nên \"biết\" về tầng giao diện
7/19/2026 · 12p đọc
title: "Clean Architecture: Tại sao tầng dữ liệu không nên "biết" về tầng giao diện"
series: "Software Architecture Masterclass"
part: "Phần 2 — Cấu trúc & Patterns"
order: 17
audience: "Solutions Architect & Technical Lead"
reading_time: "13 phút"
tags: ["clean-architecture", "dependency-rule", "use-cases", "entities", "trade-off"]
Clean Architecture: Tại sao tầng dữ liệu không nên "biết" về tầng giao diện
Một hệ thống tính hoa hồng bán hàng có quy tắc nghiệp vụ cốt lõi: "nhân viên sales chỉ được tính hoa hồng nếu hợp đồng đã thanh toán đủ và chưa quá 90 ngày kể từ ngày ký". Quy tắc này — thứ đáng lẽ là tài sản quý giá nhất của công ty — nằm ở đâu trong codebase? Trong một stored procedure MySQL dài 200 dòng, viết bằng cú pháp SQL đặc thù của phiên bản database đang dùng. Khi công ty muốn viết một dịch vụ batch job tính lại hoa hồng hàng loạt, đội kỹ thuật không tái sử dụng được quy tắc đó — vì nó bị khoá cứng bên trong một stored procedure chỉ chạy được từ query, không gọi được như một hàm thuần túy, không test được ngoài môi trường có kết nối database thật.
Tình huống ngược lại cũng phổ biến không kém: cùng quy tắc đó, nhưng nằm trong một if lồng nhau ở controller Express, trộn lẫn với logic parse request, validate input, và format response JSON. Muốn viết unit test cho riêng quy tắc nghiệp vụ, kỹ sư buộc phải mock toàn bộ HTTP request/response — một khối lượng công việc giả lập không liên quan gì đến bản chất của quy tắc đang muốn kiểm tra.
Cả hai tình huống chia sẻ một căn bệnh chung: business rule bị nhúng chặt vào một chi tiết kỹ thuật (database engine cụ thể, hoặc web framework cụ thể) đến mức không thể tách ra để tái sử dụng hay test độc lập. Clean Architecture, do Robert C. Martin ("Uncle Bob") hệ thống hóa, là câu trả lời trực diện cho căn bệnh này — không phải bằng một công cụ mới, mà bằng một kỷ luật về hướng đi của dependency (sự phụ thuộc mã nguồn) trong toàn bộ hệ thống.
The Problem
Bài toán kiến trúc cụ thể: business rule — logic quyết định "điều gì đúng, điều gì được phép xảy ra trong nghiệp vụ" — bị nhúng trực tiếp vào một trong hai loại chi tiết kỹ thuật dễ biến động nhất của hệ thống:
- Nhúng vào stored procedure / trigger trong database. Quy tắc tính hoa hồng, quy tắc duyệt hợp đồng, quy tắc tính chiết khấu được viết bằng SQL thủ tục (PL/SQL, T-SQL, hay dialect riêng của engine). Hệ quả: quy tắc chỉ chạy được khi có kết nối tới đúng loại database đó, không gọi lại được từ context khác (batch job, service khác, test), và khi công ty cần đổi engine database (vì lý do chi phí, hiệu năng, hay hạ tầng) — toàn bộ business logic phải viết lại từ đầu bằng dialect mới.
- Nhúng vào controller của web framework. Quy tắc nghiệp vụ nằm chung khối code với logic parse HTTP request, validate DTO, serialize response. Hệ quả: muốn test riêng quy tắc, phải dựng toàn bộ HTTP context giả lập; muốn tái sử dụng quy tắc đó cho một kênh khác (CLI, message consumer, gRPC endpoint), phải copy-paste logic sang controller mới — và từ đó hai bản sao bắt đầu trôi dạt (drift) khỏi nhau theo thời gian.
Gốc rễ của cả hai trường hợp giống nhau: hướng phụ thuộc mã nguồn sai. Business rule — thứ lẽ ra phải là trung tâm ổn định nhất của hệ thống, thay đổi chậm nhất vì nó phản ánh bản chất nghiệp vụ — lại đang phụ thuộc vào chi tiết kỹ thuật biến động nhanh nhất (phiên bản database, phiên bản framework). Khi chi tiết kỹ thuật thay đổi, business rule bị kéo theo và phải sửa lại, dù bản thân quy tắc nghiệp vụ không hề đổi. Đây là quan hệ phụ thuộc ngược so với những gì nên xảy ra.
The Theory
Clean Architecture giải quyết vấn đề trên bằng một nguyên tắc duy nhất, gọi là The Dependency Rule (Quy tắc phụ thuộc): dependency mã nguồn chỉ được phép trỏ vào trong, hướng về các chính sách cấp cao hơn (higher-level policies). Không có ngoại lệ. Tầng ngoài — chứa chi tiết kỹ thuật dễ đổi (UI, database, framework, thiết bị bên ngoài) — được phép biết về và phụ thuộc vào tầng trong, nhưng tầng trong tuyệt đối không được biết tầng ngoài tồn tại.
Clean Architecture tổ chức hệ thống thành bốn vòng tròn đồng tâm, từ trong ra ngoài:
1. Entities (thực thể). Vòng trong cùng, chứa các quy tắc nghiệp vụ tổng quát nhất — những gì đúng bất kể ứng dụng cụ thể nào đang chạy, bất kể có web hay không có web. Ví dụ: quy tắc "hợp đồng chỉ được kích hoạt nếu đã ký đủ chữ ký các bên" là một bất biến (invariant) thuộc về khái niệm Contract tự thân, không phụ thuộc vào việc hệ thống hiện tại có API hay chạy dạng CLI.
2. Use Cases (ca sử dụng). Vòng thứ hai, chứa business rule đặc thù cho ứng dụng (application-specific business rules) — cách các Entity phối hợp với nhau để thực hiện một luồng nghiệp vụ cụ thể. Ví dụ: use case CalculateCommission điều phối việc đọc Contract, kiểm tra điều kiện thanh toán, áp dụng công thức tính hoa hồng lên Order. Use case không biết dữ liệu đến từ MySQL hay PostgreSQL, không biết kết quả sẽ trả về qua REST hay message queue.
3. Interface Adapters (bộ chuyển đổi giao diện). Vòng thứ ba, chuyển đổi dữ liệu qua lại giữa định dạng thuận tiện cho Use Case/Entity và định dạng thuận tiện cho công cụ bên ngoài — Controller (nhận HTTP request, gọi use case), Presenter (định dạng kết quả use case thành response), Repository implementation (hiện thực hoá interface truy cập dữ liệu mà Use Case định nghĩa).
4. Frameworks & Drivers (framework và trình điều khiển). Vòng ngoài cùng — nơi mọi chi tiết kỹ thuật cụ thể sinh sống: web framework (Express, NestJS...), ORM, database engine, message broker client. Đây là tầng biến động nhanh nhất và cũng là tầng ít quan trọng nhất về mặt nghiệp vụ.
Cơ chế kỹ thuật để giữ Dependency Rule không bị phá vỡ là Dependency Inversion Principle áp dụng ở ranh giới vòng tròn: Use Case định nghĩa một interface (ví dụ ContractRepository với phương thức findById, save) — interface này nằm ở vòng trong, thuộc sở hữu của Use Case. Interface Adapter ở vòng ngoài implement interface đó bằng công nghệ cụ thể (ContractRepositoryTypeORM, ContractRepositoryPrisma...). Nhờ vậy, tại thời điểm biên dịch, Use Case chỉ phụ thuộc vào interface trừu tượng do chính nó định nghĩa — không phụ thuộc vào implementation cụ thể nào ở tầng ngoài. Luồng gọi hàm lúc chạy (runtime) đi ra ngoài (Use Case gọi Repository thật để lấy dữ liệu), nhưng luồng phụ thuộc mã nguồn lúc biên dịch (compile-time dependency) đi vào trong — đây chính là "inversion" (đảo ngược) trong tên gọi nguyên tắc.
Ranh giới giữa các vòng thường được vượt qua bằng DTO (Data Transfer Object) hoặc plain object đơn giản, không bao giờ truyền thẳng entity ORM hay HTTP request object xuyên qua ranh giới — vì làm vậy sẽ vô tình kéo chi tiết kỹ thuật của vòng ngoài rò rỉ vào vòng trong.
The Trade-off
Được gì:
- Business logic sống sót qua các cuộc di dời framework. Khi công ty quyết định đổi từ REST sang GraphQL, đổi ORM, hay thậm chí viết lại toàn bộ tầng UI, Entities và Use Cases không hề bị động đến — vì chúng không import bất cứ thứ gì từ tầng ngoài. Chi phí thay framework giảm từ "viết lại toàn bộ hệ thống" xuống "viết lại tầng Adapter".
- Test business rule không cần hạ tầng thật. Vì Use Case chỉ phụ thuộc vào interface (không phụ thuộc implementation cụ thể), unit test có thể inject một fake/in-memory implementation của
ContractRepository— test chạy trong mili-giây, không cần database, không cần HTTP server, không cần network. - Ranh giới trách nhiệm rõ ràng, review code nhanh hơn. Khi thấy một file nằm trong thư mục
use-cases/, reviewer biết ngay không được phép thấyimport from 'typeorm'ở đó — vi phạm là tín hiệu rõ ràng để reject PR, không cần tranh luận chủ quan. - Cùng một business logic tái sử dụng được cho nhiều kênh vào/ra. Use case
CalculateCommissionviết một lần, gọi được từ REST controller, từ cron job batch, từ message consumer — không có bản sao trôi dạt khỏi nhau.
Mất gì — và đây là phần thường bị giấu:
- Số lượng interface và lớp trung gian tăng đáng kể so với viết thẳng. Một thao tác CRUD đơn giản — vốn dĩ trong một framework thông thường chỉ cần một service gọi thẳng ORM — nay cần: một interface Repository trừu tượng, một implementation cụ thể, một DTO ở ranh giới vào, một DTO/Presenter ở ranh giới ra, một Use Case class điều phối. Với một tính năng CRUD không có quy tắc nghiệp vụ gì đặc biệt, khối lượng boilerplate này không đổi lấy được giá trị tương xứng.
- Setup Dependency Injection (DI) phức tạp hơn, đòi hỏi kỷ luật nhất quán. Muốn Interface Adapter implement interface do Use Case định nghĩa nhưng vẫn được "nối dây" đúng lúc chạy, cần một DI container được cấu hình cẩn thận (binding interface với implementation cụ thể). Thiếu kỷ luật, đội ngũ dễ "tiện tay" import thẳng ORM entity vào Use Case cho nhanh — và một khi việc đó xảy ra một lần, Dependency Rule vỡ âm thầm, không có compiler nào cảnh báo trừ khi có lint rule riêng chặn import theo thư mục.
- Đường đi của một request dài hơn, khó truy vết hơn với người mới. Một request HTTP đơn giản giờ đi qua Controller → DTO chuyển đổi → Use Case → Interface Repository → Repository implementation → ORM → database, rồi đi ngược lại qua Presenter → Controller → HTTP response. So với việc viết thẳng logic trong controller, số bước nhảy giữa các file tăng lên, và kỹ sư mới join cần thời gian dài hơn để "đọc hiểu" một luồng nghiệp vụ hoàn chỉnh.
- Lợi ích chỉ thành hiện thực nếu framework/database thực sự thay đổi — điều nhiều hệ thống không bao giờ trải qua. Nếu công ty biết chắc sẽ dùng NestJS + PostgreSQL suốt vòng đời sản phẩm, khoản đầu tư tách biệt tầng để "phòng khi đổi framework" không bao giờ hoàn vốn — nó chỉ là chi phí chìm (sunk cost) dưới danh nghĩa "kiến trúc tốt".
Nói ngắn gọn: Clean Architecture đánh đổi tốc độ viết code ban đầu và số lượng file/interface phải duy trì để lấy khả năng sống sót của business logic trước biến động công nghệ tầng ngoài — một khoản đầu tư chỉ đáng giá khi hệ thống đủ sống lâu và đủ khả năng phải đổi chi tiết kỹ thuật (framework, database, kênh giao tiếp) ít nhất một lần trong vòng đời của nó.
The Architecture Diagram
flowchart TB
subgraph L4["Frameworks & Drivers (vòng ngoài cùng)"]
direction LR
WEB["Web Framework<br/>(NestJS Controller, Express Router)"]
ORM["ORM / DB Driver<br/>(TypeORM, Prisma)"]
UI["UI / CLI / Message Consumer"]
end
subgraph L3["Interface Adapters"]
direction LR
CTRL["Controller<br/>(nhận request, gọi Use Case)"]
PRES["Presenter<br/>(định dạng response)"]
REPO_IMPL["Repository Implementation<br/>(ContractRepositoryTypeORM)"]
end
subgraph L2["Use Cases (application business rules)"]
direction LR
UC["CalculateCommission<br/>ActivateContract"]
IREPO["ContractRepository<br/>(interface, do Use Case định nghĩa)"]
end
subgraph L1["Entities (enterprise business rules)"]
direction LR
ENT["Contract, Order, Commission<br/>+ invariant nghiệp vụ thuần"]
end
UI --> WEB --> CTRL
ORM --> REPO_IMPL
CTRL -->|"gọi qua interface"| UC
UC --> ENT
UC -.implements.-> IREPO
REPO_IMPL -.implements.-> IREPO
UC --> PRES
style L1 fill:#14532d,color:#fff
style L2 fill:#1e3a5f,color:#fff
style L3 fill:#78350f,color:#fff
style L4 fill:#7f1d1d,color:#fff
Mũi tên phụ thuộc mã nguồn (compile-time) chỉ đi từ vòng ngoài vào vòng trong — REPO_IMPL implement IREPO (interface do Use Case sở hữu), không phải ngược lại. Runtime, dữ liệu vẫn chảy hai chiều bình thường qua các lớp đó — nhưng hướng phụ thuộc mã nguồn không bao giờ đảo chiều.
Implementation Guardrails
- Entities và Use Cases không được import bất cứ thứ gì từ web framework hay ORM. Không
import { Controller } from '@nestjs/common'trong thư mụcentities/hayuse-cases/, khôngimport { Entity, Column } from 'typeorm'trong định nghĩa domain entity. Nếu Use Case cần lưu dữ liệu, nó chỉ được gọi qua interface do chính nó định nghĩa (ContractRepository), không bao giờ gọi thẳng ORM. - Ranh giới vào/ra giữa các vòng chỉ trao đổi bằng DTO/plain object đơn giản, không bao giờ truyền thẳng entity ORM hay HTTP Request object. Controller nhận
req.body, tự chuyển đổi thành input DTO thuần trước khi gọi Use Case — không truyền thẳngRequestcủa Express/NestJS vào Use Case, vì làm vậy sẽ kéo theo toàn bộ dependency của framework đi cùng. - Mọi interface truy cập dữ liệu (Repository) phải được định nghĩa ở tầng Use Case, implementation cụ thể nằm ở tầng Interface Adapter. Đổi database engine đồng nghĩa với viết một implementation mới cho cùng interface — Use Case không cần sửa một dòng nào.
- Thiết lập lint rule hoặc kiểm tra kiến trúc tự động (architecture test) chặn import trái phép giữa các thư mục vòng tròn. Kỷ luật Dependency Rule không được đảm bảo bởi compiler thông thường — cần công cụ kiểm tra dependency giữa module (ví dụ rule ESLint giới hạn import theo path, hoặc test kiến trúc dùng công cụ phân tích dependency graph) chạy trong CI, vì chỉ cần một import "tiện tay" lọt qua review, ranh giới sẽ vỡ âm thầm và không ai phát hiện cho đến khi quá muộn.
Architect's Verdict
Clean Architecture không phải công cụ để "code cho đẹp" — nó là một khoản bảo hiểm cho business logic trước biến động công nghệ tầng ngoài, và như mọi bảo hiểm, nó có phí bảo hiểm phải trả đều đặn (interface, DTO, DI setup) dù rủi ro có xảy ra hay không. Với hệ thống lõi mang tính sống còn của doanh nghiệp — nơi business rule phức tạp, thay đổi framework/database là viễn cảnh thực tế, và tuổi thọ hệ thống tính bằng nhiều năm — kỷ luật này đáng giá từng dòng interface. Với một công cụ nội bộ ngắn hạn, một MVP đang tìm thị trường, hay một service phụ trợ ít business logic, áp đầy đủ bốn vòng tròn Clean Architecture là nghi thức thừa — hãy để business logic sống trong service đơn giản, và dành kỷ luật này cho nơi nó thực sự bảo vệ được thứ gì đó.
🔗 Bài viết liên quan
Bài trước: Hexagonal Architecture (Ports & Adapters) · Bài tiếp theo: CQRS (Command Query Responsibility Segregation)