API Clients in Node.js & TypeScript: Scalable Integration

Stop scattering raw HTTP requests across your codebase. Learn how to architect a reusable, robust, and type-safe API integration layer using TypeScript.

T

TechScale

October 8, 2026 6 min read
Share:
API Clients in Node.js & TypeScript: Scalable Integration

The Cost of Ad-Hoc HTTP Integrations

In the early stages of a Node.js project, fetching data from a third-party API seems trivial. You install Axios or use native fetch, drop an inline HTTP call inside a controller or route handler, and call it a day.

However, as your system grows, this direct Controller -> axios(...) coupling turns into an architectural liability. You quickly run into repeated configuration, untyped response payloads, missing timeouts, fragile error handling, and hard-to-test code blocks.

To build resilient, enterprise-grade applications, we must segregate our logic. Instead of scattered HTTP calls, we implement a highly structured tier:

Controller -> Service -> API Client -> Third-Party API

By decoupling transport mechanics from your domain logic, you gain centralized telemetry, robust retry/timeout strategies, predictable error handling, and trivial testability. Let's look at how to construct this clean integration layer in TypeScript.

System Architecture & Responsibility Segregation

Before writing code, let us define the responsibilities of each layer in this decoupled architecture:

  • Controller Layer: Handles incoming HTTP requests, performs basic payload validation, and delegates execution to the Service Layer. It should remain entirely agnostic of third-party HTTP transport mechanisms.

  • Service Layer (Domain Logic): Orchestrates your business logic. It queries databases, implements workflows, and calls one or more API Clients. It interacts strictly with domain entities and interfaces.

  • API Client Layer (Gateway): Translates domain-specific method calls (e.g., chargeCustomer) into concrete HTTP requests (e.g., POST /v1/charges). It configures headers, applies authentication, transforms payloads, and parses external responses.

  • HTTP Utility Class (Base Client): A reusable wrapper around an HTTP client library (like Axios or Fetch) that enforces system-wide policies such as standard timeouts, circuit breakers, auto-retries, and request correlation IDs.

This strict segregation ensures that if a third-party vendor changes their API format, or if you decide to swap Axios for native fetch, your core business logic in the Service Layer remains untouched.

src/interfaces/http.interface.ts
typescript
export interface HttpClientConfig {
  baseURL: string;
  timeoutMs?: number;
  retries?: number;
  headers?: Record<string, string>;
}

export interface RequestOptions {
  headers?: Record<string, string>;
  params?: Record<string, any>;
  timeoutMs?: number;
}

export class ApiError extends Error {
  constructor(
    public readonly statusCode: number,
    message: string,
    public readonly context?: Record<string, any>
  ) {
    super(message);
    this.name = 'ApiError';
    Object.setPrototypeOf(this, ApiError.prototype);
  }
}

Isolate Vendor SDKs Behind Adapters

Even if a vendor provides an official SDK (e.g., Stripe, SendGrid), it is highly recommended to wrap it inside a custom domain Service or Adapter interface. This safeguards your domain logic from sudden major SDK updates and API contract changes.

src/clients/base-http.client.ts
typescript
import axios, { AxiosInstance, AxiosError, AxiosRequestConfig } from 'axios';
import { HttpClientConfig, RequestOptions, ApiError } from '../interfaces/http.interface';

export abstract class BaseHttpClient {
  protected readonly client: AxiosInstance;

  constructor(protected readonly config: HttpClientConfig) {
    this.client = axios.create({
      baseURL: config.baseURL,
      timeout: config.timeoutMs ?? 10000,
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json',
        ...config.headers,
      },
    });

    this.initializeResponseInterceptor();
  }

  private initializeResponseInterceptor(): void {
    this.client.interceptors.response.use(
      (response) => response,
      (error: AxiosError) => {
        if (error.response) {
          throw new ApiError(
            error.response.status,
            `API call failed: ${error.message}`,
            error.response.data as Record<string, any>
          );
        } else if (error.request) {
          throw new ApiError(504, 'Network or Timeout Error: No response received', { request: error.config });
        }
        throw new Error(`Execution error: ${error.message}`);
      }
    );
  }

  protected async get<T>(url: string, options?: RequestOptions): Promise<T> {
    const response = await this.client.get<T>(url, this.mapOptions(options));
    return response.data;
  }

  protected async post<T>(url: string, data?: any, options?: RequestOptions): Promise<T> {
    const response = await this.client.post<T>(url, data, this.mapOptions(options));
    return response.data;
  }

  private mapOptions(options?: RequestOptions): AxiosRequestConfig {
    if (!options) return {};
    return {
      headers: options.headers,
      params: options.params,
      timeout: options.timeoutMs,
    };
  }
}
src/clients/resilient-http.client.ts
typescript
import { BaseHttpClient } from './base-http.client';
import { HttpClientConfig } from '../interfaces/http.interface';
import axiosRetry, { exponentialDelay } from 'axios-retry';

