Learning Guides
Menu

Structural Design Patterns

13 min readNode.js Design Patterns

Structural Design Patterns

Structural patterns are about composing objects to form larger structures while keeping them flexible and efficient. In Node.js, they're essential for middleware, adapters, and extending functionality without modifying source code.

Proxy Pattern

What is it? A Proxy provides a surrogate or placeholder for another object, intercepting and controlling access to it.

Why do we need it? Sometimes you need to add behavior to an object without modifying its code:

  • The original object is from a library you can't change
  • You want to add cross-cutting concerns (logging, caching) without polluting business logic
  • You need to control when/how the object is accessed

How it works:

PLAINTEXT
┌────────────┐          ┌─────────────┐          ┌────────────┐
│   Client   │ ───────► │    Proxy    │ ───────► │   Target   │
│            │          │ (intercept) │          │  (actual)  │
└────────────┘          └─────────────┘          └────────────┘

                    Add: logging, caching,
                    validation, lazy loading

The client thinks it's talking to the real object, but the proxy intercepts calls and adds behavior.

Use Cases

  • Logging: Track all property accesses and method calls
  • Validation: Validate data before writing
  • Caching: Return cached results for expensive operations
  • Lazy Loading: Defer initialization until needed
  • Access Control: Check permissions before allowing operations

ES6 Proxy

JavaScript has built-in Proxy support:

JAVASCRIPT
const target = {
  message: "Hello",
  count: 0,
};
 
const handler = {
  get(target, property) {
    console.log(`Getting ${property}`);
    return target[property];
  },
 
  set(target, property, value) {
    console.log(`Setting ${property} to ${value}`);
    target[property] = value;
    return true;
  },
};
 
const proxy = new Proxy(target, handler);
 
proxy.message; // Logs: Getting message
proxy.count = 5; // Logs: Setting count to 5

Logging Proxy

JAVASCRIPT
function createLoggingProxy(target, logger) {
  return new Proxy(target, {
    get(target, property) {
      if (typeof target[property] === "function") {
        return function (...args) {
          logger.log(`Calling ${property} with args:`, args);
          const result = target[property].apply(target, args);
          logger.log(`${property} returned:`, result);
          return result;
        };
      }
      logger.log(`Accessing ${property}:`, target[property]);
      return target[property];
    },
 
    set(target, property, value) {
      logger.log(`Setting ${property} from ${target[property]} to ${value}`);
      target[property] = value;
      return true;
    },
  });
}
 
// Usage
const user = createLoggingProxy(
  {
    name: "Alice",
    greet() {
      return `Hello, ${this.name}`;
    },
  },
  console,
);
 
user.name; // Logs: Accessing name: Alice
user.greet(); // Logs: Calling greet with args: []
// Logs: greet returned: Hello, Alice

Caching Proxy

JAVASCRIPT
function createCachingProxy(target, { maxAge = 60000 } = {}) {
  const cache = new Map();
 
  return new Proxy(target, {
    get(target, property) {
      if (typeof target[property] !== "function") {
        return target[property];
      }
 
      return async function (...args) {
        const key = `${property}:${JSON.stringify(args)}`;
        const cached = cache.get(key);
 
        if (cached && Date.now() - cached.time < maxAge) {
          console.log(`Cache hit for ${key}`);
          return cached.value;
        }
 
        console.log(`Cache miss for ${key}`);
        const result = await target[property].apply(target, args);
        cache.set(key, { value: result, time: Date.now() });
        return result;
      };
    },
  });
}
 
// Usage
const api = {
  async fetchUser(id) {
    // Expensive API call
    const response = await fetch(`/api/users/${id}`);
    return response.json();
  },
};
 
const cachedApi = createCachingProxy(api, { maxAge: 30000 });
 
await cachedApi.fetchUser(1); // Cache miss - fetches from API
await cachedApi.fetchUser(1); // Cache hit - returns cached
await cachedApi.fetchUser(2); // Cache miss - different args

Lazy Loading Proxy

What is it? A proxy that defers the creation of an expensive object until it's actually needed.

Why do we need it? Some objects are expensive to create (database connections, large modules, external services). If you might not need them, why pay the cost upfront?

JAVASCRIPT
function createLazyLoader(loader) {
  let instance = null;
  let loading = null;
 
  return new Proxy(
    {},
    {
      get(target, property) {
        if (!instance) {
          if (!loading) {
            loading = loader().then((obj) => {
              instance = obj;
              return obj;
            });
          }
 
          return async (...args) => {
            const obj = await loading;
            if (typeof obj[property] === "function") {
              return obj[property](...args);
            }
            return obj[property];
          };
        }
 
        return instance[property];
      },
    },
  );
}
 
// Heavy module loaded only when first used
const heavyModule = createLazyLoader(async () => {
  console.log("Loading heavy module...");
  return await import("./heavy-module.js");
});
 
