Módulo 5: Patrones para estructurar y adaptar

5. Decorator: agregar comportamiento sin tocar el original

Descripción

Al terminar esta lección vas a poder envolver un objeto para agregarle una capa —reintento, registro, caché, medición— sin modificar ni una línea de lo que envuelves, y sin que quien lo usa se entere. Vas a tener la anatomía del patrón desarmada, con énfasis en la propiedad que lo define y que ninguna otra familia tiene: entra un PaymentProvider, sale un PaymentProvider. Esa simetría es lo que permite apilarlos, y también lo que los vuelve peligrosos.

Vas a hacer el caso completo de Boletia: agregar reintento a los cuatro proveedores de pago a la vez, sin tocar ninguno. Y vas a ver la parte que casi ningún material sobre este patrón menciona, que es la que de verdad importa cuando hay dinero de por medio: reintentar un cobro sin una llave de idempotencia sólida es la forma más común de cobrarle dos veces a un cliente. El Decorator es fácil de escribir; hacerlo correcto exige entender qué operación es segura de repetir.

También vas a salir con dos cosas que evitan confusiones caras. La primera: la diferencia entre el patrón Decorator y la sintaxis @decorador de Python, que comparten nombre y no son lo mismo. Y la segunda: por qué el orden en que apilas importa, y a partir de cuántas capas el flujo se vuelve imposible de seguir con el dedo.

Conexión con el módulo: las lecciones 2 y 3 envolvieron para traducir —el SDK de Zafiro detrás de un PaymentProvider limpio—; la 4 envolvió para simplificar —los diez pasos de la compra detrás de un checkout(order)—. Esta envuelve para agregar, y es la que cobra el dividendo del trabajo anterior: solo porque existe un contrato común es posible escribir una capa que sirva para los cuatro proveedores a la vez. La lección 6 pasa a Composite, el último de la familia y el más fácil de forzar donde no corresponde. Y la lección 7 vuelve con la pregunta de siempre, que aquí tiene una respuesta especialmente incómoda: en Python, muchos decoradores son una función de seis líneas.

La funda del teléfono

Le pones una funda al teléfono. Ahora resiste caídas, tiene un borde que protege la cámara y quizá agarra mejor en la mano.

Fíjate en lo que no cambió. La pantalla sigue en el mismo lugar. Los botones siguen donde estaban y hacen lo mismo. El puerto de carga sigue accesible. Puedes usar el teléfono exactamente igual que antes: el cargador entra, el cable de audio entra, la mano hace los mismos gestos. Desde afuera, un teléfono con funda sigue siendo un teléfono.

Esa es toda la idea del patrón, y es una idea con más consecuencias de las que parece.

Primera consecuencia: nadie tuvo que rediseñar el teléfono. El fabricante no sabe que existe tu funda. Las fundas se inventaron después y funcionan con teléfonos que ya estaban fabricados. En software esto significa que puedes agregarle comportamiento a una clase que no puedes o no quieres modificar —incluida una de un tercero—.

Segunda: puedes apilar. Le pones una funda, y encima un soporte magnético, y encima un protector de pantalla. Cada capa agrega algo y todas conservan la forma general del teléfono. Esto es exclusivo de este patrón: no puedes "apilar adaptadores" ni "apilar fachadas" de forma natural, porque esos cambian la interfaz y después de uno ya no encaja el siguiente.

Y tercera, que es la advertencia: el orden importa y a partir de cierto punto ya no sabes qué capa hace qué. Si le pones el protector de pantalla debajo de la funda, la funda no cierra. Y si un día el teléfono no carga, con cuatro accesorios encima tienes que ir quitándolos uno por uno para averiguar cuál estorba. Eso, en código, es el costo real del patrón, y la sección de errores comunes vuelve sobre él.

Qué es un Decorator, en una frase

Un Decorator es un objeto que implementa la misma interfaz que otro, lo contiene, y agrega comportamiento antes o después de delegarle.

La parte que hay que subrayar es "la misma interfaz". Compáralo con los dos patrones anteriores del módulo:

EntraSale
AdapterUn ZafiroClientUn PaymentProvider (interfaz distinta)
FacadeDiez piezas del subsistemaUn CheckoutService (interfaz nueva)
DecoratorUn PaymentProviderUn PaymentProvider (la misma)

Esa igualdad es lo que hace apilable al Decorator y lo que hace que quien lo usa no se entere. El checkout sigue escribiendo provider.charge(order); no sabe ni le importa si ese provider es el de Zafiro pelado, o el de Zafiro con reintento, o el de Zafiro con reintento y registro y medición.

La anatomía tiene cuatro piezas:

1. La interfaz común. El contrato que comparten el decorado y el decorador. En Boletia es PaymentProvider, que existe desde el módulo 4 y quedó limpio en la lección 3. Sin un contrato, no hay Decorator posible: si cada proveedor tuviera métodos distintos, una capa genérica no podría envolverlos.

