Adapter Pattern para Integrar APIs REST Externas
Usa el Adapter pattern para normalizar respuestas de APIs REST externas en un modelo interno consistente sin filtrar formatos de terceros en tu dominio
Resumen
El patrón Adapter convierte la interfaz de una clase en otra que el cliente espera. Aplicado a APIs REST de terceros, se convierte en una capa anti-corrupción: el límite donde los contratos externos desordenados — los paymentIntents de Stripe, los purchase_units de PayPal, lo que traiga el renombrado del año que viene — se traducen a modelos de dominio limpios que tu código realmente controla.
Sin ella, los nombres de campos, formas de error y caprichos de versionado de cada proveedor se filtran a servicios que no deberían saber que existen. Con ella, cambiar de proveedor es un cambio de configuración, no un refactor.
Cuándo Usarlo
- Consumes múltiples APIs de terceros con diferentes formatos de respuesta
- Los contratos de API cambian y quieres contener cambios disruptivos en el límite
- Tu modelo de dominio debe permanecer independiente de la representación externa
- El payload de un proveedor cambia entre versiones y las migraciones deben ser escalonadas
- Necesitas probar contra respuestas del proveedor sin llamadas en vivo
Evítalo para una integración única y estable — una función mapper simple hace el trabajo hasta que aparezca el segundo proveedor o el primer cambio disruptivo.
Problema
Stripe, PayPal y Square exponen los datos de pago de formas distintas. Stripe anida los importes como céntimos enteros bajo payment_intent.amount; PayPal expone purchase_units[0].amount.value como string en unidades mayores; Square devuelve money.amount como entero en unidades menores — tres formas de decir “cuánto”, y ninguna es la tuya.
Si tu servicio de checkout accede a los tres directamente, cada salto de versión de API contamina tu dominio con lógica condicional: ramas if provider, tres enums de estado, tres formas de error, tres formatos de fecha. Añade un cuarto proveedor y el coste crece exponencial.
El adapter marca la línea: una interfaz Payment que tus servicios hablan, y un adapter por proveedor que hace la traducción. El dominio nunca llega a saber que los proveedores existen.
Solución
// domain/Payment.ts
interface Payment {
id: string;
amount: number;
currency: string;
status: 'pending' | 'completed' | 'failed';
createdAt: Date;
}
// adapters/PaymentAdapter.ts
interface PaymentAdapter {
fetchPayment(externalId: string): Promise<Payment>;
}
// adapters/StripeAdapter.ts
class StripeAdapter implements PaymentAdapter {
async fetchPayment(externalId: string): Promise<Payment> {
const stripePayment = await stripeClient.paymentIntents.retrieve(externalId);
return {
id: stripePayment.id,
amount: stripePayment.amount / 100,
currency: stripePayment.currency.toUpperCase(),
status: this.mapStatus(stripePayment.status),
createdAt: new Date(stripePayment.created * 1000),
};
}
private mapStatus(status: string): Payment['status'] {
const map: Record<string, Payment['status']> = {
'requires_payment_method': 'pending',
'succeeded': 'completed',
'canceled': 'failed',
};
return map[status] || 'failed';
}
}
// adapters/PayPalAdapter.ts
class PayPalAdapter implements PaymentAdapter {
async fetchPayment(externalId: string): Promise<Payment> {
const paypalPayment = await paypalClient.orders.retrieve(externalId);
return {
id: paypalPayment.id,
amount: parseFloat(paypalPayment.purchase_units[0].amount.value),
currency: paypalPayment.purchase_units[0].amount.currency_code,
status: paypalPayment.status === 'COMPLETED' ? 'completed' : 'pending',
createdAt: new Date(paypalPayment.create_time),
};
}
}
// services/PaymentService.ts
class PaymentService {
constructor(private adapter: PaymentAdapter) {}
async getPaymentSummary(id: string) {
const payment = await this.adapter.fetchPayment(id);
return {
total: payment.amount,
currency: payment.currency,
isPaid: payment.status === 'completed',
};
}
}
Uso
const service = new PaymentService(new StripeAdapter());
// Cambiar proveedor sin tocar lógica de dominio
// const service = new PaymentService(new PayPalAdapter());
Normalización de Errores y Versionado
El trabajo más duro del adapter no es mapear campos — es mapear fallos. Stripe devuelve card_error con code y decline_code; PayPal devuelve enums ISSUE e INSTRUMENT_DECLINED; Square anida errores bajo errors[]. Si esas formas llegan a tus servicios, cada manejador de error se convierte en un switch por proveedor.
// Error de dominio que produce el adapter — agnóstico al proveedor
type PaymentError =
| { kind: 'card_declined'; reason: string }
| { kind: 'not_found' }
| { kind: 'rate_limited'; retryAfter: number }
| { kind: 'unknown'; raw: unknown };
// Dentro de StripeAdapter — el único sitio que conoce la forma de Stripe
private mapError(err: StripeError): PaymentError {
if (err.code === 'card_declined')
return { kind: 'card_declined', reason: err.decline_code };
if (err.code === 'rate_limit')
return { kind: 'rate_limited', retryAfter: 60 };
return { kind: 'unknown', raw: err };
}
La misma lógica aplica al versionado. Cuando Stripe sube de versión su API, la jugada equivocada son ramas if (version === 'v2') dentro del adapter; la correcta es StripeV2Adapter — una clase nueva implementando la misma interfaz PaymentAdapter, seleccionada por factory. Vieja y nueva corren en paralelo durante la ventana de migración, luego la vieja se borra limpia.
Pruebas de Contrato en el Límite
Los tests unitarios con mocks verifican que tu mapeo es correcto; no pueden verificar que la API siga haciendo lo que mapeaste. Los contract tests cierran ese hueco: llamadas grabadas o en vivo contra el proveedor real que afirman la forma del payload de la que depende tu adapter.
// Corre contra el entorno de test de Stripe en programación, no por commit
test('Contrato Stripe: forma de paymentIntent', async () => {
const pi = await stripeClient.paymentIntents.retrieve(TEST_ID);
expect(pi).toMatchObject({
id: expect.any(String),
amount: expect.any(Number),
currency: expect.any(String),
status: expect.any(String),
});
});
Cuando Stripe renombra paymentIntents o elimina un campo, el contract test falla antes que tus usuarios — ese es todo el sentido de pagar por una capa adapter.
Cuándo No Usarlo
El adapter justifica su complejidad solo cuando el límite que protege es real. Para un proveedor único con contrato estable, un adapter es ceremonia — una función mapper más un cliente tipado hace el mismo trabajo con un tercio del código. El patrón rinde con dos o más proveedores, o uno cuyo contrato sabes que va a cambiar.
También te pelea cuando la API externa ES el dominio. Si construyes un dashboard de Stripe — donde los objetos de Stripe SON el producto — envolverlos en tu propio modelo añade indirección sin aislamiento; el dominio no es tuyo para protegerlo.
Y no arregla APIs malas, las contiene. Un proveedor con semántica rota sigue roto dentro de una interfaz limpia; el adapter para la fuga, no cura la enfermedad.
Dónde Poner el Límite
Dónde dibujas la línea del adapter importa más que cómo lo implementas. Demasiado fino — un mapper que solo renombra campos — y las formas de error y la semántica de paginación se siguen filtrando. Demasiado grueso — un adapter que valida, reintenta, cachea y traduce — y has construido un servicio dentro de un servicio que nadie puede probar.
En resumen: delimita por representación, no por preocupación. La línea que funciona en producción: el adapter posee la representación (campos, tipos, errores, forma de paginación) y nada más. Reintentos, caché, rate limits y auth viven en decoradores o el cliente HTTP — componibles, testeables por separado. Las Técnicas Avanzadas que siguen muestran esa composición: cada wrapper añade una preocupación sin tocar la traducción central.
Variaciones
El adapter compone bien — la mayoría de integraciones en producción apilan una o más de estas sobre la traducción central:
- Two-Way Adapter: convierte modelos internos de vuelta al formato externo para escrituras — mismos beneficios de traducción en los dos sentidos.
- Caching Adapter: combina con Proxy para cachear búsquedas externas; los datos de pago e identidad piden TTLs cortos.
- Batch Adapter: adapta endpoints de lista a consultas internas paginadas — resuelve el desfase de “traer 500 registros en páginas de 100”.
- Adapter Factory: elige el adapter correcto por configuración o contexto de request — selección de proveedor sin cadenas de
if. - Retry Adapter: envuelve las llamadas en backoff exponencial — los fallos transitorios se absorben en el límite, no en la lógica de negocio.
- Validating Adapter: normaliza errores y valida payloads en el borde — las respuestas malas nunca llegan al dominio.
Técnicas Avanzadas
Adapter bidireccional para conversión bidireccional
Soporta convertir modelos internos de vuelta a formatos externos para actualizaciones:
// adapters/BiDirectionalPaymentAdapter.ts
interface BiDirectionalPaymentAdapter extends PaymentAdapter {
toExternal(payment: Payment): Promise<ExternalPayment>;
}
interface ExternalPayment {
provider: 'stripe' | 'paypal';
data: any;
}
class StripeBiDirectionalAdapter implements BiDirectionalPaymentAdapter {
async fetchPayment(externalId: string): Promise<Payment> {
const stripePayment = await stripeClient.paymentIntents.retrieve(externalId);
return this.fromStripe(stripePayment);
}
async toExternal(payment: Payment): Promise<ExternalPayment> {
const stripePayment = await this.toStripe(payment);
return { provider: 'stripe', data: stripePayment };
}
private fromStripe(stripePayment: any): Payment {
return {
id: stripePayment.id,
amount: stripePayment.amount / 100,
currency: stripePayment.currency.toUpperCase(),
status: this.mapStatus(stripePayment.status),
createdAt: new Date(stripePayment.created * 1000),
};
}
private async toStripe(payment: Payment): Promise<any> {
return {
amount: Math.round(payment.amount * 100),
currency: payment.currency.toLowerCase(),
metadata: { internal_id: payment.id },
};
}
private mapStatus(status: string): Payment['status'] {
const map: Record<string, Payment['status']> = {
'requires_payment_method': 'pending',
'succeeded': 'completed',
'canceled': 'failed',
};
return map[status] || 'failed';
}
}
Adapter con caché usando el patrón proxy
Combina adapter con proxy para cachear búsquedas externas y reducir llamadas a la API:
// adapters/CachingPaymentAdapter.ts
class CachingPaymentAdapter implements PaymentAdapter {
private cache = new Map<string, { payment: Payment; expiresAt: number }>();
private readonly ttl = 60000; // 1 minuto
constructor(private delegate: PaymentAdapter) {}
async fetchPayment(externalId: string): Promise<Payment> {
const cached = this.cache.get(externalId);
if (cached && cached.expiresAt > Date.now()) {
return cached.payment;
}
const payment = await this.delegate.fetchPayment(externalId);
this.cache.set(externalId, {
payment,
expiresAt: Date.now() + this.ttl,
});
return payment;
}
invalidate(externalId: string): void {
this.cache.delete(externalId);
}
}
// Uso
const stripeAdapter = new StripeAdapter();
const cachingAdapter = new CachingPaymentAdapter(stripeAdapter);
const service = new PaymentService(cachingAdapter);
Adapter batch para endpoints paginados
Adapta endpoints de lista a consultas internas paginadas:
// adapters/BatchPaymentAdapter.ts
interface PaginatedResult<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}
class StripeBatchAdapter implements PaymentAdapter {
async fetchPayment(externalId: string): Promise<Payment> {
// Traducción de fetch individual idéntica a StripeAdapter — elidida por brevedad
const sp = await stripeClient.paymentIntents.retrieve(externalId);
return {
id: sp.id,
amount: sp.amount / 100,
currency: sp.currency.toUpperCase(),
status: this.mapStatus(sp.status),
createdAt: new Date(sp.created * 1000),
};
}
async fetchPayments(options: {
page?: number;
pageSize?: number;
status?: Payment['status'];
}): Promise<PaginatedResult<Payment>> {
const page = options.page || 1;
const pageSize = options.pageSize || 20;
const stripePayments = await stripeClient.paymentIntents.list({
limit: pageSize,
starting_after: page > 1 ? this.getPageCursor(page - 1) : undefined,
});
const payments = stripePayments.data.map(sp => ({
id: sp.id,
amount: sp.amount / 100,
currency: sp.currency.toUpperCase(),
status: this.mapStatus(sp.status),
createdAt: new Date(sp.created * 1000),
}));
return {
items: payments,
total: stripePayments.total_count || 0,
page,
pageSize,
};
}
private mapStatus(status: string): Payment['status'] {
const map: Record<string, Payment['status']> = {
'requires_payment_method': 'pending',
'succeeded': 'completed',
'canceled': 'failed',
};
return map[status] || 'failed';
}
private getPageCursor(page: number): string {
// Stripe pagina por id de elemento — starting_after es el último id de la
// página anterior, no un número de página. Conéctalo a tu store de cursores.
return '';
}
}
Factory de adapter para selección de proveedor
Usa el patrón factory para seleccionar el adapter apropiado en runtime:
// adapters/SquareAdapter.ts — Square devuelve money.amount en unidades menores
class SquareAdapter implements PaymentAdapter {
async fetchPayment(externalId: string): Promise<Payment> {
const sq = await squareClient.payments.get(externalId);
return {
id: sq.id,
amount: sq.money.amount / 100,
currency: sq.money.currency,
status: sq.status === 'COMPLETED' ? 'completed' : 'pending',
createdAt: new Date(sq.created_at),
};
}
}
// adapters/PaymentAdapterFactory.ts
class PaymentAdapterFactory {
private adapters = new Map<string, PaymentAdapter>();
register(provider: string, adapter: PaymentAdapter): void {
this.adapters.set(provider, adapter);
}
getAdapter(provider: string): PaymentAdapter {
const adapter = this.adapters.get(provider);
if (!adapter) {
throw new Error(`No adapter registrado para proveedor: ${provider}`);
}
return adapter;
}
getAdapterFromConfig(config: { provider: string }): PaymentAdapter {
return this.getAdapter(config.provider);
}
}
// Uso
const factory = new PaymentAdapterFactory();
factory.register('stripe', new StripeAdapter());
factory.register('paypal', new PayPalAdapter());
factory.register('square', new SquareAdapter());
// Selección en runtime
const config = { provider: 'stripe' };
const adapter = factory.getAdapterFromConfig(config);
const service = new PaymentService(adapter);
Adapter con retry y backoff exponencial
Añade lógica de retry a los adapters para absorber fallos transitorios:
// adapters/RetryPaymentAdapter.ts
class RetryPaymentAdapter implements PaymentAdapter {
private readonly maxRetries = 3;
private readonly baseDelay = 1000;
constructor(private delegate: PaymentAdapter) {}
async fetchPayment(externalId: string): Promise<Payment> {
let lastError: Error;
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
try {
return await this.delegate.fetchPayment(externalId);
} catch (error) {
lastError = error;
if (attempt < this.maxRetries) {
const delay = this.baseDelay * Math.pow(2, attempt);
await this.sleep(delay);
}
}
}
throw lastError;
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
// Uso
const stripeAdapter = new StripeAdapter();
const retryAdapter = new RetryPaymentAdapter(stripeAdapter);
const service = new PaymentService(retryAdapter);
Adapter con validación y normalización de errores
Normaliza errores de diferentes proveedores en errores internos consistentes:
// adapters/ValidatingPaymentAdapter.ts
class PaymentAdapterError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly provider: string
) {
super(message);
this.name = 'PaymentAdapterError';
}
}
class ValidatingPaymentAdapter implements PaymentAdapter {
constructor(
private delegate: PaymentAdapter,
private provider: string
) {}
async fetchPayment(externalId: string): Promise<Payment> {
try {
const payment = await this.delegate.fetchPayment(externalId);
this.validatePayment(payment);
return payment;
} catch (error) {
throw this.normalizeError(error);
}
}
private validatePayment(payment: Payment): void {
if (!payment.id) {
throw new PaymentAdapterError(
'Payment ID es requerido',
'MISSING_ID',
this.provider
);
}
if (payment.amount < 0) {
throw new PaymentAdapterError(
'El monto de pago no puede ser negativo',
'INVALID_AMOUNT',
this.provider
);
}
}
private normalizeError(error: any): PaymentAdapterError {
if (error instanceof PaymentAdapterError) {
return error;
}
// Normaliza errores específicos del proveedor
if (error.code === 'resource_missing') {
return new PaymentAdapterError(
'Pago no encontrado',
'NOT_FOUND',
this.provider
);
}
return new PaymentAdapterError(
error.message || 'Error desconocido',
'UNKNOWN',
this.provider
);
}
}
Mejores Prácticas
- Mantén los adapters stateless. Solo traducción, nada de estado — más fáciles de probar y reutilizar.
- Mapea IDs externos a UUIDs internos. No filtres formatos de ID del proveedor al dominio.
- Versiona los adapters por separado. StripeV1Adapter, StripeV2Adapter — ambos conviven durante las migraciones.
- Normaliza errores en el límite. Convierte las formas de error de cada proveedor a tus tipos de error de dominio dentro del adapter, y en ningún otro sitio.
- Prueba con respuestas grabadas. Mocks para los unit tests; llamadas reales solo en contract tests.
- Loguea las transformaciones. Cuando un adapter se comporta mal, quieres el payload crudo y en qué se convirtió.
Errores Comunes
- Lógica de negocio dentro del adapter. Los adapters traducen; los servicios de dominio deciden. Mezclarlos hace ambos intestables.
- Devolver tipos externos directamente. Mapea a modelos internos aunque hoy solo necesites dos campos — los demás se filtran mañana.
- Ignorar campos ausentes o nulos. Las APIs externas omiten campos libremente; manéjalo en el adapter o rompe más abajo.
- Lógica de proveedor hardcodeada en servicios. Un
if (provider === 'stripe')en un servicio significa que la capa adapter falló. - Saltarse la normalización de errores. El
card_errorde Stripe y el enumISSUEde PayPal no son lo mismo; tradúcelos o tu manejo de errores se convierte en un switch. - Sin contract tests. Los mocks verifican tu lógica de adapter; solo los contract tests verifican que la API sigue haciendo lo que adaptaste.
- Cachear datos financieros sin estrategia. Los datos de pago envejecen rápido — si cacheas, acota bien el TTL.
Lecturas Adicionales
- Martin Fowler — Catalog of Patterns of Enterprise Application Architecture — los patrones detrás de las capas de integración
- Refactoring.Guru — Adapter Pattern — la referencia canónica del patrón
- Eric Evans — Domain-Driven Design — el concepto de capa anti-corrupción
Preguntas frecuentes
¿En qué se diferencia de una función mapper?
Un adapter implementa una interfaz conocida, así que el servicio que lo consume no depende de qué proveedor esté activo. Un mapper suele ser una función suelta — bien hasta que aparece el segundo proveedor.
¿Debería probar los adapters con llamadas HTTP reales?
Respuestas grabadas o stubs para los unit tests — rápidos y deterministas. Las llamadas reales a la API van en una suite de contract tests que corre con menos frecuencia.
¿Puedo usar adapters para operaciones de escritura?
Sí. Los adapters bidireccionales convierten modelos internos de vuelta a formatos externos para actualizaciones — mismos beneficios de traducción en escrituras.
¿Cómo versiono los adapters cuando las APIs externas cambian?
Clases de adapter separadas por versión de API (StripeV1Adapter, StripeV2Adapter) seleccionadas por una factory según configuración. Nunca versiones dentro de un mismo adapter con ramas if.
¿Deberían los adapters manejar autenticación?
No — la auth va en el cliente HTTP o en la capa de interceptores. Los adapters traducen datos; mezclar preocupaciones hace ambas más difíciles de probar.
¿Cómo manejo fallos parciales en adapters batch?
Devuelve los ítems transformados con éxito junto a los fallidos con su detalle de error — que el llamador decida si reintenta, alerta o descarta.
¿Este patrón sirve para proyectos pequeños?
Para una sola integración de API, probablemente sobreingeniería — un mapper simple hace el trabajo. Introdúcelo cuando aparezca el segundo proveedor o el contrato del primero empiece a derivar.
Recursos Relacionados
Patrón Adapter
Convierte la interfaz de una clase en otra interfaz que los clientes esperan. Patrón de diseño estructural para compatibilidad de interfaces.
PatternPatrón Decorator en pipelines HTTP
Usa el patrón Decorator para componer aspectos transversales como logging, métricas y reintentos en pipelines de peticiones HTTP sin modificar la lógica central
PatternPatrón Facade
Provee una interfaz simplificada a un subsistema complejo. Un patrón estructural que oculta detalles de implementación detrás de una API limpia.
PatternPatrón Proxy
Proporciona un sustituto o marcador de posición para otro objeto para controlar el acceso a él. Un patrón estructural para control de acceso, carga perezosa y logging.
RecipeLlamar a una API REST: Python, JS, Java y Go
Cómo hacer peticiones HTTP a una API REST y manejar la respuesta JSON en Python, JavaScript, Java y Go.