The Decorator Pattern (vs Decorator Syntax)

The getUser function was nine lines when it shipped. By the time I opened it, it was fifty-two, and four separate concerns were braided through it so tightly that touching the cache meant re-reading the retry loop to make sure you had not broken it.

async function getUser(id) {
  const cached = cache.get(id);
  if (cached && cached.at + 60_000 > Date.now()) return cached.value; // caching

  let lastErr;
  for (let attempt = 0; attempt < 3; attempt++) {                     // retry
    const start = performance.now();                                  // timing
    try {
      const res = await fetch(`/api/users/${id}`);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const user = await res.json();
      log.info("getUser ok", { id, ms: performance.now() - start });  // logging
      cache.set(id, { value: user, at: Date.now() });                 // caching
      return user;
    } catch (err) {
      lastErr = err;
      log.warn("getUser retry", { id, attempt });                     // logging
      await sleep(2 ** attempt * 100);                                // retry
    }
  }
  throw lastErr;
}

The actual work, fetch a URL and parse the JSON, is three of those lines. The other forty are caching, retry, timing, and logging, four things that have nothing to do with users and everything to do with every other fetch in the app. And that was the real problem: getOrder, getInvoice, and getTeam each grew their own copy of the same forty lines, and by the time I arrived the four copies had quietly drifted apart. One retried twice, one three times. One logged the duration, two forgot.

You do not fix this by writing the forty lines more carefully. You fix it by getting them out of getUser entirely, so that getUser is three lines again and the caching, retry, timing, and logging live in one place each, applied from the outside.

Wrapping instead of editing

Here is the move. Instead of editing a function to do more, you put a second function around it. The wrapper does its extra work, calls through to the original, does a little more, and hands the result back. From the caller’s side nothing changed: same arguments in, same value out. From the inside, a whole concern got added without the original knowing.

getUser(7)withTiming(getUser)before: t0 = now()out = getUser(7) the originalafter: log(now() - t0)returns outsame arguments in, same value out; the wrapper only adds work around the call
One wrapper. It runs before, calls the original untouched, runs after, and returns the same kind of value. The caller cannot tell it is there.

That is the whole idea of the decorator pattern: wrap an object or a function in another that adds behaviour before or after delegating to the original, keeping the same interface. You layer capabilities at runtime by stacking wrappers, instead of baking every combination into the thing itself.

Do it twice and you have layers. withCache wraps withRetry wraps the fetch, and each layer sees the same getUser(id) shape on the way in and returns the same user on the way out.

withCacheserve a hit, else storewithRetryre-attempt on failurewithTimingrecord elapsedgetUser(id)getUser(7)the call delegates inward to the base; the result returns outward through the very same layers
Three wrappers around a base call. Every layer exposes the same getUser interface, does its bit, and delegates inward; the result travels back out through the same layers.

If that in-and-out shape feels familiar, it should. It is the same shape as server middleware, where each layer wraps the rest of the app and runs code on the way in and the way out. Middleware is the decorator pattern applied to a request handler at architecture scale. More on that below.

The subclass explosion

The pattern earns its name from the problem it was invented to kill: a class per combination of features.

The textbook version is a coffee shop, and it is textbook because it is honest. Start with a Coffee that has a price and a description. Now customers want milk. And sugar. And whipped cream. If your only tool is inheritance, you reach for subclasses:

class Coffee {
  cost() { return 2.0; }
  desc() { return "coffee"; }
}

class MilkCoffee extends Coffee {
  cost() { return super.cost() + 0.5; }
  desc() { return super.desc() + " + milk"; }
}

class MilkSugarCoffee extends MilkCoffee {
  cost() { return super.cost() + 0.25; }
  desc() { return super.desc() + " + sugar"; }
}

You see where this goes. MilkSugarCoffee extends MilkCoffee, fine. But now someone orders sugar without milk, so you need SugarCoffee. And sugar with whip but no milk. Each combination is a class, and the class hierarchy cannot express “any subset of these toppings” because a class has exactly one parent. Three toppings is eight classes. Add caramel and it is sixteen. The count doubles with every option, and half those classes are copy-pasted super.cost() + something.

