image title

Structural Design Patterns – Building Blocks for Cleaner Code

Structural design patterns are all about how your code is organized and connected. They help you compose objects and classes in a way that’s flexible, reusable, and easy to maintain.

When we talk about how code is written, most developers focus on syntax or performance. But equally important is how different parts of your application fit together. That’s where structural design patterns come in. Structural patterns are like the scaffolding of a building, they don’t decide what your system does, but they determine how its parts are held together. They make your code easier to extend, reduce duplication, and allow changes in one part without breaking everything else.

  • Adapter: Allows incompatible interfaces to work together by converting one interface into another.
  • Decorator: Adds behavior to objects dynamically without affecting other objects of the same class.
  • Facade: Provides a simplified interface to a complex subsystem.
  • Proxy: Controls access to an object, adding a layer for additional functionality like lazy loading or access control.
  • Composite: Composes objects into tree structures to represent part-whole hierarchies.
  • Flyweight: Reduces memory usage by sharing as much data as possible with similar objects.
  • Bridge: Lets you split abstraction and implementation so changes in one don’t affect the other.

#Adapter

The Adapter pattern acts as a translator between two incompatible interfaces. This allows code that wasn’t originally designed to work together to cooperate without changing their existing implementations. Think of it like a when you brought new laptop but power plug is US shape but you live in Europe. What you do? You bring the adapter lets it work in a socket with a different shape for power the plug and this is exactly the same that this patterns means in development.

Example: Integrating a New Payment Provider

Suppose your system works with a standard processPayment(amount) method, but a new payment provider uses a different method signature makeTransaction(total, currency). The Adapter can bridge that gap without rewriting your whole payment flow.

javascript
1// Existing payment system expects:
2class PaymentSystem {
3  pay(processor, amount) {
4    processor.processPayment(amount);
5  }
6}
7
8// Old provider (already matches our interface)
9class OldPaymentProvider {
10  processPayment(amount) {
11    console.log(`Old provider processing payment: $${amount}`);
12  }
13}
14
15// New provider (different interface)
16class NewPaymentProvider {
17  makeTransaction(total, currency) {
18    console.log(`New provider processing ${currency}${total}`);
19  }
20}
21
22// Adapter for new provider
23class NewPaymentAdapter {
24  constructor(newProvider) {
25    this.newProvider = newProvider;
26  }
27
28  processPayment(amount) {
29    // Assume default USD currency
30    this.newProvider.makeTransaction(amount, 'USD');
31  }
32}
33
34// Usage
35const paymentSystem = new PaymentSystem();
36
37const oldProvider = new OldPaymentProvider();
38paymentSystem.pay(oldProvider, 100); // Old provider processing payment: $100
39
40const newProvider = new NewPaymentProvider();
41const adaptedProvider = new NewPaymentAdapter(newProvider);
42paymentSystem.pay(adaptedProvider, 150); // New provider processing USD150
43

#Decorator

The Decorator pattern lets you add new functionality to an object without changing its original code. It’s like wrapping a gift — the gift inside doesn’t change, but the wrapping can add extra flair, features, or protection. In programming, this means you can extend behavior dynamically at runtime without modifying or subclassing the original class.

javascript
1
2// Base API client
3class ApiClient {
4  request(url) {
5    console.log(`Fetching: ${url}`);
6    return { data: `Response from ${url}` };
7  }
8}
9
10// Logging decorator
11class LoggingDecorator {
12  constructor(client) {
13    this.client = client;
14  }
15
16  request(url) {
17    console.log(`[LOG] Requesting: ${url}`);
18    return this.client.request(url);
19  }
20}
21
22// Caching decorator
23class CachingDecorator {
24  constructor(client) {
25    this.client = client;
26    this.cache = {};
27  }
28
29  request(url) {
30    if (this.cache[url]) {
31      console.log(`[CACHE] Returning cached data for: ${url}`);
32      return this.cache[url];
33    }
34    const response = this.client.request(url);
35    this.cache[url] = response;
36    return response;
37  }
38}
39
40// Usage
41let client = new ApiClient();
42client = new LoggingDecorator(client);
43client = new CachingDecorator(client);
44
45client.request('https://example.com/api'); // Logs and fetches
46client.request('https://example.com/api'); // Logs and returns from cache
47