2. El componente. Lo que se envuelve: ZafiroProvider, StripeProvider, o incluso otro decorador. El componente no sabe que está siendo envuelto y no cambia.

3. El decorador. La clase que implementa la interfaz, guarda el componente y agrega su capa alrededor de la delegación.

4. El punto de armado. Dónde se apilan las capas. Casi siempre la factory del módulo 4, y esa es la razón por la que aquella factory se paga sola: el punto único de creación es el único lugar donde tiene sentido decidir el orden de las capas.

Aquí está la forma mínima:

# El esqueleto de cualquier decorador.

class LoggingProvider:
    """Implementa PaymentProvider, contiene un PaymentProvider, agrega registro."""

    def __init__(self, inner: PaymentProvider):
        self._inner = inner            # lo envuelto

    def charge(self, order) -> PaymentResult:
        log.info("Cobrando la orden %s", order.id)     # ← lo que agrega (antes)
        result = self._inner.charge(order)             # ← delega, sin cambiar nada
        log.info("Orden %s → %s", order.id, result.status)  # ← lo que agrega (después)
        return result                                  # ← devuelve lo mismo

Cuatro líneas de sustancia. Y nota lo que no hace: no cambia el resultado, no decide nada del negocio, no transforma la entrada. Un decorador que modifica lo que pasa por él deja de ser una capa transparente y se convierte en otra cosa —normalmente en un error difícil de encontrar—.

Ejemplo trabajado: reintento sin cobrar dos veces

Boletia tiene un problema medible. Zafiro, la pasarela regional, se cae unos segundos varias veces al día —nada dramático, pero suficiente para que algunas compras fallen con un PaymentUnavailable—. El equipo estima que se pierde alrededor del 1% de las ventas por eso. La pregunta que llega a la revisión es: "¿podemos reintentar?".

La respuesta ingenua es un for con tres vueltas dentro de ZafiroProvider.charge. La respuesta ingenua está mal por dos razones, y las dos importan.

Razón uno: reintentar un cobro es peligroso. Cuando una llamada de red falla por timeout, no sabes si el servidor la recibió. Puede que el cobro se haya hecho y la respuesta se haya perdido. Si reintentas a ciegas, cobras dos veces. Esto no es un riesgo teórico: es el incidente clásico de todo sistema de pagos.

Razón dos: si lo pones dentro de ZafiroProvider, hay que repetirlo en los otros tres. Y las cuatro copias van a divergir, igual que divergieron las tres normalizaciones de la lección 3 y los diez pasos del checkout de la lección 4. Es el mismo error, por tercera vez, y por eso este módulo insiste tanto.

Vamos a hacerlo bien.

Paso 1 — Decide qué es seguro reintentar. Esta es la parte de diseño y hay que hacerla antes de escribir nada. Con los errores propios que definiste en la lección 3, la decisión se vuelve clara:

Situación¿Reintentar?Por qué
PaymentUnavailable (red caída, timeout)Puede que el proveedor ni se haya enterado. Es lo que este decorador viene a resolver
PaymentRejected (el banco dijo que no)NoEl proveedor respondió. Reintentar da el mismo resultado y suma un rechazo al historial del cliente
PaymentMisconfigured (falta la credencial)NoEs un error nuestro. Reintentar tres veces solo retrasa el diagnóstico
Resultado DECLINEDNoNi siquiera es una excepción: es un resultado normal del negocio

Fíjate en algo que vale la pena señalar: esta tabla solo se puede escribir porque en la lección 3 separamos los tipos de error. Si el adaptador dejara escapar el ZafiroError genérico —o peor, el error de la librería HTTP— no habría forma de distinguir "no respondió" de "dijo que no", y el decorador tendría que adivinar. Cada capa de este módulo se apoya en la anterior.

Paso 2 — Asegura la idempotencia. Reintentar solo es seguro si el proveedor puede reconocer que es la misma operación. Para eso existen las llaves de idempotencia, y aquí tenemos un problema concreto que ya conoces: Zafiro ignora en silencio las llaves de más de 32 caracteres. Una llave ignorada convierte el reintento en un cobro nuevo.

En la lección 3 el adaptador ya lo resolvió recortando la llave a 32 caracteres. Pero falta algo más importante: la llave tiene que ser la misma en los tres intentos. Si cambia entre intentos, la idempotencia no sirve de nada.

# En payments/zafiro_provider.py — la llave depende de la ORDEN, no del intento.

@staticmethod
def _idempotency_key(order) -> str:
    """La misma orden produce siempre la misma llave, así que los reintentos
    del decorador llegan a Zafiro como la MISMA operación.

    Ojo con el detalle que costó caro descubrir: si aquí metiéramos algo que
    cambia entre intentos —un uuid nuevo, la hora— cada reintento sería un
    cobro distinto para el proveedor, y el decorador se volvería una máquina
    de cobrar dos veces.
    """
    return f"bol-{order.id}-{order.attempts}"[:32]

