En la introducción quedó planteado el síntoma: PideYa necesita cobrar con un SDK de pagos heredado cuya interfaz no se parece en nada a nuestra PasarelaPago, y no podemos tocar ni el SDK (no es nuestro) ni los clientes de PasarelaPago (son medio sistema). Adapter es el patrón de las interfaces incompatibles: el enchufe que permite conectar una pieza existente a un hueco que no fue pensado para ella. Es probablemente el patrón estructural que más veces escribirás en tu carrera, porque el mundo real está lleno de código que no encaja: APIs de terceros, sistemas legados, librerías que no eligen tus nombres.

Contenido

  1. El problema en PideYa: el SDK que no encaja
  2. Intención y estructura: Adapter de objeto
  3. Implementación Java completa
  4. Segundo cliente del patrón: el agregador de restaurantes
  5. Adapter de clase: la variante con herencia
  6. Adaptadores bidireccionales
  7. Adapter en el JDK
  8. Cuándo usarlo y cuándo no
  9. Errores comunes, ejercicios y conclusión

El problema en PideYa: el SDK que no encaja

Negocio quiere aceptar PayPal. El único SDK disponible para el acuerdo firmado es una librería veterana, paypal-legacy-sdk, que no podemos modificar y cuya interfaz es esta:

// Código de terceros: NO podemos tocarlo.
public class PayPalLegacyClient {

    /** Cobra un importe expresado en CÉNTIMOS. Devuelve un código:
     *  0 = OK, 1 = fondos insuficientes, 2 = cuenta bloqueada, 9 = error técnico. */
    public int makePayment(long amountInCents, String payerAccountId) { /* ... */ }

    /** Identificador de la última transacción realizada por este cliente. */
    public String getLastTransactionId() { /* ... */ }
}

Nuestro checkout, en cambio, habla el idioma que fijamos en el módulo 2 y que ya usan Redsys y Conekta:

public interface PasarelaPago {
    ResultadoPago cobrar(BigDecimal importe, DatosPago datos);
}

Los desajustes son de todo tipo, y son los desajustes típicos de cualquier integración:

Aspecto PasarelaPago (nuestro) PayPalLegacyClient (de ellos)
Nombre de la operación cobrar makePayment
Importe BigDecimal en euros long en céntimos
Datos del pagador Objeto DatosPago String con el id de cuenta
Resultado Objeto ResultadoPago (aceptado/rechazado + motivo) Un int con códigos mágicos
Id de transacción Dentro de ResultadoPago Llamada aparte, getLastTransactionId()
Errores Modelados en el resultado Código 9 y a rezar

El primer impulso —esparcir conversiones por el checkout: if (metodo == PAYPAL) { long centimos = ...; int codigo = client.makePayment(...); if (codigo == 0) ... }— destroza todo lo conseguido en el módulo 2: el cliente vuelve a conocer clases concretas, el switch renace, y los códigos mágicos de PayPal contaminan nuestra lógica de negocio. La necesidad, bien formulada: que PayPal parezca una PasarelaPago más, sin tocar ni el SDK ni un solo cliente existente.

Intención y estructura: Adapter de objeto

Intención (GoF): convertir la interfaz de una clase en otra interfaz que los clientes esperan. Adapter permite colaborar a clases que no podrían hacerlo por tener interfaces incompatibles.

La solución canónica es el Adapter de objeto: una clase nueva que implementa la interfaz esperada (Target) y contiene el objeto incompatible (Adaptee), traduciendo cada llamada.

classDiagram
    class PasarelaPago {
        <<interface>>
        +cobrar(importe: BigDecimal, datos: DatosPago) ResultadoPago
    }
    class AdaptadorPayPal {
        -clientePayPal: PayPalLegacyClient
        +cobrar(importe: BigDecimal, datos: DatosPago) ResultadoPago
    }
    class PayPalLegacyClient {
        +makePayment(amountInCents: long, payerAccountId: String) int
        +getLastTransactionId() String
    }
    class ServicioCheckout

    PasarelaPago <|.. AdaptadorPayPal
    AdaptadorPayPal o-- PayPalLegacyClient : delega en
    ServicioCheckout --> PasarelaPago : usa

Los roles GoF sobre PideYa:

Rol GoF En PideYa
Target (interfaz que el cliente espera) PasarelaPago
Adaptee (clase existente incompatible) PayPalLegacyClient
Adapter (traductor) AdaptadorPayPal
Client ServicioCheckout y cualquier código que use PasarelaPago

