Clean Architecture
08 Phần IV · Kiến trúc Chương 17 · 18 · 24

Ranh giới

Boundaries · Anatomy · Partial

Kiến trúc phần mềm là nghệ thuật vạch những lằn ranh (drawing lines). Mỗi ranh giới chia tách hai phía và hạn chế bên này biết quá nhiều về bên kia — để trì hoãn & cô lập các quyết định. Vẽ đúng chỗ, hệ thống còn mềm; vẽ sai chỗ (hay không vẽ), nó hóa "hardware". Chương này: vẽ ở đâu, băng qua ranh giới thế nào, và khi nào nên vẽ "nửa vời".

01 Vạch lằn ranh ở đâu — và khi nào

Kiến trúc phần mềm là nghệ thuật vạch những đường kẻ mà tôi gọi là ranh giới. Chúng chia tách các thành phần và hạn chế bên này biết về bên kia.

Robert C. Martin · Chương 17

Bạn vạch lằn ranh giữa thứ quan trọngthứ không quan trọng. GUI không quan trọng với business rules → có một lằn ranh. Database không quan trọng với business rules → có một lằn ranh. Vẽ sớm để trì hoãn quyết định càng lâu càng tốtkhông để chúng làm ô nhiễm core business logic.

Cái gì rút cạn nhân lực? Coupling — nhất là coupling vào quyết định non (premature decisions): framework, DB, web server, DI… những thứ chẳng liên quan tới business requirements. Kiến trúc tốt khiến các quyết định ấy trở thành phụ & trì hoãn được (ancillary and deferrable).

Hướng phụ thuộc luôn trỏ về business rules

Đặt database sau một interface: BusinessRules dùng DatabaseInterface để load/save; DatabaseAccess hiện thực interface đó và điều khiển Database thật. Lằn ranh cắt ngang quan hệ kế thừa, ngay dưới interface — và mọi mũi tên phụ thuộc trỏ về phía business.

