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.

Phần 7/10 trong series Design Patterns in TypeScript. Trước: Tiếp:

Đâ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. Ở Phần 6 — Decorator & Middleware bạn bọc hành vi quanh cùng một interface; ở đây bạn đổi hình hoặc đơn giản hóa những gì gọi từ bên ngoài.

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. 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.


Ý đồ

Adapter biến interface không tương thích sẵn có khớp với interface code bạn đã kỳ vọng. Bạn vẫn gọi PaymentGateway.charge(); phía sau có thứ dịch sang stripe.paymentIntents.create().

Facade lộ ra API thống nhất, đơn giản trên một subsystem phức tạp. 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 = interface khác → interface của bạn. Facade = nhiều mảnh → ít method. Cả hai giảm coupling, nhưng giải quyết mùi khác nhau.


Adapter — định interface CỦA BẠN trước

Bắt đầu bằng hợp đồng target mà domain và UI đã phụ thuộc. 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' });
}

Adapter là chỗ duy nhất biết tên method Stripe và status: string thô. Test truyền fake PaymentGateway; production gắn createStripePaymentAdapter(realSdk) ở composition root.


Facade — một cửa vào trên nhiều subsystem

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 });

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. Giữ business rule trong subsystem; facade chỉ xếp lời gọi và gom lỗi một chỗ.


Lớp chống tham nhũng (anti-corruption)

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”. 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 ở biên (guard thủ công hoặc thư viện schema) — rồi map. 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

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 đổi hình để code hiện có giữ nguyên. Facade đổi diện tích bề mặt để client thấy ít hơn. Decorator đổi hành vi mà không đổi kiểu bạn phụ thuộc.


Use case web thực tế

  • 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 implement bởi localStorage, sessionStorage, IndexedDB hay cookie; UI chỉ import interface.
  • Analytics — Segment, Plausible và noop dev đều implement Analytics.track(event, props).
  • Auth SDK — Firebase, Auth0, Clerk adapt sang AuthService.signIn() / getSession() mà router guard đã dùng.
  • Đổi backend — REST hôm nay, GraphQL mai; adapter map cả hai về cùng ProductRepository.list().

Pitfalls & anti-pattern

Adapter rò rỉ — method của StripePaymentAdapter trả kiểu Stripe hoặc ném error class Stripe vào domain. Map lỗi sang PaymentError của bạn ở biên.

Facade béoCheckoutFacade phình validation, pricing, template email; thành file duy nhất mọi người sửa. Tách điều phối khỏi policy; đẩy rule về subsystem.

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. Chỉ adapt khi sẽ thay vendor hoặc phải thống nhất nhiều vendor.

Mô hình lỗi không khớp — vendor ném code; app bạn kỳ vọng Result<T, E>. Chuẩn hóa trong adapter/facade, không ở mọi button handler.


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 */ }

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. Định nghĩa interface SessionStore (get, set, remove) và implement adapter localStorage.

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. Xây Facade ReportFacade trên hai subsystem: CsvExporterPdfExporter, một method exportReport(format, data).

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. 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 }[].

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. Phác adapter PaymentGateway cho vendor thứ hai dùng SDK capturePayment({ cents, customer }) thay vì hình Stripe.

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',
      };
    },
  };
}

Đổi adapter lúc bootstrap; checkout() không đổi.

5. Nêu một triệu chứng cho thấy “facade” đã thành god object, và một cách sửa.

Lời giải

Triệu chứng: file facade giữ validation, pricing và HTML email — mọi PR tính năng sửa file đó. Sửa: đưa rule vào subsystem (hoặc domain service); facade chỉ còn điều phối gọi theo thứ tự.

Nâng cao: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.


Điểm chính

  • Adapter — API không tương thích khớp interface của bạn; map kiểu và lỗi ở biên.
  • Facade — API đơn giản trên nhiều subsystem; điều phối, không cài lại rule domain.
  • Anti-corruption — validate unknown, map sang model domain; không trả DTO vendor thô vào trong.
  • Decorator (Phần 6) thêm hành vi trên cùng interface; Adapter/Facade đổi những gì client thấy.

Tiếp theo

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.