Clean Architecture
10 Phần IV · Kiến trúc Chương 21 · 22 · 23 · 26

The Clean Architecture

Screaming · Clean · Humble Object · Main

Đây là trái tim của cả cuốn sách: hợp nhất Hexagonal, Onion, DCI, BCE thành bốn vòng tròn đồng tâm. Một quy tắc duy nhất buộc tất cả vào khuôn — The Dependency Rule: mọi phụ thuộc mã nguồn chỉ được trỏ VÀO TRONG, hướng về policy cấp cao. Vòng trong không biết gì về vòng ngoài. Tách được như thế, hệ thống sinh ra test được, dễ thay DB/web/framework mà không động tới nghiệp vụ.

01 Screaming Architecture — kiến trúc phải "HÉT LÊN"

Nhìn bản vẽ một căn nhà, bạn thấy ngay sảnh, phòng khách, bếp — bản vẽ "hét lên": NHÀ. Vậy kiến trúc ứng dụng của bạn đang hét lên điều gì?

Robert C. Martin · Chương 21

Mở thư mục cấp cao nhất của dự án ra. Nó hét lên "Hệ thống Y tế", "Hệ thống Kế toán", "Quản lý Kho" — hay chỉ hét lên "Rails", "Spring/Hibernate", "ASP"? Nếu cấu trúc thư mục chỉ phơi ra models / views / controllers, kiến trúc đang hét về framework chứ không về nghiệp vụ.

Kiến trúc tốt xoay quanh use case, để kiến trúc sư mô tả được cấu trúc hỗ trợ use case mà không cam kết sớm với framework, công cụ, môi trường. Framework là công cụ để dùng, không phải "lối sống" để tuân theo — "nếu kiến trúc dựa trên framework, nó không thể dựa trên use case của bạn".

Web là một detail

Web có phải là kiến trúc không? Không. Web chỉ là một cơ chế phân phối — một thiết bị I/O. Việc ứng dụng được phân phối qua web là một chi tiết, và là quyết định nên trì hoãn. Kiến trúc nên "ngu" nhất có thể về cách nó được phân phối: cùng một lõi nghiệp vụ phải chạy được dưới dạng console app, web app, thick-client, hay web service mà không phải đổi cấu trúc nền tảng.

Testable Architectures. Khi kiến trúc xoay quanh use case và giữ framework ở khoảng cách an toàn, bạn unit-test được toàn bộ use case mà không cần chạy web server, không cần kết nối database. Entities phải là plain old objects — không phụ thuộc framework hay DB.

02 Bốn vòng tròn đồng tâm

Clean Architecture hợp nhất Hexagonal (Ports & Adapters), Onion, DCI và BCE thành một ý tưởng khả thi duy nhất — mục tiêu chung: separation of concerns bằng cách chia phần mềm thành các tầng. Càng đi vào trong, phần mềm càng trừu tượng & cấp policy càng cao; vòng ngoài là cơ chế (mechanisms), vòng trong là chính sách (policies).

Frameworks & Drivers Web · DB · UI · Devices · External Interface Adapters Controllers · Presenters · Gateways Use Cases App-specific Business Rules Entities Critical Business Rules THE DEPENDENCY RULE phụ thuộc chỉ trỏ VÀO TRONG → Crossing Boundaries Controller → Use Case → Presenter control flow ra ngoài, source dependency vào trong (DIP)
Bốn vòng từ trong ra ngoài: Entities (Critical Business Rules — ít đổi nhất) → Use Cases (app-specific rules, điều phối entities) → Interface Adapters (Controllers/Presenters/Gateways — chuyển định dạng) → Frameworks & Drivers (Web/DB/UI/External — nơi mọi detail trú ngụ). Bốn vòng chỉ là sơ đồ — bạn có thể cần nhiều hơn, nhưng Dependency Rule luôn áp dụng.

Entities

Critical Business Rules

Quy tắc nghiệp vụ cấp doanh nghiệp, dùng được cho nhiều ứng dụng. Ít thay đổi nhất — đổi page navigation hay security không được động tới tầng này.

