Módulo 5: Patrones para estructurar y adaptar

2. Adapter: hacer que dos interfaces incompatibles se entiendan

Descripción

Al terminar esta lección vas a poder escribir un Adapter y —lo que importa más— vas a poder decidir dónde ponerlo. Vas a tener su anatomía desarmada en cuatro piezas, vas a saber por qué la pieza que casi nadie nombra (el punto donde se construye) es la que decide si el patrón sirve de algo, y vas a conocer la regla de oro que separa un buen adaptador de uno inútil: el contrato se diseña desde tu necesidad, no desde la librería. También vas a ver las dos formas de escribirlo en Python —por composición y por herencia— y por qué una de las dos casi siempre gana.

Esto importa porque Adapter es, de todo el catálogo, el patrón con mejor relación valor/costo en código real. La razón es simple y poco romántica: es el único que resuelve un problema que absolutamente todo sistema tiene. No todo sistema tiene comportamiento que varía, no todo sistema necesita concentrar la creación de objetos, no todo sistema tiene jerarquías en árbol. Pero todo sistema que dure más de un año consume algo que no escribió: una librería, un SDK, una API, un módulo heredado. Y esa cosa habla su propio idioma.

El costo, además, es de los más bajos del catálogo. Un Adapter es una clase con los métodos que tú necesitas y un objeto ajeno adentro. No agrega jerarquías, no agrega registros dinámicos, no invierte el flujo de control. Quien lea tu código va a entender qué hace en treinta segundos. Compáralo con el Observer del módulo 6, que a cambio de desacoplar te cobra el flujo que ya no puedes seguir con el dedo. El Adapter cobra un salto y ya.

Conexión con el módulo: la lección 1 te dejó el diagnóstico —el SDK de Zafiro desparramado en cinco archivos de Boletia, con tres normalizaciones distintas del mismo estado— y el mapa de los cuatro patrones distinguidos por intención. Esta lección toma el primero y lo trabaja en abstracto, sobre un caso pequeño y ajeno a los pagos, para que la idea quede limpia sin el ruido del caso grande. La lección 3 aplica exactamente esto a Zafiro y mide el resultado. La lección 4 pasa a Facade, que se confunde con este todo el tiempo y que aquí ya vas a saber distinguir. Y la lección 7 vuelve con la pregunta que este patrón invita a hacerse de más: ¿de verdad hacía falta una clase?

El intérprete de la reunión

Dos empresas se sientan a negociar. Una habla japonés, la otra habla español. En medio hay una persona que escucha una frase y la dice en el otro idioma.

Mira lo que hace ese intérprete y, sobre todo, lo que no hace.

No opina. No decide si la oferta es buena. No agrega condiciones ni quita matices por su cuenta. Si una de las partes dice algo incómodo, lo traduce igual. Su valor entero está en que las dos partes puedan hablar como si el idioma no existiera, y ese valor se destruye en el momento en que el intérprete empieza a tener agenda propia.

Tampoco cambia a ninguna de las dos partes. Nadie le pidió a la delegación japonesa que aprendiera español. Nadie modificó a la empresa. Las dos siguen siendo exactamente lo que eran; lo único nuevo es la persona del medio.

Y hay un tercer detalle que es el que más se olvida: el intérprete tiene que estar en un solo lugar de la mesa. Si cada ejecutivo trajera su propio intérprete y cada uno tradujera un poco distinto, la reunión sería peor que sin intérpretes, porque ahora habría cuatro versiones de lo que se dijo, todas convincentes. Ese es, exactamente, el estado de Boletia hoy con las tres normalizaciones del estado de pago: tres intérpretes que traducen distinto la misma palabra.

Un Adapter es el intérprete. Traduce, no opina, no modifica a ninguna de las dos partes, y —cuando está bien puesto— hay uno solo.

Una última cosa de la analogía, para la lección 7. Si las dos empresas hablaran el mismo idioma con acentos distintos, contratar un intérprete sería ridículo. La incomodidad de entenderse no es lo mismo que la imposibilidad de entenderse. Ese matiz decide la mitad de los casos que vas a encontrar.

Qué es un Adapter, en una frase

Un Adapter es un objeto que implementa la interfaz que tu código necesita, y por dentro delega en otro objeto que tiene una interfaz distinta.

Esa es toda la idea. Lo que la vuelve un patrón —y no simplemente "una clase que llama a otra"— es la intención: existe solo para resolver la incompatibilidad. Si le agregas lógica de negocio, dejó de ser un Adapter y se convirtió en otra cosa, normalmente en un problema.

Ahora la anatomía. Todo Adapter tiene cuatro piezas, y la cuarta es la que decide si el patrón sirve.

1. El contrato que tu código habla. La interfaz que el resto del sistema ya usa o quiere usar. En Boletia, PaymentProvider con charge(order) y refund(order, amount); o ReportExporter con write(rows) y content_type. En el catálogo se llama target. Esta pieza es tuya y su diseño es la decisión más importante de todo el patrón.

2. Lo que hay que adaptar. El objeto ajeno con su interfaz incompatible: el SDK, la librería, la clase vieja. En el catálogo se llama adaptee. Esta pieza no es tuya y normalmente no la puedes cambiar; si la pudieras cambiar, probablemente no necesitarías el patrón.

