11. Adapter (Adaptador) - Patrón Estructural

El patrón Adapter permite que dos interfaces incompatibles colaboren sin modificar su código original. Se emplea para envolver un componente existente y presentar una interfaz que el cliente espera, facilitando la reutilización de bibliotecas, sistemas heredados y servicios externos.

En la práctica, Adapter actúa como un traductor: recibe peticiones usando la interfaz objetivo y las transforma en invocaciones hacia interfaces ya disponibles. Esto reduce el acoplamiento y evita duplicar funcionalidad solo para encajar en un formato distinto.

11.1 Problema que resuelve

Los proyectos suelen integrar componentes desarrollados en momentos diferentes o por equipos con convenciones particulares. Cambiar la interfaz de una biblioteca para que encaje con nuestro modelo puede ser inviable, ya sea por restricciones de licencia, mantenimiento o estabilidad. Adapter ofrece una capa intermedia que reconcilia ambas visiones.

Este patrón resulta especialmente útil cuando una aplicación evolucionó con clases que ya consumen determinada interfaz y aparece un nuevo proveedor que expone operaciones con nombres, tipos o firmas distintas.

11.2 Intención y motivación

Su intención es convertir la interfaz de una clase en otra interfaz que los clientes esperan. De este modo, las clases incompatibles pueden trabajar juntas sin cambios internos. Se emplea cuando:

  • Se quiere usar una clase existente, pero su interfaz no coincide con la requerida.
  • Se necesita una capa anticorrupción para aislar un sistema de terceros.
  • Se busca migrar gradualmente desde una API antigua hacia una nueva sin reescribir todos los clientes.

El patrón también es fundamental en integraciones siguiendo Domain-Driven Design, donde un anticorruption layer protege el dominio de las inconsistencias externas.

11.3 Estructura y participantes

Los participantes clave son:

  • Target: interfaz que el cliente conoce y espera.
  • Client: componente que trabaja con la interfaz objetivo.
  • Adaptee: clase existente con una interfaz incompatible.
  • Adapter: implementa la interfaz objetivo y traduce las solicitudes hacia el adaptee.

La implementación concreta puede variar según la modalidad (por objeto o por clase), pero en cualquier caso el adaptador encapsula la conversión de datos y las llamadas necesarias.

11.4 Tipos de adaptadores

Existen dos variantes clásicas:

  • Adapter por objeto: el adaptador mantiene una referencia al adaptee y delega las llamadas. Es la opción más flexible porque permite adaptar clases existentes sin heredar de ellas.
  • Adapter por clase: el adaptador hereda tanto de la clase objetivo como del adaptee. Es aplicable cuando el lenguaje admite herencia múltiple, situación que Java no soporta de forma directa, aunque se puede simular mediante interfaces y herencia de una sola clase.

En la práctica, el adapter por objeto es el más utilizado en Java, mientras que el adapter por clase se reserva para lenguajes como C++.

11.5 Implementación básica en Java

Veamos un ejemplo en Java donde un sistema espera un servicio de pagos con una interfaz estandarizada, pero un proveedor externo ofrece una API distinta. El adaptador permite reutilizarlo sin cambiar el código del cliente.

public interface ServicioPagos {
    void cobrar(String clienteId, double monto);
}

public class PasarelaInterna implements ServicioPagos {
    @Override
    public void cobrar(String clienteId, double monto) {
        System.out.println("Cobrando " + monto + " a " + clienteId + " con la pasarela interna");
    }
}

// Clase incompatible que queremos reutilizar
public class ProveedorExterno {
    public void procesarPago(String correo, double cantidad) {
        System.out.println("Procesando pago para " + correo + " con proveedor externo");
    }
}

// Adapter por objeto
public class ProveedorExternoAdapter implements ServicioPagos {
    private final ProveedorExterno proveedor;

    public ProveedorExternoAdapter(ProveedorExterno proveedor) {
        this.proveedor = proveedor;
    }

    @Override
    public void cobrar(String clienteId, double monto) {
        proveedor.procesarPago(clienteId + "@correo.com", monto);
    }
}

El cliente puede alternar entre implementaciones sin conocer los detalles del proveedor:

public class Checkout {
    private final ServicioPagos servicioPagos;

