Módulo 5: Patrones para estructurar y adaptar

8. Proyecto: aísla una dependencia externa

Descripción

Al terminar este proyecto vas a haber hecho, de principio a fin, el trabajo que da nombre al módulo: tomar una integración desprolija con un proveedor externo —hoy esparcida por varios archivos de Boletia— y aislarla detrás de fronteras propias, eligiendo el patrón mínimo que sirva en cada caso. No es un ejercicio de aplicar los cuatro patrones que aprendiste: es un ejercicio de decidir cuáles hacen falta, cuáles no, y dónde exactamente va cada línea divisoria.

La entrega tiene dos partes y la segunda es la que se juzga. La primera es el código. La segunda es un documento corto que responde, frente por frente: dónde pusiste la frontera y por qué ahí. Ese documento es la habilidad que esta guía quiere dejarte y es, literalmente, lo que se te va a pedir en un equipo real —nadie revisa un cambio de arquitectura sin preguntar por qué—.

Hay cuatro frentes de la integración con Zafiro que la lección 3 no tocó, y cada uno tiene una respuesta distinta. Uno pide un adaptador nuevo con su propio contrato. Otro pide una función y nada más. Otro no es un problema de frontera aunque lo parezca. Y el cuarto no se arregla envolviéndolo: hay que reportarlo. Que las cuatro respuestas sean distintas es el punto del proyecto. Si terminas con cuatro contratos y cuatro factories, algo salió mal.

Conexión con el módulo: este proyecto usa todo. La lección 2 te dio la anatomía del Adapter y la regla del contrato diseñado desde tu necesidad; la 3 hizo el caso completo del cobro y te dejó los cinco pasos como plantilla; la 4 te dio Facade con su riesgo de God object; la 5, Decorator y la pregunta de qué es seguro repetir; la 6, Composite y cuándo no hay árbol; y la 7 te dio el freno —las tres preguntas y la escalera de cinco escalones— que es la herramienta principal aquí. Al terminar, Boletia va a tener sus dependencias externas domesticadas, que es el punto de partida del módulo 6: cuando las piezas tienen fronteras limpias, la pregunta siguiente es cómo se avisan las cosas entre ellas.

El encargo

Es martes. Llega esto al canal del equipo:

"Anoche marcamos treinta y ocho órdenes como pagadas dos veces. Zafiro reenvió unos webhooks porque nuestro endpoint tardó más de tres segundos, y los procesamos todos. A cuarenta clientes les llegó el correo de confirmación por duplicado y a doce de ellos les emitimos los boletos dos veces.

No es la primera vez que algo con Zafiro nos explota por un lado que no habíamos mirado. Ya limpiamos el cobro el mes pasado, pero quedaron cuatro rincones más y ninguno tiene dueño. Necesito que los revises los cuatro, decidas qué hacer con cada uno, y me expliques dónde pones la línea. No quiero que envuelvas todo por si acaso: quiero saber por qué cada cosa quedó donde quedó, porque a fin de trimestre alguien va a preguntar y quiero poder contestar."

Ese encargo tiene la forma exacta que tienen los encargos reales, y conviene notar tres cosas.

Llega por un incidente, no por una iniciativa de calidad. Nadie financia "mejorar la arquitectura"; se financia "que no vuelva a pasar". Tu trabajo incluye conectar lo que vas a hacer con el incidente que lo motivó.

Pide explícitamente que no envuelvas todo. Esa frase —"no quiero que envuelvas todo por si acaso"— es la lección 7 dicha por un jefe de equipo, y es la restricción más importante del proyecto.

Y pide una explicación, no solo código. "Quiero poder contestar" significa que el entregable real es un argumento. El código es la evidencia.

El estado actual: los cuatro frentes

Esto es lo que hay hoy en el repositorio. Léelo entero antes de tocar nada —es la lección 2 del módulo 8, adelantada: leer antes de tocar—.

Frente 1 — El webhook (el que causó el incidente)

Zafiro avisa por webhook cuando cambia el estado de una transacción. El endpoint está así:

# Archivo: api/webhooks.py  — ANTES

import json
import zafiropay


def post_zafiro_webhook(request):
    # Verificación de firma: si el encabezado falta, esto lanza ValueError
    # y el servidor devuelve 500. Zafiro lo interpreta como fallo y reenvía.
    if not zafiropay.verify_signature(request.raw_body, request.headers["X-Zafiro-Sig"]):
        return response(401, {"error": "firma inválida"})

    payload = json.loads(request.raw_body)
    event = payload["event"]
    data = payload["data"]

    # El identificador, otra vez con dos nombres posibles.
    txn_id = data.get("id") or data.get("transaction_id")
    order = orders.by_transaction(txn_id)

    # CUARTA normalización del estado del módulo. Y con otro vocabulario:
    # aquí no llegan estados, llegan nombres de evento.
    if event in ("payment.approved", "PAYMENT_APPROVED"):
        orders.mark_paid(order.id)
        for ticket_id in order.ticket_ids:
            tickets.issue(ticket_id, order)
        notifier.send_purchase_confirmation(customers.get(order.customer_id), order)

    elif event in ("PAYMENT_UPDATED", "payment.updated"):
        if data.get("status") in ("pending_review", "PENDING_REVIEW"):
            pass
        else:
            orders.mark_failed(order.id)

    elif event == "chargeback.opened":
        disputes.open(order.id, reason_code=data["reason_code"], deadline=data["deadline"])

    elif event == "refund.done":
        refunds.mark_done(data.get("id") or data.get("transaction_id"))

    return response(200, {"ok": True})