3. El adaptador. La clase que cumple (1) y contiene (2). Recibe llamadas en tu idioma, las traduce, delega, y traduce la respuesta de vuelta. Es la única parte del sistema que conoce las dos formas a la vez.

4. El punto donde se construye. Dónde se crea el adaptador y cómo llega a manos de quien lo usa. Esta pieza no aparece en el diagrama del libro y es la que decide todo: si el checkout construye el adaptador por su cuenta, sigue sabiendo que existe la librería y no ganaste casi nada. Si el adaptador se construye en la factory del módulo 4 y llega inyectado, el checkout deja de saber que la librería existe. El patrón no está en la clase; está en quién la conoce.

Aquí está la forma mínima, sin caso todavía, para que veas el esqueleto:

# Las cuatro piezas, con nombres genéricos.

# 1. El contrato que TU código habla.
class Thing(Protocol):
    def do_it(self, data: dict) -> Result: ...


# 2. Lo que hay que adaptar: no es tuyo, no lo puedes cambiar.
#    (vive en una librería externa)
class ForeignGadget:
    def executeOperation(self, payload_json: str) -> str: ...


# 3. El adaptador: cumple el contrato, contiene al ajeno, traduce en ambos sentidos.
class ForeignGadgetAdapter:
    def __init__(self, gadget: ForeignGadget):
        self._gadget = gadget          # composición: lo guarda, no hereda de él

    def do_it(self, data: dict) -> Result:
        raw = self._gadget.executeOperation(json.dumps(data))   # traduce la entrada
        return Result.from_foreign(json.loads(raw))             # traduce la salida


# 4. El punto de construcción: aquí, y SOLO aquí, alguien sabe que ForeignGadget existe.
def build_thing() -> Thing:
    return ForeignGadgetAdapter(ForeignGadget())

Nota que el adaptador traduce en los dos sentidos: la entrada al idioma del ajeno y la salida de vuelta al tuyo. Los adaptadores mal escritos casi siempre traducen bien la ida y se olvidan de la vuelta, y entonces el objeto ajeno se escapa por el return. Vamos a ver ese error con un caso concreto en un momento.

Ejemplo trabajado: la librería de PDF que escribe archivos

Vamos a trabajar sobre un caso pequeño y lejos de los pagos, para que la mecánica quede clara antes del caso grande de la lección 3.

Boletia exporta reportes en tres formatos. El contrato, que ya viste en el módulo 4, es este:

# Archivo: reports/exporter.py  — el contrato de Boletia. Esto es NUESTRO.
from typing import Protocol

class ReportExporter(Protocol):
    """Todo exportador recibe filas y devuelve los bytes del archivo."""
    def write(self, rows: list[dict]) -> bytes: ...

    @property
    def content_type(self) -> str: ...

Fíjate en dos decisiones de ese contrato, porque las dos van a chocar con la librería. Primero, write recibe una lista de diccionarios: cada fila es {"nombre": "Ana", "boleto": "VIP", "precio": 1250.0}. Segundo, write devuelve bytes, no escribe un archivo. Esa segunda decisión es deliberada: Boletia manda el reporte por correo, lo sube a almacenamiento o lo devuelve en una respuesta HTTP, y en ninguno de los tres casos quiere un archivo temporal en el disco.

Ahora la librería. Para los PDF, el equipo eligió pdfmagic, que es buena generando documentos y que fue escrita pensando en scripts de escritorio:

# Paquete: pdfmagic 1.9  — librería externa. NO es código de Boletia.

class Document:
    def __init__(self, page_size="A4", font="Helvetica"): ...

    def add_header(self, *titles: str) -> None:
        """Agrega la fila de encabezado. Recibe los títulos como argumentos sueltos."""

    def add_row(self, *cells: str) -> None:
        """Agrega una fila. Cada celda como argumento suelto, y TODAS como texto."""

    def save(self, path: str) -> None:
        """Escribe el PDF en la ruta dada. No hay forma de obtener los bytes."""

Tres incompatibilidades, y ninguna es culpa de nadie:

  1. Boletia tiene diccionarios; pdfmagic quiere argumentos sueltos en orden.
  2. Boletia tiene valores de varios tipos —números, fechas, None—; pdfmagic quiere texto.
  3. Boletia quiere bytes; pdfmagic escribe un archivo en disco.

Cómo se ve hoy, sin adaptador. El generador de reportes conoce la librería por dentro:

# Archivo: reports/generate.py  — ANTES

import tempfile, os
from pdfmagic import Document

def generate_pdf_report(rows: list[dict]) -> bytes:
    doc = Document(page_size="Letter", font="Helvetica")

    # Boletia tiene diccionarios; la librería quiere argumentos sueltos y en orden.
    columns = list(rows[0].keys())          # ← si rows viene vacío, esto explota
    doc.add_header(*columns)

    for row in rows:
        # Y todo tiene que ir como texto, así que convertimos a mano.
        doc.add_row(*[str(row[c]) for c in columns])

    # La librería solo sabe escribir archivos, así que inventamos uno temporal.
    fd, path = tempfile.mkstemp(suffix=".pdf")
    os.close(fd)
    try:
        doc.save(path)
        with open(path, "rb") as f:
            return f.read()
    finally:
        os.unlink(path)                     # y hay que acordarse de borrarlo

