Skip to content

Decorator Pattern and Middleware

advanced18 min read

Adding Behavior Without Touching the Original

You have a fetchUser function that works perfectly. Now product wants you to add logging. Then caching. Then retry on failure. Then rate limiting. Each feature is independent — you might want logging without caching, or caching without retries. How do you add all of these without turning fetchUser into a 200-line monster?

// This is what NOBODY wants:
async function fetchUser(id: string) {
  console.log(`Fetching user ${id}`);        // logging
  const cached = cache.get(id);               // caching
  if (cached) return cached;
  for (let i = 0; i < 3; i++) {              // retry
    try {
      if (rateLimiter.isLimited()) await delay(1000); // rate limit
      const user = await api.get(`/users/${id}`);
      cache.set(id, user);
      return user;
    } catch (e) { if (i === 2) throw e; }
  }
}

The Decorator pattern solves this by wrapping functions with layers of new behavior. Each decorator adds one concern. They compose together like layers of an onion — the request passes through each layer going in, and the response passes back through going out.

Mental Model

Think of decorators like gift wrapping. You have a present (the original function). You can wrap it in tissue paper (logging), then a box (caching), then wrapping paper (retry), then a bow (rate limiting). Each layer adds something without changing the present inside. To access the present, you unwrap each layer in reverse. And you can mix and match layers — tissue paper and a bow, no box. The present stays exactly the same regardless.

Function Decorators

In JavaScript, the simplest decorator is a higher-order function — a function that takes a function and returns a new function with added behavior:

type AsyncFn<T> = (...args: unknown[]) => Promise<T>;

function withLogging<T>(fn: AsyncFn<T>, label: string): AsyncFn<T> {
  return async (...args) => {
    console.log(`[${label}] Called with:`, args);
    const start = performance.now();
    try {
      const result = await fn(...args);
      console.log(`[${label}] Returned in ${(performance.now() - start).toFixed(1)}ms`);
      return result;
    } catch (error) {
      console.error(`[${label}] Failed:`, error);
      throw error;
    }
  };
}

function withRetry<T>(fn: AsyncFn<T>, attempts: number, delay: number): AsyncFn<T> {
  return async (...args) => {
    let lastError: Error;
    for (let i = 0; i < attempts; i++) {
      try {
        return await fn(...args);
      } catch (error) {
        lastError = error as Error;
        if (i < attempts - 1) {
          await new Promise(resolve => setTimeout(resolve, delay * (i + 1)));
        }
      }
    }
    throw lastError!;
  };
}

function withCache<T>(fn: AsyncFn<T>, ttlMs: number): AsyncFn<T> {
  const cache = new Map<string, { data: T; expires: number }>();

  return async (...args) => {
    const key = JSON.stringify(args);
    const cached = cache.get(key);
    if (cached && cached.expires > Date.now()) return cached.data;

    const result = await fn(...args);
    cache.set(key, { data: result, expires: Date.now() + ttlMs });
    return result;
  };
}

Now compose them:

const fetchUser = async (id: string) => {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
};

const resilientFetchUser = withLogging(
  withRetry(
    withCache(fetchUser, 60_000),
    3,
    1000
  ),
  "fetchUser"
);

await resilientFetchUser("user_123");
// [fetchUser] Called with: ["user_123"]
// (cache miss → retry-protected fetch)
// [fetchUser] Returned in 142.3ms

Each decorator is independent. Remove caching? Just remove the withCache wrapper. The inner function never changed.

Quiz
In what order do the decorators execute when calling resilientFetchUser?

A Cleaner Composition with pipe

The nested wrapping is hard to read. A pipe function fixes that:

function pipe<T extends AsyncFn<unknown>>(
  fn: T,
  ...decorators: ((fn: AsyncFn<unknown>) => AsyncFn<unknown>)[]
): T {
  return decorators.reduce(
    (decorated, decorator) => decorator(decorated),
    fn as AsyncFn<unknown>
  ) as T;
}

const resilientFetchUser = pipe(
  fetchUser,
  (fn) => withCache(fn, 60_000),
  (fn) => withRetry(fn, 3, 1000),
  (fn) => withLogging(fn, "fetchUser")
);

This reads top-to-bottom: start with fetchUser, add caching, add retry, add logging. Much clearer than the nested version.

Express Middleware Is Decorator Pattern

If you've used Express, you've used decorators. Middleware functions wrap the request/response cycle:

import express from "express";

const app = express();

function requestLogger(req: express.Request, _res: express.Response, next: express.NextFunction) {
  console.log(`${req.method} ${req.path}`);
  const start = Date.now();
  next();
  console.log(`${req.method} ${req.path} - ${Date.now() - start}ms`);
}

function authGuard(req: express.Request, res: express.Response, next: express.NextFunction) {
  const token = req.headers.authorization;
  if (!token) {
    res.status(401).json({ error: "Unauthorized" });
    return;
  }
  next();
}

function rateLimiter(limit: number, windowMs: number) {
  const hits = new Map<string, number[]>();

  return (req: express.Request, res: express.Response, next: express.NextFunction) => {
    const ip = req.ip ?? "unknown";
    const now = Date.now();
    const windowStart = now - windowMs;

    const timestamps = (hits.get(ip) ?? []).filter(t => t > windowStart);
    if (timestamps.length >= limit) {
      res.status(429).json({ error: "Too many requests" });
      return;
    }
    timestamps.push(now);
    hits.set(ip, timestamps);
    next();
  };
}

