Siddhant Deval
Siddhant Deval
system design18 min read

Structural Design Patterns: Adapters, Proxies, Facades & Trees

Structural patterns solve the friction of connecting incompatible interfaces, managing expensive resources, and representing hierarchical systems. This article covers the Adapter as an anti-corruption layer against vendor SDK churn, ES native Proxy for transparent interception, Facade for subsystem simplification, and Composite for recursive fee calculation trees.

Structural Design Patterns: Adapters, Proxies, Facades & Trees

In TypeScript 5+, OOP is an architectural contract, not an inheritance tree: enforce domain invariants at compile time, encapsulate mutation strictly within aggregates, and invert dependencies so high-level business policy never couples to execution details. Structural patterns enforce the last phrase at a practical level — they are the anti-corruption layers, transparent interceptors, and simplified facades that insulate high-level policy from vendor SDK churn, cross-cutting infrastructure concerns, and deeply recursive domain structures.


1. The Anti-Pattern Graveyard: The Vendor Entanglement

Here is the pattern that makes teams afraid to upgrade payment SDKs:

TYPESCRIPT
// ❌ Anti-Pattern: Direct Stripe SDK calls scattered across domain services

import Stripe from 'stripe'; // Vendor SDK imported into domain logic

class OrderFulfillmentService {
  private stripe = new Stripe(process.env.STRIPE_KEY!, { apiVersion: '2023-10-16' });

  async fulfillOrder(orderId: string, amount: number): Promise<void> {
    // Domain logic interleaved with Stripe-specific API calls
    const paymentIntent = await this.stripe.paymentIntents.create({
      amount: Math.round(amount * 100),
      currency: 'usd',
      metadata: { orderId },     // Stripe-specific field
      confirm: true,
      payment_method: 'pm_card_visa',
    });

    if (paymentIntent.status !== 'succeeded') {
      throw new Error(`Stripe payment failed: ${paymentIntent.last_payment_error?.message}`);
    }

    // More domain logic...
  }
}

When Stripe updates their API (they did with payment_method_configuration in 2024), every OrderFulfillmentService, RefundService, SubscriptionService, and every other class that imported Stripe directly must be audited and updated. The domain is coupled to the vendor's version lifecycle.


2. Adapter: Anti-Corruption Layer

The Adapter pattern translates a third-party interface into the domain's own interface. The domain never sees the vendor SDK — only the adapter does.

TYPESCRIPT
// Domain interface (Part 7: DIP — defined by the domain, owned by the domain)
interface IPaymentGateway {
  charge(amount: number, currency: string, metadata: Record<string, string>): Promise<PaymentResult>;
  refund(transactionId: string, amount?: number): Promise<void>;
}

type PaymentResult = { status: 'OK'; transactionId: string } | { status: 'FAILED'; reason: string };

// ✅ Adapter — the ONLY file that imports Stripe
import Stripe from 'stripe';

class StripeAdapter implements IPaymentGateway {
  private readonly client: Stripe;

  constructor(apiKey: string) {
    this.client = new Stripe(apiKey, { apiVersion: '2023-10-16' });
  }

  // Translates from domain vocabulary to Stripe vocabulary
  async charge(
    amount: number,
    currency: string,
    metadata: Record<string, string>,
  ): Promise<PaymentResult> {
    try {
      const intent = await this.client.paymentIntents.create({
        amount: Math.round(amount), // Domain passes cents; Stripe expects cents too
        currency: currency.toLowerCase(),
        metadata,
        confirm: true,
        automatic_payment_methods: { enabled: true },
      });

      if (intent.status === 'succeeded') {
        return { status: 'OK', transactionId: intent.id };
      }
      return { status: 'FAILED', reason: intent.last_payment_error?.message ?? 'Unknown' };
    } catch (err) {
      const message = err instanceof Stripe.errors.StripeError ? err.message : 'Gateway error';
      return { status: 'FAILED', reason: message };
    }
  }

  async refund(transactionId: string, amount?: number): Promise<void> {
    await this.client.refunds.create({
      payment_intent: transactionId,
      ...(amount !== undefined ? { amount } : {}),
    });
  }
}

// Swapping to Adyen: write AdyenAdapter implementing IPaymentGateway. Zero domain changes.
Pro Tip & Optimization

Anti-Corruption Layer (ACL) is the DDD term for this pattern. In large systems with multiple bounded contexts, the ACL is a dedicated module (directory) that owns all vendor-specific translation logic. No file outside the ACL directory imports vendor SDKs.


3. ECMAScript Native Proxy: Transparent Interception

TypeScript inherits ECMAScript's Proxy API — a mechanism to wrap any object with custom traps that intercept get, set, has, deleteProperty, and 10 other fundamental operations. This is fundamentally different from a wrapping Decorator pattern: a Proxy intercepts at the object model level, not at the method level.

