Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 018 / 100

Structural patterns overview: what developers should know

Composing objects into larger structures.

Expona Admin12 min read

Structural patterns are the glue that holds complex systems together—they show us how to compose objects into larger structures without duplicating logic or breaking existing code. After five years building platforms where scalability matters, I've learned that mastering these five patterns cuts hours off code review and prevents the "refactor hell" that comes from tangled dependencies. Today we're diving into the family of structural patterns and why they're everywhere you look.

The family — Compare PATTERN vs PURPOSE

Structural patterns solve a common problem: how do you organize code so that objects and classes work together elegantly? Each pattern in this family takes a different angle. Let me break down the five core patterns side by side.

Adapter: Make incompatible interfaces fit

Think of an adapter as a translator. You have two things that need to talk, but their languages don't match. The Adapter pattern lets you create a middle layer that speaks both languages without changing either original class.

Real example: In one of our e-commerce platforms, we had a legacy payment gateway expecting XML and a new third-party provider sending JSON. Instead of rewriting either system, we built an adapter.

typescript
// Legacy system expects this interface
interface LegacyPaymentGateway {
  processPaymentXML(xml: string): string;
}

// New provider has this interface
class ModernPaymentProvider {
  processPayment(data: { amount: number; currency: string }): Promise<boolean> {
    // Returns JSON response
    return Promise.resolve(true);
  }
}

// Adapter bridges the gap
class PaymentAdapter implements LegacyPaymentGateway {
  constructor(private modern: ModernPaymentProvider) {}

  async processPaymentXML(xml: string): Promise<string> {
    const parsed = this.parseXML(xml);
    const result = await this.modern.processPayment(parsed);
    return this.toXMLResponse(result);
  }

  private parseXML(xml: string) {
    // Parse logic
    return { amount: 100, currency: 'USD' };
  }

  private toXMLResponse(success: boolean): string {
    return `<response><status>${success ? 'OK' : 'FAILED'}</status></response>`;
  }
}

The adapter doesn't change the legacy system or the modern provider—it just connects them. This pattern shows up constantly when integrating third-party APIs or migrating systems gradually.

Facade: Simplify a complex subsystem

A facade is the front desk of a hotel. Guests don't need to know about housekeeping, maintenance, and accounting—they ask the front desk, which coordinates everything behind the scenes. It simplifies a complex subsystem into a single entry point.

In our CRM platform, the order fulfillment process involved inventory checks, payment processing, shipping label generation, and notification queuing. Without a facade, controllers became bloated.

typescript
// Complex subsystem with many moving parts
class InventoryService {
  check(sku: string, qty: number): boolean { /* ... */ }
  reserve(sku: string, qty: number): void { /* ... */ }
}

class PaymentService {
  charge(amount: number, token: string): Promise<string> { /* ... */ }
}

class ShippingService {
  generateLabel(order: Order): Promise<Label> { /* ... */ }
}

class NotificationService {
  queue(type: string, data: any): void { /* ... */ }
}

// Facade hides the complexity
class OrderFulfillmentFacade {
  constructor(
    private inventory: InventoryService,
    private payment: PaymentService,
    private shipping: ShippingService,
    private notification: NotificationService
  ) {}

  async fulfillOrder(order: Order): Promise<FulfillmentResult> {
    // Coordinated workflow—caller doesn't see the steps
    if (!this.inventory.check(order.sku, order.quantity)) {
      throw new Error('Out of stock');
    }

    this.inventory.reserve(order.sku, order.quantity);
    const transactionId = await this.payment.charge(order.total, order.token);
    const label = await this.shipping.generateLabel(order);
    this.notification.queue('order_confirmed', { orderId: order.id, label });

    return { transactionId, label, status: 'fulfilled' };
  }
}

// Controllers become clean
app.post('/orders', async (req, res) => {
  const result = await fulfillmentFacade.fulfillOrder(req.body);
  res.json(result);
});

Facades are ideal for APIs, workflow orchestration, and any place where you're coordinating multiple services. They're not hiding implementation details from the developer—they're making the happy path obvious.

Decorator: Add behavior by wrapping

A decorator wraps an object and adds functionality without modifying it. Think of decorating a Christmas tree—you're adding ornaments, not replacing the tree. In code, decorators let you stack behavior dynamically.

In our trading platform, we needed to add logging, caching, and permission checks to various services. Decorators kept the logic orthogonal.

typescript
// Original service
class OrderService {
  async getOrder(id: string): Promise<Order> {
    return db.query(`SELECT * FROM orders WHERE id = $1`, [id]);
  }
}

// Decorator adds caching
class CachedOrderService {
  private cache = new Map<string, Order>();

  constructor(private inner: OrderService) {}

  async getOrder(id: string): Promise<Order> {
    if (this.cache.has(id)) {
      return this.cache.get(id)!;
    }
    const order = await this.inner.getOrder(id);
    this.cache.set(id, order);
    return order;
  }
}