Pros:

  • Adds behavior without touching existing code and follows the Open/Closed Principle.
  • Flexible: can stack multiple decorators to build up features.
  • Reusable: decorators can be applied to different objects.

Cons:

  • More objects and layers can make debugging harder.
  • Order matters: stacking decorators in the wrong sequence might break logic.
  • Easier to misuse: too many decorators can make the flow hard to follow.

Real-World Usage Cases:

  • HTTP middleware — like Express.js, where each middleware layer adds functionality.
  • UI components — adding styling, animations, or behaviors without altering the base component.
  • Logging, caching, validation — extend services with cross-cutting concerns dynamically.
  • Feature toggles — enable/disable features at runtime by wrapping objects.

When to Use:

  • When you want to add extra features to objects without changing their source code.
  • When subclassing would lead to a huge number of variations.

When to Avoid:

  • When the added behavior is always required — integrate it directly instead of decorating.
  • When multiple layers make debugging and maintenance harder.

#Facade Pattern

The Facade pattern provides a simple, unified interface to a complex system. It doesn’t change how the system works internally — it just gives you a cleaner, easier way to interact with it. Think of it like a hotel reception desk: you don’t need to deal directly with housekeeping, maintenance, and room service — you just call the front desk, and they handle it for you.

javascript : Simplifying a File Conversion Workflow
1// Complex subsystems
2class FileReader {
3  read(filePath) {
4    console.log(`Reading file: ${filePath}`);
5    return "file content";
6  }
7}
8
9class FileCompressor {
10  compress(data) {
11    console.log(`Compressing data...`);
12    return `compressed(${data})`;
13  }
14}
15
16class FileUploader {
17  upload(data, destination) {
18    console.log(`Uploading to ${destination}`);
19    return true;
20  }
21}
22
23// Facade
24class FileConversionFacade {
25  constructor() {
26    this.reader = new FileReader();
27    this.compressor = new FileCompressor();
28    this.uploader = new FileUploader();
29  }
30
31  convertAndUpload(filePath, destination) {
32    const data = this.reader.read(filePath);
33    const compressed = this.compressor.compress(data);
34    return this.uploader.upload(compressed, destination);
35  }
36}
37
38// Usage
39const facade = new FileConversionFacade();
40facade.convertAndUpload('file.txt', 'https://storage.example.com');
41

Pros:

  • Simplifies complexity — hides unnecessary details from the client.
  • Reduces learning curve — you don’t need to understand every part of the system.
  • Improves maintainability — changes inside the subsystem don’t affect the external interface.

Cons

  • Can become a “god object” if it tries to handle too much.
  • Might hide powerful features of the subsystem if overly simplified.
  • Adds an extra abstraction layer to maintain.

Real-World Usage Cases

  • Library wrappers — hide complex API calls behind a simple function.
  • Third-party integrations — offer one interface for multiple underlying services.
  • Build/Deploy tools — one command triggers multiple processes behind the scenes.
  • Service gateways — microservices expose a single endpoint for multiple internal calls

When to Use:

  • When you need to simplify complex APIs or workflows.
  • When you want to reduce the dependencies between subsystems and the rest of your code.

When to Avoid:

  • When simplicity hides too much flexibility or advanced features.
  • When the underlying system is already simple enough.

#Proxy Pattern

The Proxy pattern acts as a stand-in or placeholder for another object.
It controls access to the real object, allowing you to add extra functionality like lazy loading, caching, logging, or access control without changing the original object.
Think of it like a personal assistant — instead of dealing directly with a busy CEO, you pass all your requests through the assistant, who decides what gets through and when.

javascript : Adding Caching to an API Client Using Proxy
1// Real subject
2class ApiService {
3  request(url) {
4    console.log(`Fetching: ${url}`);
5    return { data: `Response from ${url}` };
6  }
7}
8
9// Proxy
10class ApiServiceProxy {
11  constructor() {
12    this.apiService = new ApiService();
13    this.cache = {};
14  }
15
16  request(url) {
17    if (this.cache[url]) {
18      console.log(`[CACHE] Returning cached data for: ${url}`);
19      return this.cache[url];
20    }
21    const result = this.apiService.request(url);
22    this.cache[url] = result;
23    return result;
24  }
25}
26
27// Usage
28const api = new ApiServiceProxy();
29
30api.request('https://example.com/data'); // Fetches and caches
31api.request('https://example.com/data'); // Returns from cache
32