Esa función funciona. El problema es qué contiene: catorce líneas de las cuales once son traducción y tres son el trabajo real. Y esa traducción vive en el archivo que genera reportes, que no debería saber nada de rutas temporales ni de argumentos sueltos.

Paso 1 — Diseña el contrato desde tu necesidad. Ya está hecho: ReportExporter existe desde el módulo 4 y se diseñó mirando lo que Boletia necesita —filas como diccionarios, bytes como resultado—. Este es el orden correcto y conviene subrayarlo: el contrato primero, la librería después. Si hubiéramos diseñado ReportExporter mirando pdfmagic, el contrato tendría un save(path) y hoy los tres exportadores estarían escribiendo archivos temporales.

Paso 2 — Escribe el adaptador. Toda la traducción, en un archivo cuyo nombre dice lo que es:

# Archivo: reports/pdf_exporter.py  — DESPUÉS
# Este archivo es el ÚNICO del sistema que sabe que pdfmagic existe.

import tempfile
from pathlib import Path
from datetime import date, datetime

import pdfmagic


class PdfExporter:
    """Adapta pdfmagic.Document al contrato ReportExporter de Boletia.

    Traduce tres cosas: diccionarios → argumentos sueltos, valores de cualquier
    tipo → texto, y archivo en disco → bytes en memoria.
    """

    def __init__(self, page_size: str = "Letter", font: str = "Helvetica"):
        # Guardamos la configuración, no el Document: cada exportación necesita
        # uno nuevo, porque un Document acumula filas y no se puede reiniciar.
        self._page_size = page_size
        self._font = font

    @property
    def content_type(self) -> str:
        return "application/pdf"

    def write(self, rows: list[dict]) -> bytes:
        doc = pdfmagic.Document(page_size=self._page_size, font=self._font)

        columns = self._columns_of(rows)
        doc.add_header(*columns)
        for row in rows:
            doc.add_row(*[self._as_cell(row.get(c)) for c in columns])

        return self._to_bytes(doc)

    # ── La traducción, en métodos privados y con nombre ──────────────────

    @staticmethod
    def _columns_of(rows: list[dict]) -> list[str]:
        """Las columnas salen de la primera fila. Con lista vacía, no hay columnas.

        Sin este método, un reporte de un evento sin asistentes lanzaba IndexError
        en producción. Ahora devuelve un PDF vacío, que es lo que el usuario espera.
        """
        return list(rows[0].keys()) if rows else []

    @staticmethod
    def _as_cell(value) -> str:
        """pdfmagic solo acepta texto. Convertimos aquí, con las reglas de Boletia.

        No usamos str() a secas porque str(None) da "None" —que el usuario lee como
        un dato— y str(1250.0) da "1250.0" en vez del formato de moneda del reporte.
        """
        if value is None:
            return ""
        if isinstance(value, bool):
            return "sí" if value else "no"
        if isinstance(value, float):
            return f"{value:,.2f}"
        if isinstance(value, (date, datetime)):
            return value.isoformat(sep=" ", timespec="minutes")
        return str(value)

    @staticmethod
    def _to_bytes(doc) -> bytes:
        """pdfmagic solo sabe escribir archivos. Le damos uno temporal y leemos.

        No es elegante, y no hay forma de que lo sea: la librería no ofrece otra
        salida. Que la fealdad viva AQUÍ, y no en quien pide un reporte, es
        justamente el punto de este archivo.
        """
        with tempfile.TemporaryDirectory() as tmp:
            path = Path(tmp) / "report.pdf"
            doc.save(str(path))
            return path.read_bytes()
        # El directorio temporal se borra solo al salir del with, pase lo que pase.

Paso 3 — El punto de construcción. El generador de reportes ya no importa pdfmagic. Pide un exportador a la factory del módulo 4 y usa el contrato:

# Archivo: reports/generate.py  — DESPUÉS

def generate_report(report_kind, event_id, output_format) -> bytes:
    rows = fetch_rows(report_kind, event_id)
    exporter = get_exporter(output_format)   # la factory decide cuál
    return exporter.write(rows)              # y aquí solo se habla el contrato

Qué esperar de este refactor. Vamos por lo concreto, después por lo que se mide y al final por lo que casi nadie señala.

Lo concreto: generate.py pasó de catorce líneas a tres, y perdió cuatro importtempfile, os, pdfmagic y el manejo de rutas—. Toda esa masa se mudó a pdf_exporter.py, que es más largo que antes. La cantidad total de código subió, y eso hay que decirlo con honestidad: un Adapter no reduce líneas, las reubica y les pone nombre. Lo que bajó es la cantidad de archivos que conocen pdfmagic, que pasó de dos —generate.py y el que hacía lo mismo para los reportes por correo— a uno.

Lo que se mide: aparecieron dos correcciones que antes no existían y no porque alguien fuera más listo, sino porque poner la traducción en un lugar con nombre te obliga a mirarla. _columns_of arregla el reporte de un evento sin asistentes, que antes lanzaba IndexError. Y _as_cell arregla dos cosas: los campos vacíos, que antes se imprimían como la palabra None en el PDF que recibía el organizador, y los montos, que salían como 1250.0 en vez de 1,250.00. Cuando juntas la traducción dispersa, los errores de traducción se vuelven visibles. Es uno de los efectos más consistentes de este patrón y vale más que la limpieza.

