La bomba de tiempo que llevas instalando
Existe un patrón de comportamiento muy predecible en los equipos de desarrollo.
Alguien encuentra una librería que resuelve un problema. La instala. La usa directamente en diez componentes distintos. Funciona. Todo feliz.
Dos años después, esa librería está abandonada, tiene una vulnerabilidad crítica, o el lenguaje ya hace lo mismo de forma nativa. Toca migrar. Y entonces llega el momento de la verdad: abres el codebase y te das cuenta de que la librería no está en un lugar, está en todos lados. Decenas de archivos importando directamente de ella. Docenas de llamadas a su API específica, con sus nombres de métodos particulares, con su firma concreta.
No tienes una dependencia. Tienes un acoplamiento.
La diferencia no es semántica. Una dependencia la controlas. Un acoplamiento te controla a ti.
En este artículo vas a ver:
- Qué diferencia hay entre una dependencia y un acoplamiento
- Cómo implementar el Patrón Adapter en JavaScript para fechas, HTTP y analytics
- Cómo migrar de librería tocando un único archivo en lugar de treinta
- En qué casos conviene usarlo y en cuáles no vale la pena
Qué es el Patrón Adapter (sin rodeos)
El Patrón Adapter es uno de los patrones de diseño más útiles y menos aplicados del día a día.
La idea es simple: nunca dejes que el código de una librería de terceros toque directamente tu código de negocio. En su lugar, crea un intermediario. Una capa fina que expone la funcionalidad que necesitas bajo tu contrato, tu API, tus nombres de métodos. Por dentro puede usar lo que quiera. A tu aplicación le da igual.
Es el principio DIP (Dependency Inversion Principle) de SOLID aplicado a dependencias externas. Tu código depende de una abstracción que tú controlas, no de una implementación concreta de otra persona. Si quieres profundizar en cómo aplicar SOLID para construir sistemas que escalen, el artículo sobre por qué tu monolito no escala cubre los patrones de desacoplamiento con más detalle.
La próxima vez que quieras cambiar de librería, cambias el adapter. Solo el adapter. El resto de tu codebase no se entera.
El problema sin adapter
Antes de ver la solución, necesitas ver el dolor. Aquí está el escenario clásico: formateo de fechas con moment.js.
// src/components/InvoiceCard.jsx
import moment from "moment";
export function InvoiceCard({ invoice }) {
return (
<div>
<p>Emitida: {moment(invoice.issuedAt).format("DD/MM/YYYY")}</p>
<p>Vence: {moment(invoice.dueAt).fromNow()}</p>
</div>
);
}
// src/components/UserProfile.jsx
import moment from "moment";
export function UserProfile({ user }) {
return (
<div>
<p>Miembro desde: {moment(user.createdAt).format("MMMM [de] YYYY")}</p>
<p>{moment(user.lastLogin).fromNow()}</p>
</div>
);
}
// src/utils/reports.js
import moment from "moment";
export function generateReportTitle(startDate, endDate) {
return `Reporte ${moment(startDate).format("DD/MM")} - ${moment(endDate).format("DD/MM/YYYY")}`;
}
export function isDateExpired(date) {
return moment(date).isBefore(moment());
}
Multiplica esto por las treinta pantallas de tu aplicación. Cuando el mantenedor de moment.js anunció en 2020 que la librería estaba en modo mantenimiento y recomendó migrar a alternativas más modernas, los equipos que tenían este patrón se enfrentaron a una refactorización masiva. Archivo por archivo. Llamada por llamada.
El código funcionaba. El problema era que no era suyo.
La solución: un adapter de fechas
Mira la diferencia cuando introduces un 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);
},
};
Ahora todos tus componentes importan del adapter, no de moment:
// src/components/InvoiceCard.jsx
import { dateAdapter } from "@/adapters/date.adapter";
export function InvoiceCard({ invoice }) {
return (
<div>
<p>Emitida: {dateAdapter.format(invoice.issuedAt)}</p>
<p>Vence: {dateAdapter.fromNow(invoice.dueAt)}</p>
</div>
);
}
// src/utils/reports.js
import { dateAdapter } from "@/adapters/date.adapter";
export function generateReportTitle(startDate, endDate) {
return `Reporte ${dateAdapter.format(startDate, "DD/MM")} - ${dateAdapter.format(endDate)}`;
}
export function isDateExpired(date) {
return dateAdapter.isBefore(date);
}
Los componentes no saben que existe moment. Solo saben que existe dateAdapter.format(). Y ese contrato lo controlas tú.
Ahora, la migración que antes te hacía llorar
Seis meses después decides migrar a date-fns porque es más pequeño, tree-shakeable y soporta mejor TypeScript. ¿Cuántos archivos tienes que tocar?
Uno.
// src/adapters/date.adapter.js — actualizado
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 y todos los demás archivos: sin cambios. Ni una línea. El codebase entero migró de librería tocando un único archivo.
O incluso mejor: si el día de mañana decides usar la API nativa Intl de JavaScript y quitar la dependencia por completo:
// src/adapters/date.adapter.js — sin dependencias externas
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 "hoy";
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);
},
};
Eliminaste una dependencia de tu package.json y tu aplicación no sintió nada.
Segundo ejemplo: el cliente HTTP
El mismo principio aplica perfectamente a las llamadas HTTP. axios fue durante años el estándar de facto. Hoy, fetch es nativo en Node.js 18+ y en todos los navegadores modernos. Muchos equipos quieren migrar. Los que no usaron adapter lo están pasando muy mal.
Sin adapter
// Disperso por toda la aplicación
import axios from "axios";
// En el servicio de usuarios
const { data: users } = await axios.get("/api/users");
const { data: newUser } = await axios.post("/api/users", payload);
// En el servicio de productos
const { data: products } = await axios.get("/api/products");
const { data } = await axios.delete(`/api/products/${id}`);
Nota el detalle: axios retorna { data }. Si migras a fetch, retorna el response completo y tienes que llamar .json(). Ese cambio de interfaz, por sí solo, te obliga a modificar cada llamada en cada archivo.
Con 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;
},
};
// Los servicios ya no saben que existe axios
import { http } from "@/adapters/http.adapter";
const users = await http.get("/api/users");
const newUser = await http.post("/api/users", payload);
Cuando decides migrar a fetch nativo, el adapter absorbe todos los detalles del cambio:
// src/adapters/http.adapter.js — migrado a 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 }),
};
El contrato no cambió. http.get() sigue devolviendo los datos directamente. El codebase no vio nada.
Tercer ejemplo: analytics y tracking
Este es probablemente el más doloroso en la práctica. Los equipos cambian de herramienta de analytics con frecuencia: Google Analytics, Mixpanel, Amplitude, PostHog. Cada una tiene su API diferente. Sin adapter, cada cambio de herramienta implica buscar todos los gtag(), mixpanel.track() o analytics.identify() desperdigados por el proyecto.
// src/adapters/analytics.adapter.js
// Por ahora usa 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 });
},
};
Cuando mañana decides migrar a PostHog por sus features de session replay y feature flags:
// src/adapters/analytics.adapter.js — ahora con 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 });
},
};
Todos tus componentes siguen llamando analytics.trackEvent('button_click', { button: 'cta' }). Ninguno sabe ni le importa cuál herramienta procesa ese evento.
Cómo organizar tus adapters
La convención más común es crear una carpeta src/adapters/ con un archivo por dominio:
src/
└── adapters/
├── date.adapter.js # Formateo de fechas
├── http.adapter.js # Llamadas HTTP
├── analytics.adapter.js # Tracking de eventos
├── storage.adapter.js # localStorage / sessionStorage
├── logger.adapter.js # Logging (console, Sentry, Datadog)
└── index.js # Re-exports centralizados
El index.js centraliza todos los 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";
Importación limpia desde cualquier parte del proyecto:
import { http, dateAdapter, analytics } from "@/adapters";
Cuándo NO usar adapters
El Patrón Adapter no es una ley universal. Aplicarlo a todo es tan malo como no aplicarlo a nada.
No lo necesitas para:
- Librerías de UI como React, Vue o Svelte. No vas a migrar de framework tocando un adapter.
- Dependencias core del lenguaje o el framework (Node.js APIs, Next.js routing).
- Utilidades triviales donde el costo de crear el adapter supera el beneficio (
lodash.clamp, por ejemplo). - Librerías de tipos o helpers de TypeScript que no producen efectos en runtime.
Sí lo necesitas para:
- Clientes HTTP (
axios,got,ky) - Librerías de fechas (
moment,date-fns,dayjs,luxon) - Analytics y tracking (
GA,Mixpanel,PostHog,Amplitude) - Almacenamiento (
localStorage, cookies, IndexedDB) - Logging y monitoreo (
Sentry,Datadog,console) - Envío de emails o notificaciones
- Cualquier SDK de servicio externo (Stripe, Twilio, SendGrid)
La pregunta que tienes que hacerte es: ¿Si mañana cambia esta dependencia, cuántos archivos tendría que tocar? Si la respuesta es "más de uno", necesitas un adapter.
Si tienes un sistema con deuda acumulada y no sabes por dónde empezar a limpiarla, el proceso de 5 pasos para resolver deuda técnica sin hundir el proyecto puede ayudarte a priorizar.
La diferencia real
El Patrón Adapter no es magia. No reduce la complejidad de tu aplicación. No acelera el desarrollo inicial. De hecho, requiere un paso extra: crear el adapter antes de usar la librería.
Lo que sí hace es cambiar quién tiene el control.
Sin adapter, tu proyecto es un rehén de las decisiones pasadas: de la librería que elegiste, de su mantenedor, de sus cambios de API. Con adapter, eres tú quien decide cuándo y cómo cambiar. La librería trabaja para ti, no al revés.
En sistemas que duran años, esa diferencia es la que separa el código que sobrevive del código que se reescribe.
Si estás trabajando en reducir la deuda técnica de tu sistema y quieres entender cómo priorizar y gestionar ese proceso de forma sistemática, te puede interesar leer Deuda técnica: el impuesto invisible que frena a tu equipo.
¿Ya usas adapters en tus proyectos? ¿O todavía tienes moment.js importado en cuarenta archivos? Cuéntame en LinkedIn.