Lo que hay que saber del comportamiento de Zafiro, sacado de su documentación:

  • Manda el nombre del evento a veces en minúsculas con punto (payment.approved) y a veces en mayúsculas con guion bajo (PAYMENT_APPROVED). Depende de qué parte de su sistema lo emita.
  • El identificador viene como id o como transaction_id, igual que en las respuestas.
  • Reenvía el mismo webhook hasta cinco veces si no recibe un 200 en menos de tres segundos.
  • No garantiza el orden: un payment.approved puede llegar después de un PAYMENT_UPDATED del mismo pago.

Con eso ya puedes reconstruir el incidente del martes. Y fíjate en dónde está el trabajo pesado: tickets.issue y el envío del correo ocurren dentro del manejo del webhook, así que el endpoint tarda, Zafiro reenvía, y el reenvío vuelve a emitir boletos y a mandar correos.

Frente 2 — El archivo de conciliación

Cada madrugada, un job descarga el reporte de liquidación del día anterior para cuadrar lo cobrado contra lo depositado:

# Archivo: jobs/settlement.py  — ANTES

import csv, io
import zafiropay


def reconcile_settlement(day: str):
    client = zafiropay.ZafiroClient()
    raw = client.Settlements(day=day)          # devuelve TEXTO CSV, no una lista

    reader = csv.DictReader(io.StringIO(raw))
    for row in reader:
        # Las columnas se llaman distinto en producción y en sandbox, así que
        # alguien puso este or encadenado y funciona por accidente.
        txn_id = row.get("transaction_id") or row.get("txn_id")
        gross = float(row.get("gross") or row.get("gross_amount"))
        fee = float(row.get("fee") or row.get("commission"))

        settlements.record(txn_id, gross=gross, fee=fee, net=gross - fee)

Detalles del formato, según la documentación de Zafiro:

  • Las columnas se llaman transaction_id, gross, fee, net, currency en producción y txn_id, gross_amount, commission, net_amount, curr en el entorno de pruebas.
  • Los montos llegan con coma decimal cuando la moneda es COP y con punto cuando es MXN. float("1250,00") lanza ValueError.
  • Si no hubo operaciones ese día, devuelve una cadena vacía en vez de un CSV con solo encabezados.

Frente 3 — El panel de disputas

Cuando un cliente desconoce un cargo, el proveedor abre una disputa. El panel permite responderla:

# Archivo: admin/disputes.py  — ANTES

import zafiropay


def respond_to_dispute(dispute_id: int, operator, evidence_url: str):
    dispute = disputes.get(dispute_id)
    order = orders.get(dispute.order_id)
    client = zafiropay.ZafiroClient()

    client.SubmitEvidence(dispute=dispute.external_id, url=evidence_url)

    # Si la disputa se resolvió a nuestro favor, volvemos a cobrar.
    if dispute.status == "won":
        res = client.doCharge(
            amount=f"{order.total:.2f}",
            currency=order.currency,
            ref=f"BOL-{order.id}",
            idem_key=f"bol-{order.id}-{order.attempts}",   # ← la MISMA llave del cobro original
        )
        if res.get("ok"):
            orders.mark_paid(order.id)

Ese idem_key merece atención. Es exactamente la misma llave que usó el cobro original, así que Zafiro trata el nuevo cobro como una repetición del anterior y no cobra nada —devuelve el resultado guardado del cobro de hace tres meses—. El panel muestra "cobrado" y no entró un peso. Nadie lo ha notado porque las disputas ganadas son pocas.

Frente 4 — Las tarjetas guardadas

Boletia permite guardar la tarjeta para comprar más rápido la próxima vez:

# Archivo: customers/wallet.py  — ANTES

import zafiropay


def save_card(customer, card_number: str, cvv: str, exp: str):
    zafiropay.tokens.save_card(
        customer_ref=str(customer.id),
        card_number=card_number,        # ← el número completo, en claro
        cvv=cvv,                        # ← el CVV, en claro
        expires=exp,
    )


def list_saved_cards(customer):
    return zafiropay.tokens.list_cards(customer_ref=str(customer.id))

