Episode 9 — System Design / 9.4 — Structural Design Patterns

9.4.d -- Decorator Pattern

In one sentence: The Decorator pattern lets you attach new behaviors to objects dynamically by wrapping them in special objects that add functionality -- like stacking toppings on a pizza where each topping wraps the previous layer without changing the pizza itself.

Navigation: <- 9.4.c -- Proxy | 9.4.e -- Composite ->


Table of Contents


1. What is the Decorator Pattern?

The Decorator pattern attaches additional responsibilities to an object dynamically. It provides a flexible alternative to subclassing for extending functionality. Each decorator wraps the original object and adds behavior before or after delegating to it.

  WITHOUT DECORATOR (inheritance explosion):

  DataSource
    |
    +-- FileDataSource
    +-- DatabaseDataSource
    +-- EncryptedFileDataSource          Too many
    +-- CompressedFileDataSource         subclasses!
    +-- EncryptedCompressedFileDataSource
    +-- LoggedEncryptedCompressedFileDataSource  (explosion)

  WITH DECORATOR (compose at runtime):

  ┌─────────────────────────────────────────────────────┐
  │  LoggingDecorator                                   │
  │  ┌───────────────────────────────────────────────┐  │
  │  │  EncryptionDecorator                          │  │
  │  │  ┌─────────────────────────────────────────┐  │  │
  │  │  │  CompressionDecorator                   │  │  │
  │  │  │  ┌───────────────────────────────────┐  │  │  │
  │  │  │  │  FileDataSource (core object)     │  │  │  │
  │  │  │  └───────────────────────────────────┘  │  │  │
  │  │  └─────────────────────────────────────────┘  │  │
  │  └───────────────────────────────────────────────┘  │
  └─────────────────────────────────────────────────────┘

  Each layer adds behavior. Stack any combination you need.

The key insight: each decorator has the same interface as the object it wraps, so decorators can be stacked infinitely.


2. The Problem It Solves

Imagine you have a notification system. You need to support:

  • Email notifications
  • SMS notifications
  • Slack notifications
  • Any combination: Email + SMS, Email + Slack, SMS + Slack, all three

With inheritance, you need a subclass for every combination:

  Notifier
    |-- EmailNotifier
    |-- SMSNotifier
    |-- SlackNotifier
    |-- EmailSMSNotifier
    |-- EmailSlackNotifier
    |-- SMSSlackNotifier
    |-- EmailSMSSlackNotifier
    
  3 channels = 7 subclasses
  4 channels = 15 subclasses
  5 channels = 31 subclasses   <-- combinatorial explosion!

With decorators, you have:

  Notifier (base)
  + EmailDecorator
  + SMSDecorator
  + SlackDecorator

  3 channels = 4 classes, combine however you want at runtime.

3. Structure and Participants

  ┌────────────────────────────┐
  │     <<interface>>          │
  │     Component              │
  │────────────────────────────│
  │  + execute(data)           │
  └──────────────┬─────────────┘
                 │
       ┌─────────┴──────────┐
       │                    │
  ┌────▼────────────┐  ┌───▼──────────────────┐
  │ ConcreteComponent│  │  Decorator (base)     │
  │─────────────────│  │──────────────────────│
  │ + execute(data) │  │ - wrapped: Component │
  │ (core logic)    │  │ + execute(data) {    │
  └─────────────────┘  │    wrapped.execute() │
                       │ }                    │
                       └───────┬──────────────┘
                               │
                 ┌─────────────┼──────────────┐
                 │             │              │
          ┌──────▼──────┐ ┌───▼─────┐  ┌─────▼─────┐
          │ LogDecorator│ │AuthDeco │  │CacheDeco  │
          │ + execute() │ │+execute │  │+execute() │
          │   log()     │ │  check()│  │  lookup() │
          │   wrapped   │ │  wrapped│  │  wrapped  │
          │   .execute()│ │  .exec()│  │  .exec()  │
          └─────────────┘ └─────────┘  └───────────┘