La geometría es mínima —una clase, una interfaz implementada, una referencia contenida— pero repara exactamente la fractura: el cliente sigue monolingüe en PasarelaPago, y toda la "aduana" (unidades, códigos, nombres) vive en un único sitio, cumpliendo SRP: la única razón de cambio de AdaptadorPayPal es que cambie el SDK o nuestro contrato.

Implementación Java completa

public class AdaptadorPayPal implements PasarelaPago {

    private final PayPalLegacyClient clientePayPal;

    public AdaptadorPayPal(PayPalLegacyClient clientePayPal) {
        this.clientePayPal = clientePayPal;
    }

    @Override
    public ResultadoPago cobrar(BigDecimal importe, DatosPago datos) {
        // 1. Traducir los datos de entrada: euros -> céntimos, DatosPago -> id de cuenta.
        long centimos = importe.movePointRight(2)
                               .setScale(0, RoundingMode.HALF_UP)
                               .longValueExact();
        String cuenta = datos.getCuentaPayPal();

        // 2. Delegar en el adaptee.
        int codigo = clientePayPal.makePayment(centimos, cuenta);

        // 3. Traducir el resultado: códigos mágicos -> nuestro modelo.
        return switch (codigo) {
            case 0 -> ResultadoPago.aceptado(clientePayPal.getLastTransactionId());
            case 1 -> ResultadoPago.rechazado("Fondos insuficientes");
            case 2 -> ResultadoPago.rechazado("Cuenta PayPal bloqueada");
            default -> ResultadoPago.error("Error técnico de PayPal (código " + codigo + ")");
        };
    }
}

Tres detalles del oficio que separan un adaptador correcto de uno peligroso:

  • La conversión de unidades es sagrada. movePointRight(2) con longValueExact() falla ruidosamente si el importe tuviera fracciones de céntimo, en vez de truncar en silencio. En pagos, un adaptador que redondea mal es un incidente contable; escribe tests específicos de la traducción (19,99 € → 1999; 0,105 € → excepción).
  • La traducción de errores también es traducción. El código 9 del SDK se convierte en nuestro vocabulario (ResultadoPago.error(...)). Un adaptador que deja escapar excepciones o códigos del adaptee está incompleto: el cliente acabaría conociendo al SDK a través de sus errores.
  • El adaptee se inyecta, no se construye dentro con new. Así el adaptador se testea con un doble del SDK, y la creación queda donde el módulo 2 mandó: en la raíz de composición o en una fábrica.

Y la integración con lo ya construido es gratis: como AdaptadorPayPal es una PasarelaPago, encaja en la maquinaria creacional del módulo 2 sin tocarla. Por ejemplo, si mañana el mercado España ofrece PayPal, FabricaEspana puede devolverlo desde su factory method:

@Override
public PasarelaPago crearPasarelaPago() {
    return switch (config.getMetodoPagoPreferido()) {
        case REDSYS -> new PasarelaRedsys(config.getClaveRedsys());
        case PAYPAL -> new AdaptadorPayPal(new PayPalLegacyClient());
    };
}

El checkout no se ha enterado de nada. Esa es la prueba del algodón de un buen adaptador: su existencia es invisible para el cliente.

Segundo cliente del patrón: el agregador de restaurantes

El mismo movimiento aparece en la otra frontera de PideYa: un agregador externo nos cede su catálogo de restaurantes, con su propio modelo:

// API del agregador (terceros): nombres en inglés, teléfonos sin normalizar,
// categorías con su propia taxonomía...
public class ExternalRestaurantApi {
    public List<ExternalVenue> fetchVenues(String cityCode) { /* ... */ }
}

Nuestro catálogo habla ProveedorRestaurantes (con List<Restaurante> buscarPorCiudad(Ciudad ciudad)). La solución es idéntica: un AdaptadorAgregador implements ProveedorRestaurantes que contiene ExternalRestaurantApi y traduce ExternalVenue → Restaurante (mapeo de campos, normalización de teléfonos, conversión de taxonomías). No repetiremos el código: lo que importa es reconocer la firma del contexto, siempre la misma:

  1. Existe un contrato nuestro contra el que programa el sistema (Target).
  2. Llega una pieza que no lo cumple y no podemos modificar (Adaptee).
  3. No queremos que el desajuste contamine a los clientes.

Cuando esas tres condiciones se dan, Adapter. Cuando falta la segunda —la pieza sí es nuestra y podemos cambiarla—, la respuesta suele ser más simple: cambia la pieza, no le pongas un enchufe encima.