El número de tarjeta y el CVV pasan por el servidor de Boletia en claro. El SDK ofrece esa función y alguien la usó.

Qué se entrega

Tres cosas. Las tres importan y la segunda es la que se juzga.

1. El código

Los cuatro frentes resueltos, con la frontera que hayas decidido para cada uno. Se espera que:

  • Ningún archivo fuera de la frontera importe zafiropay. Es verificable con una búsqueda, y la tercera entrega es justamente eso.
  • Los errores del SDK no se filtren: nadie fuera debe atrapar zafiropay.ZafiroError ni el error de su librería HTTP.
  • Los tipos de retorno sean propios: nada de dict crudos ni de objetos del SDK cruzando la frontera.
  • Cada frente esté en el escalón de la lección 7 que le corresponde, no en el más alto.

2. El documento de frontera

Un archivo FRONTERAS.md de una o dos páginas, con una entrada por frente. Esta es la plantilla, y conviene respetarla porque es la forma en que se defiende una decisión en un equipo:

## Frente N — <nombre>

**Qué había:** una frase sobre el estado anterior y el problema concreto.

**Las tres preguntas:**
- ¿Más de una implementación, hoy? — Sí/No, con el dato.
- ¿Sustituir en pruebas? — Sí/No, con la prueba concreta que hoy no se puede escribir.
- ¿Interfaz inestable? — Sí/No, con el hecho observable (versiones, anuncios, si es servicio).

**Escalón elegido:** 0 a 4, y el nombre del patrón si lo hay.

**Dónde queda la frontera:** el archivo o archivos exactos, y qué queda de cada lado.

**Por qué ahí y no más arriba ni más abajo:** dos o tres líneas. Esta es la parte que se lee.

**Qué NO hice y por qué:** lo que sería razonable esperar y decidiste no hacer.

Esa última sección es la que separa un buen entregable de uno correcto. Decir "no puse una factory porque hay una sola implementación y _PROVIDERS con una entrada no documenta nada" demuestra más criterio que haberla puesto.

3. La prueba que demuestra que la frontera existe

Una prueba automática que falle si alguien vuelve a importar el SDK fuera de los archivos autorizados. No es un lujo: es lo único que evita que la frontera se llene de agujeros en seis meses.

# Archivo: tests/test_boundaries.py

import pathlib

# Los ÚNICOS archivos que pueden hablar con el SDK. Agregar uno a esta lista
# debe ser una decisión consciente, discutida en una revisión.
ALLOWED = {
    "payments/zafiro_provider.py",
    # … completa con los archivos de tu solución
}


def test_only_the_adapters_import_the_sdk():
    root = pathlib.Path("boletia")
    offenders = [
        str(path.relative_to(root))
        for path in root.rglob("*.py")
        if "zafiropay" in path.read_text() and str(path.relative_to(root)) not in ALLOWED
    ]
    assert not offenders, f"Estos archivos rompen la frontera: {offenders}"

Cómo hacerlo: cinco pasos

Los mismos de la lección 3, aplicados frente por frente. El paso 0 es nuevo y es el que decide el proyecto.

Paso 0 — Clasifica los cuatro frentes antes de escribir nada. Para cada uno, contesta las tres preguntas de la lección 7 con datos y decide el escalón. Escribe primero el FRONTERAS.md con las decisiones y después el código. Si lo haces al revés, el documento va a justificar lo que ya escribiste en vez de guiarlo.

Paso 1 — Escribe la llamada que te gustaría hacer. Para cada frente, la línea que quisieras poder escribir desde el código de negocio, ignorando qué ofrece el SDK.

Paso 2 — Deriva el contrato y los tipos propios. Solo para los frentes que lleguen al escalón 3 o más. Para los otros, el "contrato" es la firma de la función.

Paso 3 — Escribe la traducción. Unidades, vocabularios, identificadores, errores. Recuerda que traducir los errores vale la pena incluso en el escalón 2: es lo más barato y lo que más se filtra.

Paso 4 — Conecta el punto de construcción y migra a los llamadores. Y corre la prueba de frontera.

Qué esperar de cada frente

Esto es orientación, no la solución. Las decisiones son tuyas y hay más de una defendible; lo que sigue son las preguntas que cada frente te va a obligar a hacerte.

Frente 1 (webhook). Es el más grande y el que tiene el giro conceptual del proyecto. Antes de decidir el escalón, nota algo: PaymentProvider es un contrato de llamadas que tú haces. Un webhook es lo contrario: el proveedor te llama a ti. Preguntarte si el webhook "va dentro de PaymentProvider" es la pregunta equivocada, y contestarla que sí produce un contrato con métodos que no se parecen entre sí —el error del contrato que crece de la lección 6—.

Lo que sí es cierto es que hay la misma clase de problema: un vocabulario ajeno inconsistente que hay que traducir al de Boletia. Así que la forma es la de un Adapter, pero el contrato es otro.

