Technical Debt
Mar 20269 min read

The Adapter Pattern in JavaScript: Swap Libraries Without the Pain

Every npm install is a bet on the future. The Adapter Pattern is the strategy that lets you swap third-party libraries without rewriting half your system. With real JavaScript examples.

FT

Felix Tineo

Technology Strategist · Fractional CTO

The Adapter Pattern in JavaScript: Swap Libraries Without the Pain

The time bomb you keep installing

There's a very predictable behavior pattern in development teams.

Someone finds a library that solves a problem. They install it. They use it directly in ten different components. It works. Everyone's happy.

Two years later, that library is abandoned, has a critical vulnerability, or the language now does the same thing natively. Time to migrate. And then the moment of truth arrives: you open the codebase and realize the library isn't in one place — it's everywhere. Dozens of files importing directly from it. Dozens of calls to its specific API, with its particular method names, with its concrete signature.

You don't have a dependency. You have a coupling.

The difference isn't semantic. You control a dependency. A coupling controls you.

In this article you'll see:

  • The difference between a dependency and a coupling
  • How to implement the Adapter Pattern in JavaScript for dates, HTTP, and analytics
  • How to migrate libraries by touching a single file instead of thirty
  • When it makes sense to use it and when it's not worth the effort

What the Adapter Pattern is (no fluff)

The Adapter Pattern is one of the most useful and least applied design patterns in day-to-day work.

The idea is simple: never let third-party library code touch your business code directly. Instead, create an intermediary. A thin layer that exposes the functionality you need under your contract, your API, your method names. Under the hood it can use whatever it wants. Your application doesn't care.

It's the DIP (Dependency Inversion Principle) from SOLID applied to external dependencies. Your code depends on an abstraction you control, not on someone else's concrete implementation. If you want to dive deeper into how to apply SOLID to build systems that scale, the article on why your monolith doesn't scale covers decoupling patterns in more detail.

Next time you want to swap libraries, you change the adapter. Just the adapter. The rest of your codebase doesn't even notice.

The problem without an adapter

Before seeing the solution, you need to see the pain. Here's the classic scenario: date formatting with moment.js.

// src/components/InvoiceCard.jsx
import moment from "moment";

export function InvoiceCard({ invoice }) {
  return (
    <div>
      <p>Issued: {moment(invoice.issuedAt).format("DD/MM/YYYY")}</p>
      <p>Due: {moment(invoice.dueAt).fromNow()}</p>
    </div>
  );
}
// src/components/UserProfile.jsx
import moment from "moment";

export function UserProfile({ user }) {
  return (
    <div>
      <p>Member since: {moment(user.createdAt).format("MMMM YYYY")}</p>
      <p>{moment(user.lastLogin).fromNow()}</p>
    </div>
  );
}
// src/utils/reports.js
import moment from "moment";

export function generateReportTitle(startDate, endDate) {
  return `Report ${moment(startDate).format("DD/MM")} - ${moment(endDate).format("DD/MM/YYYY")}`;
}

export function isDateExpired(date) {
  return moment(date).isBefore(moment());
}

Multiply this across the thirty screens in your application. When the maintainer of moment.js announced in 2020 that the library was in maintenance mode and recommended migrating to modern alternatives, teams with this pattern faced a massive refactor. File by file. Call by call.

The code worked. The problem was it wasn't theirs.

The solution: a date adapter

See the difference when you introduce an adapter:

// src/adapters/date.adapter.js
import moment from "moment";
import "moment/locale/es";

moment.locale("es");

export const dateAdapter = {
  format: (date, pattern = "DD/MM/YYYY") => {
    return moment(date).format(pattern);
  },
  fromNow: (date) => {
    return moment(date).fromNow();
  },
  isBefore: (date, reference = new Date()) => {
    return moment(date).isBefore(reference);
  },
};

Now all your components import from the adapter, not from moment:

// src/components/InvoiceCard.jsx
import { dateAdapter } from "@/adapters/date.adapter";

export function InvoiceCard({ invoice }) {
  return (
    <div>
      <p>Issued: {dateAdapter.format(invoice.issuedAt)}</p>
      <p>Due: {dateAdapter.fromNow(invoice.dueAt)}</p>
    </div>
  );
}
// src/utils/reports.js
import { dateAdapter } from "@/adapters/date.adapter";

export function generateReportTitle(startDate, endDate) {
  return `Report ${dateAdapter.format(startDate, "DD/MM")} - ${dateAdapter.format(endDate)}`;
}