Adapter de clase: la variante con herencia

El GoF cataloga una segunda forma, el Adapter de clase: en lugar de contener al adaptee, el adaptador hereda de él y a la vez implementa el target.

classDiagram
    class PasarelaPago { <<interface>> +cobrar(importe, datos) ResultadoPago }
    class PayPalLegacyClient { +makePayment(amountInCents, payerAccountId) int }
    class AdaptadorPayPalPorHerencia { +cobrar(importe, datos) ResultadoPago }

    PasarelaPago <|.. AdaptadorPayPalPorHerencia
    PayPalLegacyClient <|-- AdaptadorPayPalPorHerencia : hereda
public class AdaptadorPayPalPorHerencia extends PayPalLegacyClient implements PasarelaPago {
    @Override
    public ResultadoPago cobrar(BigDecimal importe, DatosPago datos) {
        int codigo = makePayment(convertirACentimos(importe), datos.getCuentaPayPal());
        // ... misma traducción del resultado
    }
}

Comparativa honesta, que en Java se decide casi sola:

Aspecto Adapter de objeto (composición) Adapter de clase (herencia)
Ámbito GoF Objeto: la relación se fija al construir Clase: la relación se fija al compilar
Puede adaptar subclases del adaptee Sí, cualquiera que le inyecten No: solo la clase de la que hereda
Puede adaptar varios adaptees a la vez Sí (contiene varios) No en Java (herencia simple)
Sobrescribir comportamiento del adaptee No directamente Sí (es una subclase)
Riesgo Ninguno especial Expone la interfaz del adaptee al cliente (¡makePayment sigue siendo público!)
Requiere adaptee heredable No: vale interfaz, clase final, varias instancias Sí: clase no final con constructor accesible

La última fila de riesgo merece subrayado: AdaptadorPayPalPorHerencia es un PayPalLegacyClient, así que cualquiera puede saltarse la traducción y llamar a makePayment directamente. El adaptador de objeto encapsula; el de clase filtra. Por eso —y por la herencia simple de Java, y por el lema "composición sobre herencia" de la lección de principios— la variante de objeto es la elección por defecto, y la de clase queda para casos muy concretos (necesitas sobrescribir métodos protegidos del adaptee, o el rendimiento de una indirección te importa de verdad, que es casi nunca).

Adaptadores bidireccionales

A veces la traducción hace falta en los dos sentidos. Ejemplo real de PideYa: durante la migración desde un sistema antiguo de reparto, hay módulos nuevos que hablan ServicioReparto (nuestro) y módulos antiguos que aún hablan LegacyDispatchSystem (el viejo), y ambos deben convivir meses. Un adaptador bidireccional implementa las dos interfaces y traduce en ambas direcciones:

public class AdaptadorRepartoBidireccional implements ServicioReparto, LegacyDispatchSystem {

    private final ServicioReparto repartoNuevo;      // para atender a los módulos viejos
    private final LegacyDispatchSystem repartoViejo; // para atender a los módulos nuevos

    // Llamada de un módulo nuevo -> se traduce al sistema viejo
    @Override
    public void asignarRepartidor(Pedido pedido, Repartidor repartidor) {
        repartoViejo.dispatch(pedido.getId().toString(), repartidor.getCodigoLegacy());
    }

    // Llamada de un módulo viejo -> se traduce al sistema nuevo
    @Override
    public void dispatch(String orderId, String courierCode) {
        repartoNuevo.asignarRepartidor(buscarPedido(orderId), buscarRepartidor(courierCode));
    }
    // ...
}

Es un patrón de transición: útil mientras conviven dos mundos, y candidato a desaparecer cuando la migración termina. Si un adaptador bidireccional cumple años en tu código, no es un puente: es una frontera que nadie se atrevió a cerrar.

Adapter en el JDK

El JDK está lleno de adaptadores con nombre y apellidos; reconocerlos fija el patrón mejor que cualquier ejemplo inventado:

  • InputStreamReader: el adaptador más citado de Java. Target: Reader (mundo de caracteres). Adaptee: InputStream (mundo de bytes). El constructor new InputStreamReader(inputStream, UTF_8) es literalmente "envuelvo un adaptee y lo presento como target", con el charset como regla de traducción. Su simétrico es OutputStreamWriter.
  • Arrays.asList(...): presenta un array (adaptee) tras la interfaz List (target). La traducción es tan directa que ni copia los datos.
  • Collections.enumeration(...) / Collections.list(...): par de adaptadores entre la vieja Enumeration y las colecciones modernas — un bidireccional de museo, nacido exactamente de una migración como la del apartado anterior.

