Skip to content
SP StackPractices
intermediate By Mathias Paulenko

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

Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.

Decorator Pattern for HTTP Request Pipelines

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!
);

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

Best Practices

  • 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

FAQ

Q: How is this different from middleware in Express? A: Express middleware operates on request/response objects in sequence. Decorators wrap a single client interface and can be composed at any granularity.

Q: Can decorators be removed dynamically? A: Only if you reassign the client reference. Decorators are typically composed at initialization and remain fixed.