RoleWhat it does
ComponentInterface shared by core objects and decorators
ConcreteComponentThe original object being decorated
DecoratorBase class that holds a reference to a wrapped component
ConcreteDecoratorAdds specific behavior before/after delegating to wrapped component

4. Express Middleware as Decorators

Express.js middleware is the decorator pattern in action. Each middleware wraps the request handler and adds behavior.

// ============================================================
// EXPRESS MIDDLEWARE AS DECORATORS -- FULL IMPLEMENTATION
// ============================================================

// Simplified Express-like request handler pipeline

class RequestHandler {
  constructor(handler) {
    this.handler = handler;
    this.middlewares = [];
  }

  use(middleware) {
    this.middlewares.push(middleware);
    return this; // chainable
  }

  async handle(req, res) {
    // Build the chain: each middleware wraps the next
    let index = 0;
    const middlewares = this.middlewares;
    const finalHandler = this.handler;

    const next = async () => {
      if (index < middlewares.length) {
        const middleware = middlewares[index++];
        await middleware(req, res, next);
      } else {
        await finalHandler(req, res);
      }
    };

    await next();
  }
}


// ---- Middleware decorators ----

// Logging middleware (decorator)
function loggingMiddleware(req, res, next) {
  const start = Date.now();
  console.log(`  [LOG] --> ${req.method} ${req.url}`);
  await next();
  console.log(`  [LOG] <-- ${req.method} ${req.url} [${res.statusCode}] (${Date.now() - start}ms)`);
}

// Auth middleware (decorator)
function authMiddleware(req, res, next) {
  const token = req.headers?.authorization;
  if (!token || !token.startsWith('Bearer ')) {
    res.statusCode = 401;
    res.body = { error: 'Unauthorized' };
    console.log('  [AUTH] Rejected -- no valid token');
    return; // do NOT call next() -- stops the chain
  }
  req.user = { id: 'user_42', role: 'admin' }; // decoded from token
  console.log('  [AUTH] Authenticated as user_42');
  await next();
}

// Rate limiting middleware (decorator)
const rateLimitStore = new Map();
function rateLimitMiddleware(req, res, next) {
  const ip = req.ip || '127.0.0.1';
  const now = Date.now();
  const windowMs = 60000; // 1 minute
  const maxRequests = 5;

  if (!rateLimitStore.has(ip)) {
    rateLimitStore.set(ip, []);
  }

  const requests = rateLimitStore.get(ip).filter((t) => now - t < windowMs);
  requests.push(now);
  rateLimitStore.set(ip, requests);

  if (requests.length > maxRequests) {
    res.statusCode = 429;
    res.body = { error: 'Too many requests' };
    console.log(`  [RATE] Blocked ${ip} (${requests.length} requests in window)`);
    return;
  }

  console.log(`  [RATE] ${ip} OK (${requests.length}/${maxRequests} in window)`);
  await next();
}

// CORS middleware (decorator)
function corsMiddleware(req, res, next) {
  res.headers = res.headers || {};
  res.headers['Access-Control-Allow-Origin'] = '*';
  res.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE';
  console.log('  [CORS] Headers added');
  await next();
}


// ---- The actual handler (core component) ----

async function getUserHandler(req, res) {
  console.log(`  [HANDLER] Fetching user data for ${req.user.id}`);
  res.statusCode = 200;
  res.body = { id: req.user.id, name: 'Alice', email: 'alice@example.com' };
}


// ---- Compose the decorators ----

const handler = new RequestHandler(getUserHandler);
handler
  .use(loggingMiddleware)      // outermost decorator
  .use(corsMiddleware)         // adds headers
  .use(rateLimitMiddleware)    // checks rate limits
  .use(authMiddleware);        // checks authentication

// Simulate a request
const req = { method: 'GET', url: '/api/users/42', headers: { authorization: 'Bearer abc123' }, ip: '192.168.1.1' };
const res = { statusCode: null, body: null, headers: {} };

console.log('=== Request with all middleware decorators ===');
handler.handle(req, res).then(() => {
  console.log('\nFinal response:', res.body);
});