export function isDateExpired(date) {
  return dateAdapter.isBefore(date);
}

The components don't know moment exists. They only know dateAdapter.format() exists. And you control that contract.

Now, the migration that used to make you cry

Six months later you decide to migrate to date-fns because it's smaller, tree-shakeable, and has better TypeScript support. How many files do you need to touch?

One.

// src/adapters/date.adapter.js — updated
import { format, formatDistanceToNow, isBefore, parseISO } from "date-fns";
import { es } from "date-fns/locale";

const parseDate = (date) =>
  typeof date === "string" ? parseISO(date) : new Date(date);

export const dateAdapter = {
  format: (date, pattern = "dd/MM/yyyy") => {
    return format(parseDate(date), pattern, { locale: es });
  },
  fromNow: (date) => {
    return formatDistanceToNow(parseDate(date), {
      locale: es,
      addSuffix: true,
    });
  },
  isBefore: (date, reference = new Date()) => {
    return isBefore(parseDate(date), reference);
  },
};

InvoiceCard, UserProfile, reports.js, and every other file: no changes. Not a single line. The entire codebase migrated libraries by touching one file.

Or even better: if tomorrow you decide to use JavaScript's native Intl API and drop the dependency entirely:

// src/adapters/date.adapter.js — no external dependencies
const locale = "es-ES";

export const dateAdapter = {
  format: (
    date,
    options = { day: "2-digit", month: "2-digit", year: "numeric" },
  ) => {
    return new Intl.DateTimeFormat(locale, options).format(new Date(date));
  },
  fromNow: (date) => {
    const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
    const diffMs = new Date(date) - new Date();
    const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));

    if (Math.abs(diffDays) < 1) return "today";
    if (Math.abs(diffDays) < 7) return rtf.format(diffDays, "day");
    if (Math.abs(diffDays) < 30)
      return rtf.format(Math.round(diffDays / 7), "week");
    return rtf.format(Math.round(diffDays / 30), "month");
  },
  isBefore: (date, reference = new Date()) => {
    return new Date(date) < new Date(reference);
  },
};

You removed a dependency from your package.json and your application didn't feel a thing.

Second example: the HTTP client

The same principle applies perfectly to HTTP calls. axios was the de facto standard for years. Today, fetch is native in Node.js 18+ and all modern browsers. Many teams want to migrate. Those who didn't use an adapter are having a very bad time.

Without an adapter

// Scattered across the entire application
import axios from "axios";

// In the users service
const { data: users } = await axios.get("/api/users");
const { data: newUser } = await axios.post("/api/users", payload);

// In the products service
const { data: products } = await axios.get("/api/products");
const { data } = await axios.delete(`/api/products/${id}`);

Notice the detail: axios returns { data }. If you migrate to fetch, it returns the full response and you have to call .json(). That interface change alone forces you to modify every call in every file.

With an adapter

// src/adapters/http.adapter.js
import axios from "axios";

const instance = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_URL,
  timeout: 10000,
});

export const http = {
  get: async (url, config = {}) => {
    const response = await instance.get(url, config);
    return response.data;
  },
  post: async (url, body, config = {}) => {
    const response = await instance.post(url, body, config);
    return response.data;
  },
  put: async (url, body, config = {}) => {
    const response = await instance.put(url, body, config);
    return response.data;
  },
  delete: async (url, config = {}) => {
    const response = await instance.delete(url, config);
    return response.data;
  },
};
// Services no longer know axios exists
import { http } from "@/adapters/http.adapter";

const users = await http.get("/api/users");
const newUser = await http.post("/api/users", payload);

When you decide to migrate to native fetch, the adapter absorbs all the change details:

// src/adapters/http.adapter.js — migrated to fetch
const BASE_URL = process.env.NEXT_PUBLIC_API_URL;

async function request(url, options = {}) {
  const response = await fetch(`${BASE_URL}${url}`, {
    ...options,
    headers: {
      "Content-Type": "application/json",
      ...options.headers,
    },
  });

  if (!response.ok) {
    throw new Error(`Error ${response.status}: ${response.statusText}`);
  }

  return response.json();
}

export const http = {
  get: (url, config = {}) => request(url, { method: "GET", ...config }),
  post: (url, body, config = {}) =>
    request(url, { method: "POST", body: JSON.stringify(body), ...config }),
  put: (url, body, config = {}) =>
    request(url, { method: "PUT", body: JSON.stringify(body), ...config }),
  delete: (url, config = {}) => request(url, { method: "DELETE", ...config }),
};