Use Cases

App-specific Business Rules

Điều phối luồng dữ liệu đến/đi từ entities, ra lệnh cho entities dùng Critical Business Rules để đạt mục tiêu. Cô lập khỏi DB, UI, framework.

Interface Adapters

Controllers · Presenters · Gateways

Chuyển dữ liệu từ định dạng tiện cho use case/entity sang định dạng tiện cho web/DB. Trọn bộ MVC của GUI nằm ở đây; mọi SQL cũng bị nhốt ở đây.

Frameworks & Drivers

Web · DB · UI · External

Vòng ngoài cùng — chỉ chứa glue code nói chuyện với vòng kế trong. "Web là detail. Database là detail." Giữ chúng ở ngoài để chúng ít gây hại nhất.

03 The Dependency Rule & Crossing Boundaries

The Dependency Rule: source code dependencies chỉ được trỏ VÀO TRONG, hướng về policy cấp cao hơn. Vòng trong không được biết bất cứ điều gì về vòng ngoài — kể cả tên của một class, function, biến khai báo ở vòng ngoài đều không được xuất hiện trong code vòng trong. Định dạng dữ liệu do framework vòng ngoài sinh ra cũng không được lọt vào vòng trong.

Nhưng đôi khi control flow cần đi ngược ra ngoài: use case cần gọi presenter (vòng ngoài). Gọi thẳng sẽ vi phạm Dependency Rule. Lời giải kinh điển ở góc dưới-phải sơ đồ: dùng Dependency Inversion Principle (DIP) — use case gọi một interface (vd "use case output port") nằm ở vòng trong, còn presenter ở vòng ngoài implement interface đó.