5. Logging Decorator

A pure class-based logging decorator that wraps any service.

// ============================================================
// LOGGING DECORATOR
// ============================================================

// ---- Base service interface ----

class UserService {
  async getUser(id) {
    return { id, name: 'Alice', email: 'alice@example.com' };
  }

  async createUser(data) {
    return { id: 'new_' + Date.now(), ...data };
  }

  async deleteUser(id) {
    return { deleted: true, id };
  }
}


// ---- Logging Decorator ----

class LoggingDecorator {
  constructor(service, serviceName = 'Service') {
    this.service = service;
    this.serviceName = serviceName;

    // Dynamically wrap all methods
    const methodNames = Object.getOwnPropertyNames(
      Object.getPrototypeOf(service)
    ).filter((name) => name !== 'constructor' && typeof service[name] === 'function');

    for (const method of methodNames) {
      this[method] = async (...args) => {
        const start = Date.now();
        const argsStr = args.map((a) => JSON.stringify(a)).join(', ');
        console.log(`[${this.serviceName}] ${method}(${argsStr}) -- called`);

        try {
          const result = await this.service[method](...args);
          const duration = Date.now() - start;
          console.log(`[${this.serviceName}] ${method} -- completed in ${duration}ms`);
          console.log(`[${this.serviceName}] ${method} -- result:`, result);
          return result;
        } catch (error) {
          const duration = Date.now() - start;
          console.log(`[${this.serviceName}] ${method} -- FAILED in ${duration}ms: ${error.message}`);
          throw error;
        }
      };
    }
  }
}


// ---- Usage ----

const userService = new UserService();
const loggedUserService = new LoggingDecorator(userService, 'UserService');

async function demo() {
  await loggedUserService.getUser('42');
  await loggedUserService.createUser({ name: 'Bob', email: 'bob@test.com' });
}

demo();

Output:

[UserService] getUser("42") -- called
[UserService] getUser -- completed in 0ms
[UserService] getUser -- result: { id: '42', name: 'Alice', email: 'alice@example.com' }
[UserService] createUser({"name":"Bob","email":"bob@test.com"}) -- called
[UserService] createUser -- completed in 0ms
[UserService] createUser -- result: { id: 'new_1712345678', name: 'Bob', email: 'bob@test.com' }

6. Auth Decorator

// ============================================================
// AUTH DECORATOR
// ============================================================

class OrderService {
  async getOrder(orderId) {
    return { id: orderId, items: ['Widget', 'Gadget'], total: 49.99 };
  }

  async cancelOrder(orderId) {
    return { id: orderId, status: 'cancelled' };
  }

  async getAllOrders() {
    return [
      { id: 'ORD-1', total: 29.99 },
      { id: 'ORD-2', total: 149.99 },
    ];
  }
}


class AuthDecorator {
  constructor(service, currentUser, permissions = {}) {
    this.service = service;
    this.currentUser = currentUser;
    this.permissions = permissions;
    // permissions example: { getOrder: ['user', 'admin'], cancelOrder: ['admin'], getAllOrders: ['admin'] }

    // Wrap each method with auth check
    const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(service))
      .filter((n) => n !== 'constructor' && typeof service[n] === 'function');

    for (const method of methods) {
      this[method] = async (...args) => {
        const requiredRoles = this.permissions[method];

        if (requiredRoles && !requiredRoles.includes(this.currentUser.role)) {
          const msg = `Access denied: ${this.currentUser.role} cannot call ${method} (requires: ${requiredRoles.join(', ')})`;
          console.log(`  [Auth] ${msg}`);
          throw new Error(msg);
        }

        console.log(`  [Auth] ${this.currentUser.id} (${this.currentUser.role}) authorized for ${method}`);
        return this.service[method](...args);
      };
    }
  }
}


// ---- Usage: compose auth + logging ----

const orderService = new OrderService();

// Wrap with auth, then with logging
const authedOrders = new AuthDecorator(orderService, { id: 'user_1', role: 'user' }, {
  getOrder: ['user', 'admin'],
  cancelOrder: ['admin'],
  getAllOrders: ['admin'],
});