Y ahora lo interesante. Mira el método _to_bytes: sigue siendo feo. Crea un directorio temporal, escribe un archivo, lo lee y lo borra. No hay manera de que sea bonito, porque la librería no ofrece otra salida. Ese es exactamente el trabajo del adaptador y conviene entenderlo bien: no es limpiar la fealdad, es confinarla. Un buen adaptador suele ser el archivo más desagradable del proyecto, y es una buena señal —significa que absorbió la fealdad que antes estaba repartida—. Si tu adaptador quedó elegante, vale la pena revisar si de verdad estaba traduciendo algo.

Un último detalle, porque es el que hace que este refactor se pague. Ahora se puede probar el generador de reportes sin generar un solo PDF:

# Archivo: tests/test_generate.py

class RecordingExporter:
    """Un ReportExporter falso: guarda lo que le pasaron en vez de exportarlo."""
    content_type = "text/plain"

    def __init__(self):
        self.received: list[dict] = []

    def write(self, rows):
        self.received = rows
        return b"fake"


def test_report_includes_only_paid_orders():
    exporter = RecordingExporter()
    generate_report_with(exporter, kind="attendees", event_id=42)
    assert all(r["status"] == "paid" for r in exporter.received)

Esa prueba verifica una regla de negocio —el reporte de asistentes solo incluye órdenes pagadas— sin tocar el disco, sin cargar pdfmagic y en milisegundos. Antes esa prueba tenía que generar un PDF de verdad y después abrirlo para ver qué decía, cosa que nadie hace, así que la regla simplemente no estaba probada. La costura para probar es, en muchos equipos, el motivo principal para escribir un adaptador, por encima de la limpieza.

Composición o herencia: las dos formas de escribirlo

Hay dos maneras de escribir un Adapter, y el catálogo original las llama object adapter y class adapter. La diferencia es de una línea y las consecuencias son grandes.

Por composición (adaptador de objeto). Es la que acabas de ver: el adaptador guarda el objeto ajeno en un atributo y le delega.

class PdfExporter:
    def __init__(self, page_size="Letter"):
        self._page_size = page_size          # guarda la config

    def write(self, rows):
        doc = pdfmagic.Document(self._page_size)   # crea el ajeno y delega
        ...

Por herencia (adaptador de clase). El adaptador hereda del objeto ajeno y agrega los métodos que tu contrato pide.

# ⚠️ Se puede, y casi siempre es mala idea. Aquí para que lo reconozcas.

class PdfExporter(pdfmagic.Document):
    """Hereda de Document y le agrega write() para cumplir ReportExporter."""

    def write(self, rows):
        columns = list(rows[0].keys())
        self.add_header(*columns)            # ← llama a métodos heredados
        for row in rows:
            self.add_row(*[str(row[c]) for c in columns])
        ...

La segunda forma se ve más corta y tiene tres problemas serios.

Filtra la interfaz ajena. Tu PdfExporter ahora tiene, además de write y content_type, todos los métodos públicos de Document: add_row, add_header, save, y cualquiera que la librería agregue en el futuro. Alguien en otro archivo va a llamar a exporter.save("/tmp/x.pdf") porque estaba disponible, y en ese momento la frontera dejó de existir. Un adaptador que expone lo que adapta no está adaptando: está reenviando.

Te ata a la versión. Si pdfmagic 2.0 renombra add_row a append_row, la versión por composición se arregla en una línea dentro de write. La versión por herencia puede romperse de formas más raras: un método nuevo de la clase padre que colisione con el tuyo, un cambio en el constructor, un atributo interno que ahora se llama distinto.

Solo puedes adaptar una cosa. Si mañana necesitas combinar dos librerías —una que arma el documento y otra que le pone la marca de agua— la herencia se complica y la composición no: guardas dos objetos.

La regla práctica, dicha sin rodeos: usa composición. La herencia para adaptar se justifica en casos muy contados —típicamente cuando tienes que pasarle tu objeto a un código ajeno que verifica el tipo con isinstance— y aun ahí conviene preguntarse si no hay otra salida. En Python, además, la composición es especialmente cómoda porque no hace falta declarar que implementas nada: si tu clase tiene los métodos del Protocol, ya lo cumple.

Por qué es el patrón con mejor relación valor/costo

Vale la pena hacer explícito el argumento, porque es el tipo de cosa que vas a tener que defender en una revisión.

Del lado del valor. Un Adapter compra cuatro cosas, y las cuatro son concretas:

  • Un punto único de cambio. Cuando la dependencia cambie —y va a cambiar—, hay exactamente un archivo que revisar. Este es el beneficio que se anuncia siempre.
  • Una costura para probar. El resto del sistema depende de tu contrato, así que se puede sustituir con una implementación falsa de diez líneas. Este es el beneficio que más se usa en el día a día.
  • Una traducción con nombre. Las conversiones de unidad, de formato y de error dejan de estar repartidas en expresiones sueltas y pasan a ser métodos con nombre que se pueden leer y probar. Este es el beneficio que menos se anuncia y el que más errores encuentra.
  • La posibilidad real de cambiar de proveedor. No la posibilidad teórica: la concreta, medida en archivos tocados. En Boletia son cinco hoy y uno después.