// Decorator adds logging
class LoggedOrderService {
  constructor(private inner: OrderService) {}

  async getOrder(id: string): Promise<Order> {
    console.log(`[OrderService] getOrder called with id=${id}`);
    const start = Date.now();
    const order = await this.inner.getOrder(id);
    console.log(`[OrderService] getOrder completed in ${Date.now() - start}ms`);
    return order;
  }
}

// Decorator adds permission checks
class ProtectedOrderService {
  constructor(private inner: OrderService, private auth: AuthService) {}

  async getOrder(id: string): Promise<Order> {
    if (!this.auth.canAccess('order.read')) {
      throw new Error('Access denied');
    }
    return this.inner.getOrder(id);
  }
}

// Stack decorators—order matters!
const base = new OrderService();
const cached = new CachedOrderService(base);
const logged = new LoggedOrderService(cached);
const protected = new ProtectedOrderService(logged, auth);

await protected.getOrder('123');

Decorators shine when you need to add cross-cutting concerns like logging, caching, validation, or permission checks without editing the original class. They're also used extensively in frameworks—think TypeScript decorators or middleware in Express.

Proxy: Control access to an object

A proxy stands in front of a real object and controls how you interact with it. Unlike a decorator (which adds behavior), a proxy controls access. It might delay instantiation, restrict calls, or manage resource usage.

In our ride-hailing platform, creating a real GPS tracker for every user was expensive. We used a proxy to instantiate them lazily and manage connections.

typescript
// Real GPS tracker—expensive to create and maintain
class RealGPSTracker {
  private location = { lat: 0, lng: 0 };
  private socket: WebSocket;

  constructor(userId: string) {
    // Expensive: opens WebSocket connection, subscribes to location stream
    this.socket = new WebSocket(`wss://gps-service/track/${userId}`);
    console.log(`[GPS] Real tracker created for ${userId}`);
  }

  getLocation() {
    return this.location;
  }

  startTracking() {
    // Start consuming data
  }
}

// Proxy controls access
class GPSTrackerProxy {
  private real: RealGPSTracker | null = null;

  constructor(private userId: string) {
    console.log(`[GPS] Proxy created for ${userId} (no real tracker yet)`);
  }

  getLocation() {
    // Lazy initialization—only create when needed
    if (!this.real) {
      this.real = new RealGPSTracker(this.userId);
    }
    return this.real.getLocation();
  }

  startTracking() {
    if (!this.real) {
      this.real = new RealGPSTracker(this.userId);
    }
    this.real.startTracking();
  }

  disconnect() {
    // Control cleanup
    if (this.real) {
      this.real = null;
      console.log(`[GPS] Tracker disconnected for ${this.userId}`);
    }
  }
}

// Usage
const tracker = new GPSTrackerProxy('user123');
// No real tracker yet
const location = tracker.getLocation(); // Now it's created
tracker.disconnect(); // Controlled cleanup

Proxies are powerful for lazy initialization, connection pooling, access control, and monitoring. In web development, you see them as middleware checking permissions before passing requests through, or as database connection pools limiting concurrent connections.

Composite: Treat trees uniformly

The Composite pattern lets you build tree structures where every node—leaf or branch—has the same interface. This is perfect for hierarchies: organizational charts, file systems, UI component trees, or nested permissions.

In our SaaS platform, we modeled permissions as a tree—individual permissions could be grouped, and groups could contain subgroups. Composite made it elegant to check access recursively.

typescript
// Single interface for both leaf and composite
interface Permission {
  getName(): string;
  has(permission: string): boolean;
}

// Leaf node
class AtomicPermission implements Permission {
  constructor(private name: string) {}

  getName(): string {
    return this.name;
  }

  has(permission: string): boolean {
    return this.name === permission;
  }
}

// Composite node
class PermissionGroup implements Permission {
  private children: Permission[] = [];

  constructor(private name: string) {}

  getName(): string {
    return this.name;
  }

  add(permission: Permission): void {
    this.children.push(permission);
  }

  has(permission: string): boolean {
    // Check if any child has it
    return this.children.some(child => child.has(permission));
  }
}

// Usage—same interface, different behavior
const userPerms = new PermissionGroup('user');
userPerms.add(new AtomicPermission('profile.read'));
userPerms.add(new AtomicPermission('profile.write'));

const adminPerms = new PermissionGroup('admin');
adminPerms.add(new AtomicPermission('users.delete'));
adminPerms.add(userPerms); // Composite contains composite

console.log(adminPerms.has('profile.read')); // true—traverses the tree
console.log(adminPerms.has('nonexistent')); // false

Composite patterns appear everywhere: React component trees, DOM elements (where a div can contain other divs), organizational hierarchies, and any recursive structure where you want to apply the same operation to all levels.