one class per combinationCoffeeMilkCoffeeSugarCoffeeWhipCoffeeMilkSugarCoffeeMilkWhipCoffeeSugarWhipCoffeeMilkSugarWhipCoffee3 toppings need 8 classes.2 to the n. every new option doubles the count.one wrapper per featurecoffee (base)milk(c)sugar(c)whip(c)compose any subset at runtime:whip(sugar(milk(coffee)))3 toppings need 3 wrappers.add caramel and it is 4. linear, not doubling.
Left: a class for every subset of toppings, doubling with each new option. Right: one small wrapper per topping, composed a la carte at runtime.

The decorator version does not subclass anything. Each topping is a small function that takes a coffee and returns a coffee-shaped thing with a bit added:

const coffee = { cost: () => 2.0, desc: () => "coffee" };

const milk  = (c) => ({ cost: () => c.cost() + 0.5,  desc: () => c.desc() + " + milk" });
const sugar = (c) => ({ cost: () => c.cost() + 0.25, desc: () => c.desc() + " + sugar" });
const whip  = (c) => ({ cost: () => c.cost() + 0.7,  desc: () => c.desc() + " + whip" });

const order = whip(sugar(milk(coffee)));
order.desc(); // "coffee + milk + sugar + whip"
order.cost(); // 3.45

Three functions, not eight classes. A latte with no sugar is milk(coffee). Extra whip is whip(whip(coffee)), which the class approach cannot express at all. You compose the exact drink at runtime from a menu of small, independent pieces. This is the composition answer to the subclass explosion, and it is the reason the standard advice is to reach for composition over inheritance when you feel a class hierarchy branching.

Decorators are just higher-order functions

Here is the part that matters for how JavaScript is actually written. In a language built on Java-style classes, the decorator pattern is a ceremony: an abstract Component, a ConcreteComponent, an abstract Decorator that holds a reference to a Component and forwards every method, then concrete decorators that extend it. In JavaScript almost all of that evaporates. A function that takes a function and returns a wrapped one is a decorator, and you have been writing them for years, probably without calling them that.

You have met them as higher-order functions and they run on closures: the wrapper closes over the original and any config, and returns a new function with the same signature.

Back to the fifty-two-line getUser. Pull each concern out into its own wrapper. The shape is always the same: take fn, return a new function that does something around await fn(...args).

function withRetry(fn, { tries = 3, base = 100 } = {}) {
  return async function (...args) {
    let lastErr;
    for (let attempt = 0; attempt < tries; attempt++) {
      try {
        return await fn(...args);
      } catch (err) {
        lastErr = err;
        await sleep(2 ** attempt * base); // exponential backoff
      }
    }
    throw lastErr;
  };
}
function withTiming(fn, label = fn.name) {
  return async function (...args) {
    const start = performance.now();
    try {
      return await fn(...args);
    } finally {
      log.info(label, { ms: performance.now() - start }); // fires on success and on throw
    }
  };
}
function withCache(fn, ttl = 60_000) {
  const store = new Map();
  return async function (id) {
    const hit = store.get(id);
    if (hit && hit.at + ttl > Date.now()) return hit.value; // no call to fn at all
    const value = await fn(id);
    store.set(id, { value, at: Date.now() });
    return value;
  };
}

Each of these is complete, tiny, and testable on its own. withRetry knows nothing about users or HTTP. The ...args forwarding is what preserves the interface: whatever the wrapped function took, the wrapper takes too, and passes it straight through. That is the rule that lets you stack them without anyone downstream noticing.

Now getUser is honest again:

export const getUser = withLogging(
  withCache(
    withRetry(
      (id) => fetchJSON("/api/users/" + id),
    ),
  ),
);

The base is three-ish lines: fetch and parse. Everything else is a labelled wrapper you can read top to bottom. And because getOrder needs the exact same treatment, it is the same wrappers around a different base, which means retry behaves identically across every endpoint by construction instead of by four people remembering to copy it right. The retry and caching policies live in one place each.

getUser = withLogging(withCache(withRetry(fetch)))call in, before phase, withLogging runs firstwithLogginglog every callwithCacheserve a hitwithRetryre-attemptfetchthe real workresult out, after phase, withLogging runs lastcache sits outside retry on purpose: a hit returns here and never reaches the retry loop or the networkthe wrapper you write outermost is the first to touch the call and the last to touch the result
withLogging wraps withCache wraps withRetry wraps the fetch. On the way in, the outer wrapper runs first; on the way out, it runs last. Reading order is execution order.