const loggedAuthedOrders = new LoggingDecorator(authedOrders, 'OrderService');

async function demo() {
  console.log('=== User can get orders ===');
  await loggedAuthedOrders.getOrder('ORD-1');

  console.log('\n=== User cannot cancel orders ===');
  try {
    await loggedAuthedOrders.cancelOrder('ORD-1');
  } catch (e) {
    console.log(`  Caught: ${e.message}`);
  }
}

demo();

7. Caching Decorator

// ============================================================
// CACHING DECORATOR
// ============================================================

class ProductService {
  async getProduct(id) {
    console.log(`    [DB] Loading product ${id} from database...`);
    // Simulate slow database query
    await new Promise((r) => setTimeout(r, 100));
    return { id, name: `Product ${id}`, price: 29.99, stock: 42 };
  }

  async searchProducts(query) {
    console.log(`    [DB] Searching for "${query}"...`);
    await new Promise((r) => setTimeout(r, 200));
    return [
      { id: '1', name: 'Widget', relevance: 0.95 },
      { id: '2', name: 'Widget Pro', relevance: 0.87 },
    ];
  }
}


class CachingDecorator {
  constructor(service, options = {}) {
    this.service = service;
    this.cache = new Map();
    this.ttl = options.ttl || 30000; // 30 seconds
    this.cachedMethods = new Set(options.methods || []); // which methods to cache
    this.stats = { hits: 0, misses: 0 };

    // Wrap methods
    const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(service))
      .filter((n) => n !== 'constructor' && typeof service[n] === 'function');

    for (const method of methods) {
      if (this.cachedMethods.has(method)) {
        this[method] = async (...args) => {
          const key = `${method}:${JSON.stringify(args)}`;
          const cached = this.cache.get(key);

          if (cached && Date.now() - cached.time < this.ttl) {
            this.stats.hits++;
            console.log(`  [Cache] HIT ${key}`);
            return cached.data;
          }

          this.stats.misses++;
          console.log(`  [Cache] MISS ${key}`);
          const result = await this.service[method](...args);
          this.cache.set(key, { data: result, time: Date.now() });
          return result;
        };
      } else {
        // Non-cached methods pass through
        this[method] = (...args) => this.service[method](...args);
      }
    }
  }

  clearCache() {
    this.cache.clear();
    console.log('  [Cache] Cleared');
  }

  getCacheStats() {
    const total = this.stats.hits + this.stats.misses;
    return {
      ...this.stats,
      hitRate: total > 0 ? ((this.stats.hits / total) * 100).toFixed(1) + '%' : 'N/A',
    };
  }
}


// ---- Usage ----

async function demo() {
  const productService = new ProductService();
  const cachedProducts = new CachingDecorator(productService, {
    ttl: 5000,
    methods: ['getProduct', 'searchProducts'],
  });

  console.log('=== First call (miss) ===');
  await cachedProducts.getProduct('42');

  console.log('\n=== Second call (hit) ===');
  await cachedProducts.getProduct('42');

  console.log('\n=== Different args (miss) ===');
  await cachedProducts.getProduct('99');

  console.log('\n=== Stats ===');
  console.log(cachedProducts.getCacheStats());
}

demo();

8. Function Composition Approach in JavaScript

In JavaScript, you do not always need classes for decorators. Higher-order functions (functions that wrap functions) are a natural and idiomatic approach.

// ============================================================
// FUNCTION COMPOSITION DECORATORS
// ============================================================

// ---- Base function ----

async function fetchUser(userId) {
  console.log(`  [fetch] Getting user ${userId} from DB`);
  return { id: userId, name: 'Alice', email: 'alice@example.com' };
}


// ---- Decorator functions (higher-order functions) ----

function withLogging(fn, label = fn.name) {
  return async function (...args) {
    const start = Date.now();
    console.log(`[LOG] ${label} called with:`, args);
    try {
      const result = await fn(...args);
      console.log(`[LOG] ${label} returned in ${Date.now() - start}ms:`, result);
      return result;
    } catch (error) {
      console.log(`[LOG] ${label} threw after ${Date.now() - start}ms:`, error.message);
      throw error;
    }
  };
}