Pros:

  • Controls access to an object without changing its code.
  • Can add extra behavior (logging, caching, access control).
  • Supports lazy initialization — object is created only when needed.

Cons:

  • Introduces an extra layer — may impact performance for very frequent calls.
  • Can make code more complex to follow.
  • May hide the fact that you’re not interacting directly with the real object.

Real-World Usage Cases:

  • Caching results of expensive operations.
  • Access control and authentication checks before hitting the real service.
  • Lazy loading large resources only when needed.
  • Logging or monitoring calls to critical services.

When to Use:

  • When you want to control access to an object.
  • When adding caching, logging, or other cross-cutting concerns without modifying the original class.
  • When deferring creation of heavy objects until actually needed.

When to Avoid:

  • When direct access is simpler and performance-critical.
  • When the extra abstraction adds no clear benefit.

#Composite Pattern

The Composite pattern lets you treat individual objects and groups of objects in the same way.
It’s often used to represent part-whole hierarchies — like files and folders, where a folder can contain files or other folders, and you interact with them through the same interface.
Think of it like a to-do list: an item can be a single task or a group of tasks, but you can mark either one as complete using the same action.

javascript : Representing Files and Folders with Composite Pattern
1// Component interface
2class FileSystemItem {
3  getName() {}
4  display(indent = 0) {}
5}
6
7// Leaf - File
8class FileItem extends FileSystemItem {
9  constructor(name) {
10    super();
11    this.name = name;
12  }
13
14  getName() {
15    return this.name;
16  }
17
18  display(indent = 0) {
19    console.log(`${' '.repeat(indent)}📄 ${this.name}`);
20  }
21}
22
23// Composite - Folder
24class FolderItem extends FileSystemItem {
25  constructor(name) {
26    super();
27    this.name = name;
28    this.children = [];
29  }
30
31  add(item) {
32    this.children.push(item);
33  }
34
35  getName() {
36    return this.name;
37  }
38
39  display(indent = 0) {
40    console.log(`${' '.repeat(indent)}📁 ${this.name}`);
41    this.children.forEach(child => child.display(indent + 2));
42  }
43}
44
45// Usage
46const rootFolder = new FolderItem('root');
47const fileA = new FileItem('fileA.txt');
48const fileB = new FileItem('fileB.txt');
49
50const subFolder = new FolderItem('sub');
51const fileC = new FileItem('fileC.txt');
52
53subFolder.add(fileC);
54rootFolder.add(fileA);
55rootFolder.add(fileB);
56rootFolder.add(subFolder);
57
58rootFolder.display();
59

Pros:

  • Treats single objects and groups uniformly.
  • Makes it easy to work with tree structures.
  • Adding new element types is straightforward.

Cons:

  • Can make the design overly general — hard to restrict which objects can be in a composite.
  • May make simple structures more complex.
  • Can be less efficient if operations are applied to many nested objects.

Real-World Usage Cases:

  • File systems (files and folders).
  • UI components with nested child components.
  • Organization charts or hierarchical data.
  • Workflow/task management systems.

When to Use:

  • When you have part-whole hierarchies.
  • When you want clients to treat individual and composite objects the same way.

When to Avoid:

  • When your object structure is flat and doesn’t need hierarchical grouping.
  • When uniform treatment of all objects could cause unintended behavior.

#Flyweight Pattern

The Flyweight pattern is used to minimize memory usage by sharing as much data as possible between similar objects.
Instead of creating a new object for every instance, it reuses existing ones that have the same intrinsic state, and only stores unique (extrinsic) data separately.
Think of it like a font rendering engine: every letter “A” doesn’t need a new shape in memory — you store it once and reuse it wherever it appears.

