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)

Network Layer Abstraction — adapter boundary Application code calls a single HttpClient interface. In production the FetchAdapter satisfies that interface and calls the real API. In development and CI the MockAdapter (optionally backed by MSW) satisfies the same interface and returns fixture responses. Both paths return an identical typed Response. Application code useUser(), fetchOrders() HttpClient interface get(url, opts): Promise<T> post / put / del … FetchAdapter production — real API MockAdapter dev / CI — fixtures or MSW NODE_ENV=production NODE_ENV=test / dev Identical typed Response<T> shape

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 an HttpClient method will surface here
  • npm test -- --reporter=verbose — all tests should pass and test output should include no real network requests (check for [MSW] Mocking enabled or equivalent adapter log)
  • grep -r "import.*fetch\|from 'axios'" src/features — should return no results; all feature code should import from @/lib/http only
  • VITE_API_ADAPTER=real npm run build && npx vite-bundle-visualizer — confirm the MockAdapter module does not appear in the production chunk
  • Run the app locally with VITE_API_ADAPTER=mock npm run dev and 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 — only createHttpClient() is used
  • Switching VITE_API_ADAPTER from mock to real (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
}

← Back to API Mocking Fundamentals & Architecture