Y esto obliga a una decisión explícita: order.attempts solo sube cuando el usuario decide volver a intentar desde la pantalla, no cuando el decorador reintenta por su cuenta. Un reintento automático es el mismo intento; un reintento del usuario es uno nuevo. Esa distinción tiene que estar escrita en algún lado o alguien la va a romper sin saberlo.

Paso 3 — Escribe el decorador.

# Archivo: payments/retrying.py

import logging
import random
import time

from payments.errors import PaymentUnavailable

log = logging.getLogger(__name__)


class RetryingProvider:
    """Reintenta las operaciones de pago cuando el proveedor no responde.

    Implementa PaymentProvider y envuelve a otro PaymentProvider, así que sirve
    para los cuatro sin que ninguno se entere ni cambie.

    IMPORTANTE: solo reintenta PaymentUnavailable —el proveedor no respondió—.
    Un rechazo no se reintenta nunca: el banco ya contestó.
    """

    def __init__(self, inner, *, attempts: int = 3, base_delay: float = 0.2, sleep=time.sleep):
        self._inner = inner
        self._attempts = attempts
        self._base_delay = base_delay
        # sleep se inyecta para que las pruebas no esperen de verdad.
        self._sleep = sleep

    def charge(self, order) -> PaymentResult:
        return self._with_retries("charge", lambda: self._inner.charge(order))

    def refund(self, order, amount) -> RefundResult:
        return self._with_retries("refund", lambda: self._inner.refund(order, amount))

    def status_of(self, transaction_id) -> PaymentStatus:
        return self._with_retries("status_of", lambda: self._inner.status_of(transaction_id))

    # ── la capa ───────────────────────────────────────────────────────────

    def _with_retries(self, operation: str, call):
        last_error = None

        for attempt in range(1, self._attempts + 1):
            try:
                return call()
            except PaymentUnavailable as err:
                # Este es el ÚNICO error que se reintenta. Cualquier otro
                # —incluido PaymentRejected— sube sin tocarse, porque este
                # decorador no tiene nada que decir sobre él.
                last_error = err
                if attempt < self._attempts:
                    delay = self._backoff(attempt)
                    log.warning("%s falló (intento %d/%d), reintentando en %.2fs",
                                operation, attempt, self._attempts, delay)
                    self._sleep(delay)

        log.error("%s falló los %d intentos", operation, self._attempts)
        raise last_error

    def _backoff(self, attempt: int) -> float:
        """Espera creciente con un poco de azar.

        Crece (0.2s, 0.4s, 0.8s…) para no golpear a un proveedor que ya está
        en problemas, y lleva azar para que mil compras simultáneas no
        reintenten todas en el mismo milisegundo. Ese golpe coordinado tiene
        nombre en la industria —efecto manada— y es la forma más eficiente
        de tumbar del todo a un servicio que estaba a punto de recuperarse.
        """
        return self._base_delay * (2 ** (attempt - 1)) * (0.5 + random.random())

Paso 4 — Ármalo en la factory. Aquí es donde se decide quién envuelve a quién, y es una línea:

# Archivo: payments/factory.py

def get_payment_provider(name: str) -> PaymentProvider:
    provider = _build_bare_provider(name)      # el de siempre, sin capas
    return RetryingProvider(provider, attempts=3)

Y eso es todo. Los cuatro proveedores acaban de ganar reintento y ninguno cambió. El checkout tampoco: sigue escribiendo provider.charge(order) sin saber que ahora hay una capa en medio.

Las pruebas del decorador son cómodas justamente porque la interfaz es la misma:

# Archivo: tests/test_retrying.py

class FlakyProvider:
    """Falla las primeras `fail_times` veces y después funciona."""
    def __init__(self, fail_times):
        self.fail_times = fail_times
        self.calls = 0

    def charge(self, order):
        self.calls += 1
        if self.calls <= self.fail_times:
            raise PaymentUnavailable("caído")
        return PaymentResult(PaymentStatus.SUCCEEDED, "txn_1")


def test_recovers_after_two_failures():
    inner = FlakyProvider(fail_times=2)
    provider = RetryingProvider(inner, attempts=3, sleep=lambda s: None)

    result = provider.charge(make_order())

    assert result.status is PaymentStatus.SUCCEEDED
    assert inner.calls == 3


def test_a_rejection_is_not_retried():
    """La regla que evita sumarle rechazos al historial del cliente."""
    inner = AlwaysRejects()
    provider = RetryingProvider(inner, attempts=3, sleep=lambda s: None)

    with pytest.raises(PaymentRejected):
        provider.charge(make_order())

    assert inner.calls == 1      # ← una sola vez, no tres

Qué esperar de este refactor. Tres cosas concretas y una que hay que mirar de cerca.

Lo concreto: el reintento existe una vez, sirve para los cuatro proveedores, y el día que Boletia integre el quinto lo hereda gratis. Ninguno de los proveedores fue modificado, así que sus pruebas siguen valiendo tal cual. Y el comportamiento se puede desactivar en un lugar —quitando una línea de la factory— cosa que en un incidente vale mucho.

