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 17Bạn vạch lằn ranh giữa thứ quan trọng và thứ 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ốt và khô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;
Ở 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
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
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:
# --- 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
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ới | Cách băng qua | Chi phí giao tiếp | Độ chắc / độc lập |
|---|---|---|---|
| Monolith source-level | Function call, cùng address space; quản lý qua dynamic polymorphism | rất thấp — chatty thoải mái | Thấ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ại | thấp | Trung 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 / OS | trung bình — hạn chế chatty | Cao (OS call, marshal, context switch) |
| Service network | Mọi giao tiếp qua network packet; chục ms → vài giây | cao — phải lo latency | Rấ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;
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 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).
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 và 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 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
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
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:
# 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ẻ.
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.