Stop Repeating fetch(): How to Write Reusable API Service Layer in JavaScript

how to write reusable API service layer in JavaScript

You push a deploy after an infra tweak and everything looks green until one forgotten hardcoded request keeps calling the old endpoint. It returns 404s or silent garbage and you spend a morning hunting down the single offending fetch call. That exact outage is why you need a stable contract behind your calls.

Your goal is simple: stop scattering base URLs, headers, retries, JSON parsing, and error mapping across features. Centralize those concerns under an apis/ boundary that exposes Promise-returning functions your controllers await.

This guide is not a beginner tutorial on calling endpoints. It shows design boundaries so your app code stops caring whether you use fetch or Axios. You get a single request primitive, per-service Axios clients, per-endpoint modules, and controllers that stay glue-free.

“Optimized” means fewer duplicate lines, consistent error semantics, uniform response shaping, and fast changes when an external URL or auth scheme shifts. Later, we cite Axios request-config docs and RFC 9110 for retry and idempotency guidance.

The real problem you’re solving: duplicated request logic across endpoints and features

A single missed URL change can turn a routine deploy into an all-hands debugging session. You update nineteen call sites to a new base host and one hardcoded caller still hits https://old-service/…, producing a partial outage that reads like random failures.

This failure mode is common because teams copy working request blocks, tweak headers or params, and drop near-identical snippets across the codebase. Small differences—a timeout here, a header there—make those duplicates dangerous and brittle.

The problem spans environments. On Node backends, controllers and services call external endpoints directly. In React frontends, components and hooks do the same. Both sides end up with repeated request code that must be updated everywhere when an endpoint, auth scheme, or timeout changes.

  • You spend time hunting every hardcoded url when a host moves.
  • Teams copy-paste request code and introduce subtle divergences.
  • Changes to auth headers, retry policy, or cancellation require edits at every call site.

You’re not shaving LOC; you’re shrinking the number of places bugs can hide when an endpoint or url shifts mid-sprint. Centralizing the plumbing gives the rest of your application a single function call and one place to fix problems.

Service layer boundaries that don’t rot: API layer vs controller vs domain logic

When parsing endpoint responses becomes a scavenger hunt across controllers, maintenance grinds to a halt. You must pick a clear boundary and enforce it.

The API boundary owns transport concerns: HTTP method, path building, param serialization, headers, timeouts, retries, cancellation, and error mapping. It returns Promises that settle with normalized data objects, not raw HTTP blobs.

What belongs here and what absolutely does not

  • The API boundary owns only transport and response normalization.
  • It never contains feature flags, permission checks, or domain decisions such as premium logic.
  • Controllers act as thin HTTP adapters: map incoming requests to calls and map normalized results back to responses.
  • Domain logic stays in services and models; parsing and DTO shaping remain at the boundary so your controller never digs into vendor quirks.

This architecture keeps your controller and domain logic stable when you swap clients or runtimes. Clear ownership prevents creeping parsing logic and makes external-contract changes safe.

File and folder structure that scales past your second external service

A clear, scalable folder layout keeps every new external integration from becoming a hunt for missing configs. Organize a single apis/ boundary under src so controllers and domain code import one module per external vendor.

Drop‑in structure that you can copy into a repo

  • src/apis/configs/axiosClients.js
  • src/apis/configs/axiosUtils.js
  • src/apis/CmsAPI.js
  • src/apis/SemaphoreAPI.js

The naming contract is simple: <externalService>API.js. That makes it obvious where endpoints live and where you look during outages. No mystery meat like utils/http2.js.

What belongs where

Put one axios.create() client per vendor inside axiosClients.js so timeouts, baseURL, and headers never bleed across services. Keep interceptors there when they apply only to that vendor.

Reserve axiosUtils.js for shared helpers: error mapping, request logging guards, and serialization helpers. These utilities stop copy/paste and keep per-endpoint files thin.

With this shape, controllers import from apis/CmsAPI instead of reconstructing Axios configs. Next step: standardize a single request primitive so each endpoint method becomes a two-line function that returns normalized data.

Pick your HTTP client: Fetch API vs Axios, and why Promise semantics matter

A lean contract starts with a predictable client that enforces common request semantics.

Fetch is native and minimal, but you often rebuild features Axios already provides: interceptors, consistent timeout behavior, cancellation ergonomics, and opinionated JSON handling. Axios is a Promise-based HTTP client for browser and Node that gives you a config-driven surface you can wrap and constrain.

  • You avoid repeating serialization and retry wiring when the client exposes interceptors.
  • Centralized request config keeps headers, baseURL, and timeouts consistent across modules.
  • Cancellation and unified error shaping are simpler when a single client enforces policies.