Lo que hay que mirar de cerca: el decorador es genérico y la política no lo es. Reintentar tres veces con esperas crecientes está bien para cobrar; para el status_of del job de conciliación quizá quieras diez intentos con esperas largas, porque nadie está esperando del otro lado. Y para el reembolso desde el panel de administración quizá quieras uno solo, porque hay un operador mirando la pantalla y prefiere un error rápido a treinta segundos de espera. Ese ajuste se hace en el punto de armado, no en el decorador:

# Distintas políticas para distintos usos, mismo decorador.

def provider_for_checkout(name):      # alguien espera: rápido y pocos intentos
    return RetryingProvider(_build_bare_provider(name), attempts=3, base_delay=0.2)

def provider_for_reconciliation(name):  # nadie espera: paciente
    return RetryingProvider(_build_bare_provider(name), attempts=10, base_delay=2.0)

Y una observación honesta sobre el refund. Lo estamos reintentando igual que el cobro, y eso merece pensarse: si el reembolso no es idempotente del lado del proveedor, un reintento podría devolver el dinero dos veces. En Zafiro, refund_payment no acepta llave de idempotencia. Eso significa que reintentar reembolsos con este decorador es riesgoso, y la solución honesta es que el decorador sepa qué operaciones son seguras:

    def refund(self, order, amount) -> RefundResult:
        # El SDK no acepta llave de idempotencia en el reembolso, así que un
        # reintento podría devolver el dinero dos veces. No lo reintentamos.
        return self._inner.refund(order, amount)

Prefiero mostrarlo así —con la primera versión y su corrección— porque es exactamente como ocurre en la vida real: escribes el decorador genérico, funciona, y en la revisión alguien pregunta "¿y el reembolso es idempotente?". Un decorador que agrega reintento no es una decisión técnica; es una decisión sobre qué operaciones se pueden repetir, y esa pregunta hay que hacérsela método por método.

Cómo se apilan y por qué el orden importa

La propiedad que define al Decorator —misma interfaz de entrada y de salida— permite ponerlos uno dentro de otro. Agreguemos dos capas más:

# Archivo: payments/observability.py

class LoggingProvider:
    """Deja registro de cada llamada al proveedor. No cambia nada más."""

    def __init__(self, inner, name: str):
        self._inner = inner
        self._name = name

    def charge(self, order):
        log.info("[%s] cobrando orden %s por %s", self._name, order.id, order.total)
        try:
            result = self._inner.charge(order)
        except Exception as err:
            log.error("[%s] orden %s falló: %s", self._name, order.id, err)
            raise                      # ← re-lanza SIEMPRE. Un decorador no traga errores.
        log.info("[%s] orden %s → %s", self._name, order.id, result.status)
        return result

    # refund y status_of siguen el mismo molde.


class TimingProvider:
    """Mide cuánto tarda cada llamada y lo manda a las métricas."""

    def __init__(self, inner, metrics, name: str):
        self._inner = inner
        self._metrics = metrics
        self._name = name

    def charge(self, order):
        started = time.monotonic()
        try:
            return self._inner.charge(order)
        finally:
            # En finally, para medir también las llamadas que fallan: si solo
            # midieras las exitosas, un proveedor caído se vería rapidísimo.
            self._metrics.timing(f"payments.{self._name}.charge",
                                 time.monotonic() - started)

Ahora se apilan en la factory:

def get_payment_provider(name: str) -> PaymentProvider:
    provider = _build_bare_provider(name)
    provider = LoggingProvider(provider, name=name)      # ← la más interna
    provider = TimingProvider(provider, metrics, name=name)
    provider = RetryingProvider(provider, attempts=3)    # ← la más externa
    return provider

Se lee de adentro hacia afuera: la llamada entra por RetryingProvider, baja a TimingProvider, después a LoggingProvider, y al final llega al proveedor real. La respuesta sube por el mismo camino en sentido inverso.

Y ese orden cambia lo que mides. Es el punto de la sección:

  • Reintento por fuera de la medición (el de arriba): cada intento se mide por separado. Ves tres mediciones de 5 segundos cada una y sabes que el proveedor está lento. También ves tres entradas en el registro, una por intento, que es lo que quieres cuando estás diagnosticando.
  • Reintento por dentro de la medición: mides una sola vez, y la medición incluye los reintentos y las esperas. Ves un cobro de 15 segundos y no sabes si el proveedor tardó eso o si hubo tres intentos con esperas.

Ninguna de las dos es incorrecta: miden cosas distintas. La de arriba mide la salud del proveedor; la otra mide lo que sufre el usuario. Muchos equipos maduros ponen las dos, con nombres de métrica distintos. Lo que sí es un error es apilarlas sin haber pensado cuál querías, y después mirar un panel que no dice lo que crees.

