Bài 8 · Nâng cao · 20 phút
Ranh giới (Boundaries)
Biên soạn bởi Nguyễn Anh Tuấn
Ranh giới (boundaries) với code bên thứ ba: bọc API ngoài sau interface (adapter); "learning test" để dò thư viện lạ; đừng để kiểu thư viện rò khắp dự án.
Bất kỳ thư viện nào bạn dùng - logger, HTTP client, ORM, payment gateway - đều là ranh giới (boundary): code mà bạn không viết, không kiểm soát, và có thể thay đổi mà không hỏi ý kiến bạn. Có ba vấn đề xảy ra khi dùng thư viện ngoài trực tiếp:
❌ Dùng trực tiếp - kiểu thư viện rò khắp dự án
// file: src/services/payment.ts
import Stripe from 'stripe'; // kieu Stripe ro ra ngoai
export async function charge(amount: number): Promise<Stripe.PaymentIntent> {
const stripe = new Stripe(process.env.STRIPE_KEY!);
return stripe.paymentIntents.create({ amount, currency: 'vnd' });
// Tra ve kieu Stripe.PaymentIntent - caller phai import Stripe de dung
}
// file: src/routes/checkout.ts
import Stripe from 'stripe'; // phai import lai - ro ra them mot lan nua
async function handleCheckout(amount: number) {
const intent: Stripe.PaymentIntent = await charge(amount);
// Doi sang BrainTree? Phai sua payment.ts VA checkout.ts VA moi file khac
console.log(intent.status);
} - ▸Kiểu thư viện rò ra: khi caller phải import tên thư viện để dùng kết quả của bạn - coupling cứng.
- ▸Đổi thư viện = phải grep + sửa mọi file đang import tên thư viện - tốn kém và dễ bỏ sót.
- ▸API thư viện đổi sau update: nếu không có lớp cách ly, lỗi compile lan ra khắp nơi.
Giải pháp: định nghĩa interface theo nhu cầu của mình, rồi viết một adapter bọc thư viện ngoài vào sau interface đó. Phần còn lại dự án chỉ biết interface của bạn - không biết thư viện gì đang chạy bên dưới.
Đây là nguyên tắc đã gặp ở bài Xử lý lỗi sạch (bọc lỗi bên thứ ba) - nhưng áp dụng rộng hơn cho toàn bộ API.
✅ Adapter - phần còn lại dự án chỉ biết interface của mình
// Buoc 1: dinh nghia interface THEO NHU CAU MINH (khong phai 1:1 voi Stripe)
interface PaymentGateway {
charge(amount: number, currency: string): Promise<{ id: string; status: string }>;
refund(paymentId: string): Promise<void>;
}
// Buoc 2: adapter boc Stripe - chi mot file nay biet den Stripe
import Stripe from 'stripe';
class StripeAdapter implements PaymentGateway {
private client: Stripe;
constructor(apiKey: string) {
this.client = new Stripe(apiKey);
}
async charge(amount: number, currency: string) {
const intent = await this.client.paymentIntents.create({ amount, currency });
// Anh xa sang kieu cua minh - caller khong biet Stripe.PaymentIntent
return { id: intent.id, status: intent.status };
}
async refund(paymentId: string) {
await this.client.refunds.create({ payment_intent: paymentId });
}
}
// Buoc 3: phan con lai du an dung interface - khong import Stripe
async function handleCheckout(gateway: PaymentGateway, amount: number) {
const result = await gateway.charge(amount, 'vnd');
// Doi sang BrainTree? Chi viet BrainTreeAdapter - checkout.ts khong doi gi
console.log(result.status);
} - ▸Interface của mình định nghĩa theo NHU CẦU - không nhất thiết 1:1 với API thư viện.
- ▸Adapter: một file duy nhất biết đến thư viện ngoài; phần còn lại dự án dùng interface.
- ▸Đổi thư viện = viết adapter mới; không đụng vào caller - đổi trong vô hình với codebase.
Inject dependency - đừng new trong hàm
Khi gặp thư viện mới, phản xạ thường là đọc tài liệu rồi đoán. Cách tốt hơn: learning test - viết test nhỏ để xác nhận hiểu biết của mình về hành vi thư viện, thay vì đoán. Test đó không kiểm tra code của bạn; nó kiểm tra thư viện hoạt động như bạn nghĩ không.
Learning test - xác nhận hiểu biết về thư viện, không phải về code mình
// learning-tests/json-parse.test.ts
// Muc dich: hoc cach JSON.parse xu ly cac truong hop bien
// Chay lai sau moi lan cap nhat dep de bat breaking change
import { describe, it, expect } from 'vitest';
describe('JSON.parse - learning tests', () => {
it('parse object hop le', () => {
// Xac nhan: truong so va truong string deu duoc anh xa dung
const result = JSON.parse('{"name":"Meo Con","age":3}');
expect(result.name).toBe('Meo Con');
expect(result.age).toBe(3);
});
it('null trong JSON thanh null trong JS', () => {
// Hoc: JSON null -> JS null (khac undefined)
const result = JSON.parse('{"val":null}');
expect(result.val).toBeNull();
expect(result.val).not.toBeUndefined();
});
it('JSON sai cu phap throw SyntaxError', () => {
// Hoc: loai exception cu the de boc dung kieu loi
expect(() => JSON.parse('{bad}')).toThrow(SyntaxError);
});
}); Learning test có thêm một lợi ích sau khi viết xong: chạy lại sau mỗi lần update thư viện. Nếu test fail sau update - thư viện đã đổi hành vi (breaking change) và bạn biết ngay, không phải đợi đến lúc code chạy ra lỗi runtime.
- ▸Learning test: kiểm tra thư viện hoạt động như mình hiểu - không phải kiểm tra code của mình.
- ▸Xác nhận hiểu biết sớm: rẻ hơn nhiều so với đợi đến runtime mới phát hiện mình hiểu sai.
- ▸Chạy lại sau update thư viện: learning test là tấm lưới bắt breaking change sớm nhất.
Có một trường hợp khác: bạn cần dùng code mà team khác chưa viết xong (hoặc API chưa deploy). Đừng chờ. Định nghĩa interface mong muốn rồi viết một stub/fake implement interface đó với dữ liệu cứng - code phần của bạn ngay bây giờ.
Stub cho code chưa tồn tại - code song song không cần chờ
// Buoc 1: dinh nghia interface the hien dieu MINH CAN (khong phai dieu doi phuong co)
interface InventoryService {
checkStock(productId: string): Promise<number>;
reserveItems(productId: string, qty: number): Promise<boolean>;
}
// Buoc 2: stub don gian - du lieu cung, implement interface dung
class FakeInventoryService implements InventoryService {
async checkStock(productId: string): Promise<number> {
// Toa cung: 100 cai kho hang - du de code va test phan cua minh
return 100;
}
async reserveItems(productId: string, qty: number): Promise<boolean> {
console.log(`[FAKE] Da dat cho ${qty} san pham ${productId}`);
return true; // luon thanh cong - de test hanh phuc
}
}
// Phan cua minh: viet va test hoan toan doc lap voi team khac
async function placeOrder(
inventory: InventoryService, // nhan qua tham so - de doi sau
productId: string,
qty: number
): Promise<void> {
const stock = await inventory.checkStock(productId);
if (stock < qty) throw new Error(`Het hang: chi con ${stock}, can ${qty}`);
const reserved = await inventory.reserveItems(productId, qty);
if (!reserved) throw new Error('Dat cho that bai');
console.log('Don hang thanh cong');
}
// Chay thu voi fake - khong phu thuoc team khac hay server that
await placeOrder(new FakeInventoryService(), 'SP001', 5);
// Don hang thanh cong - ▸Định nghĩa interface trước - đây là hợp đồng (contract) giữa hai team; cả hai code song song.
- ▸Stub/fake: implement interface bằng dữ liệu cứng đủ để code và test không bị chặn.
- ▸Khi code thật sẵn sàng: hoán vị từ FakeXxx sang implementation thật - chỗ gọi không đổi.
Nguyên tắc xuyên suốt bài này: giữ ranh giới sạch - đừng để chi tiết bên ngoài (kiểu thư viện, cách thư viện lỗi, cấu trúc dữ liệu ngoài) rò vào codebase của bạn. Kết hợp với bài Xử lý lỗi sạch (bọc exception bên thứ ba), bạn có đủ công cụ để code của mình tồn tại lâu dài dù môi trường xung quanh thay đổi.
- ▸Adapter: một file duy nhất biết thư viện - phần còn lại chỉ biết interface của bạn.
- ▸Learning test: xác nhận hiểu biết và bắt breaking change sau khi update thư viện.
- ▸Interface trước: code song song với stub khi code bên kia chưa có - không chờ.
Sẽ học thêm ở bài tiếp theo: Unit test sạch
Câu hỏi thường gặp
Không phải mọi thư viện đều cần adapter. Câu hỏi là: "Nếu thư viện này đổi hoặc bị thay, bao nhiêu file phải sửa?" Thư viện tiện ích nhỏ dùng ở một chỗ - không cần. Thư viện cốt lõi (logger, HTTP client, ORM, payment gateway) dùng khắp nơi - adapter trả công rõ ràng. Kinh nghiệm thực tế: nếu bạn thấy tên thư viện xuất hiện ở hơn 5-10 file, đó là lúc đáng bọc lại.
Unit test bình thường kiểm tra CODE CỦA MÌNH hoạt động đúng. Learning test kiểm tra CODE NGOÀI (thư viện) hoạt động như mình HIỂU - hai mục đích khác nhau. Learning test không bao giờ fail vì lỗi của mình; nó fail khi thư viện thay đổi hành vi. Chạy lại sau mỗi lần cập nhật thư viện là cách bắt breaking change sớm nhất.
Không cần hoàn hảo ngay từ đầu - chỉ cần đủ để tiếp tục viết code phần còn lại. Interface mong muốn thường rõ hơn khi bạn đã viết vài caller xong và nhìn lại. Sửa interface - và adapter đi kèm - rẻ hơn nhiều so với thay đổi tất cả các chỗ gọi trực tiếp thư viện.
Stub: thay thế dependency bằng implementation đơn giản trả kết quả cố định - dùng khi test cần dependency đó chạy theo một kịch bản cụ thể. Mock: thay thế dependency và còn GHI LẠI xem ai gọi method nào, bao nhiêu lần, với tham số gì - dùng khi bạn cần kiểm tra interaction (vd "hàm này có gọi logger.error không?"). Bài này dùng "stub" theo nghĩa rộng hơn: implementation tạm thời để code song song, chưa nói đến kiểm tra interaction.
Rò kiểu (type leakage) trở thành vấn đề khi: (1) bạn import kiểu từ thư viện ở nhiều file - đổi thư viện là phải grep + sửa khắp nơi; (2) caller biết quá nhiều về implementation nên code trở nên giòn; (3) bạn không thể test mà không kéo theo dependency thật. Nếu chỉ import kiểu ở một file adapter duy nhất - rò chưa thành vấn đề.
Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.
Trả lời vài câu để chắc rằng em đã nắm bài.
Vì sao dùng trực tiếp kiểu của thư viện ngoài (vd Stripe.PaymentIntent) trong nhiều file là vấn đề?
- 1
Kiểm kê rò kiểu
Grep codebase tìm import từ một thư viện ngoài (axios, prisma, winston...). Đếm bao nhiêu file import trực tiếp từ thư viện đó.
Hoàn thành khi: Một danh sách file import trực tiếp và nhận xét: nếu thư viện này đổi tên method hoặc bị thay, sẽ phải sửa bao nhiêu chỗ?
- 2
Định nghĩa interface của mình
Chọn một thư viện đang import nhiều nơi (HTTP client, logger, storage...). Định nghĩa interface TypeScript chỉ gồm những method BẠN THỰC SỰ DÙNG - không nhất thiết 1:1 với API thư viện.
Hoàn thành khi: Một interface TypeScript với tên phản ánh vai trò trong dự án (vd
Logger,HttpClient,FileStorage); chỉ liệt kê method bạn thực sự gọi. - 3
Viết adapter đầu tiên
Từ interface vừa định nghĩa, viết class adapter bọc thư viện thật. Chỉnh một chỗ caller dùng trực tiếp thư viện sang dùng qua interface.
Hoàn thành khi: Adapter class implement interface của mình; chỗ caller được sửa không còn import tên thư viện ngoài; code chạy y như trước.
- 4
Viết learning test
Chọn một thư viện bạn còn chưa hiểu kỹ một hành vi cụ thể (vd "JSON.parse xử lý số lớn thế nào?", "dayjs có tính thứ Hai đầu tuần đúng múi giờ không?"). Viết 2-3 learning test để xác nhận hiểu biết của mình.
Hoàn thành khi: Mỗi test kiểm tra đúng một hành vi; test xanh xác nhận hiểu biết đó là đúng; comment mô tả bạn đang kiểm tra điều gì và tại sao.
- 5
Stub cho team chưa xong
Nghĩ ra một scenario: bạn cần gọi một service chưa tồn tại (hoặc team khác chưa viết xong). Định nghĩa interface và viết một FakeXxx implement interface đó với dữ liệu cứng. Dùng fake đó để tiếp tục viết code phần của mình.
Hoàn thành khi: Một interface và một fake implement trả dữ liệu cứng; code phần của bạn dùng interface - không phụ thuộc vào service thật.
- 6
So sánh trước và sau
Với codebase đã sửa ở bài tập 3: viết đoạn giải thích ngắn (3-5 câu) - nếu bây giờ cần thay thư viện bằng một thư viện khác, chỉ cần sửa ở đâu? So với trước khi có adapter thì đỡ hơn bao nhiêu?
Hoàn thành khi: Mô tả cụ thể: chỉ cần sửa file adapter (và có thể file config inject); các file caller không cần thay đổi gì.