// Nothing loaded yet
await heavyModule.doSomething(); // Now it loads

Decorator Pattern

What is it? A pattern that wraps an object to add new behavior, without changing the original object's code.

Why do we need it? You often need to extend functionality:

  • Add logging to existing methods
  • Add caching to any function
  • Add retry logic to HTTP calls
  • Stack multiple enhancements

The key difference from Proxy: Decorators add new responsibilities; Proxies control access. In practice, they're often used similarly in JavaScript.

How it works:

PLAINTEXT
┌───────────────────────────────────────────────┐
│           Decorator Stack                       │
│  ┌───────────────────────────────────────┐      │
│  │  Retry (retries failed requests)       │      │
│  │  ┌─────────────────────────────────┐      │      │
│  │  │  Auth (adds token)                │      │      │
│  │  │  ┌───────────────────────────┐      │      │      │
│  │  │  │  Logging (tracks calls)    │      │      │      │
│  │  │  │  ┌─────────────────────┐      │      │      │      │
│  │  │  │  │    HTTP Client     │      │      │      │      │
│  │  │  │  └─────────────────────┘      │      │      │      │
└──┴──┴──┴──────────────────────────┴──────┴──────┴──────┴──────┘

Each layer wraps the next, adding its behavior before/after calling the wrapped object.

Object Composition

Decorators dynamically add behavior to objects without modifying their structure.

Object Composition

JAVASCRIPT
function withTimestamp(obj) {
  return {
    ...obj,
    save(...args) {
      return obj.save({
        ...args[0],
        createdAt: new Date(),
        updatedAt: new Date(),
      });
    },
  };
}
 
function withValidation(obj, validate) {
  return {
    ...obj,
    save(data) {
      const errors = validate(data);
      if (errors.length) {
        throw new Error(`Validation failed: ${errors.join(", ")}`);
      }
      return obj.save(data);
    },
  };
}
 
// Usage
let userRepo = {
  save(data) {
    return db.insert("users", data);
  },
};
 
userRepo = withTimestamp(userRepo);
userRepo = withValidation(userRepo, (data) => {
  const errors = [];
  if (!data.email) errors.push("Email required");
  if (!data.name) errors.push("Name required");
  return errors;
});
 
// Now save() validates, adds timestamps, then saves
userRepo.save({ name: "Alice", email: "alice@example.com" });

Class Decorators (Stage 3)

Modern JavaScript supports decorator syntax:

JAVASCRIPT
// Decorator factory
function logged(target, context) {
  const methodName = context.name;
 
  return function (...args) {
    console.log(`Entering ${methodName}`);
    const result = target.apply(this, args);
    console.log(`Exiting ${methodName}`);
    return result;
  };
}
 
function cached(maxAge = 60000) {
  const cache = new Map();
 
  return function (target, context) {
    return async function (...args) {
      const key = JSON.stringify(args);
      const cached = cache.get(key);
 
      if (cached && Date.now() - cached.time < maxAge) {
        return cached.value;
      }
 
      const result = await target.apply(this, args);
      cache.set(key, { value: result, time: Date.now() });
      return result;
    };
  };
}
 
class UserService {
  @logged
  @cached(30000)
  async getUser(id) {
    return await db.query("SELECT * FROM users WHERE id = ?", [id]);
  }
}

Decorator Stack for HTTP Client

JAVASCRIPT
// Base client
class HttpClient {
  async request(url, options = {}) {
    const response = await fetch(url, options);
    return response.json();
  }
}
 
// Decorators
function withRetry(client, { retries = 3, delay = 1000 } = {}) {
  return {
    ...client,
    async request(url, options) {
      for (let i = 0; i <= retries; i++) {
        try {
          return await client.request(url, options);
        } catch (error) {
          if (i === retries) throw error;
          await new Promise((r) => setTimeout(r, delay * (i + 1)));
        }
      }
    },
  };
}
 
function withAuth(client, getToken) {
  return {
    ...client,
    async request(url, options = {}) {
      const token = await getToken();
      return client.request(url, {
        ...options,
        headers: {
          ...options.headers,
          Authorization: `Bearer ${token}`,
        },
      });
    },
  };
}
 
function withLogging(client, logger) {
  return {
    ...client,
    async request(url, options) {
      const start = Date.now();
      logger.log(`Request: ${options.method || "GET"} ${url}`);
 
      try {
        const result = await client.request(url, options);
        logger.log(`Response: ${url} (${Date.now() - start}ms)`);
        return result;
      } catch (error) {
        logger.error(`Error: ${url} - ${error.message}`);
        throw error;
      }
    },
  };
}
 