Hay una regla práctica que ordena el 90% de los casos: las capas que reintentan van por fuera; las que observan, en medio; las que transforman, lo más adentro posible. Y una que casi siempre se cumple: la caché va por fuera de todo, porque si un resultado está en caché no tiene sentido registrar, medir ni reintentar nada.

Cuándo dejar de apilar

Ahora la advertencia, que es real y no retórica.

Con tres capas, alguien que lea provider.charge(order) en el checkout y quiera saber qué pasa tiene que abrir cuatro archivos y reconstruir el orden en su cabeza. Con seis, ya no lo hace: prueba cosas hasta que funciona. Y en un incidente a las tres de la mañana, esa diferencia es todo.

Las señales de que te pasaste:

Cuando el rastro de una excepción tiene más líneas de decoradores que de código real. Es el síntoma más visible y el más molesto: quince marcos de pila de los cuales dos son tu lógica.

Cuando ya no puedes decir de memoria el orden de las capas. Si necesitas abrir la factory para contestar "¿el reintento está antes o después del registro?", el sistema tiene más capas de las que tu equipo puede sostener.

Cuando dos capas interactúan de formas que nadie previó. El caso clásico: una capa de caché por dentro de una de reintento. El primer intento falla, se reintenta, y la caché devuelve el error guardado del primer intento. El reintento no reintenta nada y nadie entiende por qué.

Cuando agregas una capa para arreglar el efecto de otra. Si aparece un SkipRetryForRefunds que envuelve al RetryingProvider, el problema no era la falta de una capa: era que RetryingProvider estaba mal diseñado.

La regla de bolsillo: tres capas es cómodo, cinco es el límite, más de cinco necesita justificación por escrito. Y si estás cerca del límite, vale la pena preguntarse si dos de esas capas no deberían ser una sola —registrar y medir suelen ir juntas y casi nunca se usan por separado—.

El patrón Decorator y el @decorador de Python no son lo mismo

Esta confusión aparece en toda revisión de código en Python y conviene resolverla de una vez.

# Esto es la SINTAXIS de decoradores de Python: una función que envuelve
# a otra FUNCIÓN, aplicada con @ en el momento de definirla.

def with_retries(fn):
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        for attempt in range(3):
            try:
                return fn(*args, **kwargs)
            except PaymentUnavailable:
                if attempt == 2:
                    raise
                time.sleep(0.2 * 2 ** attempt)
    return wrapper


class ZafiroProvider:
    @with_retries                       # ← se decide AQUÍ, al escribir la clase
    def charge(self, order): ...

Comparten la idea de envolver, y ahí termina el parecido. Las diferencias que importan:

El patrón envuelve objetos; la sintaxis envuelve funciones. El patrón te deja envolver cualquier PaymentProvider, incluidos los que no escribiste tú. La sintaxis solo se aplica a funciones cuyo código puedes editar.

El patrón decide en tiempo de ejecución; la sintaxis, al definir. Con el patrón puedes armar el proveedor de la conciliación con diez reintentos y el del checkout con tres, desde la misma clase. Con la sintaxis, la política queda fija en el código del método y es la misma para todos.

El patrón se puede probar y sustituir; la sintaxis no tanto. Un RetryingProvider se prueba solo, con un componente falso. Un @with_retries pegado a un método solo se prueba llamando al método, y quitarlo en una prueba exige trucos.

¿Cuándo usar cada uno? La sintaxis de Python es excelente para cosas transversales y sin política: registrar el tiempo de una función, marcar un endpoint, memorizar un cálculo puro. El patrón es lo que quieres cuando la capa tiene configuración, cuando debe aplicarse a varias implementaciones de un contrato, o cuando quieres poder quitarla o cambiarla sin tocar el código decorado. En Boletia, el reintento cumple las tres.

Y una nota de honestidad, que enlaza con la lección 7: si tu capa no tiene configuración, se aplica a una sola implementación y nunca vas a quitarla, la sintaxis de Python es más corta y probablemente mejor. No todo lo que se envuelve merece una clase.

Errores comunes

Que el decorador se trague los errores (de implementación). Qué pasa: alguien escribe una capa de registro y, ya que está poniendo un try/except, decide que si algo falla lo registra y devuelve None. El sistema deja de fallar —lo cual se siente como una mejora— y empieza a comportarse raro: el checkout recibe None donde esperaba un PaymentResult, la orden se queda en un estado imposible y el error real quedó enterrado en una línea de log que nadie mira. Por qué pasa: el try/except está ahí por otra razón —registrar el fallo— y agregar un return None en vez de un raise parece defensivo. Cómo detectarlo: busca except sin raise en tus decoradores. Un decorador que no reintenta ni transforma debe re-lanzar siempre. Cómo corregirlo: raise al final del except, o mejor, usa finally cuando solo necesitas hacer algo pase lo que pase —como en TimingProvider—. Y si de verdad quieres que un fallo no tumbe la operación —"si el índice de búsqueda no responde, publica igual"—, esa es una decisión de negocio y va en la fachada, donde se ve, no escondida en una capa transparente.