function withCache(fn, ttl = 5000) {
  const cache = new Map();
  return async function (...args) {
    const key = JSON.stringify(args);
    const cached = cache.get(key);
    if (cached && Date.now() - cached.time < ttl) {
      console.log(`[CACHE] Hit for ${key}`);
      return cached.data;
    }
    console.log(`[CACHE] Miss for ${key}`);
    const result = await fn(...args);
    cache.set(key, { data: result, time: Date.now() });
    return result;
  };
}

function withRetry(fn, maxRetries = 3, delayMs = 1000) {
  return async function (...args) {
    let lastError;
    for (let attempt = 1; attempt <= maxRetries; attempt++) {
      try {
        return await fn(...args);
      } catch (error) {
        lastError = error;
        console.log(`[RETRY] Attempt ${attempt}/${maxRetries} failed: ${error.message}`);
        if (attempt < maxRetries) {
          await new Promise((r) => setTimeout(r, delayMs));
        }
      }
    }
    throw lastError;
  };
}

function withTimeout(fn, ms = 5000) {
  return async function (...args) {
    return Promise.race([
      fn(...args),
      new Promise((_, reject) =>
        setTimeout(() => reject(new Error(`Timeout after ${ms}ms`)), ms)
      ),
    ]);
  };
}

function withAuth(fn, requiredRole) {
  return async function (context, ...args) {
    if (!context?.user) {
      throw new Error('Authentication required');
    }
    if (requiredRole && context.user.role !== requiredRole) {
      throw new Error(`Role "${requiredRole}" required, got "${context.user.role}"`);
    }
    return fn(context, ...args);
  };
}


// ---- Compose decorators ----

// Method 1: Manual wrapping (inside out)
const enhancedFetchUser = withLogging(withCache(withRetry(fetchUser)));

console.log('=== Composed function ===');
enhancedFetchUser('42').then(() => {
  console.log('\n=== Second call (cached) ===');
  return enhancedFetchUser('42');
});


// Method 2: compose utility
function compose(...decorators) {
  return function (fn) {
    return decorators.reduceRight((decorated, decorator) => decorator(decorated), fn);
  };
}

const enhance = compose(
  (fn) => withLogging(fn, 'fetchUser'),
  (fn) => withCache(fn, 10000),
  (fn) => withRetry(fn, 3),
  (fn) => withTimeout(fn, 5000)
);

const superFetchUser = enhance(fetchUser);


// Method 3: pipe utility (left to right -- reads more naturally)
function pipe(...decorators) {
  return function (fn) {
    return decorators.reduce((decorated, decorator) => decorator(decorated), fn);
  };
}

const pipeline = pipe(
  (fn) => withTimeout(fn, 5000),    // innermost: timeout
  (fn) => withRetry(fn, 3),         // then: retry
  (fn) => withCache(fn, 10000),     // then: cache
  (fn) => withLogging(fn, 'User')   // outermost: logging
);

const pipelinedFetchUser = pipeline(fetchUser);
  COMPOSITION ORDER (inside out):

  withLogging(                    <-- 4. logs the call
    withCache(                    <-- 3. checks cache first
      withRetry(                  <-- 2. retries on failure
        withTimeout(              <-- 1. enforces timeout
          fetchUser               <-- 0. actual logic
        )
      )
    )
  )

  Call flow:
  logging -> cache check -> retry wrapper -> timeout -> fetchUser

9. Before and After Comparison

Before (monolithic function with all concerns)