Y hay una segunda decisión, separada de la frontera, que es la que arregla el incidente: el reenvío. Zafiro reenvía hasta cinco veces y no garantiza el orden, así que procesar un webhook tiene que ser idempotente. Piensa dónde va esa protección —¿en la traducción?, ¿en quien procesa el evento?— y ten en cuenta la advertencia de la lección 5: un decorador no debería cambiar el resultado del negocio, y "no procesar dos veces" lo cambia.

Y una tercera, que es de diseño y no de patrones: si el endpoint responde en menos de tres segundos, Zafiro no reenvía. Hoy emite boletos y manda correos antes de responder. Eso apunta a separar "recibir y registrar el evento" de "procesarlo", y es una decisión que conviene que aparezca en tu documento aunque no la implementes.

Frente 2 (conciliación). Un solo lugar de uso. Aplica las tres preguntas con honestidad y fíjate en qué contestas a la segunda: hoy, probar el manejo de un CSV con coma decimal exige llamar al proveedor. Eso es una prueba concreta que no se puede escribir, que es el criterio que la pregunta busca.

El trabajo de traducción aquí es real —dos juegos de nombres de columna, dos formatos de número, el caso del día vacío— y esa cantidad de traducción es la que decide entre el escalón 2 y el 3. Nota además que hay dos cosas distintas: obtener el archivo y entenderlo. Separarlas suele hacer que la parte difícil se pueda probar sin la parte que sale a la red, y es la decisión que va a determinar cuánto te cuesta el cambio a JSON del ejercicio 3.

Dos detalles que conviene no pasar por alto. El primero: el float() sobre montos de dinero es el mismo error que corregiste en la lección 3, ahora del lado de la lectura —un fee de "12,50" en COP lanza ValueError y uno de "0.1" en MXN entra con la imprecisión del punto flotante—. El segundo: el or encadenado entre nombres de columna funciona hoy por accidente y falla en silencio el día que Zafiro agregue una columna fee con otro significado. Una traducción explícita —un diccionario de alias, verificado contra el encabezado real— convierte un fallo silencioso en un error claro al arrancar el job.

Frente 3 (disputas). Aquí hay una trampa deliberada. Sí, hay una llamada al SDK que hay que mover detrás de la frontera —SubmitEvidence—. Pero el problema principal de ese archivo no es de frontera: es que un recobro está usando la llave de idempotencia del cobro original, y por eso no cobra. Ningún patrón arregla eso.

Tu entrega tiene que separar las dos cosas: qué es un problema de aislamiento y qué es un error de negocio que encontraste al mirar. Reportar el segundo vale más que resolver el primero, y en una revisión real es lo que te va a distinguir. Piensa también qué haces con SubmitEvidence: ¿pertenece a PaymentProvider? ¿Merece su propio contrato? ¿O es un caso de escalón 1?

Frente 4 (tarjetas). Este frente existe para enseñar la lección más incómoda del módulo, y es la del tercer error común de la lección 1: la frontera no protege de todo.

Puedes escribir un adaptador impecable alrededor de zafiropay.tokens con tipos propios y errores traducidos, y el número de tarjeta y el CVV van a seguir pasando por el servidor de Boletia en claro. Eso es un problema de cumplimiento —el estándar de la industria de tarjetas es explícito sobre no almacenar ni transmitir el CVV— y la solución no es de diseño: es usar el mecanismo de tokenización del lado del cliente que casi todas las pasarelas ofrecen, donde el navegador manda los datos directo al proveedor y tu servidor solo recibe un token.

Lo que se espera de ti en este frente no es que lo resuelvas: es que lo reportes en el documento, con claridad y sin dramatismo, y que digas explícitamente que envolverlo no lo arregla. Un adaptador bonito sobre un problema de cumplimiento es peor que no hacer nada, porque da la impresión de que alguien se hizo cargo.

Y la decisión transversal: ¿los cuatro frentes van al mismo contrato? Casi seguro que no. Cobrar, recibir webhooks, conciliar y guardar tarjetas son cuatro responsabilidades distintas que comparten proveedor. Un PaymentProvider con quince métodos es el God object de la lección 4 con otro nombre. Compartir el proveedor no obliga a compartir el contrato —igual que compartir el subsistema no obligaba a compartir la fachada—.

Un esbozo de referencia (frente 1, parcial)

Para que tengas una vara y no una página en blanco, aquí está la forma que puede tomar la frontera del webhook. No es la solución: falta la mitad, y las decisiones que faltan son justamente las que te tocan.

La idea es partir el endpoint en tres responsabilidades que hoy están mezcladas: verificar y traducir lo que llega, decidir si ya lo procesamos, y procesarlo.

# Archivo: payments/zafiro_webhooks.py
# Segundo archivo autorizado a importar zafiropay. La traducción, y nada más.

from dataclasses import dataclass
from enum import Enum