Reintentar operaciones que no son seguras de repetir (de criterio). Qué pasa: alguien escribe RetryingProvider genérico y lo aplica a todos los métodos por simetría. Funciona en las pruebas, porque los proveedores falsos no cobran de verdad. En producción, un timeout durante un reembolso hace que el cliente reciba el dinero dos veces. Por qué pasa: el decorador es genérico por diseño —esa es su gracia— y la seguridad de repetir una operación no es genérica: depende de cada operación y de cada proveedor. La simetría del código esconde una asimetría del mundo. Cómo detectarlo: para cada método que reintentas, pregunta si el proveedor puede reconocer que es la misma operación. Si no hay llave de idempotencia, o si la hay pero cambia entre intentos, no es seguro. Cómo corregirlo: reintenta solo lo que sea seguro y deja los demás métodos delegando directo, con un comentario que diga por qué. Y donde el proveedor no ofrezca idempotencia, considera que el reintento lo decida una persona con la información delante, no un for.

Poner reglas de negocio en un decorador (de criterio). Qué pasa: la capa empieza siendo transparente y termina decidiendo. Primero es "si el monto es menor a 50 pesos, no reintentar". Después es "si el cliente es VIP, usar el otro proveedor". Después es "si el evento es de cortesía, no cobrar". El resultado es una regla de negocio invisible: no está en el checkout, no está en el proveedor, está en una capa que alguien apiló en la factory y que la mayoría del equipo no sabe que existe. Por qué pasa: el decorador ve pasar todas las llamadas, así que es un lugar tentador para meter cualquier cosa que dependa de "todas las llamadas". Y como no hay que modificar nada para agregarlo, la barrera es bajísima. Cómo detectarlo: si tu decorador lee campos del dominio —order.total, customer.tier, event.kind— para decidir algo distinto de si repetir la llamada, ya no es transparente. Cómo corregirlo: sube la regla a donde se pueda ver. Si "los VIP van por otro proveedor" es cierto, eso es una decisión de la factory o de una función choose_provider, y debe estar donde alguien la encuentre leyendo el flujo. La prueba definitiva: quitar un decorador no debería cambiar el resultado del negocio, solo su rendimiento, su registro o su resistencia a fallos.

Ejercicios

Ejercicio 1 — ¿Decorator o no? Para cada caso, di si corresponde un Decorator y justifica con la propiedad que lo define —misma interfaz de entrada y de salida—.

(a) Boletia quiere guardar en caché el resultado de status_of(transaction_id) durante 30 segundos, porque el job de conciliación consulta la misma transacción varias veces seguidas. (b) El equipo quiere que, si el proveedor principal falla, se intente con uno de respaldo. (c) Boletia quiere que todas las llamadas al proveedor queden registradas en una tabla de auditoría con el usuario que las originó. (d) El panel de administración necesita una vista que muestre, de un tirón, la orden, sus boletos, el cliente y el historial de pagos.

Ver solución

(a) Sí, y es el caso de libro. Entra un PaymentProvider, sale un PaymentProvider, el resultado es el mismo y solo cambia de dónde salió. Dos cuidados: cachear status_of está bien y cachear charge jamás —devolver un cobro guardado significa no cobrar—, así que la capa debe cachear solo el método que corresponde. Y hay que decidir qué pasa con los errores: cachear un fallo es el bug clásico que mencionamos al hablar del orden de las capas.

(b) No, o al menos no exactamente. La forma se parece —envuelves un proveedor y la interfaz no cambia—, pero fíjate en la intención: esto decide con qué proveedor cobrar, que es una decisión de negocio con consecuencias reales —comisiones distintas, países distintos, la posibilidad de un cobro doble si el primero sí pasó y no te enteraste—. Escondida en una capa que alguien apiló en la factory, esa decisión es invisible. Este patrón tiene su propio nombre —fallback, y su primo el circuit breaker— y merece vivir donde se vea. Si aun así lo escribes como decorador, que sea una decisión consciente y documentada, no un efecto de que la forma encajaba.

(c) Sí. Es idéntico a LoggingProvider, con la salvedad de que necesita saber quién originó la llamada, y eso no está en charge(order). Dos salidas: pasarlo por el constructor —un decorador por petición, que es válido si lo armas en el punto de entrada— o leerlo de un contexto de ejecución. La primera es más explícita; la segunda, más cómoda y más fácil de olvidar.

(d) No: eso es un Facade. No hay una interfaz que conservar ni comportamiento que agregar; hay cuatro consultas que se convierten en una. Cantidad, no forma ni capa.

Por qué funciona: (b) es el que separa a quien entendió el patrón de quien reconoce su forma. La forma encaja perfectamente y la intención no, y este módulo entero insiste en que la intención es lo que define un patrón. Un decorador debe poder quitarse sin cambiar el resultado del negocio; un fallback cambia con quién cobras, que es lo más de negocio que hay.

