Módulo 5: Patrones para estructurar y adaptar
4. Facade: una puerta simple a algo complicado
Descripción
Al terminar esta lección vas a poder escribir una fachada y, sobre todo, vas a saber dónde ponerle el límite. Vas a tener la diferencia con el Adapter en una frase que puedes decir en una revisión sin dudar. Vas a ver el caso completo de Boletia: un checkout de diez pasos que hoy está copiado en tres archivos, convertido en una sola llamada. Y vas a salir con las señales concretas de la fachada que se está convirtiendo en un God object, más el criterio para partirla antes de que sea tarde.
Esto importa por una razón que casi nadie dice en voz alta: Facade es el patrón más fácil de aplicar y el más fácil de arruinar. Es fácil de aplicar porque no exige nada —una clase, un método, unas llamadas adentro— y porque la mejora es inmediata y visible: diez líneas se vuelven una. Y es fácil de arruinar porque esa misma facilidad no tiene freno. Nada en el patrón te dice cuándo parar. Una fachada que empezó con un método útil termina, dos años después, con treinta métodos, quince dependencias y el nombre ServiceManager, y para entonces es el archivo que todo el mundo tiene que tocar y nadie entiende. Ese archivo tiene nombre en el módulo 7: God object. La misma herramienta produce los dos resultados; lo único que los separa es el criterio.
Conexión con el módulo: las lecciones 2 y 3 resolvieron el contacto con algo que hablaba otro idioma —el SDK de Zafiro— y cerraron con una observación que abre esta: la frontera del Adapter está al nivel del proveedor, no al del negocio, así que cada llamador sigue teniendo que orquestar. Esta lección resuelve eso. Aquí el problema no es la traducción sino la cantidad de pasos, y el patrón que lo resuelve es distinto. La lección 5 pasa a Decorator, que también envuelve pero con otra intención: agregar comportamiento manteniendo la interfaz. La lección 6 es Composite. Y la lección 7 vuelve con la pregunta de siempre, que aquí muerde fuerte: una fachada suele ser, literalmente, una función; ¿hacía falta la clase?
El conserje del hotel
Llegas a un hotel en una ciudad que no conoces y quieres cenar bien esta noche. Puedes hacerlo tú: buscar restaurantes, leer reseñas, descartar los que cierran los lunes, llamar a tres para ver cuál tiene mesa a las nueve, confirmar que aceptan tu tarjeta, pedir un taxi y calcular a qué hora salir. Es perfectamente posible y te va a llevar cuarenta minutos.
O puedes ir al mostrador y decir: "quiero cenar bien esta noche, somos dos, cerca de aquí". El conserje hace exactamente lo mismo que habrías hecho tú, pero lo hace en cinco minutos porque conoce el terreno, y te devuelve una respuesta: "a las nueve, en tal lugar, el taxi los recoge a las 8:40".
Fíjate en las cuatro propiedades de ese mostrador, porque son las cuatro del patrón.
No hace nada nuevo. El conserje no cocina, no maneja el taxi, no es dueño del restaurante. Lo único que aporta es saber el orden y a quién llamar. Una fachada tampoco implementa nada: llama a las piezas que ya existen, en el orden correcto.
No te quita la opción de hacerlo tú. Si eres experto en la ciudad y quieres un restaurante específico, llamas directo y nadie te lo impide. El conserje es una comodidad, no una aduana. Esto marca la diferencia más importante con el Adapter, y volveremos sobre ella.
Convierte muchas preguntas en una. Tú haces una petición en el lenguaje de lo que quieres —"cenar bien"— y no en el lenguaje de los pasos —"llama al restaurante X y pregunta por la mesa Y"—. Una fachada se diseña siempre desde la intención de quien llama, nunca desde los pasos que hay adentro.
Y tiene un límite natural. Un conserje que además te vendiera seguros de viaje, te tramitara la visa y te cortara el pelo dejaría de ser un conserje y se volvería un mostrador confuso donde nadie sabe qué pedir. Ese es el riesgo del patrón, y en software no tiene límite natural: hay que ponérselo a mano.
Qué es un Facade, en una frase
Un Facade es un objeto —o una función— que ofrece una operación simple y por dentro coordina varios pasos de un subsistema.
Y ahora la frase que quiero que te lleves, porque resuelve la confusión más común de este módulo:
Adapter traduce. Facade simplifica.
Un Adapter existe porque hay dos interfaces que no encajan; si encajaran, sobraría. Un Facade existe porque hay muchos pasos y quien llama solo quiere uno; los pasos podrían estar perfectamente diseñados y el Facade seguiría valiendo la pena. Dicho de otra forma: el Adapter cambia la forma, el Facade cambia la cantidad.
Hay tres diferencias más que conviene tener a mano:
| Adapter | Facade | |
|---|---|---|
| Qué envuelve | Normalmente una cosa ajena | Normalmente varias cosas, casi siempre tuyas |
| De quién es lo envuelto | De un tercero, no lo puedes cambiar | Tuyo, podrías cambiarlo si quisieras |
| ¿Se puede saltar? | No debería: si alguien llama al SDK directo, la frontera tiene un agujero | Sí, y está bien: quien necesite los pasos sueltos los usa |
| Qué mide su éxito | Cuántas traducciones hace | Cuántos pasos le ahorra a quien llama |
La tercera fila es la que más se malinterpreta y merece un momento. Una fachada no cierra el subsistema. Si el equipo de reportes necesita calcular un precio sin comprar nada, tiene que poder llamar al calculador directo. Convertir la fachada en la única puerta legal es el camino más rápido a que se llene de métodos raros —checkout_but_only_calculate, checkout_without_notifying— hasta volverse ilegible. La fachada es el camino cómodo para el 90% de los casos, no una aduana.
Ahora la anatomía. Un Facade tiene cuatro piezas:
1. El subsistema. Las piezas que ya existen y que hacen el trabajo real: en Boletia, el calculador de precios, la factory de proveedores de pago, el emisor de boletos, el notificador. Cada una sigue siendo pública y probable por separado.
2. La operación de negocio. El nombre de lo que quiere quien llama, dicho en su idioma: checkout(order), publish_event(event), refund(order, amount). Si el nombre de tu fachada describe la mecánica —run_payment_and_notify— y no la intención, va a envejecer mal.
3. La fachada. La clase o función que coordina. Recibe la petición, llama a las piezas en orden, maneja los errores del camino y devuelve un resultado propio.
4. Sus dependencias, inyectadas. Las piezas del subsistema le llegan por el constructor en vez de construirlas ella. Es la misma idea del punto de construcción de la lección 2, y aquí decide si la fachada se puede probar: una fachada que construye sus cuatro dependencias adentro solo se puede probar con las cuatro reales.
Ejemplo trabajado: los diez pasos de una compra
Vamos al caso grande de Boletia, que es el que da nombre al módulo 1: checkout.
Qué pasa realmente cuando alguien compra un boleto. No son dos cosas. Son diez, y en un orden que importa:
- Validar que la orden tenga boletos y que el evento no haya empezado.
- Reservar los asientos, si el evento es numerado.
- Calcular el precio de cada boleto con su regla —general, VIP, early-bird, cortesía—.
- Aplicar los descuentos que correspondan.
- Guardar la orden en estado pendiente, para que exista aunque el cobro falle.
- Elegir el proveedor de pago y cobrar.
- Interpretar el resultado: pagada, pendiente o rechazada.
- Si quedó pagada, emitir los boletos —generar el código QR y el PDF—.
- Avisarle al cliente por los canales que tenga, y al organizador.
- Registrar la métrica de venta.
Cómo está hoy. Esa secuencia vive en api/routes.py, escrita a mano:
# Archivo: api/routes.py — ANTES
def post_checkout(request):
order = build_order(request)
# 1. Validación
if not order.ticket_ids:
return response(400, {"error": "La orden no tiene boletos"})
event = events.get(order.event_id)
if event.starts_at < now():
return response(409, {"error": "El evento ya empezó"})
# 2. Asientos
if event.is_seated:
seats = seating.reserve(event.id, order.ticket_ids)
if seats is None:
return response(409, {"error": "Los asientos ya no están disponibles"})
# 3 y 4. Precio y descuentos
order.total = pricing.calculate(order, purchased_at=now())
order.total = discounts.apply(order)
# 5. Persistir en pendiente
orders.save(order, status="pending")
# 6 y 7. Cobro
provider = get_payment_provider(order.provider)
try:
result = provider.charge(order)
except PaymentUnavailable:
seating.release(event.id, order.ticket_ids) # ← soltar los asientos
return response(503, {"error": "El pago no está disponible ahora"})
if result.status is PaymentStatus.DECLINED:
seating.release(event.id, order.ticket_ids)
orders.save(order, status="failed")
return response(402, {"error": result.decline_reason})
if result.is_pending:
orders.save(order, status="pending", transaction_id=result.transaction_id)
return response(202, {"order_id": order.id, "instructions": result.instructions})
orders.save(order, status="paid", transaction_id=result.transaction_id)
# 8. Emitir boletos
for ticket_id in order.ticket_ids:
pdf = tickets.issue(ticket_id, order)
storage.put(f"tickets/{ticket_id}.pdf", pdf)
# 9. Notificar
customer = customers.get(order.customer_id)
notifier.send_purchase_confirmation(customer, order)
notifier.send_organizer_alert(event.organizer_id, order)
# 10. Métrica
metrics.increment("sales", amount=order.total, event_id=event.id)
return response(200, {"order_id": order.id, "transaction": result.transaction_id})
Cuarenta y tantas líneas. Y no está mal escrito: cada paso es claro, los errores se manejan, los asientos se sueltan cuando el cobro falla. El problema no es la calidad de este archivo.
El problema es que esta secuencia está en tres lugares. Boletia vende por tres caminos, y los tres tienen que hacer los mismos diez pasos:
api/routes.py— la compra normal desde el sitio o la app.admin/box_office.py— la venta en taquilla, donde un operador cobra en el mostrador. Igual, pero el cliente puede no tener correo y el proveedor siempre es efectivo.jobs/reserved_release.py— cuando una reserva de un revendedor autorizado se convierte en venta, de madrugada.
Los tres tienen los diez pasos escritos a mano. Y ya divergieron: box_office.py no suelta los asientos cuando el cobro falla —lo que significa que un asiento puede quedar bloqueado para siempre si el terminal de pago se cae— y reserved_release.py no registra la métrica, así que las ventas por ese canal no aparecen en ningún reporte y nadie sabe cuánto vende ese canal.
Eso es el diagnóstico. No es que la secuencia sea complicada: es que está copiada.
Paso 1 — Nombra la operación desde la intención. Antes de mover código, decide el nombre. Lo que los tres caminos quieren es lo mismo: comprar boletos. No "cobrar y notificar", que es la mecánica. El nombre es checkout(order) y devuelve un resultado propio:
# Archivo: checkout/result.py
from dataclasses import dataclass
from enum import Enum
class CheckoutOutcome(Enum):
"""Los tres desenlaces posibles de una compra, en el idioma del negocio."""
COMPLETED = "completed" # pagada, boletos emitidos, avisos enviados
AWAITING_PAYMENT = "awaiting" # transferencia o efectivo: falta que paguen
REJECTED = "rejected" # el banco dijo que no
@dataclass(frozen=True)
class CheckoutResult:
outcome: CheckoutOutcome
order_id: int
transaction_id: str | None = None
instructions: dict | None = None # cómo completar el pago, si quedó pendiente
rejection_reason: str | None = None
Nota que CheckoutOutcome no es lo mismo que el PaymentStatus de la lección 3, aunque se parezcan. El del pago dice qué hizo el banco; este dice qué pasó con la compra. Son dos niveles distintos y mezclarlos es un error que se paga: el día que una compra pagada falle al emitir los boletos, vas a necesitar un desenlace que el estado del pago no puede expresar.
Paso 2 — Escribe la fachada. Las dependencias entran por el constructor:
# Archivo: checkout/service.py
class CheckoutError(Exception):
"""Errores de negocio de la compra, con un código que las rutas traducen a HTTP."""
def __init__(self, code: str, message: str):
self.code = code
super().__init__(message)
class CheckoutService:
"""La puerta simple a la compra de boletos.
No implementa nada: coordina las piezas que ya existen, en el orden correcto,
y se asegura de que lo reservado se suelte si algo falla en el camino.
"""
def __init__(self, *, events, seating, pricing, discounts, orders,
payments, tickets, storage, notifier, metrics):
# Todo por parámetro con nombre. Son diez dependencias y eso ya es una
# señal que vamos a discutir más abajo; por ahora, lo importante es que
# entran desde afuera y por eso esta clase se puede probar con falsos.
self._events = events
self._seating = seating
self._pricing = pricing
self._discounts = discounts
self._orders = orders
self._payments = payments
self._tickets = tickets
self._storage = storage
self._notifier = notifier
self._metrics = metrics
def checkout(self, order) -> CheckoutResult:
event = self._validate(order) # pasos 1
self._reserve_seats(event, order) # paso 2
try:
self._price(order) # pasos 3 y 4
self._orders.save(order, status="pending") # paso 5
result = self._charge(order) # pasos 6 y 7
except Exception:
# Si algo falla después de reservar, hay que SOLTAR los asientos.
# Este try/except es la razón principal de que la fachada exista:
# es lo que box_office.py se olvidó de hacer.
self._release_seats(event, order)
raise
if result.status is PaymentStatus.DECLINED:
self._release_seats(event, order)
self._orders.save(order, status="failed")
return CheckoutResult(CheckoutOutcome.REJECTED, order.id,
rejection_reason=result.decline_reason)
if result.is_pending:
self._orders.save(order, status="pending",
transaction_id=result.transaction_id)
return CheckoutResult(CheckoutOutcome.AWAITING_PAYMENT, order.id,
transaction_id=result.transaction_id,
instructions=result.instructions)
self._orders.save(order, status="paid", transaction_id=result.transaction_id)
self._issue_tickets(order) # paso 8
self._notify(event, order) # paso 9
self._metrics.increment("sales", amount=order.total, event_id=event.id) # 10
return CheckoutResult(CheckoutOutcome.COMPLETED, order.id,
transaction_id=result.transaction_id)
# ── Cada paso en su método privado, con nombre ────────────────────────
def _validate(self, order):
if not order.ticket_ids:
raise CheckoutError("empty_order", "La orden no tiene boletos")
event = self._events.get(order.event_id)
if event.starts_at < now():
raise CheckoutError("event_started", "El evento ya empezó")
return event
def _reserve_seats(self, event, order):
if not event.is_seated:
return
if self._seating.reserve(event.id, order.ticket_ids) is None:
raise CheckoutError("seats_taken", "Los asientos ya no están disponibles")
def _release_seats(self, event, order):
if event.is_seated:
self._seating.release(event.id, order.ticket_ids)
def _price(self, order):
order.total = self._pricing.calculate(order, purchased_at=now())
order.total = self._discounts.apply(order)
def _charge(self, order):
provider = self._payments.get(order.provider)
return provider.charge(order)
def _issue_tickets(self, order):
for ticket_id in order.ticket_ids:
pdf = self._tickets.issue(ticket_id, order)
self._storage.put(f"tickets/{ticket_id}.pdf", pdf)
def _notify(self, event, order):
customer = self._customers_of(order)
self._notifier.send_purchase_confirmation(customer, order)
self._notifier.send_organizer_alert(event.organizer_id, order)
Paso 3 — Los tres llamadores. La ruta HTTP queda así:
# Archivo: api/routes.py — DESPUÉS
_HTTP_FOR = {"empty_order": 400, "event_started": 409, "seats_taken": 409}
def post_checkout(request):
order = build_order(request)
try:
result = checkout_service.checkout(order)
except CheckoutError as err:
return response(_HTTP_FOR.get(err.code, 400), {"error": str(err)})
except PaymentUnavailable:
return response(503, {"error": "El pago no está disponible ahora"})
if result.outcome is CheckoutOutcome.REJECTED:
return response(402, {"error": result.rejection_reason})
if result.outcome is CheckoutOutcome.AWAITING_PAYMENT:
return response(202, {"order_id": result.order_id,
"instructions": result.instructions})
return response(200, {"order_id": result.order_id,
"transaction": result.transaction_id})
Y la taquilla, que antes tenía su propia copia divergente de los diez pasos:
# Archivo: admin/box_office.py — DESPUÉS
def sell_at_counter(operator, order):
order.provider = "cash"
result = checkout_service.checkout(order) # los mismos diez pasos
if result.outcome is CheckoutOutcome.COMPLETED:
print_receipt(operator, result.order_id)
return result
Qué esperar de este refactor. Vamos por lo que se ve, después por lo que se arregló y al final por lo que hay que vigilar.
Lo que se ve: los tres llamadores pasaron de cuarenta líneas cada uno a menos de diez. routes.py volvió a ser lo que debe ser —traducir HTTP a llamadas y de vuelta— y dejó de conocer el orden de la compra, los estados de la orden y la existencia de los asientos.
Lo que se arregló, que es lo importante: las dos divergencias desaparecieron. La taquilla ahora suelta los asientos cuando el cobro falla, porque el try/except está en la fachada y no en cada copia. Y el job del revendedor registra la métrica, porque el paso 10 vive en un solo lugar. Ninguno de los dos se arregló escribiendo un parche: se arreglaron porque dejó de haber tres copias que mantener sincronizadas. Es el mismo efecto que viste en la lección 3 con las tres normalizaciones del estado, y es el argumento más fuerte a favor de este patrón: la fachada no es para escribir menos, es para que la secuencia exista una sola vez.
Y una ganancia que aparece sin buscarla: ahora hay un lugar donde probar la compra completa.
# Archivo: tests/test_checkout_service.py
def test_seats_are_released_when_the_charge_fails():
"""La regla que box_office.py se había olvidado, ahora probada una vez."""
seating = FakeSeating(available=True)
service = build_service(
seating=seating,
payments=FakeProviders(charge_raises=PaymentUnavailable("caído")),
)
with pytest.raises(PaymentUnavailable):
service.checkout(make_order(seated=True))
assert seating.released == [("evt_1", ["t1", "t2"])]
def test_a_rejected_payment_does_not_issue_tickets():
tickets = RecordingTickets()
service = build_service(tickets=tickets, payments=FakeProviders(declined=True))
result = service.checkout(make_order())
assert result.outcome is CheckoutOutcome.REJECTED
assert tickets.issued == []
Esas dos pruebas verifican reglas de negocio de verdad —qué pasa con los asientos y con los boletos cuando el pago falla— y no tocan red, ni disco, ni base de datos. Antes eran imposibles: la secuencia solo existía dentro de una función de ruta HTTP, así que probarla exigía levantar la aplicación entera.
Lo que hay que vigilar, y por eso la sección que sigue: mira el constructor de CheckoutService. Diez dependencias. Eso es mucho, y no es un accidente de la implementación: es una señal. Vamos a leerla.
El riesgo: la fachada que se vuelve un God object
Un Facade tiene una tendencia documentada a crecer, y crece por buenas razones —lo cual es exactamente lo que lo hace peligroso—.
La historia es siempre la misma. Alguien necesita comprar boletos con un código de cortesía y agrega un parámetro: checkout(order, courtesy_code=None). Otro necesita comprar sin enviar avisos, para las pruebas de carga: checkout(order, notify=True). Otro necesita cancelar, y como cancelar toca las mismas piezas, agrega cancel(order) a la misma clase. Después llegan refund, transfer_to_another_customer, reissue_tickets. Cada paso es razonable. Al año, CheckoutService tiene dieciocho métodos, dieciocho dependencias y es el archivo con más conflictos de fusión del repositorio.
Ese archivo ya no es una fachada. Es lo que el módulo 7 llama un God object: una clase que sabe demasiado, que cambia por demasiadas razones y que nadie puede modificar sin miedo.
Estas son las señales, de la más temprana a la más tardía:
Más de cinco o seis dependencias en el constructor. No es una regla mágica, es un termómetro. Cada dependencia es un motivo distinto por el que esta clase puede cambiar, y en las fundaciones eso tiene nombre: baja cohesión. Nuestro CheckoutService tiene diez, así que ya está en el límite.
Parámetros booleanos que encienden o apagan pasos. notify=False, skip_validation=True, dry_run=True. Cada uno de esos es un caso de uso distinto disfrazado de bandera. Tres banderas booleanas son ocho comportamientos posibles, de los cuales probablemente pruebas dos.
Métodos que no comparten dependencias. Si checkout usa siete piezas y export_daily_report usa dos completamente distintas, esos dos métodos no tienen nada que ver entre sí y están juntos por costumbre, no por diseño.
El nombre se volvió vago. CheckoutService describe algo. OrderManager describe menos. BusinessService ya no describe nada. Cuando el nombre se generaliza para que quepa lo nuevo, es que lo nuevo no pertenecía.
Las pruebas necesitan armar medio sistema. Si para probar un método hay que construir doce objetos falsos —de los cuales el método usa dos—, la clase agrupa cosas que no van juntas.
Cómo se corrige, y cuándo. La regla que mejor funciona es esta: una fachada por caso de uso, no una por subsistema. Comprar boletos, cancelar una compra y reembolsar son tres casos de uso distintos, con reglas distintas, que cambian por razones distintas y que suelen tocarlos personas distintas. Que compartan piezas del subsistema no significa que deban compartir clase.
# En vez de un OrderManager con dieciocho métodos:
class CheckoutService: # comprar
def checkout(self, order) -> CheckoutResult: ...
class CancellationService: # cancelar antes del evento
def cancel(self, order, reason) -> CancellationResult: ...
class RefundService: # devolver dinero
def refund(self, order, amount, operator) -> RefundResult: ...
Tres clases pequeñas, cada una con las tres o cuatro dependencias que de verdad usa, cada una probable por separado, y cada una con un nombre que dice qué hace. Comparten el subsistema —las tres usan orders y notifier— y eso no es duplicación: es exactamente para lo que existe el subsistema.
¿Y si una fachada empieza a necesitar lo que hace otra? Ahí hay dos salidas honestas. Si es un paso completo —"cancelar también reembolsa"— una fachada llama a la otra, y está bien. Si es solo un pedazo, ese pedazo pertenece al subsistema y las dos lo llaman desde ahí. Lo que no funciona es fusionarlas: dos casos de uso metidos en una clase producen métodos con banderas, y ahí empieza el ciclo otra vez.
Un último matiz sobre las diez dependencias de nuestro ejemplo. No siempre significa que la fachada esté mal: a veces significa que el subsistema está demasiado desmenuzado. Si pricing y discounts siempre se usan juntos y en ese orden, quizá deberían ser una sola pieza —Pricing.total_for(order)— y la fachada bajaría a nueve dependencias sin perder nada. Vale la pena hacerse esa pregunta antes de partir la fachada: ¿el problema es que la fachada hace demasiado, o que las piezas de abajo son demasiado pequeñas? Las dos causas se ven igual desde el constructor y se arreglan al revés.
Errores comunes
Convertir la fachada en la única puerta (de criterio). Qué pasa: alguien escribe CheckoutService, ve que quedó bien, y decide que a partir de ahora nadie puede llamar al calculador de precios, al notificador ni a la factory de pagos directamente. Al mes llega el equipo de reportes, que necesita calcular precios sin comprar nada, y como no puede usar el calculador, aparece checkout(order, dry_run=True). Después alguien necesita reenviar un correo de confirmación y aparece checkout(order, only_notify=True). La fachada se convirtió en un router de casos raros. Por qué pasa: se confunde Facade con Adapter. En un Adapter tiene sentido cerrar el paso —si alguien llama al SDK directo, la frontera tiene un agujero—; en un Facade no, porque el subsistema es tuyo y no hay nada de qué proteger. Cómo detectarlo: busca parámetros que apaguen pasos. Cada dry_run, notify=False o skip_x es alguien que quería el subsistema y solo tenía la fachada. Cómo corregirlo: deja el subsistema accesible y documenta la fachada como el camino cómodo, no como el obligatorio. Si de verdad quieres proteger una invariante —"nadie debe cobrar sin haber reservado asiento"—, esa protección va en la pieza que la sostiene, no en una prohibición de acceso.
Que la fachada empiece a decidir reglas de negocio (de criterio). Qué pasa: la fachada, que solo debía coordinar, empieza a contener reglas. Primero es un if pequeño —"si el evento es de cortesía, no cobrar"—. Después es el cálculo de si un cliente puede comprar más de cuatro boletos. Después es la política de reembolsos. El problema no es que esas reglas estén mal escritas: es que ahora viven en la capa de coordinación, así que el equipo de reportes o el panel de administración —que no pasan por la fachada— no las aplican, y el sistema empieza a comportarse distinto según por dónde entres. Por qué pasa: la fachada es el lugar donde pasa todo, así que atrae cualquier decisión que necesite ver varias piezas a la vez. Y la primera vez parece la solución más simple. Cómo detectarlo: pregúntate si la regla seguiría siendo cierta llamando al subsistema sin la fachada. Si la respuesta es "debería, pero no lo sería", la regla está en el lugar equivocado. Cómo corregirlo: baja la regla a la pieza que le corresponde —el límite de boletos por cliente es del dominio de la orden, no de la compra— y deja en la fachada solo el orden de los pasos y el manejo de lo que hay que deshacer.
Escribir una fachada que solo reenvía a una pieza (de criterio). Qué pasa: alguien crea PricingFacade con un método calculate(order) que hace return self._calculator.calculate(order). Un paso, una dependencia, cero coordinación. Es el equivalente en Facade del adaptador que solo renombra métodos de la lección 3: un archivo más, un salto más y ninguna ganancia. Por qué pasa: casi siempre por simetría —"si checkout tiene su fachada, pricing debería tener la suya"— o por una regla de arquitectura aplicada sin criterio, del tipo "toda capa se accede por su fachada". Cómo detectarlo: cuenta los pasos que coordina tu fachada. Si es uno, no está simplificando nada, porque simplificar significa reducir cantidad y no hay cantidad que reducir. Cómo corregirlo: borra la fachada y llama a la pieza. Y si la razón real era otra —querer una costura para pruebas, o dejar lugar para agregar caché después— dila en voz alta, porque esas sí son razones y llevan a soluciones distintas: para la costura basta con inyectar la pieza, y para la caché lo que quieres es un Decorator, que es la lección siguiente.
Ejercicios
Ejercicio 1 — Adapter o Facade. Para cada situación, di cuál de los dos corresponde y justifica en una línea usando la frase de la lección —Adapter traduce, Facade simplifica—. Ojo, en dos de los cinco la respuesta es "los dos" o "ninguno".
(a) Publicar un evento exige siete llamadas en orden: validar, crear el registro, generar los boletos, subir la imagen, indexar la búsqueda, programar los recordatorios y avisar al organizador.
(b) La librería de mapas que usa Boletia devuelve coordenadas como una cadena "19.4326,-99.1332" y el sistema trabaja con un objeto Coordinates(lat, lng).
(c) El proveedor de facturación electrónica expone un SDK con nombres raros, y además emitir una factura exige cuatro llamadas suyas en orden: crear el borrador, agregar los conceptos, timbrar y descargar el XML.
(d) El equipo quiere una clase NotificationFacade con un solo método send(customer, message) que llama a notifier.send(customer, message).
(e) Boletia quiere que todas las llamadas al proveedor de pago se reintenten dos veces si falla la red.
Ver solución
(a) Facade. Siete pasos, una intención. Fíjate en que los siete podrían estar perfectamente escritos: no hay nada que traducir, hay cantidad que reducir.
(b) Adapter. Hay una forma que te dan —una cadena— y otra que necesitas —un objeto—, y no coinciden. Traducir esa diferencia es la definición del patrón. (Aunque conviene guardarse la duda de la lección 7: si esto se usa en un solo lugar, quizá sea una función de dos líneas.)
(c) Los dos, y en capas. El Adapter traduce el idioma del SDK a los conceptos de Boletia —una Invoice propia, errores propios—; el Facade convierte las cuatro llamadas en issue_invoice(order). Este es el caso más frecuente en sistemas reales y es exactamente lo que la lección 3 anticipó al hablar de la frontera puesta demasiado abajo. El orden importa: primero el adaptador, para no tener que orquestar en el idioma ajeno.
(d) Ninguno. Un paso, una dependencia, cero traducción. Es el tercer error común de esta lección: una fachada que reenvía. Bórrala y llama al notificador.
(e) Ninguno de los dos: es un Decorator, y es la lección 5. La pista está en que la interfaz no cambia —entra un PaymentProvider, sale un PaymentProvider— y en que no se reduce ningún paso: se agrega comportamiento alrededor del que ya había.
Por qué funciona: los cinco tienen la misma forma de diagrama —algo que envuelve a algo— y solo distinguiéndolos por intención se aciertan. La pregunta que los separa es una sola: ¿qué cambió del otro lado? Si cambió la forma, es Adapter. Si cambió la cantidad de pasos, es Facade. Si no cambió nada pero ahora hace algo más, es Decorator.
Ejercicio 2 — Escribe la fachada de publicar un evento. Publicar un evento en Boletia exige estos pasos, en orden: (1) validar que tenga nombre, sede y fecha futura; (2) crear el registro del evento; (3) generar los boletos según el aforo y los tipos definidos; (4) subir la imagen de portada al almacenamiento; (5) indexarlo en el motor de búsqueda; (6) programar el recordatorio para 24 horas antes; (7) avisarle al organizador que ya está publicado.
Escribe EventPublishingService. Presta atención a tres decisiones: qué pasa si falla el paso 5, qué devuelve, y cuántas dependencias termina teniendo.
Ver solución
# Archivo: events/publishing.py
@dataclass(frozen=True)
class PublishResult:
event_id: int
tickets_created: int
search_indexed: bool # ← puede ser False y el evento seguir publicado
class PublishError(Exception):
def __init__(self, code: str, message: str):
self.code = code
super().__init__(message)
class EventPublishingService:
"""La puerta simple a publicar un evento: siete pasos, una llamada."""
def __init__(self, *, events, tickets, storage, search, scheduler, notifier):
self._events = events
self._tickets = tickets
self._storage = storage
self._search = search
self._scheduler = scheduler
self._notifier = notifier
def publish(self, draft) -> PublishResult:
self._validate(draft) # 1
event = self._events.create(draft) # 2
try:
count = self._tickets.generate(event, draft.capacity, draft.kinds) # 3
if draft.cover_image:
url = self._storage.put(f"events/{event.id}/cover.jpg",
draft.cover_image) # 4
self._events.set_cover_url(event.id, url)
except Exception:
# Un evento a medio crear es peor que ninguno: lo deshacemos.
self._events.delete(event.id)
raise
indexed = self._index(event) # 5 — puede fallar
self._scheduler.schedule_reminder(event.id, event.starts_at - timedelta(hours=24)) # 6
self._notifier.send_event_published(event.organizer_id, event) # 7
return PublishResult(event.id, count, indexed)
def _validate(self, draft):
if not draft.name or not draft.venue:
raise PublishError("incomplete", "Falta el nombre o la sede")
if draft.starts_at <= now():
raise PublishError("past_date", "La fecha del evento ya pasó")
def _index(self, event) -> bool:
"""El índice de búsqueda NO es crítico: si falla, el evento igual se
publica y una tarea nocturna lo reindexa. Por eso este paso se registra
en el log en vez de lanzar."""
try:
self._search.index(event)
return True
except SearchUnavailable:
log.warning("El evento %s se publicó sin indexar", event.id)
return False
Las tres decisiones:
Qué pasa si falla el paso 5. La respuesta correcta es "depende, y hay que decidirlo a propósito". Aquí decidimos que el índice de búsqueda no es crítico: un evento publicado que tarda unas horas en aparecer en el buscador sigue siendo un evento publicado, y tumbar toda la publicación por eso sería peor. Lo que no es aceptable es que falle en silencio, y por eso hay un log.warning y un campo search_indexed en el resultado. Compáralo con los pasos 2, 3 y 4, donde sí deshacemos: un evento sin boletos es un evento roto. Decidir cuáles pasos son críticos y cuáles no es el trabajo intelectual de escribir una fachada; el resto es llamar métodos en orden.
Qué devuelve. Un tipo propio, no el Event de la base de datos ni un booleano. El PublishResult puede llevar información que ningún paso individual tiene —cuántos boletos se crearon, si el índice quedó pendiente— y esa información es justamente la que quien llama necesita para armar su respuesta.
Cuántas dependencias. Seis. Está en el límite cómodo y conviene notarlo: si mañana alguien quiere agregar "y también cobra la cuota de publicación" y "y también genera el contrato del organizador", las dependencias suben a ocho y la clase deja de tener una sola razón para cambiar. Ese es el momento de partirla, no después.
Si tu solución puso el try/except alrededor de los siete pasos en vez de solo de los críticos, vale la pena revisarlo: deshacer un evento porque falló el aviso al organizador es peor que el problema que evita. El manejo de errores de una fachada casi nunca es uniforme.
Ejercicio 3 — Diagnostica esta fachada. El siguiente código apareció en una revisión. Encuentra al menos tres problemas y propón cómo los partirías.
class OrderManager:
def __init__(self, events, seating, pricing, discounts, orders, payments,
tickets, storage, notifier, metrics, reports, invoices,
support_tickets, audit_log):
...
def checkout(self, order, notify=True, dry_run=False, skip_seats=False): ...
def cancel(self, order, reason, refund=True): ...
def refund(self, order, amount, operator): ...
def resend_confirmation(self, order): ...
def daily_sales_report(self, event_id, fmt="csv"): ...
def open_support_ticket(self, order, message): ...
Ver solución
Problema 1: catorce dependencias. Es el termómetro más claro. Catorce dependencias son catorce motivos distintos por los que esta clase puede cambiar, y significa que cualquier cambio en cualquier rincón del sistema puede obligar a tocar este archivo. En el vocabulario de las fundaciones: cohesión mínima, acoplamiento máximo. Y en el del módulo 7: God object.
Problema 2: métodos que no comparten nada. Mira daily_sales_report y open_support_ticket. El primero usa reports y orders; el segundo usa support_tickets y notifier. Ninguno de los dos toca seating, pricing ni payments. No están aquí porque pertenezcan: están aquí porque alguien tenía la clase abierta. Esa es la señal de "métodos que no comparten dependencias" y es la más fácil de verificar objetivamente: haz la tabla de qué método usa qué dependencia y verás los grupos separarse solos.
Problema 3: las banderas booleanas. notify, dry_run, skip_seats en checkout; refund en cancel. Cada una es un caso de uso distinto disfrazado. dry_run=True en particular es el síntoma del primer error común de esta lección: alguien quería calcular un precio sin comprar y no tenía acceso al subsistema. Además, tres banderas son ocho combinaciones posibles, y con casi total seguridad hay pruebas para dos.
Y un cuarto, si lo viste: el nombre. OrderManager no dice qué hace. "Manager" es la palabra que aparece cuando una clase ya no tiene una responsabilidad que nombrar. Que el nombre se haya vuelto vago es consecuencia del crecimiento, no causa, pero es la señal más barata de detectar en una revisión.
Cómo lo partiría:
| Nueva clase | Métodos | Dependencias que necesita de verdad |
|---|---|---|
CheckoutService | checkout(order) | events, seating, pricing, discounts, orders, payments, tickets, storage, notifier, metrics |
CancellationService | cancel(order, reason) | orders, seating, notifier, audit_log |
RefundService | refund(order, amount, operator) | orders, payments, invoices, notifier, audit_log |
SalesReporting | daily_sales(event_id, fmt) | orders, reports |
SupportDesk | open_ticket(order, message) | support_tickets, notifier |
Y las banderas desaparecen así: notify=False se resuelve inyectando un notificador que no hace nada en las pruebas de carga; dry_run=True se resuelve dejando que quien quiera un precio llame al calculador directo; skip_seats=False probablemente no debería existir. cancel(refund=True) se vuelve CancellationService.cancel() llamando a RefundService.refund() cuando corresponda —una fachada llamando a otra, que es legítimo—.
Por qué funciona: partir un God object parece un trabajo enorme y casi siempre es mecánico. La tabla de "qué método usa qué dependencia" hace visible la partición, porque los grupos ya existen en el código: lo único que falta es darles nombre. Si en tu propuesta las clases quedaron distintas pero cada una tiene pocas dependencias y un nombre que dice qué hace, está bien; no hay una única partición correcta, hay particiones defendibles.
Resumen y siguiente paso
En esta lección definiste Facade en una frase —un objeto que ofrece una operación simple y por dentro coordina varios pasos de un subsistema— y te llevaste la que resuelve la confusión del módulo: Adapter traduce, Facade simplifica. El Adapter cambia la forma; el Facade cambia la cantidad. Y viste la diferencia que casi nadie menciona: una fachada no cierra el subsistema, porque el subsistema es tuyo y no hay nada de qué proteger.
Hiciste el caso completo de Boletia. Los diez pasos de una compra estaban escritos a mano en tres archivos, y ya habían divergido: la taquilla no soltaba los asientos cuando el cobro fallaba y el job del revendedor no registraba la métrica. Al ponerlos en CheckoutService los tres llamadores bajaron de cuarenta líneas a menos de diez, las dos divergencias desaparecieron sin parche —porque dejó de haber copias que sincronizar— y aparecieron pruebas de reglas de negocio que antes eran imposibles. La lección detrás: la fachada no es para escribir menos, es para que la secuencia exista una sola vez.
Y viste el riesgo, que es el más real de este módulo. Una fachada crece por buenas razones hasta volverse un God object, y las señales son concretas: más de cinco o seis dependencias, parámetros booleanos que apagan pasos, métodos que no comparten dependencias, un nombre que se volvió vago, pruebas que necesitan armar medio sistema. La regla que lo evita: una fachada por caso de uso, no una por subsistema. Y el matiz que conviene revisar antes de partir: a veces el problema no es que la fachada haga demasiado, sino que las piezas de abajo son demasiado pequeñas.
Antes de avanzar deberías poder: decir la diferencia con Adapter sin dudar; escribir una fachada con sus dependencias inyectadas y decidir qué pasos son críticos y cuáles no; reconocer las cinco señales de una fachada que creció de más; y proponer una partición defendible cuando la encuentres.
La lección 5 cambia de intención otra vez. Hasta aquí envolvimos para traducir y para simplificar; ahora vamos a envolver para agregar. Boletia necesita que los cobros se reintenten cuando falla la red, que cada llamada al proveedor quede registrada y que las consultas de estado se guarden en caché —y necesita las tres cosas sin modificar ninguno de los cuatro proveedores—. Eso es un Decorator: envolver conservando la interfaz, de modo que quien llama no se entere. Vas a ver cómo se apilan, en qué orden, y por qué apilar de más vuelve el flujo imposible de seguir con el dedo.
Recursos
- Refactoring Guru — Facade — el patrón con su diagrama y ejemplos en varios lenguajes. Corto, porque el patrón lo es.
- Martin Fowler — Service Layer — el nombre que recibe una fachada cuando organiza los casos de uso de una aplicación entera. Es lo que acabas de escribir, visto desde la arquitectura.
- Refactoring — Extract Class — el refactor que se aplica cuando la fachada creció de más. El ejercicio 3 es este catálogo en acción.
- The Grug Brained Developer — sobre la complejidad que crece por acumulación de decisiones razonables. Es, en el fondo, la sección del God object contada como comedia.