Search…
Low Level Design · Part 4

Design patterns: Structural

In this series (15 parts)
  1. Introduction to low level design
  2. SOLID principles
  3. Design patterns: Creational
  4. Design patterns: Structural
  5. Design patterns: Behavioral
  6. Designing a parking lot
  7. Designing a library management system
  8. Designing an elevator system
  9. Designing a hotel booking system
  10. Designing a ride-sharing model
  11. Designing a rate limiter
  12. Designing a logging framework
  13. Designing a notification system
  14. API design and contract-first development
  15. Data modeling for system design

Creational patterns handled object birth. Structural patterns handle object composition: how do you combine classes and objects into larger units without creating a tangled mess? The seven structural patterns each solve a distinct wiring problem that shows up repeatedly in production systems.

Adapter

Adapter converts the interface of an existing class into the interface a client expects. You have a class that does what you need, but its method signatures do not match what the rest of your system calls. Rather than rewriting the existing class (which might be a third-party library you cannot modify), you wrap it.

// Existing third-party XML parser
class XmlParser {
    public XmlDocument parseXml(String raw) { ... }
}

// Your system expects this interface
interface DataParser {
    Map<String, Object> parse(String input);
}

// Adapter bridges the gap
class XmlParserAdapter implements DataParser {
    private XmlParser xmlParser;

    public Map<String, Object> parse(String input) {
        XmlDocument doc = xmlParser.parseXml(input);
        return convertToMap(doc);
    }
}

The rest of your codebase works with DataParser. The adapter translates between what your system speaks and what the third-party library speaks. When you later add a JSON parser, it implements DataParser directly; no adapter needed.

Production use: payment gateway integrations. Stripe, PayPal, and Square each have different SDKs with different method names and response formats. An adapter for each gateway normalizes them behind a single PaymentGateway interface.

Bridge

Bridge decouples an abstraction from its implementation so both can vary independently. This sounds abstract until you see the problem it solves: a combinatorial explosion of subclasses.

Imagine a drawing application with shapes (Circle, Rectangle) and renderers (SVG, Canvas, PDF). Without Bridge, you need SVGCircle, SVGRectangle, CanvasCircle, CanvasRectangle, PDFCircle, PDFRectangle. Six classes for two shapes and three renderers. Add one more shape and you need three more classes. Add one more renderer and you need two more.

With Bridge, shapes hold a reference to a renderer interface. The two hierarchies vary independently.

interface Renderer {
    void renderCircle(float x, float y, float radius);
    void renderRect(float x, float y, float w, float h);
}

abstract class Shape {
    protected Renderer renderer;
    abstract void draw();
}

class Circle extends Shape {
    void draw() { renderer.renderCircle(x, y, radius); }
}

Five classes (two shapes plus three renderers) instead of six, and the ratio improves as the dimensions grow.

Production use: database drivers. JDBC is a Bridge: the Connection and Statement abstractions are separate from the vendor-specific driver implementations. Your code writes JDBC; the driver translates to MySQL, PostgreSQL, or Oracle wire protocol.

Composite

Composite lets you treat individual objects and groups of objects uniformly. It models part-whole hierarchies where a container holds items that might themselves be containers.

File systems are the textbook example: a directory contains files and other directories. Both implement a common interface with getSize(), getName(), and list(). Calling getSize() on a directory recursively sums its children.

interface FileSystemNode {
    String getName();
    long getSize();
}

class File implements FileSystemNode {
    public long getSize() { return this.bytes; }
}

class Directory implements FileSystemNode {
    private List<FileSystemNode> children;
    public long getSize() {
        return children.stream().mapToLong(FileSystemNode::getSize).sum();
    }
}

Production use: UI component trees (React’s component model is a Composite), organization charts, permission hierarchies where a role can contain other roles.

Decorator

Decorator attaches additional behavior to an object dynamically, without altering the class of the original object. Decorators implement the same interface as the object they wrap, forward calls to it, and add their own logic before or after.

classDiagram
  class DataSource {
      <<interface>>
      +writeData(String data)
      +readData() String
  }
  class FileDataSource {
      -String filename
      +writeData(String data)
      +readData() String
  }
  class DataSourceDecorator {
      <<abstract>>
      #DataSource wrappee
      +writeData(String data)
      +readData() String
  }
  class EncryptionDecorator {
      +writeData(String data)
      +readData() String
  }
  class CompressionDecorator {
      +writeData(String data)
      +readData() String
  }
  class LoggingDecorator {
      +writeData(String data)
      +readData() String
  }
  DataSource <|.. FileDataSource
  DataSource <|.. DataSourceDecorator
  DataSourceDecorator <|-- EncryptionDecorator
  DataSourceDecorator <|-- CompressionDecorator
  DataSourceDecorator <|-- LoggingDecorator
  DataSourceDecorator o--> DataSource : wrappee