Ejercicio 2 — Escribe la capa de caché. Escribe CachingProvider, que guarde el resultado de status_of(transaction_id) durante un tiempo configurable. Presta atención a cuatro decisiones: qué métodos cachear, qué hacer con los errores, qué hacer con los estados que aún pueden cambiar, y dónde ponerlo en la pila.

Ver solución
# Archivo: payments/caching.py

class CachingProvider:
    """Guarda el estado de una transacción por unos segundos.

    Solo cachea status_of. charge y refund pasan derecho SIEMPRE: cachear un
    cobro significaría no cobrar, y cachear un reembolso, no devolver.
    """

    def __init__(self, inner, *, ttl_seconds: float = 30.0, clock=time.monotonic):
        self._inner = inner
        self._ttl = ttl_seconds
        self._clock = clock            # inyectable para poder probar el vencimiento
        self._cache: dict[str, tuple[float, PaymentStatus]] = {}

    def charge(self, order):
        return self._inner.charge(order)          # nunca se cachea

    def refund(self, order, amount):
        return self._inner.refund(order, amount)  # nunca se cachea

    def status_of(self, transaction_id) -> PaymentStatus:
        now = self._clock()
        cached = self._cache.get(transaction_id)
        if cached is not None and now - cached[0] < self._ttl:
            return cached[1]

        # Si esto lanza, el error sube sin guardarse: NO cacheamos fallos.
        # Guardar un error significa devolver el mismo error durante 30
        # segundos aunque el proveedor ya se haya recuperado.
        status = self._inner.status_of(transaction_id)

        # Un estado FINAL no cambia nunca más, así que se puede guardar sin
        # riesgo. Uno PENDING sí puede cambiar en cualquier momento, y devolver
        # "pendiente" durante 30 segundos retrasa la conciliación de una compra
        # que ya se aprobó. Por eso el pendiente no se guarda.
        if status is not PaymentStatus.PENDING:
            self._cache[transaction_id] = (now, status)

        return status

Las cuatro decisiones:

Qué cachear. Solo status_of. Es la decisión más importante y la que separa una solución correcta de una catástrofe: un decorador genérico que cacheara "las llamadas" convertiría el segundo cobro de la misma orden en un no-cobro silencioso. La simetría del código no debe imponerse sobre la asimetría del mundo, que es la misma lección del reintento.

Los errores no se guardan. Guardar un fallo significa seguir fallando durante todo el tiempo de vida de la caché aunque el proveedor ya se haya recuperado. Y si además hay un RetryingProvider por dentro, los reintentos ni siquiera se ejecutan: la caché contesta antes. Ese es el ejemplo concreto de "dos capas que interactúan de formas que nadie previó".

Los estados que aún pueden cambiar tampoco. PENDING es, por definición, provisional. Cachearlo es cachear "todavía no sé", que es lo único que no sirve de nada guardar. Los finales —SUCCEEDED, DECLINED— no cambian, así que ahí la caché es gratis y correcta.

Dónde ponerlo. Por fuera de todo:

provider = _build_bare_provider(name)
provider = LoggingProvider(provider, name=name)
provider = TimingProvider(provider, metrics, name=name)
provider = RetryingProvider(provider, attempts=3)
provider = CachingProvider(provider, ttl_seconds=30)   # ← la más externa

Si un resultado está en caché, no tiene sentido registrar la llamada, ni medirla, ni reintentarla: la llamada no ocurrió. Poner la caché adentro haría que cada acierto de caché apareciera como una llamada real en las métricas, y tu panel diría que el proveedor responde en microsegundos.

Y el detalle que casi nadie pone y siempre se agradece: el clock inyectado. Sin él, probar que la entrada vence a los 30 segundos exige esperar 30 segundos. Con él, la prueba avanza el reloj falso y termina en un milisegundo.

Ejercicio 3 — Encuentra los tres problemas. Este decorador llegó a una revisión de código. Encuentra al menos tres problemas y explica la consecuencia de cada uno.

class SmartPaymentProvider:
    def __init__(self, inner, backup, metrics):
        self.inner = inner
        self.backup = backup
        self.metrics = metrics

    def charge(self, order):
        if order.total > 10000:
            self.metrics.increment("big_sale")
            return self.backup.charge(order)     # los montos grandes van al otro
        try:
            return self.inner.charge(order)
        except Exception as e:
            log.error("Falló el cobro: %s", e)
            return self.backup.charge(order)

    def refund(self, order, amount):
        return self.inner.refund(order, amount)
Ver solución

Problema 1: tiene una regla de negocio escondida. El if order.total > 10000 decide con qué proveedor cobrar según el monto. Eso es una política comercial —seguramente por comisiones o por límites del proveedor— y está enterrada en una capa que se apila en la factory. Consecuencia: nadie que lea el checkout puede saber que las compras grandes van por otro proveedor. Peor: el reembolso no aplica la misma regla, así que un cobro grande se hizo con el proveedor de respaldo y su reembolso se intenta con el principal, que no conoce esa transacción. Ese es un error de dinero, no de estilo. Corrección: la elección del proveedor va en una función explícita —choose_provider(order)— visible en el flujo, y el reembolso debe usar el proveedor con el que se cobró, que es un dato de la orden.