Promises, async/await, and returning Promises by design

Your endpoint methods should return Promises. Transport is asynchronous and controllers or UI code must decide when to await, compose, or cancel calls.

Designing each method as a Promise-returning function separates concerns: the API surface is defined where calls are declared, while execution happens where you need results—controllers, workers, or hooks.

Reference point: Axios config and interceptors

Use the Axios docs as the baseline for config shape and request/response middleware. Axios supports per-client axios.create(), interceptors, and rich request config that you’ll wrap.

Be mindful: Axios rejects non-2xx responses by default while fetch does not. Your layer must normalize errors and account for browser vs Node differences like CORS, cookies, and HTTP agents.

How to write reusable API service layer in JavaScript with a single request primitive

Centralizing every outgoing call behind one small contract makes configuration shifts trivial and fast.

Define one function: request({ method, url, params, data, headers, signal }). Put it in a single file and export it. That becomes the only place for retries, timeouts, and common headers.

Type-safe wrapper

If you use TypeScript, add a generic: apiRequest<T>(config): Promise<T&gt. The generic forces callers to declare the expected data shape. This reduces any drift and makes refactors safer.

Normalize responses and errors

Return response.data, not the full HTTP object. Your modules should receive the payload they need and nothing tied to a client library.

Map transport failures into an app-level error object that still exposes status, code, and requestId when available. That preserves debuggability while giving consistent error shapes across the codebase.

  • One primitive rule: all modules import request, not Axios or fetch directly.
  • Validation belongs at the boundary: validate schema before domain logic consumes data.
  • Enforce the pattern in code review and migrate per-service files to depend on the primitive.

Implementation: build an Axios client with baseURL, timeouts, and interceptors

When multiple external integrations start failing intermittently, the root cause is often inconsistent client config across modules. This implementation shows a production-minded pattern: one file per vendor, tailored timeouts, and a single error handler wired via interceptors.

Create per-service clients with axios.create()

Make one client per external endpoint so defaults never leak between apis. Example file path: apis/configs/axiosClients.js. Each client gets its own baseURL and timeout.

Centralize error handling using response interceptors

Wire the interceptor like: client.interceptors.response.use(undefined, errorHandler). errorHandler maps transport failures into app-level error objects and logs selectively.

Working code snippets and selective logging

Example outline:

  • const cmsClient = axios.create({ baseURL: CMS_BASE, timeout: 5000 })
  • client.interceptors.response.use(undefined, errorHandler)
  • In axiosUtils.js implement errorHandler that logs 5xx and unexpected 4xx, but skips noise for 401 unless debug is enabled.

Keep runtime reality in mind: a backend handling payments needs tighter timeouts and stricter retries than a CMS. Centralizing these clients means a single file change updates headers or hosts with a predictable blast radius.

Implement per-endpoint modules: one service file per external API

When all CMS endpoints live in one file, pagination and error shaping stay consistent across your app. Keep each method concise: build a config, call the client, and return normalized data.

cmsAPI.js example with three methods

Make apis/cmsAPI.js the single home for CMS calls so controllers never call raw URLs.

  • get(slug): build { url: `/entities/${slug}`, method: ‘get’ } and return data.entity
  • getArticles(limit, offset): use params: { limit, offset } and return data.items
  • getAllArticles(): call list endpoint and return response.entities

Pagination and consistent query serialization

Always pass params to cmsClient.request({ url, method, params }). Let the client serialize query strings so names and types stay stable.

Centralizing these functions prevents common pagination bugs like wrong param names or accidental string coercion. Add new endpoints by adding methods here, not by scattering client.request calls across features.

Use the API layer from controllers without reintroducing glue code

Keep controllers focused: their job is HTTP routing and minimal mapping, not reshaping vendor payloads. A thin controller calls your boundary, handles errors, and returns a single JSON response.

Controller example

Example controller that does one job:

const articles = await CmsAPI.getAllArticles();
res.json(articles);

It does not re-serialize params or massage vendor fields. Let the exported method return normalized data so your controller stays tiny and predictable.

Align with a three-layer architecture

Map responsibilities clearly: controller = HTTP surface, service = business rules and composition, data access = database calls. Keep external calls inside the API boundary and any merging of CMS items with local rows inside the service.

  • Controllers map normalized app errors to HTTP status and JSON bodies.
  • Services perform composition, validation, and workflows that span systems.
  • Data access handles persistence and queries; it does not call external vendors.