Two gotchas that bite once each

Keep this if you are wrapping a method. The ...args version above forwards arguments but silently drops the receiver. Use a regular function and forward the receiver with .apply, not an arrow:

return function (...args) {
  return fn.apply(this, args); // an arrow function here would drop 'this'
};

If this and apply are hazy, that is exactly what call, apply and bind covers. Getting it wrong turns obj.getUser() into a call with the wrong this, and the failure shows up far from the wrapper.

Async is contagious. The moment one layer is async, the whole stack returns a promise, and every caller has to await. That is fine, but do not mix a synchronous wrapper that returns a value with an asynchronous base that returns a promise, or your “after” code runs before the work finishes. If any layer awaits, they all should.

Watch the layers stack

Toggle wrappers on and off and call the base function. Each active layer logs what it did, indented by how deep it sits. Turn on the flaky base to watch withRetry actually earn its place, and call the same id twice to watch withMemoize short-circuit every layer beneath it.

interactiveFunction decorators you can stack

Two things to try. Call getUser(7) with the flaky base on and retry on, and watch the retry layer swallow a blip and try again while the layers outside it wait. Then call getUser(7) twice with memoize on: the second call logs a single cache-hit line and nothing beneath it runs, because the outermost wrapper answered and never delegated inward. That short-circuit is the whole reason a caching decorator sits on the outside.

The other decorator: the @ syntax

Everything above is the pattern, and not once did it need the thing JavaScript spells @decorator. The shared name causes real confusion, so let us separate them cleanly.

@decorator is syntax. It is an annotation you write above a class, method, field, or accessor, and it hooks into how that member is defined. The modern, standardized form (Stage 3, and shipping in TypeScript since 5.0) is a function that receives the thing being decorated and a context object, and may return a replacement:

// a method decorator in the standard (Stage 3) shape
function logged(value, context) {
  if (context.kind !== "method") return;
  return function (...args) {
    console.log("→", context.name, args);
    const result = value.apply(this, args);
    console.log("←", context.name, result);
    return result;
  };
}

class Api {
  @logged
  getUser(id) {
    return fetchJSON("/api/users/" + id);
  }
}

Read logged and you will notice it is doing exactly what withLogging did earlier: take a function, return a wrapped one that logs around it. So yes, a method decorator can implement the decorator pattern. The syntax gives you a tidy place to attach the wrapper, right on the method, instead of rebuilding the object by hand.

But that is where the neat equivalence stops, and it stops hard. The mechanics of the @ syntax, the (value, context) signature, the kind field, addInitializer, live in decorators and static blocks; this is not the place to re-teach them. What matters here is the boundary.

same name, different thingsthe decorator PATTERNwrap to add behaviour, at runtimeworks on any function or objectyou compose it: withCache(fn)keeps the same interfacea plain closure, no syntax neededGoF, 1994. older than the languagethe @ SYNTAXan annotation on class membersonly class, method, field, accessorreceives (value, context)no @ on a plain function or objectoften registers, injects, validatesTC39 Stage 3. a metaprogramming hookthey meet only when a @ decorator wraps the method.most @ decorators in the wild do not wrap at all.
The pattern is a runtime idea that works on any function or object. The @ syntax is a compile-time annotation limited to class members. They overlap only when a decorator happens to wrap.

Two facts do most of the work of keeping them apart.

The syntax cannot decorate a plain function. @ only goes on classes and their members: methods, fields, getters, setters, auto-accessors. You cannot write @logged above a standalone function getUser or on an object literal. The pattern has no such limit, which is precisely why the idiomatic JavaScript form is a higher-order function and not the syntax. When the thing you want to wrap is a function, the pattern is the tool and the syntax is not even available.

Most @ decorators do not wrap anything. This is the bigger point. Walk through a real backend and the decorators you see are mostly not implementing the decorator pattern:

@Injectable()                       // register this class in a DI container
class UserService {
  @Column({ type: "text" })         // describe a DB column for the ORM to read later
  email: string;

  @Get("/users/:id")                // register a route in a metadata table
  getUser(@Param("id") id: string) {}
}