Problema 2: atrapa Exception y hace un cobro nuevo. El except Exception no distingue entre "no respondió" y "el banco lo rechazó". Consecuencia: un rechazo por fondos insuficientes provoca un segundo intento con el proveedor de respaldo, sumándole otro rechazo al historial del cliente. Y peor: si el error fue un timeout, el cobro pudo haberse hecho en el proveedor principal, así que cobrar con el de respaldo cobra dos veces, y encima con dos proveedores distintos, que es el escenario más difícil de reconciliar que existe. Corrección: atrapar solo PaymentUnavailable y, aun así, no cobrar con otro proveedor sin haber verificado el estado de la primera transacción.

Problema 3: el nombre y la responsabilidad. SmartPaymentProvider hace tres cosas —enrutar por monto, hacer fallback y registrar una métrica— y la palabra "smart" es la señal de que quien lo escribió no encontró un nombre para lo que hace. Consecuencia práctica: no se puede quitar ni una de las tres sin tocar las otras dos, y no se puede reutilizar ninguna. Corrección: la métrica es un TimingProvider/LoggingProvider, el enrutamiento por monto es de la factory, y el fallback —si de verdad se quiere— es su propia capa, escrita a conciencia y con verificación de estado.

Y un cuarto, si lo viste: refund no tiene ninguna de las capas. La asimetría no está comentada, así que no se sabe si fue una decisión o un olvido. En un decorador, los métodos que delegan sin agregar nada merecen un comentario que diga por qué, porque si no, el siguiente que llegue va a "arreglar la inconsistencia" y va a romper algo.

Por qué funciona: este código se escribe en veinte minutos, funciona en las pruebas y produce incidentes de dinero en producción. El punto del ejercicio no es que esté mal escrito —está razonablemente escrito— sino que tiene la forma de un decorador sin la propiedad que define a un decorador: quitarlo cambiaría el resultado del negocio, no solo su resistencia a fallos. Esa prueba de una línea es la que conviene aplicar a cualquier capa que revises.

Resumen y siguiente paso

En esta lección definiste Decorator: un objeto que implementa la misma interfaz que otro, lo contiene, y agrega comportamiento antes o después de delegarle. Viste que la propiedad que lo define —entra un PaymentProvider, sale un PaymentProvider— es lo que lo hace apilable y lo que hace que quien lo usa no se entere, y que sin un contrato común el patrón es imposible: es el dividendo del trabajo de las lecciones 2 y 3.

Escribiste RetryingProvider y con una línea en la factory los cuatro proveedores de Boletia ganaron reintento sin que ninguno cambiara. Pero antes de escribirlo hiciste el trabajo que de verdad importa: decidir qué es seguro reintentar. Solo PaymentUnavailable, nunca un rechazo, nunca un error de configuración; con una llave de idempotencia estable entre intentos; y con la conclusión incómoda de que el reembolso de Zafiro no la acepta, así que ese método no se reintenta. Un decorador de reintento no es una decisión técnica, es una decisión sobre qué operaciones se pueden repetir.

Viste cómo se apilan y por qué el orden cambia lo que mides —el reintento por fuera mide la salud del proveedor, por dentro mide lo que sufre el usuario—, la regla práctica de que reintentar va por fuera, observar en medio, transformar adentro y la caché por encima de todo. Y viste las señales de que apilaste de más: rastros de pila con más decoradores que código, no poder decir el orden de memoria, capas que interactúan de formas imprevistas, y capas nuevas que existen para corregir a otras.

Aclaraste, por último, que el patrón Decorator y la sintaxis @decorador de Python no son lo mismo: el patrón envuelve objetos, decide en tiempo de ejecución, se prueba y se sustituye; la sintaxis envuelve funciones y fija la política al definirlas. Y quedó dicho lo que la lección 7 va a desarrollar: si tu capa no tiene configuración, se aplica a una sola implementación y nunca vas a quitarla, la sintaxis de Python es más corta y probablemente mejor.

Antes de avanzar deberías poder: escribir un decorador que re-lance siempre y no modifique el resultado; decidir qué métodos son seguros de reintentar o cachear y por qué; explicar cómo cambia una métrica según el orden de las capas; y aplicar la prueba definitiva —quitar un decorador no debería cambiar el resultado del negocio—.

La lección 6 cierra el catálogo del módulo con Composite, que es el raro de la familia. Los tres anteriores resolvían el contacto con lo externo o con lo complicado; este resuelve un problema de forma de los datos: cuando algo puede ser una cosa o un grupo de cosas y quieres tratarlas igual. En Boletia aparece con los descuentos, que pueden ser uno solo o una combinación anidada. Es un patrón elegante, con un caso legítimo claro, y es también el que más se fuerza donde no hay árbol —así que la mitad de la lección va a ser sobre cuándo no usarlo—.

Recursos