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.
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.
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,
};
}
}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,
});
}
}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,
};
}
}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:
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
BaseHttpClientinterceptor layer to trace downstream failures instantly.
Discussion & Feedback
0 thoughts shared by the community
Loading discussion thread...