3.1 Validation Proxy

TYPESCRIPT
// ✅ Proxy for runtime type validation on any object
function createValidatingProxy<T extends object>(
  target: T,
  validators: Partial<Record<keyof T, (value: unknown) => boolean>>,
): T {
  return new Proxy(target, {
    set(obj: T, prop: string, value: unknown): boolean {
      const key = prop as keyof T;
      const validate = validators[key];

      if (validate && !validate(value)) {
        throw new TypeError(
          `Invalid value "${String(value)}" for property "${String(prop)}"`,
        );
      }

      (obj as Record<string, unknown>)[prop] = value;
      return true;
    },
  });
}

interface LedgerEntry {
  amount: number;
  currency: string;
  description: string;
}

const entry = createValidatingProxy<LedgerEntry>(
  { amount: 0, currency: 'USD', description: '' },
  {
    amount: (v) => typeof v === 'number' && Number.isInteger(v) && (v as number) >= 0,
    currency: (v) => typeof v === 'string' && ['USD', 'EUR', 'GBP'].includes(v as string),
  },
);

entry.amount = 5000;   // ✅ Valid
entry.currency = 'EUR'; // ✅ Valid
entry.amount = -100;   // ❌ Throws: Invalid value "-100" for property "amount"
entry.currency = 'XYZ'; // ❌ Throws: Invalid value "XYZ" for property "currency"

3.2 Logging Proxy

TYPESCRIPT
// ✅ Transparent logging proxy — wraps any gateway without changing its interface
function createLoggingProxy<T extends object>(target: T, label: string): T {
  return new Proxy(target, {
    get(obj: T, prop: string): unknown {
      const value = (obj as Record<string, unknown>)[prop];

      if (typeof value !== 'function') return value;

      // Wrap the method call with timing and logging
      return async function (this: unknown, ...args: unknown[]) {
        const start = Date.now();
        console.log(`[${label}] ${String(prop)} called`);
        try {
          const result = await (value as Function).apply(obj, args);
          console.log(`[${label}] ${String(prop)} completed in ${Date.now() - start}ms`);
          return result;
        } catch (err) {
          console.error(`[${label}] ${String(prop)} failed after ${Date.now() - start}ms:`, err);
          throw err;
        }
      };
    },
  });
}

const gateway: IPaymentGateway = new StripeAdapter(process.env.STRIPE_KEY!);
const loggedGateway = createLoggingProxy(gateway, 'StripeAdapter');
// All calls through loggedGateway are automatically logged — zero change to StripeAdapter
Crucial Requirement

Native Proxy cannot be polyfilled. It requires a runtime that supports the ES2015 Proxy global natively — all Node.js versions ≥ 6 and all modern browsers satisfy this requirement. But in interview sandboxes targeting very old environments, use a decorator-style wrapper class instead.


4. Facade: Simplifying Subsystem Orchestration

A Facade provides a simplified interface to a complex subsystem. In the fintech domain, order fulfillment involves a payment gateway, inventory service, shipping service, and notification service — coordinating all four in a caller is complex. A FulfillmentFacade simplifies this to a single method.

TYPESCRIPT
// Complex subsystem — four independent services with their own interfaces
interface IInventoryService {
  reserveStock(productId: string, quantity: number): Promise<string>; // Returns reservationId
  releaseReservation(reservationId: string): Promise<void>;
}

interface IShippingService {
  createShipment(orderId: string, addressId: string): Promise<string>; // Returns trackingId
}

interface INotificationService {
  sendOrderConfirmation(customerId: string, orderId: string, trackingId: string): Promise<void>;
}

// ✅ Facade — one method for the complex orchestration
class OrderFulfillmentFacade {
  constructor(
    private readonly payment: IPaymentGateway,
    private readonly inventory: IInventoryService,
    private readonly shipping: IShippingService,
    private readonly notifications: INotificationService,
  ) {}