// Compose decorators
let client = new HttpClient();
client = withLogging(client, console);
client = withAuth(client, () => getTokenFromStorage());
client = withRetry(client, { retries: 3 });
 
// Now client logs, adds auth, and retries!
const data = await client.request("https://api.example.com/users");

Adapter Pattern

What is it? A pattern that converts one interface into another that clients expect.

Why do we need it? Real-world integration challenges:

  • You're switching databases but don't want to rewrite all queries
  • A library uses callback style but your code uses promises
  • An API returns data in format A but your system needs format B
  • You need to support multiple vendors with different interfaces

How it works:

PLAINTEXT
┌───────────┐      ┌─────────────┐      ┌───────────────┐
│  Client   │ ───► │   Adapter   │ ───► │    Adaptee    │
│           │      │             │      │  (different   │
│ expects   │      │ translates  │      │  interface)   │
│ interface │      │   calls     │      │               │
└───────────┘      └─────────────┘      └───────────────┘

The adapter wraps the adaptee and translates method calls to the format the adaptee understands.

Object Adapter

JAVASCRIPT
// Old interface (what we have)
class OldLogger {
  logMessage(level, msg) {
    console.log(`[${level}] ${msg}`);
  }
}
 
// New interface (what we need)
// { info(), warn(), error() }
 
// Adapter
class LoggerAdapter {
  constructor(oldLogger) {
    this.oldLogger = oldLogger;
  }
 
  info(msg) {
    this.oldLogger.logMessage("INFO", msg);
  }
 
  warn(msg) {
    this.oldLogger.logMessage("WARN", msg);
  }
 
  error(msg) {
    this.oldLogger.logMessage("ERROR", msg);
  }
}
 
// Usage
const oldLogger = new OldLogger();
const logger = new LoggerAdapter(oldLogger);
 
logger.info("Application started"); // [INFO] Application started
logger.error("Something failed"); // [ERROR] Something failed

Database Adapter

JAVASCRIPT
// Different databases have different interfaces
class PostgresDriver {
  async runQuery(sql, params) {
    /* ... */
  }
  async startTransaction() {
    /* ... */
  }
  async commitTransaction() {
    /* ... */
  }
  async rollbackTransaction() {
    /* ... */
  }
}
 
class MySQLDriver {
  async execute(sql, params) {
    /* ... */
  }
  async beginTransaction() {
    /* ... */
  }
  async commit() {
    /* ... */
  }
  async rollback() {
    /* ... */
  }
}
 
// Unified interface
class DatabaseAdapter {
  constructor(driver, type) {
    this.driver = driver;
    this.type = type;
  }
 
  async query(sql, params = []) {
    if (this.type === "postgres") {
      return this.driver.runQuery(sql, params);
    } else if (this.type === "mysql") {
      return this.driver.execute(sql, params);
    }
  }
 
  async transaction(callback) {
    try {
      if (this.type === "postgres") {
        await this.driver.startTransaction();
      } else {
        await this.driver.beginTransaction();
      }
 
      const result = await callback(this);
 
      if (this.type === "postgres") {
        await this.driver.commitTransaction();
      } else {
        await this.driver.commit();
      }
 
      return result;
    } catch (error) {
      if (this.type === "postgres") {
        await this.driver.rollbackTransaction();
      } else {
        await this.driver.rollback();
      }
      throw error;
    }
  }
}
 
// Usage - same interface for both databases
const postgresDb = new DatabaseAdapter(new PostgresDriver(), "postgres");
const mysqlDb = new DatabaseAdapter(new MySQLDriver(), "mysql");
 
// Both work the same way
await postgresDb.query("SELECT * FROM users");
await mysqlDb.query("SELECT * FROM users");

Functional Adapter

JAVASCRIPT
// Adapt promise-based to callback-based
function callbackify(asyncFn) {
  return function (...args) {
    const callback = args.pop();
    asyncFn(...args)
      .then((result) => callback(null, result))
      .catch((error) => callback(error));
  };
}
 
// Adapt callback-based to promise-based
const { promisify } = require("util");
const fs = require("fs");
 
const readFile = promisify(fs.readFile);
const data = await readFile("file.txt", "utf8");

Middleware Pattern

What is it? A pattern where requests pass through a chain of processing functions, each having the opportunity to handle, modify, or pass the request to the next handler.

Why do we need it?

  • Separation of concerns: Each middleware does one thing (auth, logging, validation)
  • Reusability: Apply the same middleware to different routes
  • Composability: Mix and match middleware for different endpoints
  • Order control: Execute logic before and after the main handler

How it works:

PLAINTEXT
Request ──► Middleware 1 ──► Middleware 2 ──► Handler
               │                  │              │
               │                  │              │
Response ◄─────┴──────────────────┴──────────────┘