The contract didn't change. http.get() still returns the data directly. The codebase didn't notice a thing.

Third example: analytics and tracking

This is probably the most painful one in practice. Teams switch analytics tools frequently: Google Analytics, Mixpanel, Amplitude, PostHog. Each one has a different API. Without an adapter, every tool change means hunting down all the gtag(), mixpanel.track(), or analytics.identify() calls scattered across the project.

// src/adapters/analytics.adapter.js
// Currently using Google Analytics 4
export const analytics = {
  trackEvent: (eventName, properties = {}) => {
    if (typeof gtag === "undefined") return;
    gtag("event", eventName, properties);
  },
  identifyUser: (userId, traits = {}) => {
    if (typeof gtag === "undefined") return;
    gtag("config", process.env.NEXT_PUBLIC_GA_ID, {
      user_id: userId,
      ...traits,
    });
  },
  trackPageView: (path) => {
    if (typeof gtag === "undefined") return;
    gtag("event", "page_view", { page_path: path });
  },
};

When tomorrow you decide to migrate to PostHog for its session replay and feature flag capabilities:

// src/adapters/analytics.adapter.js — now with PostHog
import posthog from "posthog-js";

export const analytics = {
  trackEvent: (eventName, properties = {}) => {
    posthog.capture(eventName, properties);
  },
  identifyUser: (userId, traits = {}) => {
    posthog.identify(userId, traits);
  },
  trackPageView: (path) => {
    posthog.capture("$pageview", { $current_url: path });
  },
};

All your components keep calling analytics.trackEvent('button_click', { button: 'cta' }). None of them know or care which tool processes that event.

How to organize your adapters

The most common convention is to create an src/adapters/ folder with one file per domain:

src/
└── adapters/
    ├── date.adapter.js       # Date formatting
    ├── http.adapter.js       # HTTP calls
    ├── analytics.adapter.js  # Event tracking
    ├── storage.adapter.js    # localStorage / sessionStorage
    ├── logger.adapter.js     # Logging (console, Sentry, Datadog)
    └── index.js              # Centralized re-exports

The index.js centralizes all exports:

// src/adapters/index.js
export { dateAdapter } from "./date.adapter";
export { http } from "./http.adapter";
export { analytics } from "./analytics.adapter";
export { storage } from "./storage.adapter";
export { logger } from "./logger.adapter";

Clean import from anywhere in the project:

import { http, dateAdapter, analytics } from "@/adapters";

When NOT to use adapters

The Adapter Pattern isn't a universal law. Applying it to everything is just as bad as not applying it at all.

You don't need it for:

  • UI libraries like React, Vue, or Svelte. You're not going to migrate frameworks by touching an adapter.
  • Core language or framework dependencies (Node.js APIs, Next.js routing).
  • Trivial utilities where the cost of creating the adapter exceeds the benefit (lodash.clamp, for example).
  • Type libraries or TypeScript helpers that don't produce runtime effects.

You do need it for:

  • HTTP clients (axios, got, ky)
  • Date libraries (moment, date-fns, dayjs, luxon)
  • Analytics and tracking (GA, Mixpanel, PostHog, Amplitude)
  • Storage (localStorage, cookies, IndexedDB)
  • Logging and monitoring (Sentry, Datadog, console)
  • Email or notification sending
  • Any external service SDK (Stripe, Twilio, SendGrid)

The question you need to ask yourself is: If this dependency changes tomorrow, how many files would I have to touch? If the answer is "more than one," you need an adapter.

If you have a system with accumulated debt and don't know where to start cleaning it up, the process of 5 steps to fix technical debt without sinking the project can help you prioritize.

The real difference

The Adapter Pattern isn't magic. It doesn't reduce your application's complexity. It doesn't speed up initial development. In fact, it requires an extra step: creating the adapter before using the library.

What it does is change who's in control.

Without an adapter, your project is a hostage to past decisions: to the library you chose, to its maintainer, to its API changes. With an adapter, you're the one who decides when and how to change. The library works for you, not the other way around.

In systems that last years, that difference is what separates code that survives from code that gets rewritten.


If you're working on reducing your system's technical debt and want to understand how to prioritize and manage that process systematically, you might want to read Technical debt: the invisible tax slowing your team down.


Are you already using adapters in your projects? Or do you still have moment.js imported in forty files? Let me know on LinkedIn.

Need help with this?

If your team faces these challenges, I can help you design and implement a strategy tailored to your context.

Let's talk