  // Caller only needs to know about this one method
  async fulfillOrder(order: {
    orderId: string;
    customerId: string;
    productId: string;
    quantity: number;
    addressId: string;
    amountCents: number;
    currency: string;
  }): Promise<{ trackingId: string }> {
    // Step 1: Reserve inventory before charging
    const reservationId = await this.inventory.reserveStock(order.productId, order.quantity);

    try {
      // Step 2: Charge the customer
      const paymentResult = await this.payment.charge(
        order.amountCents,
        order.currency,
        { orderId: order.orderId },
      );

      if (paymentResult.status === 'FAILED') {
        await this.inventory.releaseReservation(reservationId);
        throw new Error(`Payment failed: ${paymentResult.reason}`);
      }

      // Step 3: Create shipment
      const trackingId = await this.shipping.createShipment(order.orderId, order.addressId);

      // Step 4: Notify customer
      await this.notifications.sendOrderConfirmation(order.customerId, order.orderId, trackingId);

      return { trackingId };
    } catch (err) {
      await this.inventory.releaseReservation(reservationId);
      throw err;
    }
  }
}
Two-panel structural pattern map. LEFT panel labeled 'Without Facade: Direct Subsystem Coupling' in red: shows 'OrderController' box with four separate red arrows pointing to four service boxes: 'IPaymentGateway', 'IInventoryService', 'IShippingService', 'INotificationService'. Each arrow is labeled with a specific method call. Red callout: 'Controller must orchestrate 4 services — error handling complexity multiplies'. RIGHT panel labeled 'With Facade: Simplified Interface' in cyan: shows 'OrderController' box with a single cyan arrow to 'OrderFulfillmentFacade' box. From the Facade, four smaller gray arrows point to the four service boxes. Cyan callout: 'Controller calls one method — Facade owns orchestration and error recovery'.
Two-panel structural pattern map. LEFT panel labeled 'Without Facade: Direct Subsystem Coupling' in red: shows 'OrderController' box with four separate red a…

5. Composite: Recursive Fee Calculation Tree

The Composite pattern lets clients treat individual objects and groups of objects uniformly through a common interface. In fintech, pricing rules are naturally hierarchical: a CompoundFeeRule contains multiple child rules; each child may itself be a compound rule. The total fee is the recursive sum.

TYPESCRIPT
// The shared component interface
interface IFeeRule {
  calculate(transactionAmount: number): number; // Returns fee in cents
  describe(): string;
}

// Leaf node — atomic fee rule
class PercentageFee implements IFeeRule {
  constructor(
    private readonly label: string,
    private readonly rate: number, // 0.0–1.0
  ) {}

  calculate(transactionAmount: number): number {
    return Math.round(transactionAmount * this.rate);
  }

  describe(): string {
    return `${this.label} (${(this.rate * 100).toFixed(2)}%)`;
  }
}

// Leaf node — fixed fee
class FlatFee implements IFeeRule {
  constructor(
    private readonly label: string,
    private readonly amountCents: number,
  ) {}

  calculate(_transactionAmount: number): number { return this.amountCents; }
  describe(): string { return `${this.label} ($${(this.amountCents / 100).toFixed(2)} flat)`; }
}

// Composite node — aggregates multiple IFeeRule children
class CompoundFeeRule implements IFeeRule {
  private readonly rules: IFeeRule[] = [];

  constructor(private readonly label: string) {}

  add(rule: IFeeRule): this {
    this.rules.push(rule);
    return this;
  }

  calculate(transactionAmount: number): number {
    return this.rules.reduce((sum, rule) => sum + rule.calculate(transactionAmount), 0);
  }

  describe(): string {
    return `${this.label}: [${this.rules.map(r => r.describe()).join(', ')}]`;
  }
}

// Building a fee tree for a premium international payment:
const internationalPaymentFees = new CompoundFeeRule('International Payment')
  .add(new PercentageFee('Stripe processing', 0.029))  // 2.9%
  .add(new FlatFee('Stripe flat fee', 30))             // $0.30
  .add(new CompoundFeeRule('Regulatory')               // Nested composite
    .add(new PercentageFee('Cross-border fee', 0.015)) // 1.5%
    .add(new PercentageFee('FX conversion', 0.01)),    // 1.0%
  );

const transactionAmount = 100_00; // $100.00 in cents
const totalFee = internationalPaymentFees.calculate(transactionAmount);
console.log(`Total fee: ${totalFee} cents`); // $5.20 in cents = 520
console.log(internationalPaymentFees.describe());
// International Payment: [Stripe processing (2.90%), Stripe flat fee ($0.30 flat),
//   Regulatory: [Cross-border fee (1.50%), FX conversion (1.00%)]]

The caller uses internationalPaymentFees.calculate() without needing to know whether it is a leaf or a composite. The tree structure is internal.


Summary

Pattern Problem Solved TypeScript Implementation
Adapter Domain coupled to vendor SDK specifics class VendorAdapter implements IDomainInterface
Anti-Corruption Layer Vendor API changes propagate through the domain Dedicated ACL module; no vendor import outside it
Proxy Cross-cutting concerns (logging, validation) on any object new Proxy(target, { get/set traps })
Facade Complex subsystem orchestration exposed to callers Single method coordinates multiple service calls
Composite Recursive / hierarchical domain structures IFeeRule[] tree; leaf and composite share same interface

What's Next

In Part 10, we cover the behavioral patterns — how objects communicate, delegate, and respond to events. Part 10: Behavioral Patterns: Strategy, Observer & Command builds a type-safe event bus with generic discriminated union listeners, a Strategy registry that enforces exhaustive provider coverage, and a Command pattern with undo/redo queues for financial ledger operations.

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#TypeScript#Design Patterns#Adapter Pattern#Proxy Pattern#Composite Pattern#LLD Interview
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.