Following this architecture keeps your backend scalable. Adding a second external integration grows your API modules and service logic, not your controllers.

Retry logic, idempotency, and failure handling you can actually ship

When an upstream host hiccups, your retry policy decides whether clients recover or your system repeats work.

Centralize retries on the client

Configure retries on each Axios client with axios-retry so the policy applies consistently per external vendor. That keeps retry rules out of controllers and avoids scattered retry logic.

Retry conditions and idempotency

Only auto-retry transient failures like 503. Limit retries by HTTP method: allow GET and HEAD by default, consider PUT/DELETE if your backend guarantees idempotency, and never retry unsafe POSTs unless you use explicit idempotency keys.

Follow RFC 9110 for idempotency semantics and consult the axios-retry docs for configuration examples.

Backoff, logging, and observability

Use a simple backoff you can reason about: fixed delay or linear delay such as delay = retryCount * 2000. Log each retry attempt at warn level with status, endpoint, and attempt count.

  1. Configure per-client retries via axios-retry, not per call site.
  2. Target transient errors (503); avoid retrying non-idempotent methods without keys.
  3. Use linear or fixed backoff and log attempts to correlate errors and time spikes.
  4. Log metadata only; never dump full payloads that may include customer data.

Following these shippable rules reduces flakiness from upstream brownouts while protecting you from duplicate writes and unexpected order duplication.

Auth, headers, cancellation, and browser vs Node.js differences

Auth and headers deserve a single, auditable home so one missing token doesn’t break an entire flow.

Attach tokens once per client

Set Authorization on the per-service client instead of adding headers in every function. Use defaults or an interceptor like:

  • api.defaults.headers.common[‘Authorization’] = `Bearer ${token}`
  • or an interceptor that reads a request-scoped context for SSR

This keeps your requests surface small and reduces one-off header bugs across features.

Cancel in-flight requests for React UIs

In a react app, stale responses can overwrite fresh state. Use AbortController or Axios cancellation so you abort requests on unmount or param changes.

That prevents race conditions and reduces user-facing flakiness when network timing shifts.

Respect browser vs Node differences

Cookies, CORS, and credential modes differ by environment. Centralize those settings on the client so controllers and services never leak environment checks.

For server SSR, avoid global mutable defaults; prefer request-scoped clients or interceptors that read the incoming request context and url-sensitive tokens.

Common mistakes mid-to-senior devs make when building an API service layer

A single decision about error shapes or headers can ripple into confusing bugs across features. Below are frequent operational traps and exact fixes you can apply now.

Coupling to the client by returning raw responses

Returning Axios responses ({ data, status, headers }) forces every caller to know client semantics. That couples your code and complicates future swaps.

Fix: return normalized data and a small error type. Keep the client object private and map transport details at the boundary.

Global mutable headers that leak between requests

Setting defaults on a global client can bleed tokens across SSR or concurrent requests and cause auth bugs.

Fix: use per-client instances and inject auth per request or via request‑scoped interceptors.

Blind retries on unsafe writes

Retrying POST or PUT blindly can create duplicate orders or duplicate writes and break business logic.

Fix: restrict retries to idempotent methods or require idempotency keys for writes.

Swallowing status and losing debuggability

“Friendly” handlers that return null or hide status make production errors opaque.

Fix: preserve status, method, url, and a sanitized upstream body when rethrowing or logging.

Validation drift at the boundary

Skipping response validation lets undefined fields surface deep in domain logic and wastes debugging time.

Fix: validate responses (Zod, JSON Schema) at the boundary and fail fast with actionable error messages.

Conclusion

This pattern turns scattered request code into a predictable surface you can audit and change with confidence. Put one request primitive behind per-service clients and small endpoint modules so controllers stay thin and focused on responses.

The maintainability gains are concrete: base URL swaps, auth header updates, retry policy changes, and error mapping live in one place. That implementation reduces blast radius and speeds fixes when an upstream host acts up.

Keep mind to return normalized payloads and preserve status and metadata. Keep mind that official anchors — the Axios docs for interceptors/config and RFC 9110 for idempotency — guide retry choices.

Next step: pick one external endpoint, add an ExternalServiceAPI.js, create a dedicated client, and migrate a single call. This article’s promise stands: less plumbing, more product work.

Leave a Reply

Your email address will not be published. Required fields are marked *