class ProviderEventKind(Enum):
    """Lo que a Boletia le importa de un aviso del proveedor.

    Zafiro manda los nombres en dos formatos distintos; el resto del sistema
    ve solo estos cuatro valores.
    """
    PAYMENT_SUCCEEDED = "payment_succeeded"
    PAYMENT_FAILED = "payment_failed"
    PAYMENT_STILL_PENDING = "payment_still_pending"
    CHARGEBACK_OPENED = "chargeback_opened"
    REFUND_COMPLETED = "refund_completed"


@dataclass(frozen=True)
class ProviderEvent:
    """Un aviso del proveedor, ya traducido al idioma de Boletia."""
    kind: ProviderEventKind
    transaction_id: str
    event_id: str            # ← el identificador del AVISO, no de la transacción.
    details: dict            # datos propios del tipo de evento (motivo, plazo…)


class InvalidWebhook(Exception):
    """Firma inválida, cuerpo ilegible o evento que no reconocemos."""


class ZafiroWebhookTranslator:
    """Verifica la firma y traduce el aviso. No procesa nada.

    Nota deliberada sobre el diseño: esta clase NO cumple PaymentProvider.
    Aquel contrato es de llamadas que nosotros hacemos; esto es lo contrario.
    """

    def parse(self, raw_body: bytes, signature: str | None) -> ProviderEvent:
        ...   # verificar firma (ojo con el header ausente), decodificar,
              # traducir el nombre del evento, resolver el identificador

Fíjate en el campo event_id del ProviderEvent, porque es la pieza que resuelve el incidente y que hoy no existe en ningún lado: para no procesar dos veces un aviso hace falta poder identificar el aviso, no la transacción. Si Zafiro no lo envía, tu traductor va a tener que derivar uno estable a partir del contenido —y esa es una decisión que merece estar en tu documento—.

Y del lado del endpoint, la forma queda así:

# Archivo: api/webhooks.py — ya no importa zafiropay

def post_zafiro_webhook(request):
    try:
        event = translator.parse(request.raw_body, request.headers.get("X-Zafiro-Sig"))
    except InvalidWebhook:
        return response(401, {"error": "aviso inválido"})

    # La protección contra reenvíos vive AQUÍ, en el flujo, no en una capa:
    # decide si algo le pasa o no al cliente, así que tiene que verse.
    ...

    return response(200, {"ok": True})

Lo que ese esbozo deja abierto y tienes que decidir: cómo se registra que un aviso ya se procesó y qué pasa si dos llegan a la vez; si el procesamiento ocurre dentro del endpoint o se encola; qué se hace con un tipo de evento desconocido —¿200 y un registro, o 400?—; y si el traductor pertenece a payments/ o merece su propio módulo.

El alcance: qué NO es parte del proyecto

Un encargo real siempre tiene bordes, y saber dónde están es parte del oficio. Estas cuatro cosas son tentadoras, aparecen en el camino, y no son este proyecto:

Rediseñar el modelo de datos. Vas a notar que Order.total es un float y que en dinero eso es discutible —lo señalamos en la lección 3—. Arreglarlo toca la base de datos, las migraciones y todo el cálculo de precios. Menciónalo en el documento como deuda detectada y sigue.

Reescribir el checkout. La fachada de la lección 4 ya existe y no es lo que se pidió revisar. Si tu solución del webhook necesita llamar a algo que hoy está dentro del checkout, extráelo con cuidado y dilo; no lo reorganices entero.

Migrar a otro proveedor. El encargo es aislar a Zafiro, no reemplazarlo. Si tu diseño hace que reemplazarlo sea más barato, perfecto —esa es una consecuencia, no el objetivo—.

Escribir el segundo adaptador "para validar el contrato". Es un impulso razonable: un contrato con dos implementaciones es un contrato mejor. Pero escribir una implementación falsa solo para justificar la abstracción es exactamente el argumento circular que la lección 7 desarma. Si no hay una segunda implementación real, la abstracción se justifica por las preguntas 2 y 3 o no se justifica.

Y una expectativa de esfuerzo, para calibrar: el paso 0 —clasificar los cuatro frentes y escribir el documento— debería tomarte menos de una hora. Si te toma más, probablemente estás diseñando en vez de decidir. El código viene después y es la parte fácil, porque para entonces ya sabes qué escribir.

Cómo se evalúa

Se juzga por criterio y comunicación, no por cantidad de patrones. Concretamente:

Qué se miraQué se espera
La clasificaciónLos cuatro frentes en escalones distintos, cada uno justificado con datos y no con planes
El documentoQue se pueda leer en cinco minutos y que alguien que no vio el código entienda dónde quedó cada línea
La sección "qué NO hice"Que exista y que tenga algo real: al menos una cosa razonable que decidiste no hacer, con su motivo
Los errores traducidosQue ningún error del SDK cruce la frontera, en ninguno de los cuatro frentes
La prueba de fronteraQue exista, que corra y que su lista de archivos permitidos sea corta
El hallazgo de negocioQue hayas notado y reportado los dos problemas que no son de diseño: la llave de idempotencia reutilizada y el manejo de las tarjetas

