01 Dùng code bên thứ ba
Có một căng thẳng tự nhiên giữa hai phía của ranh giới. Người cung cấp một interface muốn nó rộng để dùng được ở nhiều nơi; người dùng lại muốn một interface khít với nhu cầu cụ thể của mình. Lực kéo ngược chiều này chính là nguồn rắc rối ở ranh giới.
Nhà cung cấp (provider)
Muốn API rộng, áp dụng được cho nhiều môi trường để bán/được nhiều người dùng.
Người dùng (user)
Muốn API tập trung đúng nhu cầu cụ thể của mình.
Ví dụ kinh điển của sách: java.util.Map. Interface của nó rất rộng — bất kỳ ai cầm một Map cũng gọi được clear() để xóa sạch nó; Map lại không ràng buộc kiểu phần tử nên ai cũng nhét được kiểu khác vào. Truyền một Map trần đi khắp hệ thống nghĩa là: nếu interface Map đổi (như khi Java 5 thêm generics), bạn phải sửa rất nhiều nơi.
RAG Cùng vấn đề khi ta cầm một collection trần (như map từ tên cảm biến → đối tượng Sensor) và truyền nó khắp pipeline. Hãy bọc nó trong một lớp chuyên biệt và giấu interface ranh giới đi:
Vì sao: bản "bẩn" để interface Map phơi ra mọi nơi gọi — ai cũng clear() được, ai cũng phải tự ép kiểu, và nếu Map đổi thì cả hệ thống đổi theo. Bọc vào lớp Sensors thì interface ranh giới bị giấu, việc ép kiểu & quản lý kiểu nằm gọn một chỗ, và ta ép được luật nghiệp vụ (chỉ phơi đúng phép cần). Map tiến hóa mà ít ảnh hưởng phần còn lại.
# map trần đi khắp hệ thống — interface ranh giới phơi ra mọi nơi
sensors: dict = {}
register(sensors) # nơi nào cũng cầm dict trần
s = sensors["temp-01"] # phải tự ép kiểu, tự đoán có hay không
sensors.clear() # AI CŨNG xóa sạch được — interface quá rộng
class Sensors: # Map bị GIẤU sau ranh giới
def __init__(self):
self._sensors: dict = {} # interface ranh giới ở yên trong đây
def get_by_id(self, id: str) -> Sensor:
return self._sensors[id] # ép kiểu/quản lý kiểu gói một chỗ
# nơi dùng chỉ thấy đúng phép cần — không clear() bừa, không lộ dict
Đừng hiểu nhầm: sách không khuyên bọc mọi Map. Lời khuyên là: đừng truyền Map (hay interface ranh giới khác) đi khắp hệ thống — giữ nó trong lớp/nhóm lớp dùng nó, và tránh trả về hoặc nhận nó ở public API.
02 Khám phá & học ranh giới (learning tests)
Học một thư viện bên thứ ba và đồng thời tích hợp nó vào code production là việc rất khó — làm cả hai một lúc dễ sai. Thay vì thử nghiệm ngay trong production code, hãy viết learning tests (Jim Newkirk): những bài test nhỏ gọi API bên thứ ba đúng theo cách ta dự định dùng — như một thí nghiệm có kiểm soát để kiểm chứng hiểu biết của mình.
Learning test = thí nghiệm có kiểm soát. Ta không kiểm thử code của mình, mà kiểm chứng mình đã hiểu API đúng chưa. Sách kể chuyện học log4j: viết một test nhỏ in "hello" ra console → nó báo cần một Appender → đọc thêm tài liệu, dùng ConsoleAppender với PatternLayout → dồn dần hiểu biết vào một bộ unit test đơn giản.
RAG Trước khi nhúng SDK của một vector DB hay LLM bên thứ ba vào pipeline, viết learning test thăm dò trước — ép API hành xử đúng cái mình định nhờ nó làm:
Thử cách dùng tối thiểu
Viết một test nhỏ nhất gọi SDK theo đúng kịch bản mình cần (vd: nạp 1 vector rồi truy hồi top-1).
Để API "dạy" mình
Lỗi/mặc định của SDK lộ ra điều phải biết (cần index name? cần khai chiều vector? metric nào?). Đọc thêm tài liệu, sửa test.
Dồn hiểu biết vào test
Mỗi điều học được thành một assertion. Kết quả: một bộ test ghi lại chính xác cách ta hiểu API hoạt động.
def test_learn_vectordb_roundtrip(): # KHÔNG test code của ta — test hiểu biết
db = ThirdPartyVectorDB(dim=3) # API "dạy": phải khai số chiều
db.upsert(id="a", vector=[0.1, 0.2, 0.3])
hits = db.query(vector=[0.1, 0.2, 0.3], top_k=1)
assert hits[0].id == "a" # chốt lại: đây là cách nó hoạt động
03 Learning tests "tốt hơn miễn phí"
Ta phải bỏ công học API dù sao đi nữa, nên viết learning test không tốn thêm gì — mà còn cho lãi. Đó là lý do sách nói chúng "tốt hơn cả miễn phí" (better than free): ROI dương rõ ràng.
Lợi tức khi nâng cấp phiên bản: khi thư viện ra bản mới, chạy lại learning tests để xem hành vi có khác đi không. Nếu bản mới không tương thích với kỳ vọng của ta, ta biết ngay — chứ không phát hiện muộn trong production.
RAG Một ranh giới sạch nên có bộ "outbound tests" tập thể dục cho interface y như cách production dùng nó. Nhờ vậy việc migrate sang phiên bản mới của SDK nhẹ nhàng — ta không bị kẹt ở phiên bản cũ lâu hơn cần thiết chỉ vì sợ nâng cấp sẽ vỡ thứ gì đó không rõ.
Có learning tests
Nâng SDK → chạy lại bộ test → khác biệt hành vi hiện ra tức thì. Migrate tự tin.
Không có
Sợ nâng cấp vì không biết gì vỡ → kẹt ở bản cũ, nợ kỹ thuật chồng chất.
04 Dùng code chưa tồn tại
Có một loại ranh giới khác: ranh giới ngăn cái đã biết với cái chưa biết. Đôi khi ta phải viết code dựa vào một API mà chưa ai thiết kế xong. Đừng để bị chặn lại — hãy tự định nghĩa interface mình mong muốn ("the interface you wish you had") ngay tại ranh giới.
Ví dụ dự án radio của sách: đội cần điều khiển một bộ Transmitter nhưng API thật chưa được đội kia thiết kế. Họ không chờ — họ tự định nghĩa interface Transmitter với đúng method họ ước có: transmit(frequency, dataStream). Interface này nằm dưới quyền kiểm soát của họ nên code phía client (CommunicationsController) sạch và tập trung vào mục tiêu, không bị API ngoài làm rối.
RAG Cùng cách làm khi LLM SDK ta định dùng chưa có (đang chờ vendor, hoặc đội khác chưa làm xong). Định nghĩa interface mình mong muốn trước; khi API thật xuất hiện, viết một Adapter cầu nối:
Vì sao — mẫu Adapter: bản "bẩn" để client gọi thẳng SDK chưa-tồn-tại (hoặc hình dạng SDK còn đoán mò) → bị chặn, lại trộn chi tiết vendor vào logic. Bản "sạch" định nghĩa interface mình ước có; SdkAdapter đóng gói mọi tương tác với API thật và là một nơi duy nhất để sửa khi API tiến hóa. Client viết được ngay, không phụ thuộc vào thứ chưa kiểm soát.
def answer(q, context):
# SDK thật chưa có — đoán mò hình dạng, lại chặn việc viết client
raw = vendor_llm.completions.create( # ráp cứng vào vendor
model="???", prompt=build(q, context), max_tok=512)
return raw["choices"][0]["text"] # chi tiết vendor lan ra logic
class TextGenerator(Protocol): # interface MÌNH ƯỚC CÓ
def generate(self, prompt: str) -> str: ...
def answer(q, context, gen: TextGenerator): # client sạch, viết được ngay
return gen.generate(build(q, context))
class SdkAdapter: # cầu nối khi API thật xuất hiện
def generate(self, prompt: str) -> str: # MỘT nơi duy nhất chạm vendor
raw = vendor_llm.completions.create(model="gpt-x", prompt=prompt)
return raw["choices"][0]["text"]
05 Ranh giới sạch (Clean Boundaries)
Code tại ranh giới cần được tách biệt rõ ràng và có những bài test định nghĩa kỳ vọng của ta.
Robert C. Martin · Chương 8 (diễn giải sát)Thay đổi xảy ra ở ranh giới. Code tốt thì khi thư viện đổi không cần sửa nhiều. Bí quyết: tránh để quá nhiều phần của hệ thống biết chi tiết bên thứ ba. Tốt hơn là phụ thuộc vào thứ ta kiểm soát hơn là thứ ta không kiểm soát.
"Phụ thuộc vào thứ bạn kiểm soát hơn thứ bạn không kiểm soát, kẻo nó sẽ kiểm soát ngược lại bạn." Giữ rất ít điểm tham chiếu tới bên thứ ba. Wrap nó (như lớp Sensors với Map) hoặc dùng một Adapter để chuyển từ interface lý tưởng của ta sang interface mà thư viện cung cấp.
Wrap
Gói interface ranh giới vào một lớp chuyên biệt; giấu nó khỏi phần còn lại của hệ thống (mục 01).
Adapter
Chuyển từ interface mình mong muốn sang interface được cung cấp; một nơi duy nhất để sửa khi API đổi (mục 04).
RAG Trong hệ RAG, ranh giới service cũng là một loại ranh giới: khi Laravel gọi sang pipeline Python qua HTTP, hãy giấu chi tiết của lời gọi đó sau một lớp client thay vì rải Http::post với URL & hình dạng payload khắp controller:
Vì sao: rải lời gọi HTTP trần khắp nơi nghĩa là URL, định dạng request/response của service Python (thứ ta không kiểm soát) phơi ra nhiều điểm — đổi một thứ là sửa khắp. Bọc sau RagClient thì chỉ còn một điểm tham chiếu; phần còn lại gọi method nghiệp vụ, không thấy chi tiết ranh giới.
// chi tiết service Python phơi ra mọi nơi gọi
$res = Http::post('http://rag-py/answer', [ // URL & shape lặp khắp nơi
'q' => $request->query,
]);
$text = $res->json()['choices'][0]['text']; // hình dạng response lan ra
final class RagClient { // MỘT điểm tham chiếu tới service
public function answer(string $q): string {
$res = Http::post('http://rag-py/answer', ['q' => $q]);
return $res->json()['choices'][0]['text']; // chi tiết giấu ở đây
}
}
// controller: $ragClient->answer($request->query); — không thấy ranh giới
Kết quả: code dễ hiểu, dùng ranh giới một cách nhất quán, và có rất ít điểm phải bảo trì khi thư viện hay service phía sau đổi.
06 Ghi nhớ nhanh
Căng thẳng provider↔user. Provider muốn interface rộng, ta muốn interface khít — đó là gốc rắc rối ở ranh giới.
Đừng truyền interface ranh giới (vd Map/SDK trần) đi khắp nơi. Bọc trong lớp chuyên biệt; giấu nó; tránh trả về/nhận nó ở public API.
Học ranh giới bằng learning tests. Gọi API bên thứ ba đúng cách mình định dùng, như thí nghiệm có kiểm soát — đừng học ngay trong production code.
Learning tests "tốt hơn miễn phí". Chạy lại khi nâng cấp phiên bản → bắt thay đổi không tương thích ngay, migrate dễ.
Dùng code chưa tồn tại? Định nghĩa interface mình mong muốn; viết Adapter cầu nối khi API thật xuất hiện.
Ranh giới sạch: ít điểm tham chiếu bên thứ ba; wrap hoặc Adapter. Phụ thuộc thứ mình kiểm soát hơn thứ mình không kiểm soát.