Clean Architecture
11 Phần IV · Kiến trúc Chương 25 · 27 · 28 · 29

Layers, Services, Test & Embedded

Layers · Services · Test Boundary · Embedded

Bốn chương, một thông điệp: ranh giới kiến trúc ở khắp nơi — và mỗi ranh giới là một quyết định chi phí. Vạch quá nhiều thì lãng phí, bỏ qua thì sau này phải trả giá đắt. Service không tự sinh ra kiến trúc; test cũng là một component của hệ thống; và phần mềm nhúng chỉ "sống lâu" khi tách khỏi phần cứng. Tất cả quy về việc đặt đúng ranh giới, đúng lúc.

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;
Một game text "200 dòng" mà ranh giới ở khắp nơi. Mọi mũi tên tuân Dependency Rule — chỉ vào trong, hướng về policy. Đây là proxy cho một hệ thống lớn với nhiều ranh giới quan trọng.

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ách rời chỉ là ảo giác

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

độc lập chỉ khi không coupling

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;
Objects to the rescue: thiết kế service với component bên trong tuân SOLID. Tính năng Kittens là polymorphic extension — chỉ thêm jar/dll mới vào load path, không sửa core (Open-Closed Principle). Ranh giới kiến trúc nằm ở component bên trong service, không phải ở bản thân cơ chế giao tiếp.

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 ingestionquery 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:

Python · hai service, một schema chung = vẫn coupling
# 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.
Đúng tinh thần decoupling fallacy: ranh giới process không xóa được coupling dữ liệu. Ranh giới kiến trúc thật phải là một component "embedding policy" dùng chung, cô lập quyết định model/dim ở một nơi.

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:

Python · Testing API ẩn cấu trúc ứng dụng khỏi test
# 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ỡ
Production code (retriever, embedder) tự do refactor sang trừu tượng hơn; test giữ nguyên vì chỉ phụ thuộc Testing API ổn định, không phụ thuộc FAISS/OpenAI volatile.

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àomứ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;
HAL ẩn chi tiết hardware: app gọi 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

Hardware Abstraction Layer

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

processor & OS độc lập

Clean embedded ⟶ software test được ngoài target hardwarengoà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ế:

Python · cổng kiểu HAL: substitution point cho LLM/Vector
# "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
Giống HAL phá target-hardware bottleneck: cổng này phá "vendor bottleneck". App là policy sống-lâu; vendor SDK là detail bị cô lập sau seam — đổi nhà cung cấp không "chảy máu" lên business rules.

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.

NguồnChương 25 (Layers and Boundaries), 27 (Services: Great and Small), 28 (The Test Boundary) & 29 (Clean Embedded Architecture), Clean Architecture — Robert C. Martin, Prentice Hall 2017.