Del lado del costo. Un Adapter cuesta tres cosas, y las tres son pequeñas:

  • Un salto más al leer. Quien quiera saber qué llega de verdad a la librería tiene que abrir un archivo.
  • Un poco de código que no hace nada visible. Los métodos que solo delegan se ven como ruido para quien no conoce la intención.
  • El riesgo de que la traducción se convierta en lógica. Es el error más común y tiene su entrada en la sección siguiente.

Compáralo con los costos de otros patrones. Un Observer te quita la capacidad de seguir el flujo con el dedo. Un Composite te obliga a que todas las hojas soporten la interfaz del árbol. Un Singleton te complica las pruebas para siempre. El Adapter cobra un salto, y el salto está en un archivo cuyo nombre dice exactamente qué hace.

Hay un argumento más, menos técnico y bastante decisivo en equipos reales: es el patrón más fácil de explicar. Puedes justificarlo en una frase que entiende cualquiera —"este archivo es el único que sabe cómo habla el proveedor"— sin necesidad de que la otra persona conozca el catálogo. Los patrones que hay que explicar con un diagrama tienen una tasa de adopción mucho peor, y un patrón que el equipo no adopta no sirve para nada.

Cómo se diseña el contrato (la parte que decide todo)

Toda la lección apunta aquí, así que vale la pena decirlo despacio.

La tentación, al escribir un adaptador, es abrir la documentación de la librería y traducir método por método. El resultado tiene forma de adaptador y no adapta nada: es la librería con otros nombres. Se reconoce porque los métodos coinciden uno a uno y porque los conceptos de la librería —sus parámetros raros, sus unidades, su vocabulario— siguen ahí.

La forma correcta es al revés, y tiene tres pasos.

Primero, escribe cómo te gustaría llamarlo. Literalmente: escribe la línea de código que quisieras poder escribir en el checkout, ignorando qué ofrece la librería.

# Lo que Boletia quiere poder escribir. Sin mirar el SDK.
result = provider.charge(order)
if result.is_pending:
    show_payment_instructions(result.reference)

Segundo, deriva el contrato de esa línea. Si esa es la llamada, el contrato es charge(order) -> PaymentResult y PaymentResult tiene is_pending y reference. Nota que en ningún momento apareció idem_key, ni currency, ni env: eso son cosas del proveedor, no de Boletia.

Tercero, y solo ahora, revisa si la librería puede cumplirlo. Casi siempre puede, con trabajo. Y donde no pueda, aprendiste algo real: si el SDK no permite reembolsos parciales, ese es un límite del negocio que tienes con ese proveedor, y conviene que esté escrito en el contrato —lanzando un error claro— y no escondido en una llamada que falla raro.

Un chequeo rápido para saber si tu contrato quedó bien: léelo en voz alta y pregúntate si tendría sentido para un proveedor totalmente distinto. charge(order) -> PaymentResult tiene sentido para cualquier pasarela de pago del mundo. charge(order, idem_key, env) -> dict solo tiene sentido para Zafiro. Si tu contrato no sobrevive a cambiar de proveedor, no es un contrato: es el SDK con maquillaje.

Errores comunes

Que el adaptador devuelva objetos de la librería (de implementación). Qué pasa: alguien escribe un adaptador impecable en la entrada —traduce los parámetros, convierte las unidades— y en el return deja pasar el objeto que devolvió la librería tal cual. El resto del sistema recibe un pdfmagic.Result o un dict de Zafiro, y a partir de ahí lo usa: lee sus llaves, llama a sus métodos, y en dos meses hay quince archivos que dependen de la forma de la respuesta ajena. La frontera existe en la ida y no existe en la vuelta. Por qué pasa: traducir la entrada es evidente —el compilador o el error de ejecución te obligan— mientras que devolver el objeto ajeno funciona perfectamente hasta el día que cambia. Además es cómodo: envolver la respuesta en un tipo propio parece trabajo extra sin ganancia inmediata. Cómo detectarlo: mira el tipo de retorno de cada método de tu adaptador. Si es dict, Any, o cualquier clase que venga de la librería, no está adaptado. Otra señal, más fácil de buscar: si en otro archivo hay un res["status"] o un res.get("id") sobre algo que salió del adaptador, la respuesta ajena se escapó. Cómo corregirlo: define un tipo propio para la respuesta —un dataclass alcanza— y que el adaptador lo construya. Cuesta diez líneas y es lo que convierte un reenviador en un adaptador.

Meterle lógica de negocio al adaptador (de criterio). Qué pasa: el adaptador empieza traduciendo y termina decidiendo. Primero es "ya que estoy aquí, valido que el monto sea positivo". Después es "si el proveedor rechaza por fondos insuficientes, intento con la tarjeta de respaldo del cliente". Después es "si el evento es de cortesía, no cobro". Un año más tarde el archivo tiene cuatrocientas líneas y ya no se puede cambiar de proveedor, porque la mitad de las reglas de negocio de Boletia viven ahí dentro. Por qué pasa: el adaptador es el lugar donde pasa todo lo relacionado con el proveedor, así que atrae cualquier cosa que suene a "relacionado con el proveedor". Y la primera vez que ocurre parece razonable —validar el monto ahí ahorra una línea en el llamador—. Cómo detectarlo: pregúntate si cada línea del adaptador seguiría siendo necesaria con otro proveedor. Traducir centavos a pesos, sí. Elegir una tarjeta de respaldo, no. Otra prueba, más brutal: si tuvieras que escribir un segundo adaptador para otro proveedor, ¿cuánto código copiarías tal cual? Todo lo que copiarías es lógica de negocio que está en el lugar equivocado. Cómo corregirlo: saca esa lógica al llamador o a un servicio propio, y deja el adaptador reducido a traducción. Si te duele porque queda duplicada entre dos adaptadores, eso es la señal de que pertenece a una capa por encima de ambos.