export abstract class ResilientHttpClient extends BaseHttpClient {
  constructor(config: HttpClientConfig) {
    super(config);
    
    // Attaching resilient retry strategies to Axios instance
    axiosRetry(this.client, {
      retries: config.retries ?? 3,
      retryCondition: (error) => {
        // Retry on 5xx server errors and rate limit issues (429)
        const status = error.response?.status;
        return !status || status >= 500 || status === 429;
      },
      retryDelay: (retryCount) => {
        return exponentialDelay(retryCount);
      },
      shouldResetTimeout: true,
    });
  }
}
src/clients/payment.client.ts
typescript
import { ResilientHttpClient } from './resilient-http.client';
import { HttpClientConfig } from '../interfaces/http.interface';

export interface ChargeRequest {
  amountCents: number;
  currency: string;
  sourceToken: string;
}

export interface ChargeResponse {
  id: string;
  status: 'succeeded' | 'failed' | 'pending';
  transactionId: string;
}

export class PaymentClient extends ResilientHttpClient {
  constructor(config: HttpClientConfig, apiKey: string) {
    super({
      ...config,
      headers: {
        'Authorization': `Bearer ${apiKey}`,
        ...config.headers,
      },
    });
  }

  public async executeCharge(payload: ChargeRequest): Promise<ChargeResponse> {
    // Transforming third party structure to our strict domain schema
    const rawResponse = await this.post<{ id: string; state: string; tx_ref: string }>('/v1/charges', {
      amount: payload.amountCents,
      currency: payload.currency,
      token: payload.sourceToken,
    });

    return {
      id: rawResponse.id,
      status: rawResponse.state === 'success' ? 'succeeded' : 'failed',
      transactionId: rawResponse.tx_ref,
    };
  }
}
src/services/billing.service.ts
typescript
import { PaymentClient, ChargeResponse } from '../clients/payment.client';

export class BillingService {
  // Constructor injection permits flexible substitution for testing
  constructor(private readonly paymentClient: PaymentClient) {}

  public async processSubscriptionCharge(userId: string, amount: number, token: string): Promise<ChargeResponse> {
    if (amount <= 0) {
      throw new Error('Subscription amount must be positive');
    }

    try {
      const result = await this.paymentClient.executeCharge({
        amountCents: Math.round(amount * 100),
        currency: 'USD',
        sourceToken: token,
      });

      return result;
    } catch (error) {
      throw new Error(`Failed to charge user ${userId}: ${(error as Error).message}`);
    }
  }
}

Avoid Leaking Axios Types to Your Service Layer

Never expose third-party library types (like AxiosResponse, AxiosError, or raw vendor payloads) outside your client classes. Doing so defeats the purpose of the abstraction and tethers your entire service layer directly to external HTTP configurations.

Testing and Mocking Strategy

One of the biggest wins of this architecture is how trivial testing becomes. You no longer need to mock global HTTP requests or patch global scopes. Instead, because dependencies are explicitly injected, you can easily use mock implementations.

Unit Testing with Mocked Clients

Since BillingService relies on constructor dependency injection, writing a mock in your unit tests is straightforward:

typescript
typescript
import { BillingService } from './billing.service';
import { PaymentClient } from '../clients/payment.client';

describe('BillingService', () => {
  it('should process payment successfully', async () => {
    const mockPaymentClient = {
      executeCharge: jest.fn().mockResolvedValue({
        id: 'ch_123',
        status: 'succeeded',
        transactionId: 'tx_999'
      })
    } as unknown as PaymentClient;

    const billingService = new BillingService(mockPaymentClient);
    const response = await billingService.processSubscriptionCharge('usr_abc', 19.99, 'tok_visa');

    expect(response.status).toBe('succeeded');
    expect(mockPaymentClient.executeCharge).toHaveBeenCalledWith({
      amountCents: 1999,
      currency: 'USD',
      sourceToken: 'tok_visa'
    });
  });
});

Integration Testing with Mock Service Worker (MSW)

When you want to test your actual PaymentClient integration without hitting real production APIs, use MSW or Nock to intercept HTTP traffic at the network boundary. This ensures your custom parsing and error handling interceptors behave correctly.

Summary of Key Benefits

By moving away from inline HTTP calls to a structured client-service integration tier, you achieve:

  • Separation of Concerns: Changes to the third-party endpoint URL or parameter payload structure only require edits in the specific client class.

  • Resiliency out of the box: Global behaviors such as backoffs, retry intervals, and timeouts are configured once and applied universally.

  • Exceptional Observability: Implement APM tracing headers, request logging, and telemetry inside the BaseHttpClient interceptor layer to trace downstream failures instantly.

Enjoyed this deep dive?

Share:
Tags:Node.jsTypeScriptAPI DesignSoftware ArchitectureAxios

Discussion & Feedback

0 thoughts shared by the community

💬

Loading discussion thread...