javascript : Sharing Character Objects in a Text Editor
1// Flyweight - shared object
2class Character {
3  constructor(symbol, fontFamily, fontSize) {
4    this.symbol = symbol;
5    this.fontFamily = fontFamily;
6    this.fontSize = fontSize;
7  }
8
9  display(position) {
10    console.log(`Char '${this.symbol}' at (${position.x}, ${position.y}) with ${this.fontFamily} ${this.fontSize}px`);
11  }
12}
13
14// Flyweight Factory
15class CharacterFactory {
16  constructor() {
17    this.characters = {};
18  }
19
20  getCharacter(symbol, fontFamily, fontSize) {
21    const key = `${symbol}_${fontFamily}_${fontSize}`;
22    if (!this.characters[key]) {
23      this.characters[key] = new Character(symbol, fontFamily, fontSize);
24    }
25    return this.characters[key];
26  }
27}
28
29// Usage
30const factory = new CharacterFactory();
31
32const charA1 = factory.getCharacter('A', 'Arial', 12);
33const charA2 = factory.getCharacter('A', 'Arial', 12);
34
35console.log(charA1 === charA2); // true (shared instance)
36
37charA1.display({ x: 10, y: 20 });
38charA2.display({ x: 15, y: 25 });
39

Pros:

  • Significantly reduces memory usage for large numbers of similar objects.
  • Improves performance in memory-intensive applications.
  • Centralizes shared state for consistency.

Cons:

  • Requires separating intrinsic (shared) and extrinsic (unique) state.
  • Can make code more complex due to managing shared vs. unique data.
  • Not useful if there’s little or no shared state between objects.

Real-World Usage Cases:

  • Text rendering (sharing glyph shapes for repeated characters).
  • Game development (reusing textures, particle shapes).
  • Map applications (sharing icons for repeated markers).
  • Caching data objects to avoid duplication.

When to Use:

  • When you have a large number of similar objects in memory.
  • When most object data can be shared rather than duplicated.

When to Avoid:

  • When objects are all unique and share little state.
  • When managing shared/extrinsic state adds unnecessary complexity.

#Bridge Pattern

The Bridge pattern is used to separate an abstraction from its implementation, allowing both to vary independently.
Instead of binding a class to one specific implementation, you make the abstraction hold a reference to an implementation interface.
Think of it like a TV remote: the remote (abstraction) works with different TV brands (implementations) without changing the remote’s design.

javascript : Separating Payment Abstraction from Notification Implementation
1// Implementation interface
2class Notifier {
3  send(message) {}
4}
5
6// Concrete implementations
7class EmailNotifier extends Notifier {
8  send(message) {
9    console.log(`Sending EMAIL: ${message}`);
10  }
11}
12
13class SMSNotifier extends Notifier {
14  send(message) {
15    console.log(`Sending SMS: ${message}`);
16  }
17}
18
19// Abstraction
20class Payment {
21  constructor(notifier) {
22    this.notifier = notifier;
23  }
24
25  process(amount) {}
26}
27
28// Refined abstractions
29class CreditCardPayment extends Payment {
30  process(amount) {
31    console.log(`Processing credit card payment of $${amount}`);
32    this.notifier.send(`Payment of $${amount} completed via Credit Card`);
33  }
34}
35
36class PayPalPayment extends Payment {
37  process(amount) {
38    console.log(`Processing PayPal payment of $${amount}`);
39    this.notifier.send(`Payment of $${amount} completed via PayPal`);
40  }
41}
42
43// Usage
44const emailNotifier = new EmailNotifier();
45const smsNotifier = new SMSNotifier();
46
47const payment1 = new CreditCardPayment(emailNotifier);
48payment1.process(100);
49
50const payment2 = new PayPalPayment(smsNotifier);
51payment2.process(200);
52

Pros:

  • Allows abstraction and implementation to change independently.
  • Reduces class explosion caused by multiple dimensions of variation.
  • Improves flexibility and scalability.

Cons:

  • Introduces extra layers of abstraction.
  • Can be overkill if you only have one implementation.
  • May make code harder to navigate for newcomers.

Real-World Usage Cases:

  • Payment systems (different payment methods + different notification channels).
  • UI rendering (different UI abstractions + multiple rendering engines).
  • Database drivers (same API + different underlying database engines).
  • Device control systems (common interface + multiple device implementations).

When to Use:

  • When you have multiple dimensions of variation that should evolve independently.
  • When you want to avoid creating many subclasses for every combination.

When to Avoid:

  • When there’s only one implementation — the added abstraction is unnecessary.
  • When future variations are unlikely.

Random things I built,
develop
and care about

Because spending countless hours debugging and perfecting something no one asked for is definitely my idea of fun. 🤷🏻‍♂️