@Injectable, @Column, @Get do not add behaviour before and after a call. They attach metadata that a framework reads at startup: which classes to instantiate, which routes to mount, which fields map to which columns. That is metaprogramming, registration, and configuration, not the decorator pattern. The syntax is a general hook, and wrapping is only one of the many things people hang off it.

Decoration in the large

Zoom out from a single function and the same pattern runs your server. A middleware chain is a stack of decorators around a request handler: each layer receives the request, does something on the way in, calls the next layer, and does something on the way out, all while presenting the same (req, res) interface. Auth, logging, request ids, compression, they are wrappers, composed at runtime, exactly like withRetry and withCache but around an HTTP handler instead of a fetch.

The relationship to chain of responsibility is worth a sentence, because middleware sits on the line between them. A pure decorator always delegates inward and always adds around the result. A chain lets a link stop and answer without calling the next. Middleware does both: most layers decorate and pass through, but an auth layer can short-circuit with a 401 and never call next. That is the chain behaviour riding on the decorator structure, and it is the single most useful thing the onion buys you.

When wrapping is the wrong call

Decorators are cheap to add and that is the danger. Every wrapper is a layer of indirection, and indirection is a cost you pay on every read of the code and sometimes on every call.

Do not wrap what you use once. A withRetry used on twelve endpoints earns its keep. A withUppercaseFirstName used in exactly one place is a function you could have called inline, dressed up as a pattern. If the behaviour is not cross-cutting, a plain if or a direct call is clearer than a wrapper, and a reader does not have to chase a closure to see what happens.

Watch the stack depth. A wrapper around a wrapper around a wrapper is easy to reach one refactor at a time. Then a stack trace runs through four generated functions before it touches the line that actually threw, and stepping through in a debugger means stepping through the same forwarding boilerplate four times. If you cannot name the single concern each layer owns, collapse them.

The interface is not free to preserve. ...args forwarding and fn.apply(this, args) keep the surface identical, but two things still leak. The wrapped function’s name becomes the wrapper’s name unless you copy it over, which breaks stack traces and any code that reads fn.name. And referential identity is gone: wrapped !== original, so anything comparing function references, or using the function as a Map key, sees a different object. Usually harmless, occasionally the bug.

Order-dependence is a maintenance tax. As the order callout showed, withCache(withRetry(fn)) and withRetry(withCache(fn)) are different programs. The nesting encodes real behaviour, and the next person to add a fifth wrapper has to understand the whole stack to slot it in correctly. Comment the order, or provide a single compose helper with the order baked in, so nobody has to rediscover it.

The test is the same as for any indirection. If the wrapper adds a concern the caller genuinely should not have to think about, retry, cache, timing, auth, it earns its keep. If it forwards every call unchanged and adds nothing, you built a layer that costs a hop and teaches a reader nothing, and you should delete it.

Summary

  • The decorator pattern wraps an object or function in another that adds behaviour before or after delegating to the original, keeping the same interface. You layer capabilities at runtime by stacking wrappers instead of baking every combination into a subclass.
  • It exists to kill the subclass explosion: one class per feature combination doubles with every new option. Small composable decorators grow linearly. This is composition over inheritance in practice.
  • In idiomatic JavaScript a decorator is a higher-order function: take fn, return a new function that does work around await fn(...args). withRetry, withCache, withTiming, withLogging are decorators, built on closures and HOFs. You have written these.
  • Order is behaviour. The outermost wrapper runs first on the way in and last on the way out, so withCache(withRetry(fn)) is a different program from withRetry(withCache(fn)). Preserve the interface with ...args and, for methods, fn.apply(this, args) (see call/apply); watch for lost this, fn.name, and referential identity.
  • The @decorator syntax is a different thing that happens to share the name. It is a compile-time annotation limited to class members, cannot go on a plain function, and often does not wrap at all: @Injectable, @Column, @Get attach metadata for registration, DI, and validation. A method decorator can implement the pattern, but the pattern is older, broader, and needs no syntax; its mechanics live in decorators and static blocks. Do not equate the two. And middleware is the same pattern at architecture scale: wrappers around a request handler.
  • When to use: a cross-cutting concern (retry, cache, timing, auth, logging) you want to add to many things without editing each one, or to compose a la carte at runtime.
  • When to avoid: the behaviour is used once (call it inline), the stack is so deep you cannot name each layer, or the wrapper forwards every call unchanged and adds nothing.