flowchart RL
  subgraph BIZ["Component · BusinessRules  (policy — quan trọng)"]
    BR["BusinessRules"]:::core
    DI["«interface»
DatabaseInterface"]:::core BR --> DI end subgraph DBC["Component · Database  (detail / plugin)"] DA["DatabaseAccess"]:::out DB[("Database
Oracle · MySQL · file")]:::out DA --> DB end DA -. "implements" .-> DI DBC ==>|"phụ thuộc trỏ về business"| BIZ classDef core fill:#dce7f1,stroke:#2e5e8c,color:#14233b; classDef out fill:#e2edf3,stroke:#2f6d93,color:#14233b;
Database biết về BusinessRules; BusinessRules KHÔNG biết về Database. Database "không thể tồn tại nếu thiếu BusinessRules" — vì chính code dịch lời gọi của business sang query language nằm ở phía Database. Nhờ vậy thay Oracle ⟶ MySQL ⟶ flat file mà core không hề hay biết.

Ở một dự án thật, đội của Martin không có database trong 18 tháng đầu phát triển: không lo schema, query, connection, password… và test chạy rất nhanh. Vạch lằn ranh giúp delay & defer quyết định DB — tiết kiệm khổng lồ thời gian và phiền toái.

02 Database & GUI chỉ là detail — Plugin architecture

Khách hàng & lập trình viên hay lẫn lộn "hệ thống là gì": họ thấy GUI và tưởng GUI chính là hệ thống. Họ quên một nguyên lý cốt yếu: IO is irrelevant. Đằng sau giao diện luôn có một model — tập data structure & hàm — và model ấy không cần giao diện để vận hành.

Database

a tool, behind an interface

Business rules không cần biết schema, query language hay bất kỳ chi tiết DB nào — chỉ cần biết có một bộ hàm để fetch/save. Đặt sau interface ⟶ thay bằng SQL · NoSQL · file system tùy ý.

GUI

a presentation detail

GUI phụ thuộc vào BusinessRules, không ngược lại. Thành phần kém-liên-quan phụ thuộc thành phần quan-trọng-hơn ⟶ thay web bằng console, SOA hay service mà business không đổi.

Cộng hai quyết định DB & GUI lại, ta được Plugin Architecture: core business rules tách biệt & độc lập với các thành phần tùy chọn / có thể hiện thực nhiều kiểu. Quan hệ bất đối xứng (asymmetric): business miễn nhiễm với thay đổi của plugin, nhưng plugin sẽ chết nếu core đổi (giống ReSharper phụ thuộc Visual Studio).

Tổ chức kiểu plugin tạo firewalls chặn thay đổi lan truyền: đổi format trang web không phá business rules. Ranh giới được vạch tại axis of change — nơi hai phía đổi với tốc độ & lý do khác nhau. Đây chính là SRP nói cho ta biết vẽ ranh giới ở đâu.

RAG · VectorStore & LLM là plugin

RAG  FAISS, OpenAI là detail. Để RAG core phụ thuộc vào interface chứ không vào FAISS/OpenAI cụ thể; phụ thuộc cài đặt trỏ ngược về core:

Python · core ⟵ plugin (FAISS / OpenAI cắm vào)
# --- RAG core (policy) — KHÔNG import faiss, không import openai ---
class VectorStore(Protocol):
    def search(self, q: str, k: int) -> list[str]: ...
class LLM(Protocol):
    def generate(self, prompt: str) -> str: ...

class AskUseCase:                       # core chỉ biết interface
    def __init__(self, store: VectorStore, llm: LLM): ...

# --- plugin (detail) — phụ thuộc TRỎ VỀ core ---
class FaissStore(VectorStore): ...      # cắm vào, rút ra tùy ý
class OpenAILLM(LLM): ...               # đổi sang Llama = thay 1 plugin
Đổi FAISS ⟶ pgvector, OpenAI ⟶ Llama chỉ là thay plugin; AskUseCase không hề hay biết. Đó là firewall: thay detail không phá core.

03 Giải phẫu băng ranh giới — chi phí tăng dần

Tại runtime, băng qua ranh giới (boundary crossing) chỉ là một hàm bên này gọi hàm bên kia và truyền kèm dữ liệu. Bí quyết là quản lý source code dependencies để dựng firewall chống thay đổi. Có nhiều "độ chắc vật lý" của ranh giới — chi phí giao tiếp càng cao thì cô lập càng mạnh:

Loại ranh giớiCách băng quaChi phí giao tiếpĐộ chắc / độc lập
Monolith
source-level
Function call, cùng address space; quản lý qua dynamic polymorphismrất thấp — chatty thoải máiThấp (ranh giới vô hình khi deploy, nhưng vẫn có thật)
Deployment
jar · DLL · gem · .so
In-process call qua dynamically-linked library; deploy = gom binary, không compile lạithấpTrung bình (binary tách rời, vẫn chung process)
Local process
socket · shared mem
Tiến trình riêng, separate address space; qua socket / message queue / OStrung bình — hạn chế chattyCao (OS call, marshal, context switch)
Service
network
Mọi giao tiếp qua network packet; chục ms → vài giâycao — phải lo latencyRất cao — độc lập nhất
flowchart LR
  M["Monolith
function call"]:::core --> D["Deployment
jar / DLL"]:::mid --> P["Local process
socket / IPC"]:::mid --> S["Service
network"]:::out classDef core fill:#dce7f1,stroke:#2e5e8c,color:#14233b; classDef mid fill:#e2edf3,stroke:#2f6d93,color:#14233b; classDef out fill:#e2edf3,stroke:#2f6d93,color:#14233b;
Trái → phải: chi phí & độ trễ TĂNG, độ độc lập vật lý TĂNG. Threads không nằm trên trục này — chúng không phải ranh giới hay đơn vị triển khai, chỉ là cách tổ chức lịch thực thi, có thể xuyên nhiều component.

Hệ thống thật trộn nhiều loại ranh giới: một service thường chỉ là facade cho một nhóm local process; mỗi process lại là một monolith (source-level) hoặc tập deployment component. Dù ở mức nào, luật bất biến vẫn vậy: source dependency luôn trỏ về higher-level component (policy).

RAG · ranh giới service PHP ↔ Python

RAG  PHP (web) gọi pipeline Python qua HTTP = service-level boundary: chi phí cao (network, latency) nhưng cô lập chắc nhất, độc lập cả source lẫn binary. Vì đắt nên tránh chatty — gộp một request thay vì gọi qua lại nhiều lần:

PHP · CHỈ ở ranh giới service — một lần băng qua, không chatty
// PHP CHỈ ở ranh giới network — không nhúng logic pipeline
// Một lần băng ranh giới gói trọn ngữ cảnh (tránh latency dồn)
$res = Http::timeout(30)->post('http://rag-svc/ask', [
    'q' => $question, 'top_k' => 5, 'lang' => 'vi',
]);
return $res->json('answer');   // service-level: chậm nhất, chắc nhất
// Bên trong rag-svc, các use case vẫn chung 1 address space (source-level).
Một ranh giới service đặt đúng chỗ (PHP↔Python) là đủ. Đừng vì "cho hiện đại" mà chẻ pipeline Python thành 5 micro-service — đẩy chi phí lên mà chẳng thêm cô lập cần thiết.

04 Ranh giới một phần — rẻ hơn full boundary

Một full-fledged boundary rất đắt: cần reciprocal polymorphic interfaces (hai chiều), Input/Output data structures, và quản lý phụ thuộc để tách hai phía thành component compile & deploy độc lập. Tốn công dựng tốn công bảo trì. Khi thấy quá đắt nhưng vẫn muốn chừa chỗ, kiến trúc sư dùng partial boundary làm placeholder. Ba kiểu:

Skip the Last Step

làm hết, để chung 1 component

Làm gần hết: interface hai chiều, I/O struct đủ cả — nhưng compile & deploy chung MỘT component, bỏ bước tách cuối. Hợp "download & go". Rủi ro: dễ thoái hóa nhất — phụ thuộc dần xuyên ranh giới sai hướng (như FitNesse), tách lại sau cực khổ.

One-Dimensional

Strategy pattern

Chỉ một ServiceBoundary interface (Strategy): client dùng, ServiceImpl hiện thực. Đã có dependency inversion cô lập client khỏi impl — rẻ & nhanh hơn full. Thiếu: chiều cô lập ngược (reciprocal), nên dễ rò data struct, dễ vỡ nếu thiếu kỷ luật.

Facade

rẻ nhất · yếu nhất

Chỉ một lớp Facade liệt kê mọi service làm method, điều phối lời gọi tới các service class. Hi sinh cả dependency inversion. Client có transitive dependency vào mọi service class → đổi service buộc client recompile; backchannel dễ mọc.

Vì sao dùng partial boundary

  • Rẻ hơn nhiều full boundary cả lúc dựng lẫn lúc bảo trì.
  • Giữ chỗ (placeholder) cho một full boundary trong tương lai.
  • Vẫn giữ phụ thuộc trỏ về higher-level policy (trừ Facade hi sinh inversion).

Cái giá phải canh chừng

  • Mọi partial boundary đều có thể thoái hóa (degrade) nếu ranh giới đầy đủ chẳng bao giờ tới.
  • Thiếu kỷ luật source-control ⟶ phụ thuộc xuyên sai hướng, lằn ranh tan biến.
  • Facade: thay đổi service class lan ngược ra client (recompile).

RAG · reranker chưa tách hẳn = one-dimensional

RAG  Chưa chắc cần tách reranker thành service riêng, nhưng muốn chừa chỗ. Dựng một-chiều (Strategy): chỉ một interface, dependency inversion sẵn — chưa tốn full boundary:

Python · one-dimensional boundary cho reranker
# Strategy: 1 interface, dependency inversion sẵn — placeholder cho full boundary
class Reranker(Protocol):                       # ServiceBoundary
    def rerank(self, q: str, docs: list[str]) -> list[str]: ...

class NoopReranker(Reranker):                   # ServiceImpl mặc định
    def rerank(self, q, docs): return docs

# Core dùng interface, chưa tách thành component/deploy riêng (skip last step).
# Khi friction xuất hiện ⟶ nâng lên full boundary; chưa thì giữ rẻ.
Thiếu chiều ngược (reciprocal): nếu lỏng tay, NoopReranker có thể rò cấu trúc dữ liệu ra client. Giữ kỷ luật cho tới điểm uốn rồi mới nâng cấp.

05 YAGNI vs chừa chỗ — quyết ở điểm uốn

Cộng đồng Agile hay phản đối việc dựng sẵn ranh giới: vi phạm YAGNI ("You Aren't Going to Need It"). Nhưng kiến trúc sư đôi khi nhìn vấn đề và nghĩ: "Ừ, nhưng có thể tôi sẽ cần." Đó là lúc cân nhắc một partial boundary.

Hỡi Kiến trúc sư, anh phải thấy tương lai. Anh phải đoán — một cách thông minh. Cân đo chi phí, quyết định ranh giới nào dựng đầy đủ, nào một phần, nào bỏ qua.

Diễn giải tinh thần Chương 24

Đây không phải quyết định một lần. Bạn quan sát hệ thống tiến hóa, để ý nơi có thể cần ranh giới, canh chừng dấu hiệu ma sát (friction) đầu tiên vì thiếu nó. Mục tiêu: dựng ranh giới ngay tại điểm uốn (inflection point) — nơi chi phí dựng trở nên thấp hơn chi phí tiếp tục bỏ qua. Dựng quá sớm = phí (YAGNI); quá muộn = đã thoái hóa, gỡ rất đắt.

Phía bên kia là cái bẫy "skip the last step" của FitNesse: vì ranh giới giữa web component & wiki chẳng bao giờ thành hiện thực, nó dần yếu đi, phụ thuộc xuyên sai hướng, và nay tách lại là một cực hình. Bài học: partial boundary là lời hứa cần kỷ luật giữ — hoặc nâng lên full đúng lúc, hoặc nó sẽ tan.

06 Ghi nhớ nhanh

Vạch ranh giữa thứ quan trọng (business rules) & thứ không (GUI/DB/framework — detail). Mục đích: trì hoãn & cô lập quyết định, chống coupling vào quyết định non.

Hướng phụ thuộc luôn trỏ về business rules. DB & GUI là plugin cắm vào core; firewall chặn thay đổi lan truyền. SRP cho biết vẽ ranh giới ở đâu (axis of change).

Băng ranh giới có nhiều độ chắc: monolith (function call, rẻ) → deployment (jar/DLL) → local process (socket) → service (network, đắt nhất, độc lập nhất). Threads KHÔNG phải ranh giới.

Full boundary đắt (interface hai chiều + I/O struct + tách deploy). Ba partial rẻ hơn: skip last step · one-dimensional (Strategy) · Facade (rẻ nhất, yếu nhất, mất inversion).

Quyết ở điểm uốn. Đừng dựng sớm (YAGNI) cũng đừng để muộn (đã thoái hóa). Quan sát friction, dựng khi chi phí dựng < chi phí bỏ qua — và xem lại thường xuyên.

NguồnChương 17 (Boundaries: Drawing Lines), Chương 18 (Boundary Anatomy) & Chương 24 (Partial Boundaries), Clean Architecture — Robert C. Martin, Prentice Hall 2017.