Y las señales de que algo salió mal:

  • Cuatro contratos con Protocol, cuatro factories y cuatro carpetas. Envolviste todo por si acaso, que es exactamente lo que el encargo pedía no hacer.
  • Un solo contrato gigante con charge, handle_webhook, download_settlement y save_card. Cuatro responsabilidades en una clase.
  • El documento describe lo que hiciste en vez de por qué. "Creé la clase X con el método Y" no es una justificación; "lo dejé en escalón 2 porque hay una sola implementación y el estado cabe en un parámetro" sí.
  • No aparece ningún hallazgo de negocio. Los dos están puestos a propósito y son visibles leyendo con atención.

Errores comunes

Empezar por el código y escribir el documento al final (de proceso). Qué pasa: es tentador abrir el editor, que es donde uno se siente productivo, y dejar el FRONTERAS.md para el cierre. El resultado es previsible: el documento justifica lo que ya está escrito. Y como el código se escribió sin criterio explícito, casi siempre quedó en el escalón más alto —porque escribir un contrato con su factory se siente como hacer las cosas bien—. Por qué pasa: el paso 0 no produce nada visible y se siente como demora. Cómo detectarlo: si al escribir la sección "por qué ahí" tienes que inventar el argumento, el orden fue el equivocado. Cómo corregirlo: escribe las cuatro clasificaciones primero, aunque sean tres líneas cada una. Toma quince minutos y cambia lo que escribes después. Y hay un beneficio adicional: cuando el documento va primero, la sección "qué NO hice" se llena sola, porque estás decidiendo en vez de justificando.

Resolver el incidente del webhook con un patrón (de criterio). Qué pasa: el encargo llegó por un incidente de duplicados, y la tentación es resolverlo con la herramienta que acabas de aprender —un decorador que descarte los repetidos, por ejemplo—. Pero "no procesar dos veces el mismo evento" cambia el resultado del negocio, y la prueba definitiva de la lección 5 dice que un decorador no debe hacer eso. Escondido en una capa, el día que alguien la quite en un incidente vuelven los correos duplicados y nadie va a saber por qué. Por qué pasa: la forma encaja perfectamente —envolver el procesador y filtrar antes de delegar— y la forma siempre es lo primero que se ve. Cómo detectarlo: pregúntate si quitar esa pieza cambiaría lo que le pasa a un cliente. Si la respuesta es sí, no es una capa transparente. Cómo corregirlo: la idempotencia del webhook es una regla de negocio y va donde se vea —registrar el identificador del evento y consultarlo antes de procesar, en el mismo flujo—. La frontera se ocupa de traducir el vocabulario ajeno; el negocio se ocupa de no cobrar dos veces.

Envolver el frente 4 y darlo por resuelto (de criterio). Qué pasa: alguien escribe un CardVault limpio, con tipos propios, errores traducidos y pruebas, y lo entrega satisfecho. El adaptador está bien hecho. Y el número de tarjeta y el CVV siguen pasando por el servidor de Boletia exactamente igual que antes. Peor: ahora hay una clase con nombre de bóveda que sugiere que alguien se hizo cargo del problema. Por qué pasa: un módulo entero sobre envolver dependencias entrena a resolver todo envolviendo, y este frente se puede envolver. Que se pueda no significa que sea la respuesta. Cómo detectarlo: pregunta qué riesgo concreto elimina tu frontera. Si la respuesta es "ninguno, pero queda más ordenado", el riesgo sigue ahí. Cómo corregirlo: repórtalo, con una frase clara en el documento y sin adornos —"esto transmite datos de tarjeta en claro por nuestro servidor; envolverlo no lo cambia; hay que migrar a tokenización del lado del cliente"— y, si envuelves algo, que sea para dejar el camino preparado para esa migración, no para tapar el problema. Reconocer los límites de tu propia solución es parte del entregable.

Ejercicios

Estos tres ejercicios se hacen sobre tu entrega, cuando ya la tengas. Son la verificación que harías tú mismo antes de pedir la revisión.

Ejercicio 1 — Busca los agujeros de tu frontera. Sobre tu código terminado, corre estas cuatro verificaciones y anota qué encuentras. (a) Busca zafiropay en todo el proyecto. (b) Busca los tipos de error del SDK y de su librería HTTP. (c) Revisa los tipos de retorno de cada método o función pública de tus fronteras. (d) Busca accesos por llave —["id"], .get("status")— en archivos que no sean de frontera.

Ver solución

Lo que cada verificación encuentra, y qué significa:

(a) El nombre del SDK. Debería aparecer solo en los archivos de tu lista ALLOWED y en el FRONTERAS.md. Los descuidos típicos: un import zafiropay que quedó arriba de un archivo aunque ya no se use —los editores no siempre lo quitan—, y las pruebas, que suelen quedar fuera del radar. Sobre las pruebas hay una decisión que conviene tomar a propósito: las pruebas de un adaptador sí pueden importar el SDK —para construir sus tipos de error, por ejemplo— pero ninguna otra prueba debería.

(b) Los tipos de error. Este es el agujero más frecuente y el más silencioso, porque el código funciona hasta que algo falla. Busca ZafiroError, requests.exceptions y cualquier except Exception cerca de una llamada a tus fronteras. Un except Exception amplio en el llamador suele ser la huella de que en algún momento se escapó un error ajeno y alguien lo tapó.

(c) Los tipos de retorno. Si alguna función pública de frontera devuelve dict, Any, list[dict] o no declara nada, ahí hay una fuga. En el frente 2 es especialmente fácil que se cuele: devolver las filas del CSV como diccionarios es cómodo y significa que las columnas de Zafiro —con sus dos juegos de nombres— siguen circulando por el sistema.

(d) Los accesos por llave. Es la comprobación cruzada de la anterior. Si en jobs/settlement.py hay un row["gross"], la traducción no quedó adentro. Y si en api/webhooks.py queda un payload["data"], el evento crudo se está usando fuera de la frontera.

Por qué funciona: las cuatro son búsquedas mecánicas de menos de un minuto, y encuentran cosas que revisar el código a ojo no encuentra. La primera es la que todo el mundo hace; las otras tres son las que separan una frontera de verdad de una que solo lo parece. Vale la pena dejarlas anotadas como rutina: una frontera no se verifica leyendo, se verifica buscando.

Ejercicio 2 — Defiende una decisión en tres líneas. Toma el frente donde elegiste el escalón más bajo y escribe el comentario de revisión que responderías si alguien pregunta "¿no deberíamos ponerle una interfaz, por si cambiamos de proveedor?". Tres líneas, con datos.

Ver solución

No hay una respuesta única, pero sí una forma reconocible. Un buen comentario tiene tres piezas: el dato de hoy, el costo de esperar y la puerta abierta. Así se ve aplicado al frente 2, suponiendo que lo dejaste en el escalón 2:

Hoy hay una implementación y un solo lugar que la usa, así que un Protocol describiría la API de Zafiro con otros nombres en vez de abstraerla. Lo que sí necesitábamos —poder probar el parseo del CSV sin salir a la red— ya está resuelto con el cliente inyectable, y con eso las pruebas de la coma decimal y del día vacío corren en milisegundos. Si entra un segundo proveedor, extraer el contrato con las dos APIs delante nos cuesta una tarde y va a quedar mejor que adivinarlo hoy con una.

Fíjate en lo que ese comentario no hace: no dice "es sobre-ingeniería" ni "YAGNI" a secas. Las etiquetas cierran conversaciones sin convencer a nadie, y en el módulo 7 vas a ver por qué eso importa en una revisión. Lo que hace es dar el dato —una implementación, un lugar— y ofrecer el camino de vuelta, que es lo que desactiva la preocupación real de quien pregunta: nadie teme la función, teme quedarse atrapado.

Y lo que sí hace, que es lo más importante: menciona el beneficio que sí obtuviste. Quien pregunta suele estar preocupado por las pruebas sin saber decirlo. Contestar "las pruebas ya están resueltas, y así" resuelve la preocupación de fondo aunque la pregunta haya sido sobre interfaces.

Si tu comentario usó algún verbo en futuro para justificar lo que hiciste —"por si acaso", "cuando llegue"— revísalo: ese es el argumento del otro lado, y usarlo te deja sin defensa.

Ejercicio 3 — Predice el costo del siguiente cambio. Zafiro anuncia dos cambios para su versión 3.0: los nombres de evento del webhook se unifican en minúsculas con punto, y el archivo de conciliación pasa a entregarse como JSON en vez de CSV, con los nombres de columna de producción para todos los entornos. Con tu entrega, enumera qué archivos hay que abrir para cada cambio y qué pruebas te avisarían si algo quedó mal. Compara con lo que habría costado antes del proyecto.

Ver solución

La forma de la respuesta, que es lo que importa:

Cambio 1 — nombres de evento unificados. Con tu entrega debería ser un archivo: el traductor del webhook, donde vive la tabla de nombres de evento. Y el cambio es de los cómodos, porque quitar entradas de una tabla de traducción es seguro: si dejas las viejas por unas semanas, el sistema acepta los dos vocabularios durante la transición. Las pruebas que te avisan son las del traductor contra cargas guardadas, que deberían tener un caso por cada forma de nombre.