Escribir el adaptador y seguir usando la librería directo en otros archivos (de implementación). Qué pasa: alguien hace el trabajo, escribe un buen adaptador, migra el checkout… y deja admin/refunds.py llamando al SDK como siempre, porque "eso es del panel de administración, es otra cosa". El resultado es lo peor de los dos mundos: la complejidad del adaptador más el acoplamiento que venías a eliminar, y ahora con dos caminos que pueden comportarse distinto. Por qué pasa: los refactores se hacen por partes, cosa que está bien, pero la parte que falta se olvida. Y el sistema sigue funcionando, así que nada te lo recuerda. Cómo detectarlo: es el chequeo más fácil de este módulo. Busca el nombre de la librería en todo el proyecto: grep -rn "import pdfmagic" .. Si aparece en más de un archivo de producción, la frontera tiene un agujero. Cómo corregirlo: termina la migración, y después considera dejar la búsqueda como una verificación automática —una prueba que falle si alguien vuelve a importar la librería fuera del adaptador—. Suena exagerado hasta la tercera vez que alguien lo hace sin darse cuenta.

Ejercicios

Ejercicio 1 — Decide si estos tres casos piden un Adapter. Para cada uno, responde sí o no, y justifica con las piezas de la anatomía —sobre todo con la cuarta, el punto de construcción—.

(a) Boletia usa una librería de zonas horarias cuya función se llama convert_tz(dt, from_zone, to_zone). Se usa en once archivos, siempre igual, siempre con las mismas dos zonas. (b) El sistema de búsqueda de eventos usa un motor externo cuyo cliente devuelve resultados como una lista de tuplas (id, score, snippet). Boletia los convierte a objetos SearchHit en tres archivos distintos, cada uno a su manera. (c) Boletia genera los códigos QR de los boletos con una librería que expone una sola función: make_qr(text) -> bytes. Se usa en un archivo. La librería lleva seis años con la misma firma.

Ver solución

(a) No, y el caso es más sutil de lo que parece. Hay once usos, que suena a mucho, pero fíjate en qué son: once llamadas idénticas a una función que ya tiene la forma que Boletia necesita. No hay incompatibilidad que traducir —la interfaz encaja—, hay repetición. Lo que pide este caso no es un Adapter sino una función propia: def to_local(dt): return convert_tz(dt, UTC, VENUE_TZ). Once líneas repetidas se convierten en once llamadas a una función de una línea. Si mañana cambias de librería, esa función es el único lugar que se toca, así que además obtienes el beneficio principal del Adapter sin su costo. Este es el caso de la lección 7 apareciendo temprano: la respuesta correcta a veces es una función, y sigue siendo una frontera.

(b) Sí. Las cuatro piezas están: hay un contrato que Boletia quiere —SearchHit—, hay algo ajeno con otra forma —tuplas—, y sobre todo hay tres traducciones distintas, que es el síntoma exacto que este patrón resuelve. Igual que las tres normalizaciones del estado de pago de la lección 1, tres traducciones de lo mismo garantizan que al menos una esté mal. El adaptador aquí es una clase SearchIndex con un método search(query) -> list[SearchHit], y el punto de construcción es donde se arme el cliente del motor.

(c) No. Una implementación, un lugar de uso, API estable seis años, y la firma que ofrece la librería es exactamente la que Boletia necesita. Un adaptador aquí agregaría un archivo, una clase y un salto a cambio de absolutamente nada. Si algún día hace falta —porque entra un segundo generador de QR o porque las pruebas empiezan a tardar— extraerlo va a costar veinte minutos, y para entonces vas a saber cuál es la diferencia real que hay que abstraer. Este caso vuelve en la lección 7, así que guárdalo.

Por qué funciona: los tres casos parecen candidatos y solo uno lo es. La pregunta que separa a (b) de los otros dos no es "¿la librería es incómoda?" sino "¿cuántos lugares distintos están traduciendo lo mismo?". Un solo lugar de traducción, aunque sea feo, no pide un patrón. Tres lugares que traducen distinto lo piden a gritos.

Ejercicio 2 — Escribe el adaptador del motor de búsqueda. Toma el caso (b) del ejercicio anterior y escríbelo. El cliente externo se llama lucidsearch y su interfaz es esta:

# Paquete: lucidsearch 3.1 — externo, no se puede cambiar.

class Engine:
    def __init__(self, index_name: str): ...

    def query(self, q: str, n: int = 10) -> list[tuple]:
        """Devuelve [(doc_id: str, score: float, snippet: str), ...].

        Si no hay resultados devuelve [] — salvo que el índice no exista,
        en cuyo caso lanza lucidsearch.IndexMissing.
        """

