Decorator pattern: adding cache, metrics and retry around a client, and why the order changes the numbers Intermediate
Estimated reading time: 11 min read
Read first
The problem
Section titled “The problem”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?
What to choose? (summary first)
Section titled “What to choose? (summary first)”| 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 |
Core concepts
Section titled “Core concepts”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.
Four things called “decorator”
Section titled “Four things called “decorator””| 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.
How it works
Section titled “How it works”The demo’s NestJS wiring builds cache(metrics(retry(timeout(api)))). The API fails the first attempt and succeeds on the second:
getPrice(sku-9)CheckoutService calls getPrice(sku-9) on PRICE_SERVICE. It only sees the PriceService interface.
State after each step:
- Request in. Cache has no
sku-9; nothing has reached the API. - Metrics before retry.
calls = 1. Metrics sits outside retry, so it will never see attempt 1 separately. - Attempt 1. Timeout starts a 50 ms timer, the API answers 503,
api calls = 1. - Attempt 2. Retry catches the 503 and calls again; the API returns
129000,api calls = 2. - Back out. Metrics records success:
calls = 1, errors = 0. Cache stores the price for 60 s. - Second quote. Cache hit. No layer below cache runs;
api callsstays 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.
Order effects, measured
Section titled “Order effects, measured”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=32) 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=13) 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=1Loading 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=1shows the abandoned call completed anyway.Promise.racestops waiting; it does not stop the request. Pass anAbortSignaldown 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.
Wiring in NestJS
Section titled “Wiring in NestJS”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, interceptor, Proxy, Adapter
Section titled “Decorator, interceptor, Proxy, Adapter”| 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.
Real-world example
Section titled “Real-world example”npx --prefix examples ts-node examples/src/decorator-pattern/price-decorators-demo.tsnpx --prefix examples ts-node examples/src/decorator-pattern/nest-wiring.tsThe 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.
Trade-offs
Section titled “Trade-offs”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.
Production checklist
Section titled “Production checklist”- Write the stack in one composition root, with a comment reading it inside-out.
- Decide which metric each position measures; name attempts and calls differently.
- Put a per-attempt timeout inside retry and an overall deadline outside it, with the overall larger than the sum.
- Retry only classified retryable errors, with backoff and jitter; non-idempotent calls need an idempotency key first.
- Cache only successful, cacheable results; never cache payment calls.
- Propagate an
AbortSignalso a timeout cancels the underlying request. - Test the wiring once end to end: expected
api callsfor fail-then-ok and for a cache hit.
Common mistakes
Section titled “Common mistakes”Retry outside the overall timeout
Common
new TimeoutPriceService(new RetryingPriceService(api, 3), 50); // 50 ms for everythingPrefer
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 |
Self-check
Section titled “Self-check”- In
cache(retry(api)), how many API calls does a cache hit cost, and why? - Why does
timeout(retry(api))with a 50 ms budget fail in demo part 2, whileretry(timeout(api))succeeds? - Which position of
MeteredPriceServicewould you use to alert on-call, and which for capacity planning?
Summary
Section titled “Summary”References
Section titled “References”- Decorator — Refactoring.Guru: intent, order dependence, relations with Adapter, Proxy and Chain of Responsibility.
- Interceptors — NestJS: what interceptors can do, cache and timeout examples, the note that unsubscribing does not abort work.
- Custom providers — NestJS:
useFactoryandinjectfor composing wrappers. - TypeScript 5.0 — Decorators: standard decorators versus
experimentalDecorators.