Skip to content
Knowledge Hub

Decorator pattern: adding cache, metrics and retry around a client, and why the order changes the numbers Intermediate

Estimated reading time: 11 min read

Checkout calls PriceService.getPrice(sku), implemented by PriceApiClient, which talks to a remote price API. The API sometimes answers 503 and sometimes stalls. Product asks for three things: cache prices for 60 seconds, retry transient failures, and graph calls and errors.

Putting all of that inside PriceApiClient turns one HTTP call into a 150-line method. Tests for the cache then need a fake HTTP server, and the payment client (PaymentGateway) cannot reuse the retry code.

Decorator keeps PriceApiClient unchanged and wraps it in small objects with the same interface. The harder question this article answers: in what order do you stack them, and what does each order measure?

Situation Choose Condition
Add caching, metrics, retry or timeout around one dependency Decorator (wrapper with the same interface) Each layer is testable on its own; wiring lives in one place
The same concern on every HTTP route (request log, response mapping) NestJS interceptor It is about the request, not about one outgoing dependency
The client’s interface does not match what checkout wants Adapter first, decorators on top of the port See Adapter
You need upstream load and error rate Metrics inside retry and cache You count attempts that reach the API
You need user-visible latency and failure Metrics outside retry and cache You count calls checkout made
A latency budget for the whole call Timeout outside retry, plus a per-attempt timeout inside Overall ≥ attempts × per-attempt + backoff, or later attempts get cut off

Decorator has three roles:

Role In the example Rule
Component (interface) PriceService.getPrice(sku) Every layer implements it
Concrete component PriceApiClient Knows nothing about the layers
Decorator CachedPriceService, MeteredPriceService, RetryingPriceService, TimeoutPriceService Holds an inner PriceService and delegates to it

A decorator from the demo:

class MeteredPriceService implements PriceService {
calls = 0;
errors = 0;
constructor(private readonly inner: PriceService) {}
async getPrice(sku: string): Promise<number> {
this.calls++;
try {
return await this.inner.getPrice(sku);
} catch (err) {
this.errors++;
throw err;
}
}
}

Because every layer accepts and returns PriceService, layers nest: new Cached(new Metered(new Retrying(new Timeout(api)))).

Refactoring.Guru notes that the client controls the composition, and that it is hard to make a decorator whose behavior does not depend on its position in the stack. That position is a design decision, not a detail.

Name What it is When it runs
Decorator pattern An object wrapping another object with the same interface On every call
TypeScript @Something A function applied to a class, method or parameter Once, when the class is defined
NestJS @Injectable(), @Get() TypeScript decorators that write metadata for Nest to read Once, at definition; Nest reads metadata at bootstrap
NestJS interceptor A wrapper around a route handler, AOP style On every request

The @ syntax shares only a name with the pattern. A @Cacheable() method decorator can implement the pattern by replacing the method with a wrapper, but it hides the order inside annotations. TypeScript 5.0 supports standard ECMAScript decorators; NestJS 11 and 12 are built on experimentalDecorators, see Custom decorators & Reflector.

The demo’s NestJS wiring builds cache(metrics(retry(timeout(api)))). The API fails the first attempt and succeeds on the second:

One getPrice call through four decorators: metrics counts 1 call while the API sees 2; the second quote stops at the cache
One getPrice call through four decorators: metrics counts 1 call while the API sees 2; the second quote stops at the cacheStep 1/6: CheckoutService calls getPrice(sku-9) on PRICE_SERVICE. It only sees the PriceService interface.CheckoutServiceCacheTTL 60 smissMetricscalls, errorsRetry3 attemptsTimeout50 ms / attemptPrice APIgetPrice(sku-9)
CheckoutService → CachegetPrice(sku-9)
Step 1/6

CheckoutService calls getPrice(sku-9) on PRICE_SERVICE. It only sees the PriceService interface.

State after each step:

  1. Request in. Cache has no sku-9; nothing has reached the API.
  2. Metrics before retry. calls = 1. Metrics sits outside retry, so it will never see attempt 1 separately.
  3. Attempt 1. Timeout starts a 50 ms timer, the API answers 503, api calls = 1.
  4. Attempt 2. Retry catches the 503 and calls again; the API returns 129000, api calls = 2.
  5. Back out. Metrics records success: calls = 1, errors = 0. Cache stores the price for 60 s.
  6. Second quote. Cache hit. No layer below cache runs; api calls stays 2.

Read the stack inside-out: the innermost layer runs closest to the network, the outermost closest to checkout. A layer sees only what the layers inside it let through. Metrics outside retry cannot see retried errors; cache outside metrics hides hits from metrics.

The demo runs the same layers in swapped orders:

1) Metrics outside vs inside retry (API plan: fail, fail, ok)
metrics(retry(api)): price 129000 | metrics calls=1 errors=0 | api calls=3
retry(metrics(api)): price 129000 | metrics calls=3 errors=2 | api calls=3
2) Timeout per attempt vs overall (API plan: slow 100ms, ok; timeout 50ms; 3 attempts)
retry(timeout(api)): price 129000 | api calls=2
timeout(retry(api)): error: timeout after 50ms | api calls=1 finished=0
...80ms later the abandoned call has finished anyway: api calls=1 finished=1
3) Cache outside vs inside metrics (two requests for the same SKU)
cache(metrics(api)): metrics calls=1 cache hits=1 api calls=1
metrics(cache(api)): metrics calls=2 cache hits=1 api calls=1