Boletia quiere poder escribir results = index.search("jazz en el centro") y recibir una lista de SearchHit(event_id: int, score: float, snippet: str). Los doc_id del motor tienen la forma "event:1234". Define el contrato, el tipo de resultado y el adaptador, y decide qué hacer con IndexMissing.

Ver solución
# Archivo: search/index.py  — el contrato y el tipo de resultado. Esto es NUESTRO.
from dataclasses import dataclass
from typing import Protocol


@dataclass(frozen=True)
class SearchHit:
    """Un resultado de búsqueda, en el vocabulario de Boletia.

    Nota que event_id es int: el resto del sistema trabaja con ids numéricos.
    Que el motor los guarde como "event:1234" es problema del adaptador.
    """
    event_id: int
    score: float
    snippet: str


class SearchIndex(Protocol):
    def search(self, query: str, limit: int = 10) -> list[SearchHit]: ...
# Archivo: search/lucid_index.py  — el adaptador.
# Único archivo del sistema que importa lucidsearch.

import logging
import lucidsearch

from search.index import SearchHit

log = logging.getLogger(__name__)


class SearchUnavailable(RuntimeError):
    """Error propio: quien busca no debería tener que conocer los de lucidsearch."""


class LucidSearchIndex:
    """Adapta lucidsearch.Engine al contrato SearchIndex."""

    def __init__(self, engine: lucidsearch.Engine):
        # Recibimos el Engine ya construido en vez de construirlo aquí:
        # así las pruebas pueden pasar uno falso sin tocar este archivo.
        self._engine = engine

    def search(self, query: str, limit: int = 10) -> list[SearchHit]:
        try:
            raw = self._engine.query(query, n=limit)
        except lucidsearch.IndexMissing as err:
            # Traducimos también los ERRORES. Si dejáramos escapar IndexMissing,
            # quien busca tendría que importar lucidsearch para atraparlo, y la
            # frontera se rompería justo en el caso que más importa.
            raise SearchUnavailable("El índice de eventos no está disponible") from err

        return [hit for hit in (self._to_hit(row) for row in raw) if hit is not None]

    @staticmethod
    def _to_hit(row: tuple) -> SearchHit | None:
        """Traduce una tupla del motor a un SearchHit. Devuelve None si no se puede.

        El motor puede tener documentos que no son eventos (por ejemplo "venue:88"),
        y un id con formato inesperado no debería tumbar la búsqueda entera.
        """
        doc_id, score, snippet = row
        kind, _, raw_id = doc_id.partition(":")
        if kind != "event" or not raw_id.isdigit():
            log.warning("El índice devolvió un documento inesperado: %r", doc_id)
            return None
        return SearchHit(event_id=int(raw_id), score=float(score), snippet=snippet)

Las tres decisiones que separan una buena solución de una regular:

Traducir el error. IndexMissing es un tipo de lucidsearch. Si lo dejas escapar, todo archivo que quiera manejar ese caso tiene que importar la librería, y la frontera se rompe exactamente en el punto donde más importaba. Un SearchUnavailable propio, con from err para no perder la causa original, cuesta tres líneas.

Recibir el Engine construido en vez de construirlo. Es la cuarta pieza de la anatomía puesta en práctica. Si el adaptador hiciera self._engine = lucidsearch.Engine("events") en su constructor, probarlo exigiría parchear el módulo. Recibiéndolo, la prueba pasa un objeto falso con un método query y listo.

Decidir qué hacer con lo inesperado. Un doc_id con otra forma no debería tumbar la búsqueda, pero tampoco debería desaparecer en silencio: por eso el log.warning. Esta es la decisión que más se olvida y la que más se agradece a las tres de la mañana. Si hubieras elegido lanzar un error en vez de ignorar la fila, también sería defendible —siempre que sea una decisión y esté comentada, no un descuido—.

Y la que no hay que tomar: filtrar por score mínimo, ordenar los resultados o traducir el snippet. Eso es lógica de negocio y va en quien llame, no en el adaptador.

Ejercicio 3 — Encuentra qué le falta a este adaptador. El siguiente código pretende ser un Adapter. Encuentra al menos tres problemas y explica la consecuencia de cada uno.

# Archivo: notifications/sms_channel.py

import textblast

class SmsChannel:
    """Envía SMS a través de textblast."""

    def __init__(self):
        self.client = textblast.Client(api_key=settings.SMS_API_KEY)

    def send(self, customer, message):
        if customer.phone is None:
            return None
        if len(message) > 160:
            message = message[:157] + "..."
        return self.client.sendSMS(to=customer.phone, body=message)
Ver solución

Problema 1: devuelve el objeto de la librería. self.client.sendSMS(...) devuelve lo que devuelva textblast, y ese objeto sale del adaptador hacia el resto del sistema. A partir de ahí, quien llame va a leer sus campos —.sid, .status, lo que sea— y en tres meses habrá archivos que dependen de la forma de la respuesta de textblast sin importarla nunca. Consecuencia: la frontera funciona en la ida y no existe en la vuelta, que es el error de esta lección. Corrección: devolver un tipo propio, aunque sea DeliveryResult(sent=True, external_id=...).