// BAD: All cross-cutting concerns mixed into one function
async function getUser(userId, currentUser) {
  // Auth check
  if (!currentUser) throw new Error('Not authenticated');
  if (currentUser.role !== 'admin' && currentUser.id !== userId) {
    throw new Error('Forbidden');
  }

  // Logging
  console.log(`[${new Date().toISOString()}] getUser(${userId}) by ${currentUser.id}`);
  const start = Date.now();

  // Caching
  const cacheKey = `user:${userId}`;
  const cached = globalCache.get(cacheKey);
  if (cached) return cached;

  // Retry logic
  let lastError;
  for (let i = 0; i < 3; i++) {
    try {
      const user = await db.query('SELECT * FROM users WHERE id = ?', userId);
      globalCache.set(cacheKey, user);
      console.log(`[${new Date().toISOString()}] getUser completed in ${Date.now() - start}ms`);
      return user;
    } catch (e) {
      lastError = e;
      await sleep(1000);
    }
  }
  throw lastError;
}

// Problems:
// - 40 lines for what should be a 3-line function
// - Cannot reuse caching logic for other functions
// - Cannot disable logging without editing code
// - Testing requires mocking everything at once

After (decorators separate concerns)

// GOOD: Each concern is a separate, reusable decorator
async function getUser(userId) {
  return db.query('SELECT * FROM users WHERE id = ?', userId);
}

// Stack the exact behaviors you need
const enhancedGetUser = pipe(
  fn => withTimeout(fn, 5000),
  fn => withRetry(fn, 3),
  fn => withCache(fn, 30000),
  fn => withLogging(fn, 'getUser')
)(getUser);

// Each decorator is:
// - Independently testable
// - Reusable across any function
// - Removable without touching other code
// - Composable in any order

10. Decorator vs Proxy vs Adapter

AspectDecoratorProxyAdapter
InterfaceSameSameDifferent
PurposeAdd behaviorControl accessTranslate interface
Client knows?NoNoNo
Stackable?Yes (key feature)SometimesRarely
LifecycleCreated by clientOften manages lifecycleWraps permanently
ExampleLogging + caching + retryLazy loading, auth checkStripe -> PaymentProcessor

The gray area: a caching proxy and a caching decorator look nearly identical. The intent differs: a proxy says "I control whether you can access this," while a decorator says "I add caching behavior to this."


11. When to Use and When to Avoid

Use Decorator when:

ScenarioWhy Decorator helps
Adding cross-cutting concerns (logging, auth, caching)Separates concerns cleanly
Need different combinations of behaviorAvoids inheritance explosion
Behavior should be toggleable at runtimeStack/unstack decorators
Express/Koa middleware pipelinesNatural decorator composition
You want reusable behavior across many functionsWrite once, apply anywhere

Avoid Decorator when:

ScenarioWhy Decorator is wrong
Only one fixed combination is ever neededJust put the logic in the function
Order of decorators matters and is confusingDebug complexity increases
Deep stacks make stack traces unreadable10 layers of wrapping obscures the source
You need to modify the interfaceUse Adapter instead
You need to control object creationUse Proxy instead

12. Key Takeaways

  1. The Decorator pattern adds behavior to objects dynamically by wrapping them -- without modifying the original object or using deep inheritance.
  2. Each decorator has the same interface as the component it wraps, enabling infinite stacking.
  3. Express/Koa middleware is the decorator pattern: each middleware wraps the handler and adds behavior (logging, auth, CORS, rate limiting).
  4. In JavaScript, higher-order functions are a natural way to implement decorators: withLogging(withCache(withRetry(fetchUser))).
  5. Use compose or pipe utilities to make decorator stacking readable and maintainable.
  6. Decorators follow the Open/Closed Principle: you extend behavior without modifying existing code.
  7. Watch out for decorator order -- logging(cache(fn)) and cache(logging(fn)) behave differently.

13. Explain-It Challenge

Without looking back, explain in your own words:

  1. Why does the Decorator pattern avoid the "inheritance explosion" problem?
  2. How does Express middleware demonstrate the decorator pattern?
  3. What is the difference between withLogging(withCache(fn)) and withCache(withLogging(fn))?
  4. When would you choose a class-based decorator over a function composition decorator?
  5. Write a withTimeout decorator from memory that rejects after N milliseconds.

Navigation: <- 9.4.c -- Proxy | 9.4.e -- Composite ->