Decorator Pattern for HTTP Request Pipelines
Use the Decorator pattern to compose cross-cutting concerns like logging, metrics, and retries into HTTP request pipelines without modifying core logic
The Decorator pattern wraps an object to add responsibilities dynamically. When applied to HTTP clients, it becomes a clean way to compose cross-cutting concerns — logging, retries, metrics, authentication — without polluting the core request logic.
When to Use This
- Multiple cross-cutting concerns must wrap every API call
- You want to add or remove concerns without changing existing code
- Core request logic should remain testable and focused
Problem
Adding logging, retries, metrics, and auth to every HTTP call leads to monolithic client classes or copy-paste boilerplate at every call site.
Solution
// api/HttpClient.ts
interface HttpClient {
request(url: string, options: RequestInit): Promise<Response>;
}
// api/FetchClient.ts
class FetchClient implements HttpClient {
async request(url: string, options: RequestInit): Promise<Response> {
return fetch(url, options);
}
}
// decorators/BaseClientDecorator.ts
abstract class BaseClientDecorator implements HttpClient {
constructor(protected client: HttpClient) {}
abstract request(url: string, options: RequestInit): Promise<Response>;
}
// decorators/LoggingDecorator.ts
class LoggingDecorator extends BaseClientDecorator {
async request(url: string, options: RequestInit): Promise<Response> {
const start = performance.now();
try {
const response = await this.client.request(url, options);
console.log(`${options.method || 'GET'} ${url} → ${response.status} (${(performance.now() - start).toFixed(0)}ms)`);
return response;
} catch (error) {
console.error(`${options.method || 'GET'} ${url} → ERROR`);
throw error;
}
}
}
// decorators/RetryDecorator.ts
class RetryDecorator extends BaseClientDecorator {
constructor(client: HttpClient, private maxRetries: number = 3) {
super(client);
}
async request(url: string, options: RequestInit): Promise<Response> {
let lastError: Error;
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
try {
return await this.client.request(url, options);
} catch (error) {
lastError = error as Error;
if (attempt < this.maxRetries) {
await new Promise(r => setTimeout(r, 1000 * attempt));
}
}
}
throw lastError!;
}
}
// decorators/AuthDecorator.ts
class AuthDecorator extends BaseClientDecorator {
constructor(client: HttpClient, private token: string) {
super(client);
}
async request(url: string, options: RequestInit): Promise<Response> {
const headers = new Headers(options.headers);
headers.set('Authorization', `Bearer ${this.token}`);
return this.client.request(url, { ...options, headers });
}
}
Usage
const client = new AuthDecorator(
new RetryDecorator(
new LoggingDecorator(new FetchClient()),
3
),
process.env.API_TOKEN!
);
The outermost decorator (Auth) runs first, adding the token header. Then Retry catches failures and retries. Then Logging records timing. Finally FetchClient performs the actual network call. Each decorator wraps the next, forming a stack.
How It Works
Each decorator implements the same HttpClient interface and holds a reference to the next decorator in the chain. When request() is called, the decorator can modify the inputs, call the inner client, and then modify or inspect the output. Because every decorator shares the same interface, they compose transparently: the caller does not know how many layers exist.
The order of decoration matters. Auth should wrap Retry so that retries include the token. Logging should wrap the innermost client so it records actual network time, not retry delays. Metrics should wrap everything to capture end-to-end latency.
Best Practices
- Keep each decorator focused on one concern. A decorator that logs and retries is two decorators in disguise.
- Always spread or clone the options object before mutating headers. Mutating the original causes subtle bugs when retries re-use the same options.
- Re-throw errors unless the decorator’s purpose is to handle them (like Retry). Swallowing errors breaks the chain contract.
- Use a builder or factory function to construct the decorator stack. Inline nesting like the example above becomes unreadable past 4 layers.
- Test decorators in isolation by passing a mock inner client. Verify that the decorator calls through and transforms inputs/outputs correctly.
- Consider a
CircuitBreakerDecoratorfor production systems that call unreliable downstreams. It prevents cascading failures by short-circuiting after N consecutive errors.
Circuit Breaker Example
class CircuitBreakerDecorator extends BaseClientDecorator {
private failures = 0;
private isOpen = false;
private lastFailureTime = 0;
constructor(
client: HttpClient,
private threshold: number = 5,
private resetTimeout: number = 30000
) {
super(client);
}
async request(url: string, options: RequestInit): Promise<Response> {
if (this.isOpen) {
if (Date.now() - this.lastFailureTime > this.resetTimeout) {
this.isOpen = false;
this.failures = 0;
} else {
throw new Error('Circuit breaker open');
}
}
try {
const response = await this.client.request(url, options);
this.failures = 0;
return response;
} catch (error) {
this.failures++;
this.lastFailureTime = Date.now();
if (this.failures >= this.threshold) {
this.isOpen = true;
}
throw error;
}
}
}
The circuit breaker tracks consecutive failures. After threshold errors, it opens and rejects all requests for resetTimeout milliseconds. After the timeout, it allows one request through (half-open state). If that succeeds, the breaker closes. If it fails, the cycle repeats.
Variations
- Conditional Decorator: Apply logic only for specific URLs or HTTP methods
- Metrics Decorator: Push timing and status code distributions to Prometheus
- Cache Decorator: Combine with Proxy pattern to cache GET responses
- Circuit Breaker Decorator: Short-circuit requests after N consecutive failures, with a cooldown period before retrying
- Timeout Decorator: Abort requests that exceed a configurable deadline using
AbortController - Rate Limit Decorator: Enforce a maximum number of concurrent or per-second requests
- Tracing Decorator: Inject trace IDs into headers and emit spans to OpenTelemetry
What Works
- Keep decorators focused on one responsibility each
- Ensure decorators delegate to
client.request()without swallowing errors - Make decorators stateless when possible to avoid side effects
Common Mistakes
- Mutating the request object instead of creating a new one
- Forgetting to forward the response or error to the next decorator
- Adding too many decorators, making the call stack hard to trace
- Wrapping in the wrong order: logging outside retry records total time including retries, which may be misleading
- Not testing decorators in isolation: always pass a mock inner client
- Using decorators for business logic: they should handle cross-cutting concerns, not domain rules
- Ignoring error propagation: a decorator that catches but does not re-throw hides failures from the caller
- Hardcoding decorator configuration: pass retry counts, timeouts, and tokens through constructors for testability
Troubleshooting
- Pattern does not fit the problem: re-evaluate the forces (performance, scalability, team size, coupling). A pattern is only appropriate when its trade-offs match your constraints.
- Too many abstractions: if adding a pattern increases complexity without a clear benefit, simplify. Not every module needs a factory, decorator, or strategy.
- Tight coupling after refactoring: check that interfaces are stable and dependencies point inward.
- Tests break when the design changes: favor stable contracts over internal structure.
- Performance regression from indirection: measure before and after. Layers, decorators, and adapters can add latency; cache or inline hot paths if needed.
Further Reading
- Official documentation: check the current reference for the framework or tool used.
- Related guides: explore the decorator and middleware guides for deeper coverage.
- Complementary patterns: review design patterns applicable to your technology stack.
- Public postmortems: study real incidents from teams that faced similar production issues.
Production Notes
- Deploy gradually using canary or blue-green to catch regressions early.
- Configure alerts for error rate, p99 latency, and failure rate before enabling in production.
- Document the rollback in the runbook; test the procedure in staging at least once per quarter.
- Review structured logs with correlation IDs to trace requests end-to-end during incidents.
Key Takeaways
- Apply decorator pattern for http request pipelines when you need a practical solution for your use case.
- Monitor performance after implementation; measure latency, errors, and resource usage before and after.
- Check the Troubleshooting section for common failures; most have documented root causes with fixes.
- Keep dependencies updated and run tests in CI to prevent production regressions.
Common Production Pitfalls
- Applying the pattern where no abstraction is needed, adding accidental complexity.
- Letting the pattern leak into unrelated modules and blur ownership boundaries.
- Over-engineering the first implementation instead of starting simple and measuring pain.
- Skipping contract tests, so refactors silently break consumers.
- Ignoring failure modes that the pattern does not cover.
- Using the pattern as a default instead of choosing the right tool for the current scale.
- Forgetting to document when to stop using the pattern and what replaces it.
- Missing observability around the pattern’s performance and error propagation.
Frequently Asked Questions
How do I test a decorator?
Pass a mock HttpClient that returns a fixed response or throws. Assert that the decorator calls through, modifies headers or options correctly, and re-throws errors. Each decorator should be testable without real network calls.
Can I use decorators with fetch directly?
Yes, but wrap fetch in a class implementing HttpClient first. The decorator pattern needs a shared interface. Calling fetch() directly in each decorator defeats the purpose.
How many decorators is too many?
Past 5-6 layers, the stack becomes hard to debug. If you need more, consider grouping concerns: combine logging and metrics into an observability decorator, or use a middleware pipeline instead.
Should decorators handle business logic?
No. Decorators handle cross-cutting concerns: transport-level concerns like auth, retries, logging, caching. Business rules belong in services or controllers that call the decorated client.
How does this compare to the Chain of Responsibility pattern?
Chain of Responsibility passes a request along a chain where each handler decides to process or forward. Decorators always call through to the inner client and typically wrap or transform the call. See Chain of Responsibility.
Can I use this pattern with GraphQL clients?
Yes. Wrap the GraphQL client's query and mutate methods with the same decorator stack. Auth, logging, and retry decorators work identically for GraphQL operations.
How do I handle request cancellation?
Use a TimeoutDecorator that creates an AbortController, sets a timeout, and passes the signal to the inner client. If the timeout fires, abort the request and throw a timeout error.
Should the Retry decorator retry on all errors?
No. Retry only on transient failures: network errors, 502, 503, 504. Do not retry on 400, 401, 403, 404, or validation errors. Check the status code or error type before retrying.
Can decorators be used in the browser?
Yes. The pattern works in any TypeScript/JavaScript environment. performance.now() is available in browsers. Use AbortController for timeouts instead of Node-specific APIs.
How do I add tracing with decorators?
Create a TracingDecorator that generates or propagates a trace ID via headers (e.g., X-Trace-Id). Emit spans to OpenTelemetry or your tracing backend. Place it outermost so it captures the full request lifecycle.
Related Resources
Proxy Pattern for API Response Caching
How to implement a caching proxy that intercepts API calls and stores responses to reduce latency and avoid redundant network requests
PatternAdapter Pattern
Convert the interface of a class into another interface clients expect. A structural design pattern for interface compatibility.
RecipeCall a REST API: Python, JavaScript, Java & Go Examples
How to make HTTP requests to a REST API and handle the JSON response in Python, JavaScript, Java, and Go.
RecipeWebSocket Authentication and Security Patterns
How to authenticate WebSocket connections, implement token validation, and handle authorization for real-time messaging in production
PatternAdapter Pattern for Integrating External REST APIs
Use the Adapter pattern to normalize responses from external REST APIs into a consistent internal model without leaking third-party formats into your domain
PatternChain of Responsibility for Request Processing Middleware
Pass requests along a chain of handlers where each handler decides whether to process the request or pass it to the next handler in the pipeline