Network Layer Abstraction
This page covers how to isolate HTTP transport logic behind a typed boundary so that mock and real API adapters are interchangeable — it does not cover mock server configuration or test runner setup.
Prerequisites
- Node.js 18+ or a browser build toolchain (Vite, webpack, or esbuild)
- TypeScript 5.x (the interface pattern depends on structural typing)
- A package manager: npm, pnpm, or yarn
- Basic familiarity with request interception patterns — understanding where the boundary sits before you design around it
- Optional: MSW 2.x installed if you plan to back the mock adapter with a Service Worker (covered in the integration phase below)
Phase 1 — Define the Transport Interface
The single most important decision is that application code must never call fetch or axios directly. Instead, it calls a typed HttpClient interface. Both the real and mock adapters satisfy that interface, making them structurally interchangeable.
// src/lib/http/types.ts
export interface RequestOptions {
headers?: Record<string, string>;
signal?: AbortSignal;
timeout?: number; // milliseconds
}
export interface ApiResponse<T> {
data: T;
status: number;
headers: Record<string, string>;
}
export interface HttpClient {
get<T>(url: string, opts?: RequestOptions): Promise<ApiResponse<T>>;
post<T>(url: string, body: unknown, opts?: RequestOptions): Promise<ApiResponse<T>>;
put<T>(url: string, body: unknown, opts?: RequestOptions): Promise<ApiResponse<T>>;
del<T>(url: string, opts?: RequestOptions): Promise<ApiResponse<T>>;
}
Next, write the production adapter. It calls fetch and normalises the response into the ApiResponse<T> shape:
// src/lib/http/fetch-adapter.ts
import type { ApiResponse, HttpClient, RequestOptions } from './types';
async function parseJson<T>(res: Response): Promise<ApiResponse<T>> {
const data = (await res.json()) as T;
const headers: Record<string, string> = {};
res.headers.forEach((value, key) => { headers[key] = value; });
return { data, status: res.status, headers };
}
export class FetchAdapter implements HttpClient {
constructor(private readonly baseUrl: string = '') {}
async get<T>(url: string, opts: RequestOptions = {}): Promise<ApiResponse<T>> {
const controller = new AbortController();
const timer = opts.timeout
? setTimeout(() => controller.abort(), opts.timeout)
: undefined;
const res = await fetch(`${this.baseUrl}${url}`, {
method: 'GET',
headers: opts.headers,
signal: opts.signal ?? controller.signal,
});
if (timer) clearTimeout(timer);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${url}`);
return parseJson<T>(res);
}
async post<T>(url: string, body: unknown, opts: RequestOptions = {}): Promise<ApiResponse<T>> {
const res = await fetch(`${this.baseUrl}${url}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...opts.headers },
body: JSON.stringify(body),
signal: opts.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${url}`);
return parseJson<T>(res);
}
async put<T>(url: string, body: unknown, opts: RequestOptions = {}): Promise<ApiResponse<T>> {
const res = await fetch(`${this.baseUrl}${url}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...opts.headers },
body: JSON.stringify(body),
signal: opts.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${url}`);
return parseJson<T>(res);
}
async del<T>(url: string, opts: RequestOptions = {}): Promise<ApiResponse<T>> {
const res = await fetch(`${this.baseUrl}${url}`, {
method: 'DELETE',
headers: opts.headers,
signal: opts.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${url}`);
return parseJson<T>(res);
}
}
The mock adapter follows the same interface but returns in-memory fixtures instead of issuing network requests:
// src/lib/http/mock-adapter.ts
import type { ApiResponse, HttpClient, RequestOptions } from './types';
type RouteMap = Map<string, unknown>;
export class MockAdapter implements HttpClient {
private routes: RouteMap = new Map();
/** Register a fixture for a given path. */
register<T>(path: string, fixture: T): void {
this.routes.set(path, fixture);
}
private resolve<T>(url: string): ApiResponse<T> {
if (!this.routes.has(url)) {
throw new Error(`MockAdapter: no fixture registered for "${url}". Register one with adapter.register("${url}", { ... })`);
}
return { data: this.routes.get(url) as T, status: 200, headers: {} };
}
async get<T>(url: string, _opts?: RequestOptions): Promise<ApiResponse<T>> {
return this.resolve<T>(url);
}
async post<T>(url: string, _body: unknown, _opts?: RequestOptions): Promise<ApiResponse<T>> {
return this.resolve<T>(url);
}
async put<T>(url: string, _body: unknown, _opts?: RequestOptions): Promise<ApiResponse<T>> {
return this.resolve<T>(url);
}
async del<T>(url: string, _opts?: RequestOptions): Promise<ApiResponse<T>> {
return this.resolve<T>(url);
}
}
Phase 2 — Configuration and Wiring
The factory function is the only place that reads the environment. Everywhere else in the codebase imports createHttpClient() and receives the correct adapter automatically.
// src/lib/http/index.ts
import { FetchAdapter } from './fetch-adapter';
import { MockAdapter } from './mock-adapter';
import type { HttpClient } from './types';
let _client: HttpClient | undefined;
export function createHttpClient(): HttpClient {
if (_client) return _client;
if (
import.meta.env.VITE_API_ADAPTER === 'mock' ||
import.meta.env.MODE === 'test'
) {
_client = new MockAdapter();
} else {
_client = new FetchAdapter(import.meta.env.VITE_API_BASE_URL ?? '');
}
return _client;
}
/** Reset the singleton — call this in beforeEach() to get a fresh adapter per test. */
export function resetHttpClient(): void {
_client = undefined;
}
export type { HttpClient, ApiResponse, RequestOptions } from './types';
Set the environment variables in your local and CI configurations:
# .env.development
VITE_API_ADAPTER=mock
VITE_API_BASE_URL=
# .env.production
VITE_API_ADAPTER=real
VITE_API_BASE_URL=https://api.example.com
# .env.test (read by Vitest)
VITE_API_ADAPTER=mock
For a Docker-based local stack, inject the flag at the service level so each container gets the adapter it needs without rebuilding images:
# docker-compose.yml
services:
frontend:
build: .
environment:
- VITE_API_ADAPTER=mock
- VITE_API_BASE_URL=
ports:
- "5173:5173"
api:
image: your-api-image
environment:
- NODE_ENV=development
ports:
- "3000:3000"
The proxy vs inline mocking strategies page covers the trade-offs of keeping the mock adapter inside the application bundle versus routing all traffic through a sidecar proxy — choose based on your latency tolerance and team structure.
Phase 3 — Integration with the Mock Stack
For richer scenario simulation — latency injection, stateful responses, and error sequences — back the MockAdapter with MSW handler registration. Because MSW intercepts at the Service Worker level (browser) or http module level (Node.js), it operates below the abstraction layer. The adapter issues a normal fetch, MSW intercepts it, and the adapter receives the mocked response transparently.
// src/mocks/setup.ts (browser entry point)
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';
export const worker = setupWorker(...handlers);
// src/main.tsx
import { createHttpClient } from '@/lib/http';
async function bootstrap() {
if (import.meta.env.VITE_API_ADAPTER === 'mock') {
const { worker } = await import('./mocks/setup');
await worker.start({ onUnhandledRequest: 'error' });
}
// Mount your React/Vue/Svelte app here
}
bootstrap();
Setting onUnhandledRequest: 'error' turns every unmatched route into a hard failure — the correct behaviour when enforcing mock lifecycle management in CI, where silent fallbacks mask contract drift.
For advanced handler composition and conditional response logic, see advanced MSW handler patterns.
In CI pipelines, export the adapter selection alongside other environment variables rather than baking it into the test command:
# .github/workflows/test.yml
env:
VITE_API_ADAPTER: mock
VITE_API_BASE_URL: ''
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm test
Verification Steps
Run these commands after completing Phase 1–3 to confirm the abstraction layer is wired correctly:
-
npx tsc --noEmit— TypeScript must compile with no errors; any adapter that misses anHttpClientmethod will surface here -
npm test -- --reporter=verbose— all tests should pass and test output should include no real network requests (check for[MSW] Mocking enabledor equivalent adapter log) -
grep -r "import.*fetch\|from 'axios'" src/features— should return no results; all feature code should import from@/lib/httponly -
VITE_API_ADAPTER=real npm run build && npx vite-bundle-visualizer— confirm theMockAdaptermodule does not appear in the production chunk - Run the app locally with
VITE_API_ADAPTER=mock npm run devand open the Network tab — requests to/api/*should be intercepted (status(ServiceWorker)in Chrome DevTools) rather than hitting a real server
Troubleshooting
TypeError: Cannot read properties of undefined (reading 'get') in tests
The singleton was not reset between tests. Add resetHttpClient() to your beforeEach hook:
import { resetHttpClient } from '@/lib/http';
beforeEach(() => {
resetHttpClient();
});
MockAdapter: no fixture registered for "/api/users" thrown at runtime
The adapter is active but no fixture covers that route. Either register the fixture (adapter.register('/api/users', [...])) or add an MSW handler. If using MSW, verify the worker started before the component mounted — look for [MSW] Mocking enabled. in the browser console.
Production build includes MockAdapter in the bundle
The factory function is using a runtime check (process.env.NODE_ENV) rather than a build-time replacement. Switch to import.meta.env.VITE_API_ADAPTER (Vite) or process.env.REACT_APP_API_ADAPTER (CRA / webpack DefinePlugin). Vite replaces import.meta.env.* at build time, enabling full tree-shaking of the dead branch.
onUnhandledRequest: 'error' fires on requests you did not intend to mock
A third-party library (analytics, feature flags, fonts) is issuing a network request that MSW intercepts. Use onUnhandledRequest as a function to allowlist known external domains:
await worker.start({
onUnhandledRequest(request, print) {
const allowed = ['analytics.example.com', 'fonts.googleapis.com'];
if (allowed.some(host => request.url.includes(host))) return;
print.error();
},
});
TypeScript reports Type 'MockAdapter' is not assignable to type 'HttpClient'
A method signature diverged. Run npx tsc --noEmit and check which method has a mismatched return type. The most common cause is omitting the generic parameter on Promise<ApiResponse<T>> in one of the adapter methods.
When to Advance
The abstraction layer is correctly implemented when all of the following are true:
- No feature-level file imports
fetch,axios, or any HTTP library directly — onlycreateHttpClient()is used - Switching
VITE_API_ADAPTERfrommocktoreal(and back) requires no code change, only an env var update - The production bundle excludes
MockAdapter(verified by bundle analysis) - All unit and integration tests pass without hitting the network
- CI pipeline logs show the mock adapter initialising before any test suite runs
Once these conditions hold, the remaining work is extending handler coverage and wiring the response shaping techniques that make fixtures realistic.
FAQ
What is the difference between a network layer abstraction and a plain fetch wrapper?
A plain fetch wrapper centralises the call but keeps the transport hardcoded. An abstraction layer introduces an interface that both real and mock implementations satisfy, making the transport swappable without touching any calling code. The distinction matters in large codebases where dozens of feature modules issue requests — changing the transport requires updating one factory, not every call site.
Does adding an abstraction layer affect production bundle size?
The interface itself adds negligible weight — TypeScript interfaces erase at compile time. The mock adapter should be tree-shaken out of production builds via import.meta.env guards or separate entry points. Use a bundle analyser to verify it is not included before you ship.
Can this pattern work alongside MSW without conflict?
Yes. MSW intercepts at the Service Worker or Node.js http module level, below your abstraction layer. The adapter issues a normal fetch, MSW intercepts it, and the adapter receives the mocked response transparently. The two approaches complement each other: the adapter provides the swappable boundary; MSW provides rich handler logic and scenario state for the request interception pattern.
How do I handle authentication tokens inside the abstraction layer?
Inject a token provider function into the adapter constructor. The real adapter calls your auth service; the mock adapter returns a hardcoded test token. Neither the caller nor the test knows which one ran:
export class FetchAdapter implements HttpClient {
constructor(
private readonly baseUrl: string,
private readonly getToken: () => Promise<string | null> = async () => null
) {}
async get<T>(url: string, opts: RequestOptions = {}): Promise<ApiResponse<T>> {
const token = await this.getToken();
const authHeader = token ? { Authorization: `Bearer ${token}` } : {};
const res = await fetch(`${this.baseUrl}${url}`, {
headers: { ...authHeader, ...opts.headers },
signal: opts.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${url}`);
return parseJson<T>(res);
}
// ... other methods
}
Related
- Abstracting Network Layers for Frontend Apps — React and Vue implementation walkthroughs for this pattern
- Request Interception Patterns — how middleware captures traffic at the transport level
- Proxy vs Inline Mocking Strategies — deciding where the mock boundary should live in your stack
- Mock Lifecycle Management — starting, seeding, and tearing down mock servers in CI
- Advanced MSW Handler Patterns — stateful handlers, conditional responses, and latency simulation
← Back to API Mocking Fundamentals & Architecture