Módulo 4: Patrones para crear objetos
3. Los proveedores de pago de Boletia como Factory
Descripción
Al terminar esta lección vas a haber hecho el refactor completo: los cuatro archivos de Boletia que saben qué proveedores de pago existen se van a convertir en uno, y vas a poder medir la mejora en una unidad que se puede defender en una revisión —archivos que hay que tocar para agregar un proveedor—. Vas a ver el refactor en pasos pequeños y seguros, cada uno con el sistema funcionando, porque así es como se hace en un sistema que está en producción y no puede apagarse. Y vas a ver la parte que los libros separan artificialmente: cómo la Factory de este módulo se combina con la Strategy del módulo 3, porque en código real los patrones nunca llegan de a uno.
Esto importa porque un caso trabajado completo enseña algo que ninguna explicación abstracta enseña: el orden de los pasos. Casi todo el mundo entiende el diagrama de la Factory a la primera; casi nadie sabe por dónde empezar cuando tiene enfrente cuatro archivos en producción y un encargo que dice "agrega PayPal". El orden importa porque cada paso tiene que dejar el sistema funcionando —si el refactor solo compila al final, no es un refactor, es una reescritura con esperanza—. Y al terminar vas a tener un segundo resultado más valioso que el código: la lista honesta de lo que este refactor no resolvió, que es lo que te va a llevar al módulo 5.
Conexión con el módulo: la lección 2 te dio el patrón en abstracto, con ejemplos cortos y limpios. Esta lo aplica al caso sucio, que es donde se aprende de verdad: hay cuatro copias del conocimiento, escritas de cuatro formas distintas, en archivos que nadie recuerda. Al terminar, la primera de las tres preguntas del módulo —qué construir— queda resuelta para Boletia. Pero fíjate en lo que va a quedar abierto: el charge_order seguirá construyendo su propio proveedor por dentro, así que seguirá siendo imposible de probar sin salir a internet. Ese es el tercer problema —quién construye— y lo resuelve la inyección de dependencias de la lección 6, que va a retomar exactamente este código.
Antes de tocar nada: mira el mapa completo
Hay una regla del módulo 8 que conviene adelantar porque aquí ya la vas a necesitar: lee antes de tocar. En un refactor de creación dispersa, el error más caro no es escribir mal la factory —eso se arregla en diez minutos—: es no encontrar todos los lugares. Un lugar olvidado significa que el sistema queda con dos fuentes de la verdad, que es peor que tener una sola mala.
Así que empecemos por el inventario. Estos son los cuatro lugares de Boletia, con lo que cada uno sabe:
| Archivo | Qué hace | Qué sabe de más |
|---|---|---|
checkout/checkout.py | Cobra la orden | Los tres proveedores, sus clases, sus credenciales, sus unidades (centavos vs. float) |
api/routes.py | Recibe la petición y valida | La lista de nombres válidos y qué datos extra exige cada proveedor |
admin/refunds.py | Devuelve dinero | Los tres proveedores otra vez, con sus métodos de reembolso |
reports/reconciliation.py | Concilia de madrugada | Los tres proveedores otra vez, con sus métodos de consulta |
¿Cómo se encuentra un inventario así en un sistema que no conoces? No con una sola búsqueda, porque el conocimiento está escrito de formas distintas. Estas cuatro búsquedas, corridas en orden, encuentran casi siempre todo:
# 1. El nombre de cada opción, tal como aparece en los datos.
# Es la búsqueda más productiva y casi siempre la que se olvida.
grep -rn '"stripe"\|"mercadopago"\|"cash"' --include="*.py" .
# 2. Las clases concretas: quién las instancia.
grep -rn 'StripeClient\|MercadoPagoClient' --include="*.py" .
# 3. Las credenciales: quién las lee.
# Este suele destapar lugares que las otras dos no vieron.
grep -rn 'STRIPE_KEY\|MP_TOKEN' --include="*.py" .
# 4. El campo que actúa de llave.
grep -rn '\.provider' --include="*.py" .
Corre esas cuatro antes de escribir una línea. La tercera —buscar por las credenciales— es la que más sorpresas da, porque los lugares que leen una clave de API casi siempre están construyendo un cliente, y a veces están en scripts, tareas programadas o comandos de administración que ninguna búsqueda por "pago" encuentra.
Y una regla más: anota lo que encuentres antes de cambiar nada. Un refactor que empieza a la mitad del inventario termina con la mitad del sistema en el mundo nuevo y la otra mitad en el viejo, que es el peor de los tres estados posibles.
Ejemplo trabajado: el refactor, en seis pasos que dejan el sistema funcionando
Vamos a hacerlo. Cada paso deja Boletia corriendo y las pruebas en verde, así que puedes parar en cualquiera de ellos, irte a tu casa y seguir mañana. Eso no es un detalle de comodidad: es lo que hace que un refactor sea seguro en un sistema con usuarios.
Paso 1 — Escribe el contrato
Antes de concentrar la decisión, todos los proveedores tienen que verse iguales desde afuera. Hoy no lo son: uno cobra en centavos enteros, otro en float con descripción, el tercero no cobra sino que genera una referencia. Ese es el trabajo del contrato: decidir cómo se ven desde afuera, y dejar las diferencias adentro de cada uno.
# Archivo: payments/provider.py
from typing import Protocol
from dataclasses import dataclass
@dataclass(frozen=True)
class PaymentResult:
"""Lo que devuelve cualquier intento de cobro, sin importar el proveedor.
Es inmutable (frozen) a propósito: un resultado de pago es un hecho ocurrido,
no algo que alguien deba poder modificar después.
"""
status: str # "succeeded" | "pending" | "failed"
external_id: str | None = None # el id del cobro en el sistema del proveedor
reference: str | None = None # solo para efectivo: la referencia de pago en tienda
error_message: str | None = None
class PaymentProvider(Protocol):
"""El contrato común. Todo proveedor de pago de Boletia sabe hacer estas tres cosas."""
def charge(self, order) -> PaymentResult:
"""Cobra el total de la orden. Puede quedar 'pending' si el pago es diferido."""
...
def refund(self, order, amount: float) -> PaymentResult:
"""Devuelve dinero. Algunos proveedores lo hacen por API; otros lo agendan."""
...
def daily_charges(self, day) -> list[dict]:
"""Los cobros de un día, para la conciliación nocturna."""
...
Tres decisiones de este contrato que vale la pena que veas, porque son las que hacen que funcione con los tres proveedores y no solo con dos:
PaymentResult en vez de "lo que devuelva cada API". Antes, charge_order devolvía directamente lo que contestara Stripe —un objeto de su librería—, o un diccionario armado a mano en el caso del efectivo. Quien recibía eso tenía que saber cuál le tocó. Con un tipo propio de Boletia, el que llama recibe siempre lo mismo. Esto es más importante de lo que parece: un contrato no es solo la firma de los métodos, es también la forma de lo que devuelven.
status = "pending" existe por el efectivo. Este es el punto donde el contrato se ganó su lugar. Si hubiéramos diseñado mirando solo Stripe y MercadoPago, el resultado habría sido un booleano —cobró o no cobró— y el efectivo no habría cabido. En el módulo 2 viste que abstraer con dos casos te encierra en el eje equivocado; aquí lo estás viviendo. Con tres implementaciones a la vista, el contrato sale mejor.
refund existe en los tres, aunque el efectivo no reembolse por API. Podría haber sido tentador dejar refund fuera del contrato y que admin/refunds.py preguntara if provider == "cash". Pero eso sería devolver la decisión al lugar de donde la queremos sacar. La solución honesta es que CashProvider.refund() haga lo que de verdad hace: agendar una devolución manual y devolver status="pending".
Este paso no rompe nada porque todavía no cambiaste ninguna llamada. Solo escribiste un archivo nuevo.
Paso 2 — Mueve cada rama a su clase
Ahora cada rama del if se convierte en una clase que cumple el contrato. Nota que esto es exactamente el mismo movimiento que hiciste en el módulo 3 con las reglas de precio: una rama de condicional se convierte en una implementación. Aquí van dos de las tres, para que veas la diferencia de forma.
# Archivo: payments/stripe_provider.py
class StripeProvider:
"""Cobra con Stripe. Todo lo raro de Stripe queda encerrado aquí."""
def __init__(self, api_key: str):
self._client = StripeClient(api_key=api_key)
def charge(self, order) -> PaymentResult:
# Stripe trabaja en centavos y como entero. La conversión vive aquí,
# que es el único lugar del sistema que tiene por qué saberlo.
charge = self._client.create_charge(
amount=int(round(order.total * 100)),
currency="MXN",
)
return PaymentResult(status="succeeded", external_id=charge["id"])
def refund(self, order, amount: float) -> PaymentResult:
refund = self._client.create_refund(
charge_id=order.external_id,
amount=int(round(amount * 100)),
)
return PaymentResult(status="succeeded", external_id=refund["id"])
def daily_charges(self, day) -> list[dict]:
return self._client.list_charges(created_after=day)
# Archivo: payments/cash_provider.py
class CashProvider:
"""Pago en efectivo en tienda. No cobra: genera una referencia y espera.
Es el proveedor que más se aleja de los otros dos, y por eso es el que
prueba si el contrato está bien diseñado.
"""
def __init__(self, store_chain: str, report_bucket: str):
self._store_chain = store_chain
self._report_bucket = report_bucket
def charge(self, order) -> PaymentResult:
# Aquí no entra dinero todavía. Se genera una referencia que el cliente
# lleva a la tienda; el pago se confirma después, cuando la cadena
# nos avisa. Por eso el estado es "pending" y no "succeeded".
reference = generate_cash_reference(order.id, chain=self._store_chain)
return PaymentResult(status="pending", reference=reference)
def refund(self, order, amount: float) -> PaymentResult:
# No hay API que devuelva efectivo. Se agenda para que alguien del equipo
# lo ejecute en ventanilla, y se devuelve "pending" con honestidad.
task_id = schedule_manual_refund(order.id, amount)
return PaymentResult(status="pending", external_id=task_id)
def daily_charges(self, day) -> list[dict]:
# La cadena de tiendas nos deja un CSV cada madrugada. No hay API.
return read_cash_report_csv(self._report_bucket, day)
Mira CashProvider con atención, porque es donde este refactor se gana el sueldo. Ese proveedor no se parece en nada a los otros dos por dentro: no habla con una API, lee un CSV de un bucket, y su reembolso es una tarea humana. Y sin embargo, desde afuera, se usa idéntico a Stripe. Todo lo raro quedó encerrado en un archivo, en vez de estar salpicado en tres ramas de tres condicionales distintos.
Esto también sigue sin romper nada: los cuatro archivos originales todavía tienen sus if y siguen funcionando. Solo agregaste código nuevo que nadie llama todavía. Un refactor seguro construye primero el camino nuevo, y recién después mueve el tráfico.
Paso 3 — Escribe la factory
# Archivo: payments/factory.py
class UnknownProviderError(ValueError):
"""El proveedor pedido no existe. Error propio para poder distinguirlo."""
def __init__(self, name: str):
self.name = name
super().__init__(
f"Proveedor de pago desconocido: {name!r}. "
f"Disponibles: {', '.join(available_providers())}"
)
class ProviderNotAvailableError(RuntimeError):
"""El proveedor existe pero no se puede usar en este contexto (país, configuración)."""
# Los constructores, envueltos en funciones sin argumentos.
# Guardamos CÓMO construir, no el objeto ya construido: así nada se instancia
# al importar el módulo y las credenciales se leen solo cuando de verdad se usan.
_PROVIDERS: dict[str, callable] = {
"stripe": lambda: StripeProvider(api_key=settings.STRIPE_KEY),
"mercadopago": lambda: MercadoPagoProvider(token=settings.MP_TOKEN),
"cash": lambda: CashProvider(
store_chain=settings.CASH_STORE_CHAIN,
report_bucket=settings.CASH_REPORT_BUCKET,
),
}
def available_providers() -> list[str]:
"""Qué proveedores existen. Una sola fuente de la verdad, consultable."""
return sorted(_PROVIDERS)
def get_payment_provider(name: str) -> PaymentProvider:
"""Devuelve el proveedor pedido, ya construido y listo para cobrar.
Es el ÚNICO lugar del sistema que sabe qué proveedores existen
y cómo se construye cada uno.
"""
try:
build = _PROVIDERS[name]
except KeyError:
raise UnknownProviderError(name) from None
return build()
Elegimos el diccionario de constructores en vez del if por una razón concreta y no por gusto: queremos poder preguntar qué proveedores existen. api/routes.py necesita esa lista para validar, y la interfaz la necesita para armar el menú de formas de pago. Con un if, esa lista tendría que escribirse a mano en un segundo lugar —y ya vimos a dónde lleva eso—.
Paso 4 — Mueve el tráfico, un archivo a la vez
Ahora sí se cambian las llamadas. Uno por uno, con sus pruebas, y con la posibilidad de parar entre cada uno.
# Archivo: checkout/checkout.py — DESPUÉS
def charge_order(order) -> PaymentResult:
"""Cobra una orden. Ya no sabe qué proveedores existen ni cómo se construyen."""
provider = get_payment_provider(order.provider)
return provider.charge(order)
# Archivo: admin/refunds.py — DESPUÉS
def refund_order(order, amount: float) -> PaymentResult:
provider = get_payment_provider(order.provider)
return provider.refund(order, amount)
# Archivo: reports/reconciliation.py — DESPUÉS
def reconcile_day(day):
"""Compara lo que creemos que cobramos contra lo que dice cada proveedor."""
ours = load_paid_orders(day)
# Y aquí aparece un regalo que no habíamos pedido: ahora se puede
# RECORRER la lista de proveedores en vez de escribirla a mano.
theirs = []
for name in available_providers():
provider = get_payment_provider(name)
theirs.extend(provider.daily_charges(day))
return compare(ours, theirs)
# Archivo: api/routes.py — DESPUÉS
def post_checkout(request):
data = request.json
provider_name = data.get("provider")
try:
# Pedir el proveedor ES la validación. Si no existe, falla aquí,
# con un mensaje que ya trae la lista real de opciones.
provider = get_payment_provider(provider_name)
except UnknownProviderError as err:
return response(400, {"error": str(err)})
order = build_order(data)
result = provider.charge(order)
return response(200, serialize(result))
Detente en el reconcile_day. Antes, ese archivo tenía un if con tres ramas y una función que había que llamar una vez por proveedor, con la lista escrita a mano en algún lado. Ahora recorre available_providers(). Ese bucle es imposible sin la factory: no puedes recorrer un condicional. Y es la razón por la que el proceso nocturno ya no se puede olvidar de un proveedor nuevo —cuando PayPal entre al registro, la conciliación lo va a incluir sin que nadie toque este archivo—.
Ese es el tipo de ganancia que no aparece en el diagrama del patrón y que solo se ve en un caso trabajado: al convertir una decisión en un dato, el dato se vuelve recorrible, contable y consultable.
Paso 5 — Borra el código viejo
Este paso parece trivial y es el que más se salta. Cuando los cuatro archivos ya usan la factory, los if viejos, las constantes VALID_PROVIDERS y las funciones auxiliares que quedaron sin llamar se borran. No se comentan, no se dejan "por si acaso": se borran. El historial de versiones ya guarda todo lo que hubo. Código muerto que sabe la misma cosa que el código vivo es exactamente el problema que veníamos a resolver, solo que ahora en silencio.
Paso 6 — Escribe la prueba que antes no se podía escribir
# Archivo: tests/test_payments_factory.py
def test_every_registered_provider_honors_the_contract():
"""Todo lo que esté en el registro tiene que cumplir el contrato completo.
Esta prueba no menciona ningún proveedor por nombre: recorre el registro.
Cuando alguien agregue PayPal y olvide implementar daily_charges,
esta prueba falla sola, sin que nadie la haya modificado.
"""
for name in available_providers():
provider = get_payment_provider(name)
assert callable(getattr(provider, "charge", None))
assert callable(getattr(provider, "refund", None))
assert callable(getattr(provider, "daily_charges", None))
def test_unknown_provider_fails_loudly_and_lists_the_options():
with pytest.raises(UnknownProviderError) as err:
get_payment_provider("paypall") # dedazo típico
# El mensaje debe ayudar a quien se equivocó, no solo decir "error".
assert "stripe" in str(err.value)
Esa primera prueba es un pequeño seguro que se paga solo. Y otra vez: existe porque la lista de proveedores es un dato.
Qué esperar de todo esto
Vamos a medirlo con la unidad que se puede defender en una revisión.
Antes. Agregar PayPal exigía: una rama en checkout/checkout.py, dos cambios en api/routes.py —la lista y la validación de datos extra—, una rama en admin/refunds.py, una rama en reports/reconciliation.py. Cuatro archivos existentes, cinco cambios. Más las credenciales. Y con el detalle que viste en la lección 1: si te olvidabas de la conciliación, el sistema no te avisaba —fallaba en silencio semanas después—.
Después. Agregar PayPal exige: crear payments/paypal_provider.py con la clase, y agregar una línea a _PROVIDERS. Un archivo nuevo y una línea en un archivo existente. Ninguno de los cuatro archivos originales se toca. Y si te olvidas de registrarlo, el sistema falla de inmediato con UnknownProviderError y te dice qué opciones sí existen.
Ese "cuatro archivos existentes → uno nuevo más una línea" es la frase con la que defiendes este refactor. No "quedó más limpio", no "aplicamos el patrón Factory": bajó de cinco cambios repartidos a dos cambios juntos, y las fallas silenciosas se volvieron ruidosas.
Ahora la parte que casi nunca se dice. Vamos con lo que costó:
- Cinco archivos nuevos (
provider.py, tres proveedores,factory.py) donde antes había cero. El sistema tiene más piezas. - Un salto más al leer. Quien lee
charge_ordery quiere saber qué pasa con Stripe tiene que abrir dos archivos en vez de uno. - Un rato de trabajo real. Este refactor son unas horas, no diez minutos, y en un sistema en producción hay que hacerlo con cuidado.
¿Valió la pena? Aquí sí, y el argumento es específico: hay tres implementaciones reales y distintas de verdad, la decisión estaba en cuatro lugares, y el equipo tiene un cuarto proveedor pedido. Cambia cualquiera de esos tres números y la respuesta cambia. Con un solo proveedor, este refactor habría sido el rincón de plugins/ del módulo 2, con otro nombre. El patrón no es bueno ni malo; el número de implementaciones y de lugares de decisión es lo que decide.
Factory y Strategy: cómo se combinan de verdad
Ahora la parte que los libros separan y que en código real nunca viene separada.
Repasemos lo que hiciste en el módulo 3. Las reglas de precio de Boletia eran un condicional de ochenta líneas dentro del cálculo del total. Lo convertiste en un contrato PricingRule y cuatro implementaciones —general, VIP, early-bird, cortesía—. Eso es Strategy: varias formas intercambiables de hacer lo mismo.
Y ahora fíjate en lo que hiciste en esta lección. Un contrato PaymentProvider y tres implementaciones intercambiables. Eso también es una Strategy. Los tres proveedores son estrategias de cobro. Lo que agregaste en esta lección —el registro, get_payment_provider— es la Factory que decide cuál se usa.
Los dos patrones no compiten; son las dos mitades de una misma solución:
¿Cómo se cobra? ¿Cuál se usa?
┌──────────────────────┐ ┌──────────────────────┐
│ STRATEGY │ │ FACTORY │
├──────────────────────┤ ├──────────────────────┤
│ PaymentProvider │◄─────────│ get_payment_provider │
│ ├─ StripeProvider │ devuelve│ ├─ registro │
│ ├─ MercadoPago... │ │ └─ manejo del │
│ └─ CashProvider │ │ desconocido │
└──────────────────────┘ └──────────────────────┘
▲ ▲
│ usa el objeto │ pide el objeto
└─────────── checkout ──────────────┘
(no conoce ninguno de los dos)
La regla mnemotécnica: la Strategy define las opciones; la Factory elige entre ellas. Una sin la otra queda coja. Strategy sin Factory deja la elección repartida —el problema de la lección 1—. Factory sin Strategy no tiene nada que fabricar —el error de la factory de una sola implementación, lección 2—.
Y ahora aplícalo a lo que dejaste pendiente del módulo 3. Mira este código, que es el que quedó ahí:
# Archivo: pricing/calculator.py — como quedó al final del módulo 3
def total_for(order, purchased_at):
total = 0.0
for ticket in order.tickets:
# Aquí está la decisión, repartida: este mismo if aparece también
# en la vista previa del carrito y en el generador de facturas.
if ticket.kind == "vip":
rule = VipRule(surcharge=0.30)
elif ticket.kind == "early_bird":
rule = EarlyBirdRule(cutoff=order.event.early_bird_cutoff, discount=0.20)
elif ticket.kind == "courtesy":
rule = CourtesyRule()
else:
rule = GeneralRule()
total += rule.apply(ticket.base_price, purchased_at)
return total
La Strategy está impecable —cuatro reglas intercambiables, cada una en su archivo— y aun así la decisión de cuál usar está en tres lugares. Le falta su Factory:
# Archivo: pricing/factory.py
_RULES: dict[str, callable] = {
"general": lambda event: GeneralRule(),
"vip": lambda event: VipRule(surcharge=0.30),
# La fecha de corte sale del evento, no del código: cada evento tiene la suya.
"early_bird": lambda event: EarlyBirdRule(cutoff=event.early_bird_cutoff, discount=0.20),
"courtesy": lambda event: CourtesyRule(),
}
def get_pricing_rule(kind: str, event) -> PricingRule:
try:
build = _RULES[kind]
except KeyError:
# Falla fuerte: un kind con dedazo cobrando precio general
# es un problema de dinero que nadie detecta.
raise UnknownTicketKindError(kind) from None
return build(event)
# Archivo: pricing/calculator.py — con la Factory puesta
def total_for(order, purchased_at):
return sum(
get_pricing_rule(ticket.kind, order.event).apply(ticket.base_price, purchased_at)
for ticket in order.tickets
)
Mismo movimiento, otro dominio. Y nota un detalle del que casi nadie habla: la factory de reglas recibe el evento además de la llave, porque la fecha de corte del early-bird es distinta por evento. Una factory puede necesitar más de un dato para construir, y eso está perfectamente bien. Lo que la define no es que reciba un solo parámetro: es que sea el único lugar que sabe qué implementaciones existen.
Dos patrones, dos dominios, un mismo par. Cuando en una revisión de código alguien te diga "aquí hay una Strategy", la pregunta que te va a distinguir es la siguiente: "¿y quién decide cuál?". Si la respuesta es "en tres lugares", ya sabes qué falta.
Lo que este refactor NO resolvió
Esta sección es la más importante de la lección y la que casi ningún material incluye. Un refactor honesto termina con una lista de lo que sigue pendiente, no con una palmada en la espalda.
Sigue habiendo un str de texto libre. order.provider es un str. Nada impide que alguien guarde "Stripe" con mayúscula, o "strype". La factory ahora falla fuerte cuando eso pasa, que es una mejora enorme sobre fallar en silencio —pero falla en tiempo de ejecución, cuando el cliente ya está intentando pagar—. La solución de verdad es un Enum o una restricción en la base de datos, y no es un patrón de diseño: es tipado. Vale la pena decirlo porque muestra el límite del tema: algunos problemas que parecen pedir un patrón se resuelven mejor con una herramienta del lenguaje.
Las tres APIs siguen siendo incompatibles entre sí. El contrato las hace verse iguales desde afuera, y eso es real. Pero adentro de StripeProvider sigue viviendo el int(round(order.total * 100)), y si Stripe cambia su librería, ese archivo se rompe. Lo que hicimos fue contener el desorden, no eliminarlo —y contenerlo ya vale muchísimo, porque ahora se rompe un archivo en vez de tres—. Pero el patrón que trata específicamente ese problema es Adapter, y es el módulo 5. De hecho, StripeProvider tal como quedó ya es medio Adapter: traduce entre el idioma de Boletia y el de Stripe. En el módulo 5 vamos a separar esas dos responsabilidades del todo.
charge_order sigue construyendo su dependencia por dentro. Míralo otra vez:
def charge_order(order) -> PaymentResult:
provider = get_payment_provider(order.provider) # ← se lo fabrica solo
return provider.charge(order)
Para probar esta función sigues necesitando que la factory devuelva algo falso, y la factory lee credenciales reales de settings. La firma sigue mintiendo: dice que necesita una orden, y en realidad necesita una orden más la configuración de tres proveedores. Este es el tercer problema del módulo —quién construye— y lo resuelve la lección 6. Vas a ver que la solución es sorprendentemente pequeña: pasar el proveedor como parámetro.
La decisión de qué proveedor conviene sigue repartida. La factory concentró cómo se construye cada proveedor, no cómo se elige. Quién decide el valor de order.provider —el formulario web, la app, el proceso de renovaciones— sigue siendo cosa de cada uno. En Boletia hoy eso está bien, porque la elección es literalmente lo que el usuario apretó. Pero si mañana entra una regla de negocio del tipo "los clientes de Colombia no ven efectivo", esa lógica va a necesitar su propio lugar, y no es la factory.
Cuatro pendientes. Ninguno es un fracaso: un refactor bueno resuelve un problema, no todos. Lo que sí sería un fracaso es no saber cuáles quedaron abiertos.
Errores comunes
Hacer el refactor de una sola vez, sin pasos intermedios (de proceso). Qué pasa: alguien entiende el destino, borra los cuatro if, escribe los cinco archivos nuevos y recién entonces intenta correr las pruebas. Nada funciona, hay veinte errores a la vez, no se sabe cuál causó cuál, y después de tres horas la tentación de revertir todo es enorme —y a veces es la decisión correcta, con lo cual se perdió la tarde—. Por qué pasa: cuando tienes claro el destino, los pasos intermedios se sienten como pérdida de tiempo. Cómo detectarlo: si en algún momento de tu refactor el sistema no corre, ya estás en ese error. Cómo corregirlo: el orden de los seis pasos no es decorativo. Contrato, implementaciones, factory, mover el tráfico uno por uno, borrar, probar. Los pasos 1 a 3 solo agregan código y no pueden romper nada. El paso 4 se hace de a un archivo. Si te interrumpen a la mitad, lo que dejaste funciona.
Dejar el código viejo "por si acaso" (de proceso). Qué pasa: se hace el refactor, pero el if viejo se deja comentado o detrás de una bandera USE_NEW_PROVIDERS. Seis meses después hay dos caminos, alguien arregla un bug en uno solo, y el comportamiento depende de una variable de entorno que nadie recuerda haber puesto. Por qué pasa: miedo razonable a romper producción. Cómo detectarlo: busca banderas de configuración que nadie cambió en meses, y bloques comentados con más de diez líneas. Cómo corregirlo: la bandera temporal es legítima si tiene fecha de retiro y alguien responsable de retirarla. Sin eso, borra. El historial de versiones ya guarda el código viejo, y recuperarlo de ahí es un comando; convivir con dos caminos es un impuesto permanente.
Creer que la factory resolvió el problema de las pruebas (conceptual). Qué pasa: alguien termina el refactor, va a escribir una prueba de charge_order, y descubre que sigue sin poder porque la factory lee credenciales reales. Entonces recurre a parchear el módulo por debajo —monkeypatch.setattr(checkout, "get_payment_provider", fake)— y se queda con una prueba frágil que se rompe cuando alguien mueve un import. Por qué pasa: la Factory sí mejora la testeabilidad de lo que fabrica —ahora StripeProvider se puede probar solo—, y eso hace pensar que resolvió todo. No resolvió el acoplamiento de quien llama a la factory. Cómo detectarlo: si tus pruebas necesitan parchear módulos para funcionar, el problema no es la factory: es que alguien construye por dentro lo que debería recibir. Cómo corregirlo: lección 6. Y de paso, una regla que te va a servir siempre: el parcheo de módulos en pruebas casi nunca es la solución; casi siempre es el síntoma de una dependencia que debería pasarse como parámetro.
Ejercicios
Ejercicio 1 — Agrega PayPal. Con la factory ya escrita, agrega el cuarto proveedor. PayPal cobra en unidades decimales con dos cifras (no en centavos enteros), devuelve un identificador llamado capture_id, reembolsa con refund_capture(capture_id, value) y ofrece un listado de transacciones con list_transactions(start_date, end_date). Escribe la clase y el cambio en el registro, y después responde: ¿qué archivos existentes tuviste que tocar?
Ver solución
# Archivo: payments/paypal_provider.py — ARCHIVO NUEVO
class PaypalProvider:
"""Cobra con PayPal."""
def __init__(self, client_id: str, client_secret: str):
self._client = PaypalClient(client_id=client_id, client_secret=client_secret)
def charge(self, order) -> PaymentResult:
# PayPal sí recibe decimales, pero exige exactamente dos cifras como texto.
# Formateamos aquí; nadie fuera de este archivo tiene por qué saberlo.
capture = self._client.capture(
amount=f"{order.total:.2f}",
currency="MXN",
description=f"Boletia #{order.id}",
)
return PaymentResult(status="succeeded", external_id=capture["capture_id"])
def refund(self, order, amount: float) -> PaymentResult:
refund = self._client.refund_capture(order.external_id, value=f"{amount:.2f}")
return PaymentResult(status="succeeded", external_id=refund["refund_id"])
def daily_charges(self, day) -> list[dict]:
# PayPal pide un rango, no un día suelto: se lo damos cerrado.
return self._client.list_transactions(start_date=day, end_date=day)
# Archivo: payments/factory.py — UNA LÍNEA AGREGADA
_PROVIDERS: dict[str, callable] = {
"stripe": lambda: StripeProvider(api_key=settings.STRIPE_KEY),
"mercadopago": lambda: MercadoPagoProvider(token=settings.MP_TOKEN),
"cash": lambda: CashProvider(
store_chain=settings.CASH_STORE_CHAIN,
report_bucket=settings.CASH_REPORT_BUCKET,
),
"paypal": lambda: PaypalProvider( # ← esto es todo
client_id=settings.PAYPAL_CLIENT_ID,
client_secret=settings.PAYPAL_SECRET,
),
}
Archivos existentes tocados: uno, y con una sola entrada agregada. checkout.py, routes.py, refunds.py y reconciliation.py no se abrieron siquiera. La conciliación nocturna incluye PayPal automáticamente porque recorre available_providers(). La validación del endpoint acepta "paypal" automáticamente porque la validación es pedir el objeto. Y la prueba test_every_registered_provider_honors_the_contract ya está verificando la clase nueva sin que nadie la haya modificado.
Compáralo con el "cuatro archivos, cinco cambios, uno de ellos fácil de olvidar y silencioso" de la lección 1. Esa diferencia es todo el argumento del refactor, y es la frase exacta que pondrías en la descripción del pull request.
Un detalle que separa una buena solución: el f"{order.total:.2f}" está dentro de PaypalProvider. Si lo hubieras puesto en el checkout —"total formateado para PayPal"— habrías devuelto el conocimiento del proveedor al corazón del sistema, que es justo lo que vinimos a evitar. La regla: cada rareza de un proveedor vive en el archivo de ese proveedor, sin excepciones.
Ejercicio 2 — El proveedor que no cabe en el contrato. Boletia quiere agregar pagos con transferencia bancaria SPEI. Funciona así: el sistema le muestra al cliente una CLABE y una referencia; el cliente transfiere desde su banco cuando quiere —puede ser hoy o en tres días—; y el banco le avisa a Boletia con una petición HTTP entrante cuando el dinero llega. No hay forma de "cobrar" desde el código, y tampoco hay forma de reembolsar por API: se necesita la CLABE del cliente, que Boletia no tiene hasta que llega la transferencia. ¿Cabe en el contrato actual? ¿Qué cambiarías?
Ver solución
Sí cabe, y encaja mejor de lo que parece a primera vista —justamente porque CashProvider ya nos obligó a diseñar el contrato para pagos diferidos—.
# Archivo: payments/spei_provider.py
class SpeiProvider:
def charge(self, order) -> PaymentResult:
# No cobra: reserva una CLABE única para esta orden y espera.
# Mismo caso conceptual que el efectivo: el pago llega después.
clabe = allocate_clabe(order.id)
return PaymentResult(status="pending", reference=clabe)
def refund(self, order, amount: float) -> PaymentResult:
# Necesitamos la CLABE del cliente, que solo conocemos si ya transfirió.
if not order.customer_clabe:
# No es un error de programación: es una condición de negocio real.
return PaymentResult(
status="failed",
error_message="No se puede reembolsar por SPEI sin la CLABE del cliente",
)
task_id = schedule_bank_transfer(order.customer_clabe, amount)
return PaymentResult(status="pending", external_id=task_id)
def daily_charges(self, day) -> list[dict]:
# Los avisos del banco los guardamos nosotros al recibirlos,
# así que la conciliación consulta nuestra propia tabla.
return load_spei_notifications(day)
Lo que este ejercicio te enseña, y es el punto de fondo: cuando un contrato se diseña mirando dos implementaciones parecidas, la tercera no cabe. Cuando se diseña mirando tres —incluida una rara, como el efectivo—, la cuarta suele caber sola. Es la regla de tres del módulo 2 vista desde el otro lado: no solo evita abstraer de más, también hace que la abstracción que sí construyes sea la correcta.
Lo que sí habría que revisar es el aviso entrante del banco. Eso es una capacidad nueva que ningún proveedor tenía: "alguien de afuera nos avisa que el pago llegó". El efectivo la tiene también, en su versión rústica —el CSV de la madrugada—. Si mañana Stripe y PayPal empiezan a usar avisos entrantes en vez de respuestas directas, el contrato va a necesitar un método más. Y ahí está la decisión honesta: hoy no lo agregues. Dos proveedores con avisos entrantes, resueltos cada uno a su manera, todavía no justifican meter un método en un contrato que cumplen cinco clases. Cuando sea el tercero, extráelo con la información completa. Abstraer el contrato entero para un caso que aún no se repite es abstracción prematura, aunque estés dentro de un patrón que ya se ganó su lugar.
Ejercicio 3 — Encuentra el quinto lugar. El inventario de la lección encontró cuatro archivos. Con las cuatro búsquedas de grep que viste al principio, aparece un quinto que ninguna lección mencionó: un comando de administración scripts/export_provider_fees.py que calcula cuánto le cobró cada proveedor a Boletia en comisiones, y que tiene su propio if con las tarifas de cada uno (stripe: 3.6% + $3, mercadopago: 4.1%, cash: $12 fijos). ¿Este archivo debería usar la factory? ¿Deberían las comisiones entrar al contrato PaymentProvider?
Ver solución
Las dos respuestas son interesantes y no son la misma.
¿Debería usar la factory? Sí, para la lista. El script hoy escribe a mano cuáles son los proveedores, así que cuando entre PayPal ese reporte lo va a ignorar en silencio —el mismo error de la conciliación—. Recorrer available_providers() arregla eso de inmediato.
¿Deberían las comisiones entrar al contrato? Aquí hay que pensarlo, y la respuesta corta es: probablemente sí, pero no como un método más.
El argumento a favor: la tarifa es una propiedad de cada proveedor, tan suya como la forma de cobrar. Tenerla en un script aparte es exactamente el problema del módulo —conocimiento de un proveedor viviendo fuera del proveedor—. Cuando entre PayPal, alguien va a tener que acordarse de agregar su tarifa a ese script, y no hay nada que se lo recuerde.
El argumento en contra, que es el que hay que pesar: PaymentProvider hoy tiene tres métodos y todos son operaciones —cobrar, reembolsar, consultar—. Una tarifa no es una operación: es un dato. Meterla como método (def fee_for(self, amount)) funciona, pero empieza a convertir el contrato en un cajón donde va todo lo que tenga que ver con el proveedor. Esa es la vía rápida hacia el God object del módulo 7, solo que repartido en tres clases.
La solución que yo elegiría, y te la doy con su justificación porque el criterio es el contenido de este ejercicio: agregar la tarifa como un atributo de datos, no como un método de la operación.
@dataclass(frozen=True)
class ProviderFee:
"""Lo que cobra un proveedor por procesar un pago."""
percentage: float # 0.036 para 3.6%
fixed: float # cargo fijo en pesos
def on(self, amount: float) -> float:
return amount * self.percentage + self.fixed
# Y en el contrato, como propiedad de solo lectura:
class PaymentProvider(Protocol):
@property
def fee(self) -> ProviderFee: ...
def charge(self, order) -> PaymentResult: ...
def refund(self, order, amount: float) -> PaymentResult: ...
def daily_charges(self, day) -> list[dict]: ...
Así el conocimiento vive con su dueño, el script recorre el registro y calcula sin saber de nadie, y el contrato no se llena de operaciones que no lo son. Cuando entre PayPal, si alguien olvida la tarifa, el verificador de tipos avisa antes de desplegar.
Y la lección de fondo: el quinto lugar existía. Siempre existe. Por eso el inventario del principio no es un ritual: es la parte del refactor que más veces se hace a medias. Cuando termines uno, corre las búsquedas otra vez —con los nombres nuevos— y comprueba que no quedó nadie afuera.
Resumen y siguiente paso
En esta lección hiciste el refactor completo sobre el caso real de Boletia. Empezaste por el inventario —cuatro búsquedas que encuentran los lugares donde el conocimiento se repite, incluida la que casi nadie corre: buscar por las credenciales— y viste por qué anotar antes de tocar no es burocracia, sino lo que evita quedarse a medio camino.
Después hiciste los seis pasos, cada uno dejando el sistema funcionando: escribir el contrato —con PaymentResult y con el "pending" que existe gracias a que el efectivo nos obligó a mirar tres casos—, mover cada rama a su clase, escribir la factory con registro consultable, mover el tráfico de a un archivo, borrar el código viejo, y escribir la prueba que recorre el registro y que ahora falla sola cuando alguien agrega un proveedor incompleto.
Mediste el resultado en la unidad que se defiende en una revisión: de cuatro archivos existentes con cinco cambios, uno de ellos silencioso y fácil de olvidar, a un archivo nuevo y una línea. Y viste el regalo inesperado del refactor: al convertir la decisión en un dato, la conciliación nocturna pasó de un condicional a un bucle, y con eso dejó de poder olvidarse de un proveedor.
Viste cómo Factory y Strategy se combinan: la Strategy define las opciones intercambiables, la Factory decide cuál. Una sin la otra queda coja, y lo aplicaste al pendiente que el módulo 3 había dejado abierto en las reglas de precio. Y terminaste con la lista honesta de lo que este refactor no resolvió: el str de texto libre, las APIs que siguen siendo incompatibles por dentro, la función que sigue construyendo su dependencia, y la elección de qué proveedor conviene.
Antes de avanzar deberías poder: hacer el inventario de una decisión repartida en un sistema que no conoces; ordenar los pasos de un refactor para que el sistema nunca quede roto; explicar la relación entre Strategy y Factory en una frase; y decir qué preguntas quedan abiertas después de aplicar una Factory.
La lección 4 cambia de problema. Hasta aquí trabajamos sobre qué construir, cuando hay varias opciones. Ahora vamos al caso en el que solo hay una opción posible —una Order, y ya— pero armarla es complicado: muchos datos, varios opcionales, algunos que dependen de otros. Ese es el terreno del Builder. Y va a venir con una advertencia importante, en la línea del módulo 2: en Python, buena parte de lo que el Builder resuelve ya lo resuelven los argumentos por nombre y un dataclass.
Recursos
- Refactoring — Replace Constructor with Factory Function — el refactor que acabas de hacer, descrito paso a paso por Fowler en su catálogo.
- Martin Fowler — Refactoring: the "small steps" discipline — por qué un refactor que rompe el sistema entre pasos deja de ser un refactor. Es el fundamento de los seis pasos de esta lección.
- python-patterns.guide — The Abstract Factory Pattern — Brandon Rhodes muestra cómo la familia coherente de dependencias se arma en Python sin las clases abstractas del catálogo.
- Refactoring Guru — Strategy — para releer el patrón del módulo 3 ahora que viste cómo se combina con la Factory. La sección de "relaciones con otros patrones" al final de la página es la que más vale la pena.