flowchart LR
  C["Controller
(adapter)"]:::out -- "control flow" --> UC["Use Case
Interactor"]:::core UC -- "control flow" --> P["Presenter
(adapter)"]:::out UC -. "định nghĩa interface" .-> OP{{"Output Port
(interface, vòng trong)"}}:::port P == "source dep: implements →" ==> OP classDef core fill:#c6d6e8,stroke:#2e5e8c,color:#14233b; classDef out fill:#e2edf3,stroke:#2f6d93,color:#14233b; classDef port fill:#14233b,stroke:#0c1830,color:#fffdf7;
Control flow vs source dependency ngược chiều nhau. Luồng điều khiển đi Controller → Use Case → Presenter (ra ngoài). Nhưng phụ thuộc mã nguồn của Presenter trỏ vào trong (Presenter implements Output Port do vòng trong khai báo). Nhờ dynamic polymorphism, mọi ranh giới đều tuân thủ Dependency Rule dù control flow đi hướng nào.

Dữ liệu băng ranh giới = struct đơn giản. Truyền qua boundary chỉ nên là simple data structures / structs / DTOs / plain old objects. TUYỆT ĐỐI không tuồn "row structure" của DB hay entity object vào trong — làm vậy ép vòng trong phải biết cấu trúc vòng ngoài, vi phạm Dependency Rule. Dữ liệu luôn ở dạng tiện nhất cho vòng trong.

RAG · core import abstraction, main ráp concrete

RAG  Use case AnswerQuestion (vòng trong) chỉ biết interface; FAISS & OpenAI (vòng ngoài) implement chúng. Tên faiss, openai không bao giờ xuất hiện trong tầng use case:

Python · use case (vòng trong) phụ thuộc abstraction
# Vòng trong — KHÔNG import faiss, openai, fastapi
class Retriever(Protocol):
    def search(self, q: str) -> list["Chunk"]: ...
class Generator(Protocol):
    def complete(self, prompt: str) -> str: ...

class AnswerQuestion:                       # Use Case Interactor
    def __init__(self, ret: Retriever, gen: Generator):
        self.ret, self.gen = ret, gen
    def run(self, q: str) -> str:           # control flow ra ngoài qua interface
        ctx = self.ret.search(q)            # source dep: trỏ VÀO TRONG
        return self.gen.complete(self.prompt(ctx, q))
Use case khai báo port; concrete (FAISS/OpenAI) ở vòng ngoài implement. Đổi vector store hay LLM = thay implementation ở ngoài, lõi không đổi một dòng.

04 Presenters & The Humble Object Pattern

Presenter là một dạng của Humble Object pattern — mẫu sinh ra để giúp unit tester tách hành vi KHÓ test khỏi hành vi DỄ test. Cách làm rất đơn giản: chia hành vi thành hai module. Một module humble chứa phần khó test đã được lược bỏ tối đa; module kia chứa toàn bộ phần dễ test vừa được tách ra.

Cả hai nửa đều là thiết kế tốt: gom phần khó test vào một vỏ "khiêm tốn" càng mỏng càng tốt, để phần còn lại test thoải mái — "khó test" ở đây là cố ý, không phải khuyết điểm.

Presenter — dễ test

  • testable object. Nhận dữ liệu từ ứng dụng và format để trình bày.
  • Đưa Date → chuỗi ngày; Currency → chuỗi tiền tệ có dấu thập phân; âm thì set cờ boolean để tô đỏ; nút bị mờ → cờ boolean.
  • Mọi thứ xuất hiện trên màn hình được nạp sẵn vào View Model dưới dạng string / boolean / enum.

View — humble, khó test

  • humble object, rất khó unit-test (khó viết test "nhìn" được màn hình).
  • Không chứa logic. Chỉ lấy dữ liệu từ View Model rồi đổ lên màn hình.
  • Vì mọi quyết định đã nằm sẵn trong View Model, View chẳng còn gì để làm — nên nó "khiêm tốn".

Humble Object xuất hiện ở MỌI ranh giới kiến trúc. Không chỉ Presenter/View: Database Gateways (interface đa hình CRUD, humble object ở tầng DB chạy SQL thật; interactor test được nhờ thay gateway bằng stub/test-double), Data Mappers / ORM như Hibernate (thực chất là data mapper, thuộc tầng DB, tạo một ranh giới Humble Object), Service Listeners (nhận dữ liệu service rồi format thành struct cho ứng dụng dùng). Ở mỗi boundary đều có một struct đơn giản băng qua, ngăn cách phần khó test với phần dễ test.

RAG · tách format câu trả lời (dễ test) khỏi gọi LLM (khó test)

RAG  LlmPresenter chỉ format ViewModel — thuần, test được. Phần gọi OpenAI qua mạng (khó test, không tất định) bị đẩy ra humble adapter ở vòng ngoài:

Python · Presenter dễ test vs Humble adapter khó test
# DỄ test — thuần, không I/O: format answer thành ViewModel
class LlmPresenter:
    def present(self, answer: str, sources: list[str]) -> "AnswerVM":
        return AnswerVM(
            text=answer.strip(),
            citation=", ".join(sources) or "—",
            is_empty=not answer.strip(),     # cờ boolean cho View tô trạng thái
        )

# KHÓ test — humble: chỉ gọi OpenAI rồi giao lại, không chứa logic
class OpenAiGenerator:                       # Frameworks & Drivers
    def complete(self, prompt: str) -> str:
        return openai.responses.create(model="gpt-4o", input=prompt).output_text
Logic đáng test (ghép citation, cờ is_empty) nằm trong Presenter thuần. Adapter gọi LLM bị lược tới mức tối thiểu — đúng tinh thần humble object, dễ thay bằng stub khi test use case.

05 The Main Component — kẻ "bẩn" nhất

Trong mọi hệ thống luôn có ít nhất một component tạo, điều phối & giám sát những component khác. Martin gọi nó là Main. Nó là the ultimate detaillowest-level policy, nằm ở vòng ngoài cùng của clean architecture.

Entry point

initial entry point

Điểm vào ban đầu của hệ thống. Không gì phụ thuộc vào nó ngoài hệ điều hành. Tạo xong mọi thứ rồi trao quyền điều khiển cho phần cấp cao trừu tượng.

Wire mọi thứ

Factories · Strategies · DI

Khởi tạo tất cả Factory, Strategy & tiện ích toàn cục. Đây là nơi Dependency Injection framework tiêm phụ thuộc — rồi Main phân phối tiếp không cần dùng framework nữa.

Main là Plugin

plugin to the application

Coi Main như plugin đặt điều kiện ban đầu rồi rút lui. Vì là plugin nên có nhiều Main: một cho Dev / Test / Production, một cho mỗi quốc gia / khu vực tài phán / khách hàng.

Hãy coi Main là kẻ bẩn nhất trong các component bẩn. Mọi chuỗi cấu hình, hằng số khởi tạo, chi tiết wiring mà thân code chính không nên biết đều dồn vào đây. Thích Spring? Tốt — nhưng đừng rải @Autowired khắp business object; chỉ để Spring tiêm vào Main. Business object không được biết gì về Spring; chỉ Main được phép biết, vì Main là component cấp thấp, bẩn nhất.

RAG · main ráp concrete, đẩy vào use case

RAG  Toàn bộ chi tiết "bẩn" (FAISS, OpenAI, FastAPI route) tập trung ở main; nó wire concrete vào use case rồi giao quyền. Bên PHP web chỉ chạm ranh giới service qua HTTP:

Python · main.py — kẻ bẩn nhất, wire mọi thứ
# main = ultimate detail: chỉ ở đây mới import concrete framework
from faiss_store import FaissRetriever
from openai_gen import OpenAiGenerator
from fastapi import FastAPI

def build_ask() -> AnswerQuestion:          # Factory: ráp concrete vào port
    return AnswerQuestion(FaissRetriever("idx.faiss"), OpenAiGenerator())

app = FastAPI()                             # Frameworks & Drivers
ask = build_ask()                          # wire xong, trao quyền cho use case
@app.post("/ask")
def ask_route(q: str): return {"answer": ask.run(q)}
Một Main cho Dev (retriever in-memory + LLM giả), một cho Production (FAISS + OpenAI) — đổi cấu hình mà không sờ vào lõi nghiệp vụ. Đó là sức mạnh "Main là plugin".
PHP · chỉ ở ranh giới service, gọi sang qua HTTP
// PHP CHỈ ở ranh giới — web là detail ở vòng ngoài cùng
$res = Http::post('http://rag-svc/ask', ['q' => $question]);
return view('answer', ['vm' => $res->json()]);  // View humble: chỉ đổ ViewModel
Web (PHP) là detail; lõi RAG không biết mình được phân phối qua web — đúng tinh thần "Web là một detail" ở chương 21.

06 Ghi nhớ nhanh

Kiến trúc phải HÉT LÊN về use case (Y tế / Kế toán / Kho), không hét về framework. Framework & web là detail — trì hoãn, giữ ở khoảng cách an toàn ⟹ test được không cần web/DB.

Bốn vòng đồng tâm: Entities → Use Cases → Interface Adapters → Frameworks & Drivers. Vào trong = policy cao hơn; ra ngoài = mechanism.

The Dependency Rule: source dependency CHỈ trỏ vào trong; vòng trong không biết tên gì ở vòng ngoài. Control flow ra ngoài thì dùng DIP để source dependency vẫn trỏ vào trong. Dữ liệu băng ranh giới = struct/DTO đơn giản.

Humble Object: tách phần KHÓ test (View, gọi DB/LLM) khỏi phần DỄ test (Presenter format ViewModel). Xuất hiện ở mọi ranh giới: gateways, ORM, service listeners.

Main = component cấp thấp, bẩn nhất. Entry point, tạo & wire mọi Factory/Strategy/DI rồi trao quyền cho policy cấp cao. Là plugin ⟹ nhiều Main cho Dev/Test/Prod, từng khách hàng.

NguồnChương 21 (Screaming Architecture), 22 (The Clean Architecture), 23 (Presenters and Humble Objects) & 26 (The Main Component), Clean Architecture — Robert C. Martin, Prentice Hall 2017.