    public Checkout(ServicioPagos servicioPagos) {
        this.servicioPagos = servicioPagos;
    }

    public void completarCompra(String clienteId, double monto) {
        servicioPagos.cobrar(clienteId, monto);
    }

    public static void main(String[] args) {
        ServicioPagos pagosInternos = new PasarelaInterna();
        ServicioPagos pagosExternos = new ProveedorExternoAdapter(new ProveedorExterno());

        Checkout checkout = new Checkout(pagosExternos);
        checkout.completarCompra("cliente123", 2500);
    }
}

11.6 Ejemplo completo en Java: integración de notificaciones

Imaginemos una aplicación que envía notificaciones mediante una interfaz doméstica. Un proveedor de mensajería moderno ofrece un SDK con nombres y estructuras distintas. Implementamos un adaptador que traduce los datos y permite seleccionar en tiempo de ejecución el proveedor deseado.

public interface Notificador {
    void enviar(String destino, String mensaje);
}

public class NotificadorEmail implements Notificador {
    @Override
    public void enviar(String destino, String mensaje) {
        System.out.println("Email a " + destino + ": " + mensaje);
    }
}

// SDK externo
public class ServicioPush {
    public void push(String token, String titulo, String cuerpo) {
        System.out.println("Push a " + token + " - " + titulo + ": " + cuerpo);
    }
}

public class ServicioPushAdapter implements Notificador {
    private final ServicioPush servicioPush;

    public ServicioPushAdapter(ServicioPush servicioPush) {
        this.servicioPush = servicioPush;
    }

    @Override
    public void enviar(String destino, String mensaje) {
        servicioPush.push(destino, "Notificación", mensaje);
    }
}

public class GestorNotificaciones {
    private final List<Notificador> notificadores = new ArrayList<>();

    public void registrar(Notificador notificador) {
        notificadores.add(notificador);
    }

    public void difundir(String mensaje) {
        notificadores.forEach(n -> n.enviar(obtenerDestino(n), mensaje));
    }

    private String obtenerDestino(Notificador notificador) {
        // En un sistema real se calcularía en función del notificador
        return notificador instanceof ServicioPushAdapter ? "token-123" : "usuario@example.com";
    }

    public static void main(String[] args) {
        GestorNotificaciones gestor = new GestorNotificaciones();
        gestor.registrar(new NotificadorEmail());
        gestor.registrar(new ServicioPushAdapter(new ServicioPush()));

        gestor.difundir("La versión 2.0 ya está disponible");
    }
}

El adapter encapsula el SDK externo y evita que el resto del sistema deba conocer su interfaz específica. Esto reduce el impacto de futuras migraciones.

Adapter

11.7 Buenas prácticas

  • Eliminar conversiones triviales fuera del adaptador para que el cliente permanezca limpio.
  • Agrupar adaptadores en un paquete específico que documente la integración con terceros.
  • Considerar el patrón Facade cuando se requieren adaptadores para muchas clases del mismo proveedor.
  • Implementar pruebas unitarias que verifiquen la traducción de datos entre la interfaz objetivo y el adaptee.

11.8 Errores frecuentes y cómo evitarlos

Entre los problemas más comunes destacan:

  • Crear adaptadores que expongan detalles del adaptee en lugar de mantener la interfaz objetivo limpia.
  • Abusar del patrón para resolver pequeñas diferencias nominales que podrían ajustarse mediante refactorización.
  • No contemplar excepciones o errores del componente adaptado, generando fugas de abstracción.

Una solución habitual consiste en combinar Adapter con Strategy, seleccionando dinámicamente qué adaptador utilizar según el contexto de ejecución.

11.9 Cuándo elegir Adapter

El patrón aporta valor cuando:

  • Los clientes existentes dependen de una interfaz estable y se desea incorporar un nuevo proveedor.
  • Hay que interoperar con sistemas heredados o componentes cuyo código no controlamos.
  • Se quiere preservar la consistencia del dominio evitando referencias directas a terceros.
  • Se planea migrar gradualmente hacia una nueva API manteniendo compatibilidad con el código existente.

Adapter complementa a Facade y Bridge: una fachada puede usar adaptadores para ofrecer una interfaz simplificada, mientras que Bridge aporta extensibilidad cuando se necesita variar tanto la abstracción como la implementación.