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
- El problema en PideYa: el SDK que no encaja
- Intención y estructura: Adapter de objeto
- Implementación Java completa
- Segundo cliente del patrón: el agregador de restaurantes
- Adapter de clase: la variante con herencia
- Adaptadores bidireccionales
- Adapter en el JDK
- Cuándo usarlo y cuándo no
- 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:
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)conlongValueExact()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:
- Existe un contrato nuestro contra el que programa el sistema (Target).
- Llega una pieza que no lo cumple y no podemos modificar (Adaptee).
- 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 constructornew 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 esOutputStreamWriter.Arrays.asList(...): presenta un array (adaptee) tras la interfazList(target). La traducción es tan directa que ni copia los datos.Collections.enumeration(...)/Collections.list(...): par de adaptadores entre la viejaEnumerationy 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
AdaptadorPayPalempieza 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ódigosint) 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
AdaptadorUniversalPagosque adapta tres SDKs distintos conifinternos. Cada adaptee merece su adaptador; la uniformidad ya la da el target. - Consejo: nombra los adaptadores de forma transparente (
AdaptadorPayPal, sufijoAdapter/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
- ¿Qué son los Patrones de Diseño?
- Historia y Origen de los Patrones de Diseño
- Principios de Diseño: SOLID y Otros Fundamentos
- UML Esencial para Entender Patrones
- Clasificación de los Patrones de Diseño
- Ventajas y Desventajas de Usar Patrones de Diseño
Módulo 2: Patrones Creacionales
- Introducción a los Patrones Creacionales
- Singleton
- Factory Method
- Abstract Factory
- Builder
- Prototype
- Comparativa y Elección de Patrones Creacionales
Módulo 3: Patrones Estructurales
- Introducción a los Patrones Estructurales
- Adapter
- Bridge
- Composite
- Decorator
- Facade
- Flyweight
- Proxy
- Comparativa y Elección de Patrones Estructurales
Módulo 4: Patrones de Comportamiento
- Introducción a los Patrones de Comportamiento
- Chain of Responsibility
- Command
- Interpreter
- Iterator
- Mediator
- Memento
- Observer
- State
- Strategy
- Template Method
- Visitor
- Comparativa y Elección de Patrones de Comportamiento
Módulo 5: Aplicación de Patrones de Diseño
- Cómo Seleccionar el Patrón Adecuado
- Ejemplos Prácticos de Uso de Patrones
- Patrones de Diseño en Proyectos Reales
- Refactorización Usando Patrones de Diseño
- Antipatrones: Cuándo los Patrones se Vuelven un Problema
Módulo 6: Patrones de Diseño Avanzados
- Patrones de Diseño en Arquitecturas Modernas
- Patrones de Diseño en Microservicios
- Patrones de Diseño en Sistemas Distribuidos
- Patrones de Concurrencia
- Patrones de Diseño en Desarrollo Ágil