Cuando en el javadoc leas "bridge from X to Y" o veas un constructor que recibe "la otra" interfaz, estás casi siempre ante un Adapter (aunque el javadoc de InputStreamReader diga bridge, el patrón Bridge es otra cosa, como veremos en la próxima lección).

Cuándo usarlo y cuándo no

Úsalo cuando:

  • Necesitas usar una clase existente —de terceros, legada, generada— y su interfaz no coincide con la que tu sistema espera.
  • Quieres aislar a tus clientes de los detalles (unidades, códigos, nombres, errores) de una dependencia externa: el adaptador es además una capa anticorrupción en miniatura.
  • Estás migrando entre dos sistemas que deben convivir (bidireccional, temporal).

No lo uses cuando:

  • Las dos clases son tuyas y puedes alinear sus interfaces directamente: un adaptador entre dos piezas propias suele ser una refactorización que no te atreviste a hacer.
  • Lo que quieres no es traducir una interfaz sino añadirle comportamiento (eso es Decorator) o simplificar muchas llamadas en una (eso es Facade).
  • El desajuste es tan profundo que el "adaptador" necesita reimplementar media lógica: eso ya no es traducir, es construir otra cosa, y merece un diseño propio.

Relación con otros patrones (solo mención): las fábricas del módulo 2 son el lugar natural donde decidir si se entrega el objeto nativo o el adaptado; Decorator y Proxy comparten mecánica de envoltorio pero conservan la interfaz en vez de cambiarla; Bridge se le parece en el dibujo pero se diseña antes de que existan las piezas, no después; y Facade también media con código ajeno, pero simplificando un subsistema entero en vez de traducir una clase. El careo completo de los cuatro envoltorios llega en la comparativa.

Errores Comunes y Consejos

  • Adaptador con lógica de negocio. Si AdaptadorPayPal empieza a decidir descuentos o a validar carritos, ha dejado de ser una aduana para ser un contrabandista. Regla: en un adaptador solo hay traducción (tipos, unidades, nombres, errores). Lo demás, a su capa.
  • Traducción incompleta. Adaptar los casos felices y dejar que las excepciones del adaptee atraviesen crudas. El cliente acaba con catch (PayPalConnectionException e) y el acoplamiento que querías evitar ha vuelto por la puerta de atrás.
  • Adaptar lo que es tuyo. Ponerle un adaptador a una clase propia para no refactorizarla es acumular deuda con intereses: ahora hay dos interfaces y un traductor que mantener.
  • El adaptee se escapa. Devolver desde el adaptador tipos del SDK (ExternalVenue, códigos int) en algún método secundario. La frontera tiene que ser estanca: todo lo que cruza, se traduce.
  • Un adaptador para dominarlos a todos. Un AdaptadorUniversalPagos que adapta tres SDKs distintos con if internos. Cada adaptee merece su adaptador; la uniformidad ya la da el target.
  • Consejo: nombra los adaptadores de forma transparente (AdaptadorPayPal, sufijo Adapter/Adaptador). A diferencia de otros patrones, aquí anunciar el patrón en el nombre ayuda: quien lo lea sabrá que dentro solo hay traducción y dónde buscar la aduana de cada tercero.

Ejercicios

Ejercicio 1: adaptar el servicio de SMS de un nuevo proveedor

PideYa contrata un proveedor de SMS más barato cuyo SDK (intocable) es:

public class CheapSmsService {
    /** Devuelve true si el mensaje fue encolado. El número debe ir SIN prefijo internacional. */
    public boolean queueMessage(String phoneWithoutPrefix, String body) { /* ... */ }
}

Nuestra interfaz del módulo 2 es Notificador con void enviar(String destinatario, String mensaje), donde destinatario es un teléfono con prefijo (+34600111222), y un fallo de envío se comunica lanzando NotificacionException. Escribe AdaptadorCheapSms.

Ejercicio 2: detectar el adaptador defectuoso

¿Qué dos problemas de diseño tiene este adaptador del agregador de restaurantes?

public class AdaptadorAgregador implements ProveedorRestaurantes {
    private final ExternalRestaurantApi api = new ExternalRestaurantApi();