Each middleware can:

  1. Execute code before calling next()
  2. Call next() to pass control to the next middleware
  3. Execute code after next() returns (response phase)
  4. Skip next() to short-circuit the chain (e.g., return error)

This is the foundation of Express, Koa, and many Node.js frameworks.

Basic Middleware Pipeline

JAVASCRIPT
class Pipeline {
  constructor() {
    this.middlewares = [];
  }
 
  use(fn) {
    this.middlewares.push(fn);
    return this;
  }
 
  async execute(context) {
    let index = -1;
 
    const dispatch = async (i) => {
      if (i <= index) {
        throw new Error("next() called multiple times");
      }
      index = i;
 
      const fn = this.middlewares[i];
      if (!fn) return;
 
      await fn(context, () => dispatch(i + 1));
    };
 
    await dispatch(0);
    return context;
  }
}
 
// Usage
const pipeline = new Pipeline();
 
pipeline.use(async (ctx, next) => {
  console.log("Start");
  ctx.startTime = Date.now();
  await next();
  console.log(`Done in ${Date.now() - ctx.startTime}ms`);
});
 
pipeline.use(async (ctx, next) => {
  console.log("Middleware 2");
  await next();
  console.log("Middleware 2 after");
});
 
pipeline.use(async (ctx, next) => {
  console.log("Handler");
  ctx.result = "Hello!";
});
 
await pipeline.execute({});
// Output:
// Start
// Middleware 2
// Handler
// Middleware 2 after
// Done in Xms

Express-style Middleware

JAVASCRIPT
class Router {
  constructor() {
    this.routes = [];
    this.middlewares = [];
  }
 
  use(...fns) {
    this.middlewares.push(...fns);
    return this;
  }
 
  get(path, ...handlers) {
    this.routes.push({ method: "GET", path, handlers });
    return this;
  }
 
  post(path, ...handlers) {
    this.routes.push({ method: "POST", path, handlers });
    return this;
  }
 
  async handle(req, res) {
    const route = this.routes.find(
      (r) => r.method === req.method && r.path === req.path,
    );
 
    if (!route) {
      res.statusCode = 404;
      res.end("Not Found");
      return;
    }
 
    const handlers = [...this.middlewares, ...route.handlers];
    let index = 0;
 
    const next = async (err) => {
      if (err) {
        res.statusCode = 500;
        res.end(err.message);
        return;
      }
 
      const handler = handlers[index++];
      if (handler) {
        try {
          await handler(req, res, next);
        } catch (e) {
          next(e);
        }
      }
    };
 
    await next();
  }
}
 
// Usage
const router = new Router();
 
// Global middleware
router.use(async (req, res, next) => {
  req.startTime = Date.now();
  await next();
  console.log(`${req.method} ${req.path} - ${Date.now() - req.startTime}ms`);
});
 
// Auth middleware
const auth = async (req, res, next) => {
  const token = req.headers.authorization;
  if (!token) {
    res.statusCode = 401;
    res.end("Unauthorized");
    return;
  }
  req.user = await verifyToken(token);
  await next();
};
 
// Routes
router.get("/public", (req, res) => {
  res.end("Public content");
});
 
router.get("/private", auth, (req, res) => {
  res.end(`Hello ${req.user.name}`);
});

Composable Middleware

JAVASCRIPT
function compose(middlewares) {
  return function (context, next) {
    let index = -1;
 
    function dispatch(i) {
      if (i <= index) {
        return Promise.reject(new Error("next() called multiple times"));
      }
      index = i;
 
      let fn = middlewares[i];
      if (i === middlewares.length) fn = next;
      if (!fn) return Promise.resolve();
 
      try {
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err);
      }
    }
 
    return dispatch(0);
  };
}
 
// Compose multiple middlewares into one
const logging = async (ctx, next) => {
  console.log("Request:", ctx.path);
  await next();
};
 
const timing = async (ctx, next) => {
  const start = Date.now();
  await next();
  console.log(`Duration: ${Date.now() - start}ms`);
};
 
const auth = async (ctx, next) => {
  if (!ctx.token) throw new Error("Unauthorized");
  await next();
};
 
const combined = compose([logging, timing, auth]);
 
// Use as single middleware
app.use(combined);

Summary

Structural patterns compose objects effectively:

PatternPurposeUse Case
ProxyControl accessCaching, logging, lazy loading, validation
DecoratorAdd behaviorExtend functionality without inheritance
AdapterConvert interfacesIntegrate incompatible systems
MiddlewarePipeline processingHTTP handling, validation chains

Key takeaways:

  1. ES6 Proxy is powerful for transparent interception
  2. Decorators compose via object spread or class decorators
  3. Adapters enable integration without modifying source
  4. Middleware is Node.js's most common structural pattern

Note

These patterns shine in Node.js because JavaScript's dynamic nature makes them easy to implement. Use them to keep code modular and composable.