app.use(requestLogger);
app.use(rateLimiter(100, 60_000));
app.get("/api/protected", authGuard, (req, res) => {
  res.json({ data: "secret" });
});

Each middleware decorates the request pipeline. They compose linearly. Each one can short-circuit (return early) or pass through (next()). This is the Decorator pattern with a different API shape.

Quiz
How does Express middleware relate to the Decorator pattern?

React Higher-Order Components (HOCs)

HOCs are the React-specific incarnation of the Decorator pattern. They wrap a component and add behavior:

import { ComponentType, useEffect, useState } from "react";

function withLoadingState<P extends object>(
  WrappedComponent: ComponentType<P & { data: unknown }>,
  fetchData: () => Promise<unknown>
) {
  return function WithLoading(props: Omit<P, "data">) {
    const [data, setData] = useState<unknown>(null);
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState<Error | null>(null);

    useEffect(() => {
      fetchData()
        .then(setData)
        .catch(setError)
        .finally(() => setLoading(false));
    }, []);

    if (loading) return <div className="animate-pulse">Loading...</div>;
    if (error) return <div>Error: {error.message}</div>;

    return <WrappedComponent {...(props as P)} data={data} />;
  };
}

function UserProfile({ data }: { data: unknown }) {
  const user = data as { name: string; email: string };
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

const UserProfileWithData = withLoadingState(
  UserProfile,
  () => fetch("/api/me").then(r => r.json())
);

HOCs are less common now that React has hooks, but they're still the right choice for cross-cutting concerns that wrap entire component trees (error boundaries, analytics providers, feature flags).

Method Decorators (TC39 Stage 3)

The TC39 decorator proposal brings first-class decorator syntax to JavaScript. As of 2024, it's Stage 3 and supported in TypeScript 5.0+:

function logged(
  target: unknown,
  context: ClassMethodDecoratorContext
) {
  const methodName = String(context.name);

  return function (this: unknown, ...args: unknown[]) {
    console.log(`${methodName} called with:`, args);
    const result = (target as Function).apply(this, args);
    console.log(`${methodName} returned:`, result);
    return result;
  };
}

function bound(
  target: unknown,
  context: ClassMethodDecoratorContext
) {
  context.addInitializer(function (this: unknown) {
    const self = this as Record<string, unknown>;
    self[context.name as string] = (target as Function).bind(this);
  });
  return target;
}

class Api {
  @logged
  @bound
  async fetchUsers(): Promise<unknown[]> {
    const res = await fetch("/api/users");
    return res.json();
  }
}

Decorators apply bottom-up: @bound runs first, then @logged wraps the result. This matches the mathematical composition order — the closest decorator to the method applies first.

Quiz
In what order do stacked class method decorators apply?
What developers doWhat they should do
Creating deeply nested decorator wrappers that are impossible to debug
Each decorator adds a stack frame. With 8 layers of decoration, stack traces become unreadable and stepping through in a debugger is painful. If you need that many concerns, consider a middleware pipeline instead
Limit decorator depth to 3-4 layers. Use a pipe function for readability. Name your decorator returns for stack traces
Using HOCs in React when a custom hook would suffice
HOCs create wrapper hell in React DevTools, complicate prop types, and break ref forwarding. Hooks compose more naturally and keep the component tree flat. HOCs are still valid for tree-level concerns
Prefer hooks for logic reuse. Use HOCs only when you need to wrap the component tree (error boundaries, context providers)
Mutating the original function instead of wrapping it
Mutating the original function affects every caller, not just the decorated call site. This breaks the principle of least surprise and makes bugs extremely hard to trace
Always return a new function that calls the original — never modify the input

Challenge

Build a createMiddlewarePipeline function that chains middleware functions for processing API requests, where each middleware can modify the request, short-circuit with a response, or pass to the next handler.

Challenge:

Try to solve it before peeking at the answer.

// Requirements:
// 1. Middleware receives (request, next) and returns a Response
// 2. Calling next(request) passes to the next middleware
// 3. A middleware can modify the request before calling next
// 4. A middleware can return early without calling next (short-circuit)
// 5. The final handler processes the actual request

interface Request { path: string; headers: Record<string, string>; body?: unknown }
interface Response { status: number; data: unknown }
type Middleware = (req: Request, next: (req: Request) => Promise<Response>) => Promise<Response>;

const pipeline = createMiddlewarePipeline(
  authMiddleware,
  loggingMiddleware,
  handler
);

const response = await pipeline({ path: "/api/users", headers: {} });
Key Rules
  1. 1Decorators wrap functions with additional behavior without modifying the original — each decorator adds one concern
  2. 2Compose decorators with pipe() for readability instead of deeply nested wrapper calls
  3. 3Express middleware is the Decorator pattern with a linear API — each middleware can pre-process, post-process, or short-circuit
  4. 4Prefer React hooks over HOCs for logic reuse. Use HOCs only for tree-level wrapping (error boundaries, providers)
  5. 5TC39 decorators (Stage 3) apply bottom-to-top — the closest decorator to the method applies first