Loading diagram…

What each result means:

  • Metrics vs retry. Outside: what checkout experienced (1 call, no error). Inside: what the API suffered (3 attempts, 2 errors). Production usually wants both, under different metric names.
  • Timeout vs retry. A per-attempt timeout inside retry lets attempt 2 succeed. An overall 50 ms timeout outside retry fails before any retry happens. Set the overall budget larger than attempts × per-attempt timeout plus backoff.
  • Timeout does not cancel. finished=1 shows the abandoned call completed anyway. Promise.race stops waiting; it does not stop the request. Pass an AbortSignal down to the HTTP client.
  • Cache vs metrics. Cache outside hides hits from metrics: good for measuring upstream load, misleading for traffic. Cache outside retry also means hits never pay for retries.

One more rule from the code: CachedPriceService caches only successes. If a cache stored errors (negative caching), a retry placed outside it would replay the cached error on every attempt.

Consumers inject a token; the composition root decides the order (from nest-wiring.ts, run in the demo):

@Module({
providers: [
{ provide: PriceApiClient, useFactory: () => new PriceApiClient(['fail', 'ok']) },
{
provide: MeteredPriceService,
useFactory: (api: PriceApiClient) =>
new MeteredPriceService(new RetryingPriceService(new TimeoutPriceService(api, 50), 3)),
inject: [PriceApiClient],
},
{
provide: PRICE_SERVICE,
useFactory: (metered: MeteredPriceService) => new CachedPriceService(metered, 60_000),
inject: [MeteredPriceService],
},
CheckoutService,
],
})
export class PricingModule {}

CheckoutService asks for @Inject(PRICE_SERVICE) and never learns there are four layers. The same layers can wrap PaymentGateway, but not blindly: retrying a charge needs an idempotency key, and payments must not be cached.

Decorator NestJS interceptor Proxy Adapter
Wraps One dependency (PriceService) A route handler One object One object
Interface Same Handler result as an Observable Same Different
Who composes Client / composition root Nest, by scope (global, controller, route) Usually the proxy itself, including the object’s lifecycle Composition root
Stacks recursively Yes, by design Yes, in binding order Possible, not the goal No

An interceptor fits “log every request”. It does not fit “retry the price API”: checkout may call that API twice in one request, or from a cron job with no request at all.

Terminal window
npx --prefix examples ts-node examples/src/decorator-pattern/price-decorators-demo.ts
npx --prefix examples ts-node examples/src/decorator-pattern/nest-wiring.ts

The fake API makes no network calls; the second command prints metrics calls=1 errors=0 | api calls=2, the state at step 6 of the diagram. Production still needs backoff with jitter, a deadline, and retrying only retryable errors.

When Decorator helps

  • Each concern is one small class with its own test, reusable on other clients.
  • The concrete client stays a plain HTTP call.
  • Layers are switched on or off in one wiring file, per environment if needed.

Costs

  • Behavior depends on order, and the order lives far from the call site.
  • Stack traces and debugging cross several thin layers.
  • Every layer must forward the whole interface; a new method means editing all of them.
  • Without a shared AbortSignal, timeout and retry layers leave orphaned calls.
  1. Write the stack in one composition root, with a comment reading it inside-out.
  2. Decide which metric each position measures; name attempts and calls differently.
  3. Put a per-attempt timeout inside retry and an overall deadline outside it, with the overall larger than the sum.
  4. Retry only classified retryable errors, with backoff and jitter; non-idempotent calls need an idempotency key first.
  5. Cache only successful, cacheable results; never cache payment calls.
  6. Propagate an AbortSignal so a timeout cancels the underlying request.
  7. Test the wiring once end to end: expected api calls for fail-then-ok and for a cache hit.

Retry outside the overall timeout

Common

new TimeoutPriceService(new RetryingPriceService(api, 3), 50); // 50 ms for everything

Prefer

new TimeoutPriceService(new RetryingPriceService(new TimeoutPriceService(api, 50), 3), 200);

Why

With only an overall 50 ms, one slow attempt uses the whole budget and retry never runs (demo part 2).

Another mistake hides in the type system rather than in timing:

Misconception Consequence What to do
“@Cacheable() is the Decorator pattern” Order hidden in annotations, hard to test Compose wrappers explicitly in the module
“Order does not matter, they are independent” Dashboards show 0% or 67% errors for the same traffic Choose order per metric, document it
“Timeout cancels the call” Orphaned requests keep loading the API Pass AbortSignal
“Interceptors replace decorators” Retry runs per request, not per dependency call Interceptors for routes, decorators for clients
  1. In cache(retry(api)), how many API calls does a cache hit cost, and why?
  2. Why does timeout(retry(api)) with a 50 ms budget fail in demo part 2, while retry(timeout(api)) succeeds?
  3. Which position of MeteredPriceService would you use to alert on-call, and which for capacity planning?