Design Patterns in TypeScript · Part 7 — Adapter & Facade
Tame third-party and legacy code: the Adapter that makes an incompatible API fit your interface, the Facade that hides a messy subsystem behind one entry point, and the anti-corruption layer that keeps vendor types out of your domain.
Part 7 of 10 in the Design Patterns in TypeScript series {Phần 7/10 trong series Design Patterns in TypeScript}. Previous {Trước}: Part 6 — Decorator & Middleware · Next {Tiếp}: Part 8 — Command & Memento.
This is Part 7 of a 10-part series on the design patterns every senior web engineer should have in their hands — explained with runnable TypeScript, real frontend/back-of-front use cases, and exercises at the end of each part {Đây là Phần 7 của series 10 bài về các design pattern mà mọi senior web nên nắm — giải thích bằng TypeScript chạy được, use case web thực tế, và bài tập ở cuối mỗi phần}. In Part 6 — Decorator & Middleware you wrapped behavior around the same interface {Ở Phần 6 — Decorator & Middleware bạn bọc hành vi quanh cùng một interface}; here you reshape or simplify what you call from the outside {ở đây bạn đổi hình hoặc đơn giản hóa những gì gọi từ bên ngoài}.
Your app should not bend to a vendor SDK’s method names, error shapes, or DTO fields {App của bạn không nên uốn theo tên method, dạng lỗi hay field DTO của SDK vendor}. Wrap it at the boundary — define your contract, adapt the awkward API to it, and keep domain code speaking your language {Bọc ở biên — định nghĩa hợp đồng của bạn, adapt API lạ sang đó, và để code domain nói ngôn ngữ của bạn}.
The intent {Ý đồ}
An Adapter makes an existing incompatible interface fit the one your code already expects {Adapter biến interface không tương thích sẵn có khớp với interface code bạn đã kỳ vọng}. You keep calling PaymentGateway.charge(); behind the scenes something translates that into stripe.paymentIntents.create() {Bạn vẫn gọi PaymentGateway.charge(); phía sau có thứ dịch sang stripe.paymentIntents.create()}.
A Facade exposes a simple, unified API over a complex subsystem {Facade lộ ra API thống nhất, đơn giản trên một subsystem phức tạp}. Checkout does not mean the UI imports cart, inventory, payment, and email modules — it calls checkoutService.placeOrder() {Checkout không có nghĩa UI import cart, inventory, payment và email — nó gọi checkoutService.placeOrder()}.
Adapter = different interface → your interface {Adapter = interface khác → interface của bạn}. Facade = many moving parts → few methods {Facade = nhiều mảnh → ít method}. Both reduce coupling, but they answer different smells {Cả hai giảm coupling, nhưng giải quyết mùi khác nhau}.
Adapter — define YOUR interface first {Adapter — định interface CỦA BẠN trước}
Start with the target contract your domain and UI already depend on {Bắt đầu bằng hợp đồng target mà domain và UI đã phụ thuộc}. Only then write a class or factory that implements it by delegating to the third-party “adaptee” {Sau đó mới viết class hoặc factory implement nó bằng cách ủy quyền cho “adaptee” bên thứ ba}.
// ── Your contract (stable, owned by the app) ──
export interface PaymentGateway {
charge(input: { amountCents: number; currency: string; customerId: string }): Promise<{
id: string;
status: 'succeeded' | 'failed';
}>;
}
// ── Vendor SDK shape (you do NOT import this in domain code) ──
interface StripeLikeSdk {
paymentIntents: {
create(params: {
amount: number;
currency: string;
customer: string;
}): Promise<{ id: string; status: string }>;
};
}
// ── Adapter: translate names, units, and status vocabulary ──
export function createStripePaymentAdapter(sdk: StripeLikeSdk): PaymentGateway {
return {
async charge({ amountCents, currency, customerId }) {
const result = await sdk.paymentIntents.create({
amount: amountCents,
currency: currency.toLowerCase(),
customer: customerId,
});
// Map vendor strings to your closed union at the boundary.
const status = result.status === 'succeeded' ? 'succeeded' : 'failed';
return { id: result.id, status };
},
};
}
// Domain / UI depends only on PaymentGateway — swap Stripe for Adyen in one file.
async function checkout(gateway: PaymentGateway) {
return gateway.charge({ amountCents: 4999, currency: 'USD', customerId: 'cus_1' });
}
The adapter is the only place that knows Stripe’s method names and raw status: string {Adapter là chỗ duy nhất biết tên method Stripe và status: string thô}. Tests pass a fake PaymentGateway; production wires createStripePaymentAdapter(realSdk) at the composition root {Test truyền fake PaymentGateway; production gắn createStripePaymentAdapter(realSdk) ở composition root}.
Facade — one entry point over many subsystems {Facade — một cửa vào trên nhiều subsystem}
When placing an order touches cart, stock, payment, and notifications, a Facade orchestrates the sequence and exposes a narrow surface {Khi đặt hàng đụng cart, kho, thanh toán và thông báo, Facade điều phối thứ tự và lộ bề mặt hẹp}.
interface CartService {
getTotal(userId: string): Promise<number>;
clear(userId: string): Promise<void>;
}
interface InventoryService {
reserve(sku: string, qty: number): Promise<void>;
}
interface PaymentGateway {
charge(input: { amountCents: number; currency: string; customerId: string }): Promise<{ id: string }>;
}
interface EmailService {
sendReceipt(to: string, orderId: string): Promise<void>;
}
export class CheckoutFacade {
constructor(
private readonly cart: CartService,
private readonly inventory: InventoryService,
private readonly payments: PaymentGateway,
private readonly email: EmailService,
) {}
async placeOrder(input: {
userId: string;
email: string;
sku: string;
qty: number;
currency: string;
}): Promise<{ orderId: string }> {
const amountCents = await this.cart.getTotal(input.userId);
await this.inventory.reserve(input.sku, input.qty);
const payment = await this.payments.charge({
amountCents,
currency: input.currency,
customerId: input.userId,
});
const orderId = payment.id;
await this.cart.clear(input.userId);
await this.email.sendReceipt(input.email, orderId);
return { orderId };
}
}
// Client code — no knowledge of four subsystems
// await checkout.placeOrder({ userId, email, sku, qty, currency });
The facade coordinates; it should not grow into a god object that re-implements every subsystem’s rules {Facade điều phối; không nên phình thành god object cài lại mọi quy tắc của subsystem}. Keep business rules inside the subsystems; the facade only sequences calls and maps errors to one place {Giữ business rule trong subsystem; facade chỉ xếp lời gọi và gom lỗi một chỗ}.
The anti-corruption layer {Lớp chống tham nhũng (anti-corruption)}
An anti-corruption layer is the architectural name for “map vendor types to domain types at the edge and never let vendor types leak inward” {Anti-corruption layer là tên kiến trúc cho “map kiểu vendor sang kiểu domain ở biên và không để kiểu vendor rò vào trong”}. Returning the raw vendor DTO from a repository or hook means every consumer couples to field renames and optional quirks {Trả DTO vendor thô từ repository hay hook nghĩa là mọi consumer dính đổi tên field và optional lạ}.
// Vendor wire format (HTTP response, webhook, SDK) — stays at the edge
interface VendorUserDto {
user_id: string;
display_name: string | null;
is_active: 0 | 1;
}
// Domain model — what the rest of the app uses
export interface User {
id: string;
displayName: string;
active: boolean;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function isVendorUserDto(value: unknown): value is VendorUserDto {
if (!isRecord(value)) return false;
return (
typeof value.user_id === 'string' &&
(typeof value.display_name === 'string' || value.display_name === null) &&
(value.is_active === 0 || value.is_active === 1)
);
}
export function mapVendorUserToDomain(raw: unknown): User {
if (!isVendorUserDto(raw)) {
throw new Error('Invalid vendor user payload');
}
return {
id: raw.user_id,
displayName: raw.display_name ?? 'Anonymous',
active: raw.is_active === 1,
};
}
// ❌ Leaky: domain now depends on snake_case and 0|1 flags
function fetchUserBad(id: string): Promise<VendorUserDto> {
return fetch(`/vendor/users/${id}`).then((r) => r.json());
}
// ✅ Clean: validate once, map once, return domain
async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/vendor/users/${id}`);
const body: unknown = await res.json();
return mapVendorUserToDomain(body);
}
Validate unknown at the boundary (manual guards, or a schema library) — then map {Validate unknown ở biên (guard thủ công hoặc thư viện schema) — rồi map}. No as VendorUserDto on response.json(); that only silences the compiler while bad data crashes deeper in the stack {Không as VendorUserDto trên response.json(); nó chỉ im compiler trong khi data hỏng làm crash sâu hơn trong stack}.
Adapter vs Facade vs Decorator {Adapter vs Facade vs Decorator}
| Pattern | Interface relationship | Typical goal |
|---|---|---|
| Adapter | Different adaptee API → your target interface | Make legacy/vendor code usable without rewriting callers |
| Facade | Many subsystem APIs → one simpler API | Hide orchestration; reduce what clients import |
| Decorator (Part 6) | Same interface in/out, stacked wrappers | Add cross-cutting behavior (logging, auth, retry) |
Adapter changes shape so existing code can stay {Adapter đổi hình để code hiện có giữ nguyên}. Facade changes surface area so clients see less {Facade đổi diện tích bề mặt để client thấy ít hơn}. Decorator changes behavior without changing the type you depend on {Decorator đổi hành vi mà không đổi kiểu bạn phụ thuộc}.
Real web use cases {Use case web thực tế}
- HTTP client wrapper — adapt
fetch, Axios, orkybehind oneHttpClientwith your timeouts, auth headers, and error type {Bọc HTTP client — adaptfetch, Axios haykysau mộtHttpClientvới timeout, header auth và kiểu lỗi của bạn}. - Storage adapter —
SessionStoreimplemented bylocalStorage,sessionStorage, IndexedDB, or cookie backends; UI imports only the interface {Storage adapter —SessionStoreimplement bởilocalStorage,sessionStorage, IndexedDB hay cookie; UI chỉ import interface}. - Analytics providers — Segment, Plausible, and a dev
noopall implementAnalytics.track(event, props){Analytics — Segment, Plausible vànoopdev đều implementAnalytics.track(event, props)}. - Auth SDKs — Firebase, Auth0, or Clerk adapted to
AuthService.signIn()/getSession()your router guards already use {Auth SDK — Firebase, Auth0, Clerk adapt sangAuthService.signIn()/getSession()mà router guard đã dùng}. - Backend swap — REST today, GraphQL tomorrow; the adapter maps both to the same
ProductRepository.list(){Đổi backend — REST hôm nay, GraphQL mai; adapter map cả hai về cùngProductRepository.list()}.
Pitfalls & anti-patterns {Pitfalls & anti-pattern}
Leaky adapter — StripePaymentAdapter methods return Stripe types or throw Stripe-specific error classes into domain code {Adapter rò rỉ — method của StripePaymentAdapter trả kiểu Stripe hoặc ném error class Stripe vào domain}. Map errors to your PaymentError at the boundary {Map lỗi sang PaymentError của bạn ở biên}.
Fat facade — CheckoutFacade grows validation, pricing rules, and email templates; it becomes the only file anyone edits {Facade béo — CheckoutFacade phình validation, pricing, template email; thành file duy nhất mọi người sửa}. Split orchestration from policy; push rules back to subsystems {Tách điều phối khỏi policy; đẩy rule về subsystem}.
Adapter when direct use is fine — wrapping a library whose API already matches your needs adds files with no swap story {Adapter khi dùng trực tiếp đủ — bọc thư viện API đã khớp nhu cầu chỉ thêm file không có kế hoạch đổi}. Adapt when you will replace the vendor or must unify several vendors {Chỉ adapt khi sẽ thay vendor hoặc phải thống nhất nhiều vendor}.
Mismatched error models — vendor throws codes; your app expects Result<T, E> {Mô hình lỗi không khớp — vendor ném code; app bạn kỳ vọng Result<T, E>}. Normalize in the adapter/facade, not in every button handler {Chuẩn hóa trong adapter/facade, không ở mọi button handler}.
Cheat sheet {Bảng tra nhanh}
// Adapter — YOUR interface, delegate to awkward API
interface Target { doWork(x: string): Promise<void>; }
function createAdapter(vendor: VendorApi): Target {
return { doWork: (x) => vendor.vendorDo({ payload: x }) };
}
// Facade — few methods, orchestrate subsystems
class CheckoutFacade {
constructor(private cart: Cart, private pay: Pay, private mail: Mail) {}
placeOrder(input: OrderInput) { /* sequence calls */ }
}
// Anti-corruption — unknown → validate → domain type
function mapVendor(raw: unknown): Domain { /* guards + map */ }
Decision: one odd API → your interface → Adapter; many modules → one entry → Facade; vendor DTO → domain model at edge → anti-corruption mapper {Quyết định: một API lạ → interface bạn → Adapter; nhiều module → một cửa → Facade; DTO vendor → model domain ở biên → mapper anti-corruption}.
Bài tập / Exercises
1. Define a SessionStore interface (get, set, remove) and implement a localStorage adapter {Định nghĩa interface SessionStore (get, set, remove) và implement adapter localStorage}.
Solution {Lời giải}
export interface SessionStore {
get(key: string): string | null;
set(key: string, value: string): void;
remove(key: string): void;
}
export function createLocalStorageAdapter(storage: Storage = localStorage): SessionStore {
return {
get(key) {
return storage.getItem(key);
},
set(key, value) {
storage.setItem(key, value);
},
remove(key) {
storage.removeItem(key);
},
};
}
// test with a fake Storage object — no browser required
const mem = new Map<string, string>();
const fake: Storage = {
get length() { return mem.size; },
clear() { mem.clear(); },
getItem(k) { return mem.get(k) ?? null; },
key() { return null; },
removeItem(k) { mem.delete(k); },
setItem(k, v) { mem.set(k, v); },
};
const store = createLocalStorageAdapter(fake);
store.set('token', 'abc');
console.log(store.get('token')); // abc2. Build a Facade ReportFacade over two subsystems: CsvExporter and PdfExporter, with one method exportReport(format, data) {Xây Facade ReportFacade trên hai subsystem: CsvExporter và PdfExporter, một method exportReport(format, data)}.
Solution {Lời giải}
interface CsvExporter {
toCsv(rows: string[][]): string;
}
interface PdfExporter {
toPdf(title: string, rows: string[][]): Uint8Array;
}
type ReportFormat = 'csv' | 'pdf';
export class ReportFacade {
constructor(
private readonly csv: CsvExporter,
private readonly pdf: PdfExporter,
) {}
exportReport(format: ReportFormat, title: string, rows: string[][]): string | Uint8Array {
if (format === 'csv') return this.csv.toCsv(rows);
return this.pdf.toPdf(title, rows);
}
}
const facade = new ReportFacade(
{ toCsv: (rows) => rows.map((r) => r.join(',')).join('\n') },
{ toPdf: (title, rows) => new TextEncoder().encode(`${title}\n${rows.length} rows`) },
);
facade.exportReport('csv', 'Q1', [['a', 'b']]);3. Write mapVendorInvoiceToDomain(raw: unknown): Invoice with runtime validation (no as) for a vendor DTO with invoice_id, total_cents, and line_items: { sku: string; qty: number }[] {Viết mapVendorInvoiceToDomain(raw: unknown): Invoice có validate runtime (không as) cho DTO vendor có invoice_id, total_cents, line_items: { sku: string; qty: number }[]}.
Solution {Lời giải}
interface Invoice {
id: string;
totalCents: number;
lines: { sku: string; qty: number }[];
}
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null;
}
function isLineItem(v: unknown): v is { sku: string; qty: number } {
if (!isRecord(v)) return false;
return typeof v.sku === 'string' && typeof v.qty === 'number' && Number.isInteger(v.qty);
}
function isVendorInvoice(v: unknown): boolean {
if (!isRecord(v)) return false;
if (typeof v.invoice_id !== 'string' || typeof v.total_cents !== 'number') return false;
if (!Array.isArray(v.line_items) || !v.line_items.every(isLineItem)) return false;
return true;
}
export function mapVendorInvoiceToDomain(raw: unknown): Invoice {
if (!isRecord(raw) || typeof raw.invoice_id !== 'string' || typeof raw.total_cents !== 'number') {
throw new Error('Invalid invoice payload');
}
if (!Array.isArray(raw.line_items) || !raw.line_items.every(isLineItem)) {
throw new Error('Invalid line_items');
}
return {
id: raw.invoice_id,
totalCents: raw.total_cents,
lines: raw.line_items.map((l) => ({ sku: l.sku, qty: l.qty })),
};
}4. Sketch a PaymentGateway adapter for a second vendor whose SDK uses capturePayment({ cents, customer }) instead of Stripe’s shape {Phác adapter PaymentGateway cho vendor thứ hai dùng SDK capturePayment({ cents, customer }) thay vì hình Stripe}.
Solution {Lời giải}
interface AdyenLikeSdk {
capturePayment(params: { cents: number; customer: string; currencyCode: string }): Promise<{
paymentId: string;
ok: boolean;
}>;
}
export function createAdyenPaymentAdapter(sdk: AdyenLikeSdk): PaymentGateway {
return {
async charge({ amountCents, currency, customerId }) {
const result = await sdk.capturePayment({
cents: amountCents,
customer: customerId,
currencyCode: currency,
});
return {
id: result.paymentId,
status: result.ok ? 'succeeded' : 'failed',
};
},
};
}Swap adapters at bootstrap; checkout() stays unchanged {Đổi adapter lúc bootstrap; checkout() không đổi}.
5. Name one symptom that tells you a “facade” has become a god object, and one fix {Nêu một triệu chứng cho thấy “facade” đã thành god object, và một cách sửa}.
Solution {Lời giải}
Symptom: the facade file owns validation, pricing, and email HTML — every feature PR touches it {Triệu chứng: file facade giữ validation, pricing và HTML email — mọi PR tính năng sửa file đó}. Fix: move rules into subsystems (or domain services) and leave the facade as a thin orchestrator that only calls them in order {Sửa: đưa rule vào subsystem (hoặc domain service); facade chỉ còn điều phối gọi theo thứ tự}.
Stretch {Nâng cao}: combine Adapter + anti-corruption: an HTTP client returns unknown, you validate a vendor list DTO, map to Product[], and expose ProductRepository — no vendor types past the module boundary {kết hợp Adapter + anti-corruption: HTTP client trả unknown, validate DTO list vendor, map sang Product[], lộ ProductRepository — không có kiểu vendor vượt biên module}.
Key takeaways {Điểm chính}
- Adapter — make an incompatible API fit your interface; map types and errors at the boundary {Adapter — API không tương thích khớp interface của bạn; map kiểu và lỗi ở biên}.
- Facade — one simple API over many subsystems; orchestrate, don’t re-implement domain rules {Facade — API đơn giản trên nhiều subsystem; điều phối, không cài lại rule domain}.
- Anti-corruption layer — validate
unknown, map to domain models; never return raw vendor DTOs inward {Anti-corruption — validateunknown, map sang model domain; không trả DTO vendor thô vào trong}. - Decorator (Part 6) adds behavior on the same interface; Adapter/Facade change what clients see {Decorator (Phần 6) thêm hành vi trên cùng interface; Adapter/Facade đổi những gì client thấy}.
Next up {Tiếp theo}
Part 8 — Command & Memento: encapsulate requests as objects you can queue, undo, and replay — typed commands, action history, and UI state snapshots without tangling callers {Phần 8 — Command & Memento: đóng gói request thành object có thể xếp hàng, undo và replay — command có kiểu, lịch sử action, snapshot UI không rối caller}. Continue to Part 8 — Command & Memento. ← Part 6 — Decorator & Middleware