The common idea — Wrap, combine or simplify without changing the original classes

If you zoom out, all five patterns follow the same philosophy: composition over modification. Instead of editing existing classes (which risks breaking code and violates the Open/Closed Principle), you wrap them, adapt them, or group them.

In a production system, this becomes crucial. When you're working with third-party libraries, legacy code, or shared services, you can't always modify the original. Structural patterns give you tools to work with what you have.

Pattern Mechanism When to use
Adapter Translate interfaces Integrating incompatible systems
Facade Simplify complex subsystems Workflow coordination, APIs
Decorator Add behavior dynamically Cross-cutting concerns (logging, caching)
Proxy Control access Lazy initialization, permission checks, pooling
Composite Build tree hierarchies Recursive structures, hierarchies

The key insight: none of these patterns require you to change the original classes. You layer behavior on top, or you create a simpler interface in front. This keeps your codebase flexible as requirements evolve.

Seen everywhere — Middleware, HTTP handlers and UI components use these daily

Structural patterns aren't academic—they're the foundation of real frameworks and systems you use every day.

Middleware: Express, NestJS, and FastAPI middleware are decorators or proxies. Each middleware wraps the next one, adding logging, authentication, rate limiting, or request parsing without touching the route handler.

typescript
// Middleware as decorator pattern
app.use((req, res, next) => {
  const start = Date.now();
  res.on('finish', () => {
    console.log(`${req.method} ${req.path} - ${Date.now() - start}ms`);
  });
  next();
});

app.use((req, res, next) => {
  if (!req.headers.authorization) {
    return res.status(401).json({ error: 'Unauthorized' });
  }
  next();
});

// Your actual handler is wrapped by all the middleware above
app.get('/api/users', (req, res) => {
  res.json({ users: [] });
});

HTTP handlers: In NestJS, you'll use @Controller, @UseGuards, @UseInterceptors—these are all decorators adding cross-cutting concerns. Guards are proxies controlling access. Interceptors are decorators transforming requests and responses.

UI components: In React, higher-order components (HOCs) and render props are decorator patterns. A withAuth HOC wraps a component and handles authentication. Styled-components wraps React components and adds styling. Context providers are facades simplifying global state access.

typescript
// React HOC—decorator pattern
function withAuth(Component) {
  return (props) => {
    const [user, setUser] = React.useState(null);

    React.useEffect(() => {
      // Check authentication
      checkAuth().then(setUser);
    }, []);

    if (!user) return <Login />;
    return <Component user={user} {...props} />;
  };
}

// Usage—component is wrapped, its functionality is unchanged
const ProtectedDashboard = withAuth(Dashboard);

ORM frameworks: When you use an ORM like Prisma or TypeORM, the model classes are often proxies. They intercept database calls, handle lazy loading, and manage relationships without you writing SQL.

Once you recognize these patterns, you see them everywhere. The reason they're ubiquitous is simple: they solve real problems cleanly.

Common mistakes

Overcomplicating with decorators: It's tempting to decorate everything—logging, caching, validation. But stacking six decorators makes debugging painful. Decorate strategically; use dependency injection for clarity.

Confusing Proxy and Decorator: Both wrap objects. The difference: Proxy controls access (lazy initialization, permission checks, connection pooling). Decorator adds behavior (logging, caching, transformation). If you're not controlling access, use a Decorator.

Facade that exposes everything: A facade should simplify; if it exposes all the underlying services, it's not hiding complexity—it's just adding a layer. Keep the facade's interface minimal and focused.

Composite without thinking about traversal: Composite patterns work well for trees, but ensure traversal is efficient. A deep, wide permission tree checked on every request will hurt performance. Consider caching or flattening.

Adapter as a dumping ground: Adapters are meant to bridge incompatible interfaces. If your adapter is doing significant business logic, you've probably chosen the wrong pattern. Consider a facade or decorator instead.

Key takeaways

  • Structural patterns compose objects into larger structures—they're about organization and layering, not creating new behavior from scratch.
  • Each pattern solves a specific problem: Adapter (interface mismatch), Facade (complexity), Decorator (add behavior), Proxy (control access), Composite (tree hierarchies).
  • The core principle is composition over modification—wrap, adapt, or simplify instead of editing existing classes. This keeps your codebase flexible and respects the Open/Closed Principle.
  • You encounter these patterns daily in frameworks: middleware in Express, guards in NestJS, HOCs in React, ORMs in databases. Recognizing them helps you understand how frameworks actually work.
  • Don't over-apply them—choose the right pattern for the problem. A facade for workflow coordination, a decorator for cross-cutting concerns, a proxy for access control.
  • Clarity over cleverness: It's easy to build intricate decorator stacks or deep composite trees. Make sure your teammates can understand the flow without a diagram.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles