StackPractices
beginner Por Mathias Paulenko

Puente entre Interfaces Incompatibles con el Adapter Pattern

Cómo integrar APIs legacy, librerías de terceros e interfaces incompatibles usando object adapters, class adapters y facade adapters en Java, TypeScript y Python.

Temas: design

Nota para desarrolladores hispanohablantes: Esta guía incluye ejemplos y convenciones de nomenclatura adaptadas a equipos que trabajan en español. Cuando existen diferencias significativas en terminología técnica entre el inglés y el español, se indican explícitamente para facilitar la comunicación en equipos multiculturales.

Visión general

Tu aplicación espera una interfaz PaymentProcessor con métodos charge(amount) y refund(transactionId). El SDK de Stripe usa charges.create({ amount }) y refunds.create({ charge }). El SDK de PayPal usa orders.capture({ amount }) y payments.refund({ captureId }). Ninguno coincide con tu interfaz. Podrías esparcir código específico de Stripe y PayPal por todo tu codebase, pero cambiar de proveedor requeriría tocar cada archivo que procesa pagos.

El adapter pattern resuelve esto introduciendo una clase wrapper que implementa la interfaz de tu aplicación y traduce las llamadas al SDK de terceros. Tu código de negocio depende solo de la interfaz del adapter. Cambiar Stripe por PayPal significa escribir un nuevo adapter — sin cambios en la lógica de negocio. El siguiente enfoque cubre object adapters, class adapters, two-way adapters y adapter registries con ejemplos prácticos.

Cuándo usarlo

Usa esta receta cuando:

  • Integrando una librería de terceros con una interfaz incompatible. Consulta Arquitectura Hexagonal para aislamiento de ports/adapters.
  • Migrando desde un sistema legacy sin reescribir código dependiente. Consulta Factory Pattern para crear instancias de adapters.
  • Exponiendo una fachada simplificada sobre un subsistema complejo
  • Soportando múltiples implementaciones de la misma capacidad (pagos, storage, mensajería). Consulta Strategy Pattern para selección de algoritmos en runtime.
  • Testeando código que depende de servicios externos adaptando mocks

Solución

Object Adapter (TypeScript)

interface PaymentProcessor {
  charge(amount: number, currency: string): Promise<string>;
  refund(transactionId: string): Promise<void>;
}

class StripeSDK {
  async createCharge(params: { amount: number; currency: string }) {
    return { id: 'ch_' + Math.random().toString(36) };
  }
  async createRefund(params: { charge: string }) {}
}

class StripeAdapter implements PaymentProcessor {
  constructor(private stripe: StripeSDK) {}

  async charge(amount: number, currency: string): Promise<string> {
    const result = await this.stripe.createCharge({ amount: amount * 100, currency });
    return result.id;
  }

  async refund(transactionId: string): Promise<void> {
    await this.stripe.createRefund({ charge: transactionId });
  }
}

class CheckoutService {
  constructor(private processor: PaymentProcessor) {}

  async process(order: Order): Promise<void> {
    const txId = await this.processor.charge(order.total, order.currency);
    await this.orderRepo.save({ ...order, transactionId: txId });
  }
}

Class Adapter (Java)

interface ModernLogger {
    void log(String level, String message);
}

class LegacyLogger {
    public void writeLogEntry(String entry) {
        System.out.println("[LEGACY] " + entry);
    }
}

class LoggerAdapter extends LegacyLogger implements ModernLogger {
    @Override
    public void log(String level, String message) {
        writeLogEntry(String.format("[%s] %s", level.toUpperCase(), message));
    }
}

ModernLogger logger = new LoggerAdapter();
logger.log("info", "Application started");

Python Adapter con Registry

from abc import ABC, abstractmethod
from typing import Dict

class StorageAdapter(ABC):
    @abstractmethod
    def upload(self, key: str, data: bytes) -> str: pass

    @abstractmethod
    def download(self, key: str) -> bytes: pass

class S3Adapter(StorageAdapter):
    def __init__(self, client):
        self.client = client

    def upload(self, key: str, data: bytes) -> str:
        self.client.put_object(Bucket="my-bucket", Key=key, Body=data)
        return f"s3://my-bucket/{key}"

    def download(self, key: str) -> bytes:
        return self.client.get_object(Bucket="my-bucket", Key=key)["Body"].read()

class AzureBlobAdapter(StorageAdapter):
    def __init__(self, container_client):
        self.container = container_client

    def upload(self, key: str, data: bytes) -> str:
        self.container.upload_blob(name=key, data=data, overwrite=True)
        return f"azure://my-container/{key}"

    def download(self, key: str) -> bytes:
        return self.container.download_blob(key).readall()

class StorageFactory:
    _adapters: Dict[str, type] = {}

    @classmethod
    def register(cls, name: str, adapter_class: type):
        cls._adapters[name] = adapter_class

    @classmethod
    def create(cls, name: str, config: dict) -> StorageAdapter:
        return cls._adapters[name](**config)

StorageFactory.register("s3", S3Adapter)
StorageFactory.register("azure", AzureBlobAdapter)

storage = StorageFactory.create("s3", {"client": boto3_client})
url = storage.upload("report.pdf", pdf_bytes)

Explicación

  • Object adapter: el adapter mantiene una referencia al adaptee (la clase de terceros) y delega las llamadas a él. Es el enfoque más flexible — funciona con clases final, soporta composición sobre herencia, y permite adaptar múltiples adaptees simultáneamente.
  • Class adapter: Requiere que el adaptee no sea final y funciona solo en lenguajes de herencia simple donde el adapter no extiende otra clase. Es menos flexible pero ligeramente más rápido.
  • Two-way adapter: Traduce llamadas en ambas direcciones, actuando como un puente durante migraciones incrementales.
  • Adapter registry: cuando se soportan múltiples proveedores (Stripe, PayPal, Braintree), un registro mapea nombres de proveedor a clases adapter. La factory instancia el adapter correcto basado en configuración, aislando la selección del adapter de la lógica de negocio.

Variantes

VarianteFlexibilidadRendimientoMejor para
Object adapterAltaMedioUso general, SDKs de terceros
Class adapterBajaAltoCrítico de rendimiento, un solo adaptee
Two-way adapterMediaMedioMigración incremental
Facade adapterAltaMedioSimplificar subsistemas complejos
Registry + adapterAltaMedioSoporte de múltiples proveedores

Lo que funciona

  • Adapta en el límite, no en todas partes: introduce adapters en los límites del sistema donde las interfaces externas se encuentran con abstracciones internas. No dejes que los tipos de terceros se filtren a la lógica de negocio.
  • Documenta el comportamiento de traducción: los adapters hacen más que renombrar métodos. Pueden convertir unidades, transformar tipos de error, o batch requests.
  • Maneja errores con elegancia: las APIs de terceros lanzan excepciones específicas del vendor. El adapter debe capturarlas y mapearlas a la taxonomía de errores de tu aplicación. StripeCardError se convierte en PaymentDeclinedError.
  • Mantén los adapters delgados: un adapter con cientos de líneas de lógica es un servicio, no un adapter. Las transformaciones complejas pertenecen a servicios de aplicación. El adapter debe traducir llamadas y errores, y luego salir del camino.
  • Testea adapters con contract tests: escribe tests que verifiquen que el adapter satisface la interfaz objetivo, no tests que verifiquen el SDK de terceros. Consulta Input Validation para contratos de límite.

Errores comunes

  • Filtrar detalles del adaptee: retornar objetos de respuesta nativos del adaptee desde el adapter fuerza a los consumidores a entender la API de terceros. Siempre retorna tipos de dominio desde el adapter.
  • Adapter inflado: poner caching, reintentos y métricas dentro del adapter lo hace difícil de testear y reusar.
  • No manejar null/undefined: las APIs de terceros pueden retornar null donde tu interfaz espera un objeto vacío o una excepción. Define el contrato de null del adapter y traduce consistentemente.
  • Acoplamiento fuerte a versiones de SDK: cuando el SDK de terceros lanza un cambio breaking, el adapter lo absorbe. Si llamas al SDK directamente desde código de negocio, cada cambio breaking se propaga por todas partes. El adapter es tu amortiguador de choque.

Preguntas frecuentes

PayPal Adapter y Soporte Multi-Provider
class PayPalSDK {
  async captureOrder(params: { amount: number; currency: string }) {
    return { id: 'PAYID-' + Math.random().toString(36) };
  }
  async refundPayment(params: { captureId: string }) {}
}

class PayPalAdapter implements PaymentProcessor {
  constructor(private paypal: PayPalSDK) {}

  async charge(amount: number, currency: string): Promise<string> {
    const result = await this.paypal.captureOrder({ amount, currency });
    return result.id;
  }

  async refund(transactionId: string): Promise<void> {
    await this.paypal.refundPayment({ captureId: transactionId });
  }
}

// Selección de proveedor vía factory
class PaymentProcessorFactory {
  private static providers: Map<string, () => PaymentProcessor> = new Map();

  static register(name: string, factory: () => PaymentProcessor) {
    this.providers.set(name, factory);
  }

  static create(name: string): PaymentProcessor {
    const factory = this.providers.get(name);
    if (!factory) throw new Error(`Unknown provider: ${name}`);
    return factory();
  }
}

PaymentProcessorFactory.register('stripe', () =>
  new StripeAdapter(new StripeSDK()));
PaymentProcessorFactory.register('paypal', () =>
  new PayPalAdapter(new PayPalSDK()));

// Uso — cambia proveedores vía config
const processor = PaymentProcessorFactory.create(process.env.PAYMENT_PROVIDER);
Two-Way Adapter para Migración Incremental
// Interfaz vieja — siendo reemplazada
interface LegacyPaymentApi {
  processPayment(amount: number): Promise<{ txId: string }>;
}

// Interfaz nueva — arquitectura objetivo
interface ModernPaymentApi {
  charge(amount: number, currency: string): Promise<string>;
  refund(transactionId: string): Promise<void>;
}

// Two-way adapter — implementa ambas interfaces
class PaymentBridge implements LegacyPaymentApi, ModernPaymentApi {
  constructor(private processor: PaymentProcessor) {}

  // Interfaz legacy — delega a la nueva
  async processPayment(amount: number): Promise<{ txId: string }> {
    const txId = await this.processor.charge(amount, 'USD');
    return { txId };
  }

  // Interfaz moderna
  async charge(amount: number, currency: string): Promise<string> {
    return this.processor.charge(amount, currency);
  }

  async refund(transactionId: string): Promise<void> {
    return this.processor.refund(transactionId);
  }
}

// Código viejo puede usar processPayment(), código nuevo usa charge()
// Ambos coexisten durante la migración
In-Memory Adapter para Testing
class InMemoryPaymentAdapter implements PaymentProcessor {
  private transactions: Map<string, { amount: number; currency: string }> = new Map();
  private refunded: Set<string> = new Set();

  async charge(amount: number, currency: string): Promise<string> {
    const txId = 'test_' + Math.random().toString(36);
    this.transactions.set(txId, { amount, currency });
    return txId;
  }

  async refund(transactionId: string): Promise<void> {
    if (!this.transactions.has(transactionId)) {
      throw new Error('Transaction not found');
    }
    this.refunded.add(transactionId);
  }

  // Helpers de test
  getChargedAmount(txId: string): number | undefined {
    return this.transactions.get(txId)?.amount;
  }

  wasRefunded(txId: string): boolean {
    return this.refunded.has(txId);
  }
}

// Uso en tests
describe('CheckoutService', () => {
  it('carga el monto correcto', async () => {
    const processor = new InMemoryPaymentAdapter();
    const service = new CheckoutService(processor);

    const txId = await service.process({
      total: 99.99,
      currency: 'USD',
    });

    expect(processor.getChargedAmount(txId)).toBe(99.99);
  });
});