Structural Design Patterns
Structural patterns deal with object composition and typically identify simple ways to realize relationships between different objects. They help ensure that when one part of a system changes, the entire structure does not need to change. These patterns use inheritance and composition to create larger structures from individual objects and classes.
Adapter Pattern
The Adapter pattern converts the interface of a class into another interface that clients expect. It allows classes with incompatible interfaces to work together. This is extremely common when integrating third-party libraries or external APIs.
// Our application expects this interface
interface PaymentProcessor {
charge(amount: number, currency: string, customerId: string): Promise;
refund(chargeId: string, amount?: number): Promise;
}
// Stripe SDK has a different interface
class StripeSDK {
async createPaymentIntent(params: {
amount: number; currency: string; customer: string;
}): Promise<{ id: string; status: string; client_secret: string }> {
// Stripe-specific implementation
return { id: "pi_123", status: "succeeded", client_secret: "secret" };
}
async createRefund(params: {
payment_intent: string; amount?: number;
}): Promise<{ id: string; status: string }> {
return { id: "re_123", status: "succeeded" };
}
}
// Adapter: Wraps Stripe SDK to match our interface
class StripeAdapter implements PaymentProcessor {
constructor(private readonly stripe: StripeSDK) {}
async charge(amount: number, currency: string, customerId: string): Promise {
const intent = await this.stripe.createPaymentIntent({
amount: Math.round(amount * 100), // Stripe uses cents
currency,
customer: customerId,
});
return {
chargeId: intent.id,
success: intent.status === "succeeded",
clientSecret: intent.client_secret,
};
}
async refund(chargeId: string, amount?: number): Promise {
const result = await this.stripe.createRefund({
payment_intent: chargeId,
amount: amount ? Math.round(amount * 100) : undefined,
});
return {
refundId: result.id,
success: result.status === "succeeded",
};
}
}
// Now we can also add PayPal without changing anything
class PayPalAdapter implements PaymentProcessor {
constructor(private readonly paypal: PayPalSDK) {}
async charge(amount: number, currency: string, customerId: string): Promise {
const order = await this.paypal.createOrder({ value: amount, currency_code: currency });
return { chargeId: order.id, success: order.status === "COMPLETED" };
}
async refund(chargeId: string, amount?: number): Promise {
const result = await this.paypal.refundCapture(chargeId, amount);
return { refundId: result.id, success: true };
}
}
Decorator Pattern
The Decorator pattern attaches additional responsibilities to an object dynamically. It provides a flexible alternative to subclassing for extending functionality. Decorators wrap the original object and add behavior before or after delegating to it.
// Base interface
interface Logger {
log(level: string, message: string, meta?: Record): void;
}
// Concrete implementation
class ConsoleLogger implements Logger {
log(level: string, message: string, meta?: Record): void {
console.log(`[${level.toUpperCase()}] ${message}`, meta || "");
}
}
// Decorator: Add timestamps
class TimestampLogger implements Logger {
constructor(private readonly inner: Logger) {}
log(level: string, message: string, meta?: Record): void {
this.inner.log(level, message, {
...meta,
timestamp: new Date().toISOString(),
});
}
}
// Decorator: Add request context
class ContextLogger implements Logger {
constructor(
private readonly inner: Logger,
private readonly context: Record
) {}
log(level: string, message: string, meta?: Record): void {
this.inner.log(level, message, { ...meta, ...this.context });
}
}
// Decorator: Filter by log level
class LevelFilterLogger implements Logger {
private levels = { debug: 0, info: 1, warn: 2, error: 3 };
constructor(
private readonly inner: Logger,
private readonly minLevel: string
) {}
log(level: string, message: string, meta?: Record): void {
if ((this.levels[level as keyof typeof this.levels] ?? 0) >= (this.levels[this.minLevel as keyof typeof this.levels] ?? 0)) {
this.inner.log(level, message, meta);
}
}
}
// Compose decorators — each adds a layer of functionality
const logger = new LevelFilterLogger(
new TimestampLogger(
new ContextLogger(
new ConsoleLogger(),
{ service: "order-service", version: "2.1.0" }
)
),
"info" // Filter out debug messages
);
logger.log("info", "Order placed", { orderId: "123" });
// Output: [INFO] Order placed { orderId: "123", service: "order-service", version: "2.1.0", timestamp: "2026-..." }
Facade Pattern
The Facade pattern provides a simplified interface to a complex subsystem. It does not encapsulate the subsystem — clients can still use the subsystem directly if needed — but it provides a convenient default interface for the most common use cases.
// Complex subsystem: Multiple services for order processing
class InventoryService {
async checkStock(items: OrderItem[]): Promise { /* ... */ return { available: true }; }
async reserveStock(items: OrderItem[]): Promise { /* ... */ return "rsv_123"; }
async releaseStock(reservationId: string): Promise { /* ... */ }
}
class PricingService {
async calculateTotal(items: OrderItem[], coupon?: string): Promise { /* ... */ return { total: 0, discount: 0 }; }
async validateCoupon(code: string): Promise { /* ... */ return true; }
}
class PaymentService {
async authorize(amount: number, method: PaymentMethod): Promise { /* ... */ return "auth_123"; }
async capture(authorizationId: string): Promise { /* ... */ }
async void(authorizationId: string): Promise { /* ... */ }
}
class ShippingService {
async calculateShipping(address: Address, items: OrderItem[]): Promise { /* ... */ return { cost: 0, estimatedDays: 3 }; }
async createShipment(orderId: string, address: Address): Promise { /* ... */ return "shp_123"; }
}
// Facade: Simple interface for the common checkout flow
class CheckoutFacade {
constructor(
private readonly inventory: InventoryService,
private readonly pricing: PricingService,
private readonly payment: PaymentService,
private readonly shipping: ShippingService
) {}
async checkout(request: CheckoutRequest): Promise {
// Step 1: Check stock
const stock = await this.inventory.checkStock(request.items);
if (!stock.available) {
return { success: false, error: "Items out of stock" };
}
// Step 2: Calculate total
const pricing = await this.pricing.calculateTotal(request.items, request.couponCode);
const shippingQuote = await this.shipping.calculateShipping(request.address, request.items);
const total = pricing.total + shippingQuote.cost;
// Step 3: Reserve stock
const reservationId = await this.inventory.reserveStock(request.items);
try {
// Step 4: Authorize payment
const authId = await this.payment.authorize(total, request.paymentMethod);
// Step 5: Capture payment and create shipment
await this.payment.capture(authId);
const shipmentId = await this.shipping.createShipment(request.orderId, request.address);
return {
success: true,
orderId: request.orderId,
total,
shipmentId,
estimatedDelivery: shippingQuote.estimatedDays,
};
} catch (error) {
// Rollback
await this.inventory.releaseStock(reservationId);
return { success: false, error: (error as Error).message };
}
}
}
// Client code is simple
const checkout = new CheckoutFacade(inventory, pricing, payment, shipping);
const result = await checkout.checkout({
orderId: "ord_123",
items: [{ productId: "p1", quantity: 2 }],
address: { street: "123 Main St", city: "NYC" },
paymentMethod: { type: "card", token: "tok_123" },
});
Proxy Pattern
// Caching Proxy
class CachingUserServiceProxy implements UserService {
private cache = new Map();
constructor(
private readonly realService: UserService,
private readonly ttlMs: number = 60000
) {}
async getUser(id: string): Promise {
const cached = this.cache.get(id);
if (cached && cached.expiresAt > Date.now()) {
return cached.data;
}
const user = await this.realService.getUser(id);
this.cache.set(id, { data: user, expiresAt: Date.now() + this.ttlMs });
return user;
}
}
// Logging Proxy using ES6 Proxy
function createLoggingProxy(target: T, name: string): T {
return new Proxy(target, {
get(obj: T, prop: string | symbol) {
const value = Reflect.get(obj, prop);
if (typeof value === "function") {
return async (...args: unknown[]) => {
console.log(`[${name}] Calling ${String(prop)} with`, args);
const start = Date.now();
try {
const result = await value.apply(obj, args);
console.log(`[${name}] ${String(prop)} completed in ${Date.now() - start}ms`);
return result;
} catch (error) {
console.error(`[${name}] ${String(prop)} failed:`, error);
throw error;
}
};
}
return value;
},
});
}
// Wrap any service with logging
const userService = createLoggingProxy(new RealUserService(), "UserService");
Pattern Selection Guide
- Adapter: When you need to integrate a third-party library or legacy system with an incompatible interface
- Decorator: When you want to add behavior to objects without modifying them — logging, caching, validation
- Facade: When you want to provide a simple interface to a complex subsystem
- Proxy: When you need to control access — caching, lazy loading, access control, logging