01 Layers & Boundaries — ranh giới là quyết định chi phí
Ta dễ hình dung mọi hệ thống chỉ gồm ba thành phần: UI · business rules · database. Với vài hệ thống đơn giản, thế là đủ. Nhưng với hầu hết hệ thống, số ranh giới thực sự lớn hơn nhiều — và Martin dùng game Hunt the Wumpus (1972) để phơi bày điều đó.
Một game tưởng chừng "ba lớp" lại lộ ra hàng loạt ranh giới tiềm năng: Language boundary (tách Language khỏi Game Rules qua API để chơi nhiều thứ tiếng), Text/GUI boundary (tách Text Delivery — console, SMS, chat — khỏi Language), và cả việc Game Rules tự nó tách thành Move Management (cơ chế bản đồ) vs Player Management (policy cấp cao hơn: máu, sự kiện).
flowchart TB EN["English"]:::out --> LANG ES["Spanish"]:::out --> LANG LANG["Language API"]:::mid CON["Console"]:::out --> TXT SMS["SMS · Chat"]:::out --> TXT TXT["Text Delivery API"]:::mid LANG --> GR TXT --> GR GR["Game Rules"]:::core GR --> MOVE["Move Management
(bản đồ, hang động)"]:::mid GR --> PLAYER["Player Management
(máu, thắng/thua)"]:::core GR --> STORE["Data Storage API"]:::mid STORE --> FLASH["Flash · Cloud · RAM"]:::out classDef core fill:#dce7f1,stroke:#2e5e8c,color:#14233b; classDef mid fill:#e2edf3,stroke:#2f6d93,color:#14233b; classDef out fill:#eef4f1,stroke:#8c8675,color:#14233b;
Architecture là về CHI PHÍ
Vạch một ranh giới kiến trúc — khi triển khai đầy đủ — là việc tốn kém (expensive). Nhưng bỏ qua nó cũng tốn kém: thêm vào sau này tốn gấp bội, ngay cả khi đã có test-suite đầy đủ và kỷ luật refactoring. Kiến trúc sư mắc kẹt giữa hai bờ vực:
Over-engineering
- Vạch quá nhiều ranh giới khi chưa cần.
- Tinh thần YAGNI: "You aren't going to need it".
- Lãng phí nguồn lực bảo trì cho trừu tượng vô dụng.
Under-engineering
- Bỏ qua ranh giới thực sự cần.
- Khi phát hiện cần, chi phí & rủi ro thêm vào rất cao.
- Cross-cutting change phải mổ khắp hệ thống.
Quyết định ranh giới là quyết định KHI NÀO (when), không phải có/không. Không phải chốt một lần đầu dự án. Bạn quan sát hệ thống tiến hóa, để ý "ma sát" đầu tiên do thiếu ranh giới, rồi cân chi phí triển khai so với chi phí tiếp tục bỏ qua — và xem lại quyết định ấy thường xuyên. Mục tiêu: vạch ranh giới đúng inflection point, nơi chi phí triển khai vừa rẻ hơn chi phí bỏ qua. Cần một con mắt cảnh giác (a watchful eye).
02 Service KHÔNG tự động là kiến trúc
Dùng service / micro-service không tự sinh ra kiến trúc tốt. Kiến trúc được định nghĩa bởi ranh giới tách high-level policy khỏi low-level detail và tuân Dependency Rule. Service chỉ "chia tách hành vi" mà không có ranh giới rõ ràng thì chẳng khác gì những lời gọi hàm đắt đỏ qua mạng (expensive function calls).
The Decoupling Fallacy
Tưởng service tách rời hoàn toàn vì chạy khác process/máy. Thực ra chúng vẫn coupling chặt qua dữ liệu chia sẻ: thêm một field vào record truyền giữa các service ⟶ mọi service đụng field đó phải đổi và phải đồng thuận về cách diễn giải.
Fallacy of Independent Dev & Deploy
Phát triển & triển khai độc lập chỉ có thật khi service không coupling về dữ liệu hay behavior. Còn coupling ⟶ vẫn phải phối hợp cực chặt. Interface của service cũng không "chặt chẽ hơn" interface của một function.
The Kitty Problem — cross-cutting concern
Hệ thống điều phối taxi gồm nhiều service: UI · Finder · Selector · Dispatcher. Nay thêm tính năng "giao mèo con" (Kitty): tài xế dị ứng mèo không được chọn; xe vừa chở mèo trong 3 ngày không phục vụ khách dị ứng. Bao nhiêu service phải đổi? Tất cả. Đây là bản chất của cross-cutting concern: functional decomposition rất dễ vỡ trước tính năng mới cắt ngang mọi hành vi.
flowchart TB
subgraph BAD["✗ Chỉ chia theo chức năng — Kitty cắt ngang TẤT CẢ"]
direction LR
UI1["UI"]:::bad --> FIN1["Finder"]:::bad --> SEL1["Selector"]:::bad --> DIS1["Dispatcher"]:::bad
end
subgraph GOOD["✓ Mỗi service có component bên trong theo Dependency Rule"]
direction TB
BASE["Abstract base classes
(logic chung)"]:::core
RIDES["Rides component"]:::mid
KITS["Kittens component
(thêm = jar/dll mới)"]:::mid
RIDES -.->|override| BASE
KITS -.->|override| BASE
end
BAD --> GOOD
classDef bad fill:#f4dcd5,stroke:#b23a2e,color:#14233b;
classDef core fill:#dce7f1,stroke:#2e5e8c,color:#14233b;
classDef mid fill:#e2edf3,stroke:#2f6d93,color:#14233b;
Kết luận: service hữu ích cho scalability & develop-ability, nhưng tự thân không phải yếu tố quan trọng về kiến trúc. Kiến trúc được định nghĩa bởi ranh giới vẽ bên trong hệ thống và các phụ thuộc cắt qua chúng — không phải bởi cơ chế vật lý mà các phần tử giao tiếp.
RAG · decoupling fallacy qua schema embedding
RAG Tách ingestion và query thành hai service trông như đã decouple. Nhưng cả hai vẫn coupling chặt qua một cross-cutting concern: schema/chiều của vector embedding. Đổi model embedding = field thay đổi = phải sửa cả hai, đồng bộ tuyệt đối:
# Service A · ingestion — ghi vector theo schema (model X, dim 1536)
def ingest(doc):
vec = embed(doc, model="text-embedding-3-small") # dim=1536
store.upsert(id=doc.id, vector=vec) # field "vector"
# Service B · query — đọc vector CÙNG schema để search
def query(q):
qv = embed(q, model="text-embedding-3-small") # PHẢI khớp dim=1536
return store.search(qv, top_k=5)
# Đổi sang model dim=3072 ⟹ sửa CẢ HAI service + re-index toàn bộ.
# Chạy khác process ≠ decoupled: chúng coupling qua schema embedding.
03 The Test Boundary — test ở vòng ngoài cùng
Sự thật bất ngờ: test là một phần của hệ thống và tham gia vào kiến trúc như mọi component khác. Về mặt kiến trúc, mọi loại test đều tương đương (unit, integration, acceptance, TDD, Cucumber…). Test tuân Dependency Rule — rất cụ thể, chi tiết, luôn phụ thuộc hướng vào trong.
Test là vòng tròn ngoài cùng (outermost circle) và là component cô lập nhất: không gì trong hệ thống phụ thuộc vào test. Chúng không cần cho vận hành, không user nào dựa vào — vai trò là hỗ trợ phát triển. Nhưng đừng vì thế mà coi test "nằm ngoài thiết kế" — đó là một quan điểm tai họa.
The Fragile Tests Problem
Khi test coupling chặt với cấu trúc hệ thống, chúng phải đổi cùng hệ thống. Một thay đổi nhỏ ở component chung có thể làm hàng trăm, hàng nghìn test vỡ. Hệ quả ngược đời: test mong manh khiến hệ thống cứng nhắc — dev sợ thay đổi vì biết một sửa đổi nhỏ sẽ phá 1000 test (hãy tưởng tượng cuộc nói chuyện với team marketing xin đổi navigation).
Phụ thuộc thứ dễ biến động
- Test business rules qua GUI: đi từ màn login, lần qua page structure.
- GUI là volatile — đổi login/navigation ⟶ vô số test vỡ.
- Structural coupling: mỗi class sản xuất có một class test, mỗi method một method test.
Quy tắc thiết kế số 1
- Đừng phụ thuộc vào những thứ dễ biến động (don't depend on volatile things).
- Thiết kế để business rules test được không cần GUI.
- Design for testability ngay từ đầu.
Giải pháp: Testing API
Tạo một Testing API chuyên dụng mà test dùng để xác minh business rules. Mục đích: decouple cấu trúc của test khỏi cấu trúc của ứng dụng — không chỉ tách test khỏi UI. API này ẩn cấu trúc ứng dụng khỏi test, để hai bên tiến hóa độc lập: production code ngày càng trừu tượng & tổng quát, còn test ngày càng cụ thể & chi tiết.
Testing API có "superpowers" (vượt qua ràng buộc security…) nên nếu lo ngại, hãy đặt nó vào một component riêng, triển khai độc lập, không lẫn vào production. Test không nằm ngoài hệ thống — chúng là phần phải được thiết kế tốt, nếu không sẽ mong manh, khó bảo trì, rồi bị vứt bỏ.
RAG · Testing API tách test khỏi FAISS/OpenAI thật
RAG Nếu test pipeline gọi thẳng FAISS index và OpenAI thật, chúng coupling cấu trúc với chi tiết hạ tầng volatile — đổi vendor/index là test vỡ hàng loạt. Dựng một Testing API cho phép xác minh business rule "retrieve đúng chunk" mà không chạm chi tiết thật:
# Testing API · "siêu năng lực" để dựng trạng thái + kiểm tra rule,
# KHÔNG đụng FAISS/OpenAI thật → test khỏi structural coupling.
class RagTestHarness:
def given_documents(self, *docs): ... # nạp vào fake vector store
def ask(self, q) -> Answer: ... # chạy use case qua cổng
def assert_cited(self, ans, doc_id): ... # xác minh business rule
# Test chỉ nói NGÔN NGỮ NGHIỆP VỤ, không biết FAISS hay OpenAI:
def test_answer_cites_source(rag: RagTestHarness):
rag.given_documents(doc("HAL ẩn chi tiết GPIO", id="d1"))
ans = rag.ask("HAL làm gì?")
rag.assert_cited(ans, "d1") # đổi vector backend → test KHÔNG vỡ
04 Clean Embedded — đừng để software thành firmware
James Grenning mở chương bằng một câu của Doug Schmidt: "Phần mềm không hao mòn, nhưng firmware và hardware thì lỗi thời, buộc phải sửa software." Khoảnh khắc sáng tỏ: software có thể sống lâu, còn firmware sẽ lỗi thời khi hardware tiến hóa.
Firmware KHÔNG phải là code nằm trong ROM. Một đoạn code là firmware vì những gì nó phụ thuộc vào và mức độ khó thay đổi khi hardware tiến hóa — không phải vì nơi nó được lưu. Trộn lẫn software & firmware (intermingling) là một anti-pattern: code sẽ chống lại thay đổi, mỗi sửa nhỏ cần full regression test. "Hãy viết ít firmware hơn, nhiều software hơn."
The Target-Hardware Bottleneck
Vấn đề đặc thù của nhúng: khi code không theo nguyên lý clean, bạn chỉ test được trên target hardware thật. Mà hardware thường được phát triển song song với software — có thể chưa có chỗ để chạy code; khi có thì hardware lại dính lỗi của chính nó, làm tiến độ chậm thêm. Đó là target-hardware bottleneck.
flowchart TB APP["Software
(business rules, sống lâu)"]:::core APP --> HAL["HAL · Hardware Abstraction Layer
Indicate_LowBattery() · store(name,value)"]:::mid APP --> OSAL["OSAL · OS Abstraction Layer
message passing, test points"]:::mid HAL --> FW["Firmware / BSP
Led_TurnOn(5) · GPIO · flash"]:::out OSAL --> OS["RTOS · Embedded Linux"]:::out FW --> HW["Hardware"]:::hw TEST["Off-target tests
(thay HAL/OSAL bằng fake)"]:::test -.->|substitution points| HAL TEST -.-> OSAL classDef core fill:#dce7f1,stroke:#2e5e8c,color:#14233b; classDef mid fill:#e2edf3,stroke:#2f6d93,color:#14233b; classDef out fill:#eef4f1,stroke:#8c8675,color:#14233b; classDef hw fill:#f4dcd5,stroke:#b23a2e,color:#14233b; classDef test fill:#dbeee8,stroke:#0f7d72,color:#14233b;
Indicate_LowBattery() chứ không gọi thẳng Led_TurnOn(5); gọi store(name,value) chứ không quan tâm flash/cloud/RAM. HAL/OSAL cung cấp seam — substitution points để test off-target / off-OS, phá vỡ target-hardware bottleneck. Processor & OS đều là detail.HAL ẩn "chảy máu" hardware
Ranh giới giữa software & firmware. API của HAL thiết kế theo nhu cầu của software, không lộ cách hiện thực. Đừng để hardware knowledge "bleed" lên policy cấp cao — nếu không, code cực khó đổi cả khi hardware đổi lẫn khi user xin tính năng mới.
Off-target testing
Clean embedded ⟶ software test được ngoài target hardware và ngoài OS thật. Giữ software processor-independent (qua HAL/PAL) & OS-independent (qua OSAL). Lập trình theo interface + substitutability là chìa khóa cho tuổi thọ dài.
RAG · "HAL cho LLM/Vector" để test off-vendor
RAG Vendor LLM/Vector đóng vai "hardware": dễ lỗi thời, đắt, không có khi test. Đặt một lớp trừu tượng kiểu HAL — app gọi nhu cầu nghiệp vụ, không gọi thẳng SDK vendor — để test off-"hardware" bằng implementation thay thế:
# "HAL" — API thiết kế theo nhu cầu software, ẩn chi tiết vendor
class LlmGate(Protocol):
def answer(self, prompt: str) -> str: ... # ~ Indicate_LowBattery()
class VectorGate(Protocol):
def search(self, q: str, k: int) -> list[Chunk]: ...
# Off-"hardware": thay vendor thật bằng fake để test, không cần OpenAI/FAISS
class FakeLlm(LlmGate):
def answer(self, prompt): return "stub answer" # substitution point
def make_rag(llm: LlmGate, vec: VectorGate): # app phụ thuộc INTERFACE
... # đổi OpenAI→Anthropic, FAISS→pgvector = thay impl, app không đổi
05 Ghi nhớ nhanh
Ranh giới kiến trúc ở khắp nơi — hiếm khi chỉ 3 layer (Wumpus lộ ra language, text/GUI, move/player). Mỗi ranh giới là một quyết định chi phí: over-engineering vs under-engineering.
Vạch ranh giới là quyết định KHI NÀO. Quan sát ma sát, vạch tại inflection point nơi chi phí triển khai rẻ hơn chi phí bỏ qua — và xem lại thường xuyên.
Service không tự là kiến trúc. Decoupling fallacy: chạy khác process vẫn coupling qua dữ liệu chia sẻ (cross-cutting/Kitty). Ranh giới thật nằm ở component bên trong service.
Test là component vòng ngoài cùng. Đừng phụ thuộc thứ volatile (GUI) ⟶ tránh fragile tests; dùng Testing API tách cấu trúc test khỏi cấu trúc ứng dụng.
Đừng để software thành firmware. Firmware = phụ thuộc hardware (không phải "nằm trong ROM"). Dùng HAL/OSAL ẩn chi tiết, phá target-hardware bottleneck, test off-target cho tuổi thọ dài.