Problema 2: construye el cliente dentro del constructor. La cuarta pieza está mal puesta. Como el Client se crea aquí, no hay forma de probar SmsChannel sin que salga a la red, salvo parcheando el módulo textblast —un truco frágil que se rompe cuando alguien cambia un import—. Consecuencia: las pruebas del canal de SMS o mandan mensajes de verdad o no existen. En la práctica, no existen. Corrección: recibir el cliente por parámetro, con un valor por defecto si quieres conservar la comodidad: def __init__(self, client=None): self._client = client or textblast.Client(...).

Problema 3: tiene lógica de negocio. Dos reglas se colaron. La primera, if customer.phone is None: return None, es una decisión de negocio de Boletia —a quién se le puede mandar SMS— y no tiene nada que ver con traducir a textblast. Peor: devuelve None en silencio, así que quien llama no distingue "no se pudo enviar" de "se envió bien". La segunda, el recorte a 160 caracteres, parece técnica y no lo es: la decisión de qué se recorta y cómo se avisa al cliente que su mensaje quedó truncado es de negocio. Consecuencia: si mañana entra un segundo proveedor de SMS, estas dos reglas hay que copiarlas, y la copia se va a desincronizar. Corrección: la elección de canal va en el notificador —módulo 6—, y el recorte va en quien arma el mensaje.

Y un cuarto, si lo viste: no traduce los errores. Si textblast lanza un textblast.RateLimited o un error de red, sale tal cual hacia el checkout, que tendría que importar textblast para atraparlo. Es el mismo problema que el retorno, del lado de las excepciones.

Por qué funciona: este código no está mal escrito —es el tipo de clase que se escribe en veinte minutos un martes y que funciona durante años—. El punto del ejercicio no es que sea malo, sino que tiene forma de adaptador sin cumplir su promesa: no aísla, porque la respuesta y los errores de la librería siguen saliendo, y no se puede probar, porque construye su propia dependencia. Reconocer esa diferencia —entre parecer una frontera y ser una frontera— es exactamente la habilidad que este módulo quiere dejarte.

Resumen y siguiente paso

En esta lección definiste Adapter en una frase: un objeto que implementa la interfaz que tu código necesita y que por dentro delega en otro con una interfaz distinta. Desarmaste su anatomía en cuatro piezas —el contrato tuyo, el objeto ajeno, el adaptador y el punto donde se construye— y viste que la cuarta, la que no aparece en el diagrama del libro, es la que decide si el patrón sirve: el patrón no está en la clase, está en quién la conoce.

Hiciste el adaptador completo de pdfmagic al contrato ReportExporter de Boletia, con sus tres traducciones —diccionarios a argumentos sueltos, valores de cualquier tipo a texto, archivo en disco a bytes— y viste que juntar la traducción dispersa hizo aparecer dos errores que llevaban meses en producción. Viste que la cantidad total de código subió, y que eso está bien: un Adapter no reduce líneas, las reubica y les pone nombre. Y viste que el adaptador quedó feo por dentro, que también está bien: su trabajo no es limpiar la fealdad, es confinarla.

Comparaste las dos formas de escribirlo y te quedaste con la composición, porque la herencia filtra la interfaz ajena, te ata a la versión y solo deja adaptar una cosa. Viste por qué este patrón tiene la mejor relación valor/costo del catálogo: resuelve un problema que todo sistema tiene, cuesta un salto de lectura, y —dato nada menor— se puede explicar en una frase que entiende cualquiera. Y viste la regla que decide todo: el contrato se diseña desde tu necesidad, no desde la librería. Si tu contrato no sobrevive a cambiar de proveedor, es el SDK con maquillaje.

Antes de avanzar deberías poder: nombrar las cuatro piezas y decir cuál es la que se olvida; escribir un adaptador que traduzca en los dos sentidos, incluidos los errores; explicar por qué composición le gana a herencia; y decir en qué se diferencia un caso que pide Adapter de uno que solo pide una función.

La lección 3 lleva esto al caso grande. Vamos a tomar el SDK de Zafiro tal como lo viste en la lección 1 —con sus tres convenciones de nombre, sus dos unidades para el dinero, su identificador que cambia de llave y sus seis valores de estado— y a ponerlo detrás de un PaymentProvider limpio. Vamos a medir qué se gana en archivos tocados, en pruebas que dejan de necesitar red y en errores que desaparecen. Y vamos a mirar de frente el límite del patrón, porque es real: un adaptador que solo renombra métodos quizá no valga la pena.

Recursos

  • Refactoring Guru — Adapter — el patrón con sus dos variantes, de objeto y de clase, y ejemplos en varios lenguajes. Léelo sabiendo que presenta la variante por herencia con más simpatía de la que merece.
  • python-patterns.guide — The Adapter Pattern (sección de «Composition Over Inheritance») — Brandon Rhodes contrasta el Adapter con las alternativas de Python —monkey patching, duck typing— y explica cuándo cada una es honesta.
  • Anti-Corruption Layer (Eric Evans, Domain-Driven Design) — el mismo patrón llevado a la frontera entre dos sistemas completos. Es el término que vas a oír en equipos que trabajan con sistemas heredados.
  • PEP 544 — Protocols: Structural subtyping — cómo declarar un contrato en Python sin obligar a heredar. Es lo que hace que escribir el adaptador por composición no cueste nada.