Decorator chain for data sources. You can stack behaviors: new LoggingDecorator(new EncryptionDecorator(new CompressionDecorator(new FileDataSource("data.txt")))). Each layer adds one concern.

The power of Decorator is combinatorial flexibility. Three decorators give you eight possible combinations (each can be present or absent), and you compose them at runtime rather than defining eight subclasses.

Production use: Java’s InputStream hierarchy. BufferedInputStream wraps any InputStream to add buffering. GZIPInputStream wraps any InputStream to add decompression. Middleware stacks in web frameworks (Express, Koa, Spring) are decorator chains where each middleware wraps the next handler.

Facade

Facade provides a simplified interface to a complex subsystem. It does not add new functionality; it curates the subsystem’s API surface to make common operations easy.

classDiagram
  class OrderFacade {
      +placeOrder(userId, items) OrderConfirmation
      +cancelOrder(orderId) boolean
  }
  class InventoryService {
      +checkStock(itemId) int
      +reserveItems(items)
      +releaseItems(items)
  }
  class PaymentService {
      +charge(userId, amount) PaymentResult
      +refund(paymentId) boolean
  }
  class ShippingService {
      +calculateCost(items, address) double
      +createShipment(orderId) TrackingInfo
  }
  class NotificationService {
      +sendOrderConfirmation(userId, orderId)
      +sendShippingUpdate(userId, trackingInfo)
  }
  OrderFacade --> InventoryService
  OrderFacade --> PaymentService
  OrderFacade --> ShippingService
  OrderFacade --> NotificationService

Order placement facade. The caller invokes one method; the facade orchestrates inventory, payment, shipping, and notification services internally.

Without the facade, every client that places an order would need to know the correct sequence: check inventory, reserve items, charge payment, create shipment, send notification. That sequence would be duplicated across the web controller, the mobile API, and the admin panel. The facade centralizes the orchestration.

Production use: AWS SDKs provide high-level “resource” APIs that are facades over the low-level HTTP operations. ORMs are facades over raw SQL. The fetch() API is a facade over TCP sockets, TLS handshakes, and HTTP framing.

Flyweight

Flyweight minimizes memory usage by sharing state across many similar objects. It splits an object’s state into intrinsic state (shared, immutable) and extrinsic state (unique per instance, passed in from outside).

A text editor rendering a document might have millions of character objects. Each character has a font, size, and color (intrinsic, shared across many characters) and a position on screen (extrinsic, unique per character). Flyweight stores the intrinsic state in a shared pool and passes extrinsic state as method arguments.

class CharacterStyle {
    // intrinsic state, shared
    private String font;
    private int size;
    private Color color;
}

class Character {
    private CharacterStyle style;  // shared reference
    private int row;               // extrinsic
    private int column;            // extrinsic
}

If the document uses only twenty distinct style combinations, you store twenty CharacterStyle objects instead of millions.

Production use: game engines use Flyweight for terrain tiles, particle systems, and sprite sheets. Java’s Integer.valueOf() caches instances for values between -128 and 127, returning the same object for the same value. String interning is Flyweight applied to string literals.

Proxy

Proxy provides a surrogate or placeholder for another object, controlling access to it. The proxy implements the same interface as the real subject, so the client cannot tell the difference.

Common proxy types:

  • Virtual proxy: delays creation of an expensive object until it is first used (lazy initialization).
  • Protection proxy: checks access permissions before forwarding the call.
  • Remote proxy: handles network communication to a remote object, making it look local.
  • Caching proxy: stores results of expensive operations and returns cached values on repeated calls.
class CachingUserRepository implements UserRepository {
    private UserRepository realRepo;
    private Map<String, User> cache = new HashMap<>();

    public User findById(String id) {
        if (cache.containsKey(id)) return cache.get(id);
        User user = realRepo.findById(id);
        cache.put(id, user);
        return user;
    }
}

The caching proxy wraps the real repository, intercepts calls, and serves from cache when possible. The client code works with UserRepository and has no idea caching is involved.

Production use: ORM lazy loading (Hibernate proxies that fetch data only when accessed), API rate limiters, authentication middleware that checks tokens before forwarding requests to the actual handler.

Choosing the right structural pattern

ProblemPattern
Incompatible interface needs translationAdapter
Two dimensions of variation causing class explosionBridge
Tree structures where leaves and branches share an interfaceComposite
Adding behavior without modifying the original classDecorator
Complex subsystem needs a simpler entry pointFacade
Many objects share most of their stateFlyweight
Controlling access, adding caching, or deferring creationProxy

Decorator and Proxy are often confused because both wrap an object. The distinction: Decorator adds behavior; Proxy controls access. A logging decorator adds logging. A caching proxy controls whether the real object is called at all.

What comes next

Structural patterns handle how objects are composed. The next article on behavioral patterns covers how objects communicate and distribute responsibility: Observer, Strategy, Command, State, and more. These patterns define the protocols of collaboration between the structures you have built.

Start typing to search across all content
navigate Enter open Esc close