    @Override
    public List<Restaurante> buscarPorCiudad(Ciudad ciudad) {
        List<ExternalVenue> venues = api.fetchVenues(ciudad.getCodigo());
        List<Restaurante> resultado = new ArrayList<>();
        for (ExternalVenue v : venues) {
            if (v.getRating() >= 4.0) {          // "solo queremos restaurantes buenos"
                resultado.add(traducir(v));
            }
        }
        return resultado;
    }
}

Ejercicio 3: ¿objeto o clase?

El SDK PayPalLegacyClient tiene un método protected void refreshToken() que conviene invocar antes de cada cobro, y la clase no es final. Un compañero propone el Adapter de clase "porque así podemos llamar a refreshToken()". Evalúa la propuesta: ¿es razón suficiente? ¿Qué inconvenientes acepta a cambio? ¿Hay alternativa manteniendo la variante de objeto?

Soluciones

Solución 1:

public class AdaptadorCheapSms implements Notificador {

    private final CheapSmsService servicio;

    public AdaptadorCheapSms(CheapSmsService servicio) {
        this.servicio = servicio;
    }

    @Override
    public void enviar(String destinatario, String mensaje) {
        // Traducción de entrada: quitar el prefijo internacional (+34 -> "")
        String sinPrefijo = destinatario.replaceFirst("^\\+\\d{1,3}", "");

        // Delegación + traducción del resultado: boolean -> excepción de nuestro vocabulario
        boolean encolado = servicio.queueMessage(sinPrefijo, mensaje);
        if (!encolado) {
            throw new NotificacionException("El proveedor de SMS rechazó el mensaje a " + destinatario);
        }
    }
}

Las dos traducciones (formato del teléfono y modelo de error) quedan encerradas en el adaptador; para el RegistroNotificadores del módulo 2, registrar este canal es una línea más: registro.registrar("sms-barato", () -> new AdaptadorCheapSms(new CheapSmsService()));.

Solución 2: (1) construye su adaptee con new dentro (new ExternalRestaurantApi()): imposible de testear sin red y creación fuera de su sitio; debe inyectarse. (2) Contrabando de lógica de negocio: el filtro rating >= 4.0 es una regla de catálogo, no una traducción; mañana negocio la cambiará y nadie mirará dentro de un adaptador. El filtrado pertenece al servicio de catálogo que usa el proveedor. (Traducir rating a nuestro modelo sí sería trabajo del adaptador; decidir con él, no.)

Solución 3: no es razón suficiente por sí sola, pero es una razón legítima: acceder a miembros protected es de los pocos motivos reales del Adapter de clase. A cambio acepta: la interfaz pública del SDK queda expuesta en el adaptador (cualquiera puede llamar a makePayment sin traducción), la relación queda soldada a esa clase concreta, y se gasta la única carta de herencia de Java. Alternativa manteniendo objeto: una subclase mínima del SDK (PayPalClientConRefresh extends PayPalLegacyClient) que solo publique cobrarConTokenFresco() combinando refreshToken() + makePayment(...), y el adaptador de objeto normal envolviéndola. La herencia queda confinada a un detalle técnico y la frontera sigue estanca.

Conclusión

Adapter resuelve el desencuentro entre la interfaz que tu sistema espera y la que una pieza intocable ofrece: una clase traductora que implementa el target, contiene el adaptee y convierte llamadas, datos y errores en un único punto. Conoces sus dos variantes (objeto por defecto, clase para casos contados), su versión bidireccional para migraciones, y sus ejemplares de museo en el JDK. Y tienes su criterio de pureza: dentro de un adaptador solo vive traducción.

Fíjate en que Adapter llega siempre tarde y de urgencias: las piezas ya existían y no encajaban. La siguiente pregunta es más ambiciosa: ¿y si pudiéramos diseñar por adelantado para que dos dimensiones que van a variar —qué se comunica y por dónde se comunica— nunca lleguen a soldarse? En PideYa esa tensión ya asoma: tipos de notificación por un lado, canales de envío por otro, y una jerarquía que amenaza con multiplicarse. Ese diseño a priori tiene nombre de obra civil: nos vemos en Bridge.

Curso de Patrones de Diseño de Software

Módulo 1: Introducción a los Patrones de Diseño

Módulo 2: Patrones Creacionales

Módulo 3: Patrones Estructurales

Módulo 4: Patrones de Comportamiento

Módulo 5: Aplicación de Patrones de Diseño

Módulo 6: Patrones de Diseño Avanzados

Módulo 7: Recursos Adicionales y Conclusión

© Copyright 2026. Todos los derechos reservados