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 21Mở 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).
Entities
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
Đ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
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
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;
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:
# 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))
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
- Là 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
- Là 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:
# 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
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 detail — lowest-level policy, nằm ở vòng ngoài cùng của clean architecture.
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ứ
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
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:
# 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)}
// 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
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.