Antes del proyecto: un archivo también —api/webhooks.py—, pero mezclado con la emisión de boletos, el envío de correos y la apertura de disputas, y sin ninguna prueba que verifique la traducción sin levantar la aplicación. El número de archivos no cambió; lo que cambió es que ahora hay algo que probar y algo que leer. Vale la pena decirlo así en la comparación, porque exagerar el beneficio es la forma más rápida de perder credibilidad en una revisión.

Cambio 2 — CSV a JSON. Este es el que muestra el valor de verdad. Si separaste obtener el archivo de entenderlo, el cambio toca la parte que entiende y la que obtiene casi no cambia. Y como el resto del sistema recibe tus tipos propios —no filas de CSV—, jobs/settlement.py y settlements.record no se enteran de que el formato cambió por completo.

Antes del proyecto: el csv.DictReader, el or encadenado de nombres de columna y los float() estaban en el mismo bucle que la lógica de conciliación, así que cambiar el formato significaba reescribir la función entera sin ninguna prueba que dijera si el resultado seguía siendo el mismo.

Y la parte del ejercicio que más enseña: si al hacer este análisis descubres que un cambio toca más archivos de los que esperabas, esa es información sobre tu frontera y todavía estás a tiempo. El caso típico: si el cambio 2 te obliga a tocar settlements.record, es que los tipos de la conciliación quedaron con forma de CSV.

Por qué funciona: este es el mismo ejercicio que hiciste en la lección 1 —predecir el costo de un cambio de versión— aplicado ahora a tu propio trabajo. Y es exactamente el argumento con el que se justifica este tipo de proyecto ante alguien que decide prioridades: no "el código quedó más limpio", sino "el cambio que Zafiro anunció para el trimestre que viene nos toca un archivo con pruebas en vez de cuatro sin ellas".

Resumen y siguiente paso

En este proyecto hiciste el trabajo completo del módulo. Tomaste cuatro frentes de una integración desprolija y les diste cuatro respuestas distintas, que es el resultado que se buscaba: uno pidió un adaptador con su propio contrato, otro una función con la dependencia inyectable, otro resultó no ser un problema de frontera sino un error de negocio escondido, y el cuarto no se arregla envolviéndolo —se reporta—.

Y entregaste el documento, que es lo que de verdad se juzga. Frente por frente: las tres preguntas contestadas con datos y no con planes, el escalón elegido, dónde quedó la línea, por qué ahí y no más arriba, y qué decidiste no hacer. Ese documento es la forma en que se defiende una decisión de diseño en un equipo real, y escribirlo antes del código es lo que hace que el código salga en el escalón correcto.

Lo que este módulo te deja, en tres frases. Adapter traduce, Facade simplifica, Decorator agrega manteniendo la interfaz, Composite trata igual a uno y a varios. Una frontera se paga con lo que traduce, no con lo que renombra, y una función también es una frontera. Y la pregunta que decide todo lo demás: ¿hay más de una implementación hoy, necesitas sustituirla en pruebas, la interfaz externa es realmente inestable? — si las tres son "no", envuelve simple y sigue.

Antes de avanzar deberías poder: clasificar una dependencia nueva en un minuto; escribir un adaptador que traduzca en los dos sentidos, incluidos los errores; reconocer una fachada que se está volviendo un God object; decidir qué operaciones son seguras de repetir; distinguir una lista disfrazada de árbol; y —lo que más va a valer en tu carrera— explicar en tres líneas por qué elegiste el escalón que elegiste.

El módulo 6 cambia de pregunta. Hasta aquí trabajamos las fronteras hacia afuera: cómo aislar lo que no controlas. Ahora vamos hacia adentro, a cómo se hablan entre sí las piezas que sí controlas. Cuando alguien compra un boleto en Boletia hay que avisarle al cliente, al organizador, al sistema de inventario y —desde el trimestre pasado— al servicio de facturación. Hoy el checkout los llama a los cuatro directamente, así que agregar un quinto interesado obliga a tocar el corazón del sistema. Observer resuelve eso: avisar sin saber a quién. Y trae el costo escondido que le da nombre a una de sus lecciones —el flujo que ya no puedes seguir con el dedo—, que es la clase de tradeoff que a estas alturas ya sabes cómo pesar.

Recursos

  • Anti-Corruption Layer (Eric Evans, Domain-Driven Design) — el término con el que vas a poder nombrar este proyecto entero en una conversación de equipo.
  • PCI Security Standards — PCI DSS — el estándar que explica por qué el frente 4 es un problema de cumplimiento y no de diseño. Basta con hojear la sección sobre almacenamiento de datos de autenticación.
  • Stripe — Best practices for webhooks — cómo se manejan bien los reenvíos, el orden y la idempotencia en los webhooks. Es la referencia contra la que conviene contrastar tu solución del frente 1.
  • Sandi Metz — The Wrong Abstraction — para releer antes de decidir el escalón de cada frente. Es el mejor argumento que existe a favor de esperar a tener dos ejemplos.