jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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 Client Adapter Adaptee (odd API) conform FACADE Client Facade Subsystem A Subsystem B Subsystem C one simple entry point
Adapter conforms an odd API to your contract; Facade hides many subsystems behind one entry point

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}

PatternInterface relationshipTypical goal
AdapterDifferent adaptee API → your target interfaceMake legacy/vendor code usable without rewriting callers
FacadeMany subsystem APIs → one simpler APIHide orchestration; reduce what clients import
Decorator (Part 6)Same interface in/out, stacked wrappersAdd 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, or ky behind one HttpClient with your timeouts, auth headers, and error type {Bọc HTTP client — adapt fetch, Axios hay ky sau một HttpClient với timeout, header auth và kiểu lỗi của bạn}.
  • Storage adapterSessionStore implemented by localStorage, sessionStorage, IndexedDB, or cookie backends; UI imports only the interface {Storage adapterSessionStore implement bởi localStorage, sessionStorage, IndexedDB hay cookie; UI chỉ import interface}.
  • Analytics providers — Segment, Plausible, and a dev noop all implement Analytics.track(event, props) {Analytics — Segment, Plausible và noop dev đều implement Analytics.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 sang AuthService.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ùng ProductRepository.list()}.

Pitfalls & anti-patterns {Pitfalls & anti-pattern}

Leaky adapterStripePaymentAdapter 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 facadeCheckoutFacade grows validation, pricing rules, and email templates; it becomes the only file anyone edits {Facade béoCheckoutFacade 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')); // abc

2. Build a Facade ReportFacade over two subsystems: CsvExporter and PdfExporter, with one method exportReport(format, data) {Xây Facade ReportFacade trên hai subsystem: CsvExporterPdfExporter, 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 — validate unknown, 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