Módulo 5: Patrones para estructurar y adaptar
7. La frontera con "solo escribe una función que envuelva"
Descripción
Al terminar esta lección vas a tener el freno del módulo instalado: tres preguntas que se contestan en un minuto y que deciden si una dependencia merece un contrato, una clase y una factory, o si lo que necesitaba era una función de tres líneas. Vas a ver las tres aplicadas a dos casos reales de Boletia —uno donde dan "sí, sí, sí" y otro donde dan "no, no, no"— y vas a salir con una escalera de cinco escalones que te dice exactamente cuánto envolver y cuándo subir.
Y vas a salir con la idea que le da sentido a toda la lección, que es más importante que las tres preguntas: una función también es una frontera. La discusión no es entre "aislar la dependencia" y "no aislarla" —aislarla casi siempre conviene—; es entre cuánta maquinaria hace falta para aislarla. Una función de tres líneas en un archivo con nombre te da el 80% del beneficio de un adaptador completo por el 5% del costo. Ese trato es tan bueno que la mayoría de las veces es la respuesta correcta.
Esto importa porque llega en el momento exacto de la guía. Acabas de aprender cuatro formas de envolver cosas, cada una con su caso trabajado y su ganancia medida, y en este punto la tentación de envolverlo todo es fuerte y se siente como buen criterio. Es el síndrome del martillo nuevo que anunciamos en el módulo 1, y esta lección es su vacuna.
Conexión con el módulo: las lecciones 2 a 6 te dieron el catálogo —Adapter traduce, Facade simplifica, Decorator agrega, Composite trata igual a uno y a varios— y cada una cerró con un límite propio: el adaptador que solo renombra, la fachada que reenvía a una pieza, el decorador sin configuración, la lista disfrazada de árbol. Esta lección junta esos cuatro límites en un solo criterio y lo vuelve operativo. La lección 8 —el proyecto— te pide aplicarlo: aislar la integración con el proveedor de pago eligiendo el patrón mínimo que sirva, y justificar por escrito dónde pusiste la frontera. Sin esta lección, ese proyecto se resuelve poniendo todo; con ella, se resuelve poniendo lo que hace falta.
El archivero para tres papeles
Alguien decide organizar sus documentos. Compra un archivero de cuatro cajones, veinte carpetas colgantes, separadores de colores, etiquetas impresas y un índice alfabético. Monta el sistema durante una tarde. Queda impecable.
Tiene tres papeles.
Fíjate en lo que pasa a partir de ese momento, porque es exactamente lo que pasa con el código sobre-envuelto. Para guardar un papel nuevo hay que decidir en qué categoría va, y con tres papeles ninguna categoría es obvia. Para encontrar uno hay que abrir el cajón correcto, buscar la carpeta, revisar el separador. Y cuando llega visita y pregunta dónde está el recibo de la luz, la respuesta honesta es "déjame ver el índice", cuando antes era "ahí, encima de la mesa".
El sistema no está mal diseñado. Está sobredimensionado, que es distinto y más difícil de ver, porque cada pieza tiene una justificación razonable y el conjunto se ve profesional.
Y ahora la parte que importa: la solución no es tirar los papeles al suelo. Una carpeta —una sola, con los tres papeles adentro y un nombre escrito en el lomo— resuelve el 80% del problema. Sabes dónde están, están juntos, y el día que sean cuarenta papeles montas el archivero con la información de qué categorías hacen falta de verdad, que hoy no tienes.
Esa carpeta única es la función de tres líneas de esta lección. No es "no organizar": es organizar en proporción.
Las tres preguntas
Aquí está el freno, y es corto a propósito. Cuando estés por envolver una dependencia externa en un contrato con su clase y su factory, hazte estas tres preguntas:
1. ¿Hay más de una implementación, hoy, de verdad? 2. ¿Necesitas sustituirla en pruebas? 3. ¿La interfaz externa es realmente inestable?
Si las tres son "no": envuelve simple y sigue.
Parecen obvias y no lo son, porque cada una tiene una forma habitual de contestarse mal. Vamos una por una.
1. ¿Hay más de una implementación, hoy, de verdad?
Lo que la pregunta busca es si existe un eje de variación real. Un contrato con una sola implementación no abstrae nada: describe esa implementación con otras palabras.
Las tres formas de contestarla mal:
"Va a haber otra el próximo trimestre." Eso es un plan, no una implementación. Los planes se cancelan, se retrasan y —cuando llegan— casi nunca tienen la forma que imaginabas. El módulo 2 le puso nombre a esto: YAGNI. Y hay una ironía que conviene decir de frente: la interfaz que diseñes hoy, con una sola implementación a la vista, va a tener la forma de esa implementación, así que el día que llegue la segunda tampoco va a encajar y vas a rediseñarla igual. Pagaste por adelantado una opción que no te sirvió.
"Es que en teoría podría cambiar." Todo podría cambiar. La pregunta no es si es concebible sino si es probable y caro. Y esa es la tercera pregunta, no la primera.
"Ya somos dos: la de producción y la falsa de las pruebas." Este es el argumento más honesto de los tres y aun así hay que mirarlo con cuidado, porque una implementación falsa no es un eje de variación del negocio: es una necesidad de la infraestructura de pruebas. Es una razón legítima para poner una costura, pero no necesariamente para un contrato con factory y todo el aparato. Por eso es la pregunta 2 y está separada.
La forma correcta de contestarla es contar. Abre el proyecto y cuenta cuántas clases distintas cumplirían ese contrato hoy. Si el número es uno, la respuesta es no.
2. ¿Necesitas sustituirla en pruebas?
Esta es, en la práctica, la que más veces justifica el trabajo, y también la que más se sobreestima.
Lo que la pregunta busca: si probar tu lógica exige no ejecutar la dependencia. Los casos donde la respuesta es sí de verdad:
- La dependencia sale a la red —una pasarela de pago, un servicio de correo, una API—.
- La dependencia cuesta dinero o tiene efectos irreversibles: cobra, envía un mensaje real, borra algo.
- La dependencia es lenta: si cada prueba tarda dos segundos, la suite deja de correrse.
- La dependencia es no determinista y necesitas controlarla: la hora actual, un número aleatorio, un identificador.
Y los casos donde la respuesta es no, aunque parezca que sí:
- La dependencia es pura y rápida. Una librería que da formato a fechas, que genera un código QR, que valida un correo. Usarla de verdad en la prueba es más fiel y más simple que sustituirla, y además prueba que la usas bien. Sustituirla te dejaría probando que llamaste a un objeto falso, que no es lo mismo que probar que funciona.
- Lo que quieres verificar es lo que la dependencia hace, no lo que tú haces con ella. Si tu prueba se llama "genera el QR correcto", el objeto falso no te sirve para nada.
Una advertencia sobre esta pregunta, porque produce mucho código innecesario: sustituir en pruebas no exige un contrato con factory. Muchas veces alcanza con inyectar la función:
# Sin contrato, sin clase, sin factory: la dependencia entra por parámetro.
def issue_ticket(ticket, *, make_qr=qrforge.make_qr):
qr = make_qr(f"https://boletia.mx/v/{ticket.id}", size=300)
return build_pdf(ticket, qr)
# Y en la prueba:
def test_the_pdf_embeds_the_qr():
pdf = issue_ticket(make_ticket(), make_qr=lambda *a, **kw: b"FAKE_QR")
assert b"FAKE_QR" in pdf
Un parámetro con valor por defecto. Cero archivos nuevos, cero clases, y la costura existe. En Python esto se llama inyección de dependencias igual que la del módulo 4; lo único que cambia es que la dependencia es una función y no un objeto.
3. ¿La interfaz externa es realmente inestable?
Lo que busca: si la dependencia va a cambiar de forma que te obligue a tocar código, y si eso te va a doler.
Las señales de que sí es inestable —y son observables, no adivinables—:
- Ya cambió. Mira su historial de versiones. Si en tres años publicó dos versiones mayores con cambios que rompen, va a publicar una tercera.
- Anunció un cambio. Zafiro anunció su 3.0 con nombres de método distintos y unidades distintas. Eso no es especulación, es un hecho con fecha.
- Es joven. Una librería de dos años con la versión 0.x va a cambiar. Una de doce años en la versión 4 con política de compatibilidad, no.
- Es un servicio, no una librería. Una API remota puede cambiar sin que tú actualices nada. Una librería en tu
requirementsno cambia hasta que tú lo decidas, y eso es una diferencia enorme: con la librería, el momento del cambio lo eliges tú.
Y la señal de que no lo es: lleva años con la misma firma, es parte de la biblioteca estándar del lenguaje, o su API es tan pequeña que no hay mucho que cambiar.
La forma incorrecta de contestarla es la más común y hay que nombrarla: "es una buena práctica no depender de librerías externas". Dicha sin un costo concreto que evitar, es una regla aprendida de memoria. Todo sistema depende de librerías externas —tu lenguaje es una dependencia externa—; la pregunta es de cuáles, en cuántos lugares, y qué pasaría si cambiaran.
La cuarta pregunta que no está en la lista
Hay una que no incluí en las tres y que conviene tener como desempate, porque decide varios casos que las tres dejan empatados: ¿en cuántos lugares se usa?
No está en la lista porque no decide sola. Nueve llamadas idénticas a uuid.uuid4() no piden un contrato: piden una función, o ni siquiera eso. Pero cinco lugares que llaman a la dependencia de formas distintas y cada uno traduce el resultado a su manera —el caso de Zafiro en la lección 1— sí lo piden, y el motivo es que ahí las traducciones ya divergieron.
La regla que ordena esto: lo que multiplica el problema no es la cantidad de llamadas, es la cantidad de lugares que traducen. Cuenta traducciones, no usos.
Ejemplo trabajado: dos dependencias, dos respuestas
Vamos a aplicar las tres preguntas a dos casos de Boletia, y el contraste es lo que enseña.
Caso A: el generador de códigos QR
Cada boleto lleva un código QR que apunta a la página de validación, para que en la puerta lo escaneen. Boletia lo genera con qrforge, una librería que expone una sola función:
# Paquete: qrforge 2.1 — externo. Estable desde 2019.
def make_qr(content: str, *, size: int = 200, ecc: str = "M") -> bytes:
"""Devuelve el PNG del código QR."""
Un desarrollador con este módulo recién terminado escribió esto:
# ⚠️ Lo que NO hacía falta. Cuatro archivos.
# ── qr/provider.py ────────────────────────────────────────────────────────
class QrCodeProvider(Protocol):
def generate(self, content: str, size: int = 300) -> bytes: ...
# ── qr/qrforge_provider.py ────────────────────────────────────────────────
class QrForgeProvider:
"""Adapta qrforge al contrato QrCodeProvider."""
def __init__(self, default_size: int = 300, error_correction: str = "H"):
self._default_size = default_size
self._ecc = error_correction
def generate(self, content: str, size: int | None = None) -> bytes:
return qrforge.make_qr(content, size=size or self._default_size, ecc=self._ecc)
# ── qr/factory.py ─────────────────────────────────────────────────────────
_PROVIDERS = {"qrforge": QrForgeProvider}
def get_qr_provider(name: str | None = None) -> QrCodeProvider:
name = name or settings.QR_PROVIDER
try:
return _PROVIDERS[name]()
except KeyError:
raise UnknownQrProviderError(name) from None
# ── settings.py ───────────────────────────────────────────────────────────
QR_PROVIDER = "qrforge"
Cuatro archivos, unas cuarenta líneas, una opción de configuración, un tipo de error propio y un Protocol. Y ahora las tres preguntas:
¿Hay más de una implementación? No. Hay una, y el diccionario _PROVIDERS con una sola entrada lo grita.
¿Necesitas sustituirla en pruebas? No. make_qr es una función pura, rápida y sin red: genera bytes a partir de un texto. Las pruebas del emisor de boletos la pueden llamar de verdad y así además verifican que la usas bien. Y si algún día quisieras evitarla —porque generar cien QR en una prueba tarda—, el parámetro con valor por defecto que viste arriba lo resuelve en una línea.
¿La interfaz es inestable? No. Una función, tres parámetros, sin cambios desde 2019. Y es una librería, no un servicio: no cambia hasta que tú subas la versión.
Tres "no". La respuesta correcta es esta:
# Archivo: tickets/qr.py — DESPUÉS. Todo lo anterior se reemplaza por esto.
import qrforge
def ticket_qr(ticket_id: str) -> bytes:
"""El código QR que va impreso en el boleto.
Apunta a la página de validación, que es lo que escanean en la puerta.
ecc="H" es la corrección de errores más alta: el QR sigue siendo legible
aunque el boleto se arrugue o se manche, cosa que pasa siempre.
"""
return qrforge.make_qr(f"https://boletia.mx/v/{ticket_id}", size=300, ecc="H")
Tres líneas de código y un comentario que explica las dos decisiones no obvias.
Y aquí está el punto de toda la lección: eso sigue siendo una frontera. Verifícalo con la misma prueba que usarías para el adaptador de Zafiro:
- ¿Cuántos archivos importan
qrforge? Uno. Igual que con el adaptador completo. - ¿Qué pasa si Boletia cambia de librería de QR? Se toca una función. Igual que con el adaptador completo.
- ¿El resto del sistema conoce los detalles —el tamaño, la corrección de errores, la forma de la URL—? No. Igual que con el adaptador completo.
- ¿Se puede sustituir en pruebas? Sí, con un parámetro por defecto, el día que haga falta.
Misma protección, 5% del costo. Lo que perdiste respecto de la versión con cuatro archivos: la posibilidad de elegir el generador de QR por configuración —que nadie pidió— y un Protocol que documenta un contrato con una sola implementación —que no documenta nada—.
Caso B: el proveedor de pago
Ahora las mismas tres preguntas sobre Zafiro, para calibrar:
¿Hay más de una implementación? Sí: cuatro. StripeProvider, MercadoPagoProvider, CashProvider y ZafiroProvider, y en el ejercicio 2 de la lección 3 escribiste el quinto. Los cuatro deben verse iguales desde el checkout o el checkout vuelve a tener un if por proveedor.
¿Necesitas sustituirla en pruebas? Sí, con urgencia. Sale a la red, cobra dinero de verdad y —hasta la lección 3— consultaba su servidor al importarse, lo que ataba toda la suite de Boletia a un tercero.
¿La interfaz es inestable? Sí, y con fecha: Zafiro anunció la versión 3.0 con métodos renombrados y unidades cambiadas. Además es un servicio, así que parte de su comportamiento puede cambiar sin que tú actualices nada.
Tres "sí". Ahí el contrato, el adaptador, la factory y los decoradores se pagan solos, y la lección 3 midió exactamente cuánto.
Qué esperar de esta comparación. Lo primero, lo obvio: dos dependencias externas, dos respuestas opuestas. No hay una regla del tipo "toda dependencia externa se envuelve"; hay un cálculo que se hace caso por caso y que toma un minuto.
Lo segundo, más útil: la diferencia entre los dos casos no es el tamaño de la librería ni su calidad. qrforge no es más simple que Zafiro por casualidad; lo que la hace no necesitar maquinaria es que es estable, única y probable directamente. Si mañana Boletia empezara a generar QR con un servicio remoto de pago por uso, las tres respuestas cambiarían y el adaptador se justificaría —el mismo problema, con otra dependencia—.
Y lo tercero, que es el hábito que quiero dejarte: las tres preguntas se contestan en un minuto y se anotan. Cuando alguien pregunte en una revisión por qué el proveedor de pago tiene contrato y factory y el generador de QR es una función suelta, la respuesta no es una opinión: es una tabla.
QR (qrforge) | Pagos (zafiropay) | |
|---|---|---|
| ¿Más de una implementación? | No: una | Sí: cuatro |
| ¿Sustituir en pruebas? | No: pura y rápida | Sí: red y dinero |
| ¿Interfaz inestable? | No: sin cambios desde 2019 | Sí: 3.0 anunciada |
| Qué merece | Una función de tres líneas | Contrato + adaptador + factory + decoradores |
La escalera: cinco escalones de envoltura
Las tres preguntas dicen si envolver mucho o poco. Esta escalera dice cuánto, en orden de costo creciente. La idea es simple: empieza en el escalón más bajo que resuelva tu problema y sube solo cuando el dolor lo pida.
Escalón 0 — Llamada directa. Usas la librería donde la necesitas, sin más.
pdf_bytes = pdfmagic.Document().save(path)
Cuándo alcanza: un solo lugar de uso, dependencia trivial, código que no es crítico. Es el escalón correcto más veces de lo que la industria admite. Cuándo duele: en el momento en que aparece el segundo lugar de uso.
Escalón 1 — Una función con nombre. El salto más rentable de todos.
def ticket_qr(ticket_id: str) -> bytes:
return qrforge.make_qr(f"https://boletia.mx/v/{ticket_id}", size=300, ecc="H")
Qué compra: un solo lugar que importa la librería, los parámetros decididos una vez, un nombre en el idioma del negocio y la posibilidad de cambiar de librería tocando una función. Cuándo alcanza: casi siempre. Este es el escalón por defecto. Cuándo duele: cuando la función empieza a necesitar estado o configuración que cambia entre usos.
Escalón 2 — Un módulo de funciones, con la dependencia inyectable. Varias funciones relacionadas en un archivo, y el punto de entrada acepta la dependencia como parámetro.
# Archivo: tickets/qr.py
def ticket_qr(ticket_id: str, *, make_qr=qrforge.make_qr) -> bytes: ...
def validation_url(ticket_id: str) -> str: ...
def qr_for_organizer_pass(organizer_id: int, *, make_qr=qrforge.make_qr) -> bytes: ...
Qué compra: lo del escalón 1, más la costura para pruebas. Cuándo alcanza: cuando necesitas sustituir en pruebas pero sigue habiendo una sola implementación real. Cuándo duele: cuando hay estado que se comparte entre llamadas —una conexión, una credencial, una caché—.
Escalón 3 — Una clase con contrato. El Adapter de la lección 2, completo: un Protocol, una clase que lo cumple, dependencias inyectadas.
Qué compra: un lugar para el estado compartido, la posibilidad de tener varias implementaciones, y tipos propios de entrada y salida. Cuándo alcanza: cuando hay dos o más implementaciones reales, o cuando el estado no cabe en una función. Cuándo duele: cuando hay que decidir cuál implementación usar en varios lugares.
Escalón 4 — Contrato, factory y decoradores. Todo el aparato del módulo: el punto único de creación del módulo 4 y las capas de la lección 5.
Qué compra: elegir la implementación en un lugar, agregar comportamiento transversal sin tocar nada, y políticas distintas por contexto de uso. Cuándo alcanza: cuando hay varias implementaciones y varios lugares que las eligen y comportamiento transversal —reintento, registro, caché— que aplicar a todas. Cuándo duele: siempre un poco. Este escalón es el más caro de leer, y por eso hay que llegar a él por necesidad y no por costumbre.
Dos reglas sobre la escalera, y las dos importan:
Subir es barato; bajar casi nunca ocurre. Pasar de una función a un contrato con adaptador toma una tarde, y para entonces sabes cuál es el eje de variación real porque tienes la segunda implementación delante. Pasar de un contrato con factory y tres decoradores a una función toma la misma tarde, pero nadie la hace: hay que convencer al equipo, hay que tocar pruebas que ya pasan, y quitar código se siente como retroceder. Por eso el error de sobre-envolver es asimétrico y por eso el escalón por defecto debe ser bajo.
Un escalón por vez. Cuando el dolor aparezca, sube uno. Si estás en el 1 y necesitas la costura de pruebas, sube al 2, no al 4. Saltar escalones es como se llega al archivero para tres papeles.
Cuándo la función deja de alcanzar
Vale la pena ser preciso sobre las señales de ascenso, porque el error contrario —quedarse corto— también existe y también cuesta.
La función creció más allá de las diez o quince líneas y tiene varias ramas. Si tu envoltorio ya distingue casos, convierte unidades y normaliza errores, deja de ser un envoltorio y es un adaptador. Ponle su archivo y su contrato.
Aparece la segunda implementación de verdad. No planeada: existente. Ese día extraes el contrato con dos ejemplos delante, que es la única forma de diseñar uno bueno.
El estado se vuelve incómodo. Si estás pasando la misma credencial, cliente o conexión por parámetro en cinco funciones, ese estado quiere ser un objeto.
Las pruebas empiezan a hacer trucos. Si para probar algo hay que parchear un módulo, reemplazar un atributo global o poner una variable de entorno, falta una costura. Sube al escalón 2 —o al 3 si además hay estado—.
El mismo comportamiento transversal aparece en tres lugares. Tres funciones que reintentan, tres que registran, tres que cachean. Eso pide un contrato común y decoradores, y es el escalón 4.
Y una señal falsa que conviene desactivar: que la función se vea "poco profesional". Un archivo tickets/qr.py con una función de tres líneas se ve modesto al lado de un paquete qr/ con cuatro archivos, y el modesto es mejor. En una revisión, "esto se ve simple" no es una objeción.
Errores comunes
Confundir "no envolver mucho" con "no envolver nada" (conceptual). Qué pasa: alguien absorbe el mensaje de esta lección, decide que estaba sobre-diseñando, y empieza a llamar a las librerías directamente desde donde sea. A los seis meses qrforge está importada en cuatro archivos, cada uno con un tamaño y una corrección de errores distintos, y los QR de los boletos comprados en la app no se leen igual que los de la web. Por qué pasa: el mensaje "una función alcanza" se recuerda como "no hace falta frontera", y son cosas distintas. Cómo detectarlo: el chequeo de siempre, y es de un segundo: busca el nombre de la librería en todo el proyecto. Si aparece en más de un archivo de producción, no hay frontera —da igual si la que falta es una clase o una función—. Cómo corregirlo: escribe la función. El escalón 1 es barato precisamente para que no haya excusa para quedarse en el 0 cuando hay dos usos.
Contestar las tres preguntas con el futuro (de criterio). Qué pasa: alguien aplica el criterio honestamente y contesta "sí" a las tres… mirando el plan del año. "Vamos a agregar otro proveedor de QR", "vamos a necesitar sustituirlo cuando hagamos pruebas de carga", "seguro cambian la API". Con esas tres respuestas, el aparato completo queda justificado y el criterio no sirvió de nada, porque se puede contestar "sí" a cualquier cosa si te permites hablar del futuro. Por qué pasa: las tres preguntas están en presente por diseño, y el presente es incómodo —admitir que hoy hay una sola implementación obliga a escribir menos código del que uno querría escribir—. Cómo detectarlo: si al contestar usaste algún verbo en futuro, la respuesta no vale. Reformula en pasado y presente: ¿cuántas implementaciones existen hoy?, ¿qué prueba concreta no puedo escribir hoy?, ¿cuántas veces cambió esta API en los últimos tres años?. Cómo corregirlo: contesta con datos verificables y anótalos. Y recuerda el argumento que desarma el "por si acaso": la interfaz que diseñes hoy va a tener la forma de la única implementación que conoces, así que ni siquiera te sirve para el futuro que estás imaginando.
Tratar la escalera como una escalera de calidad (conceptual). Qué pasa: alguien entiende los cinco escalones como niveles de madurez —como si el escalón 4 fuera "código profesional" y el 1 "código de principiante"— y empieza a subir todo lo que toca, o siente vergüenza de dejar algo en el escalón 0. El resultado es un proyecto donde cada dependencia tiene su paquete con contrato, adaptador y factory, y donde entender cualquier flujo exige abrir cinco archivos. Por qué pasa: en casi toda la formación técnica, "más estructura" se presenta como "mejor", y los ejemplos de los libros siempre muestran la versión completa. Cómo detectarlo: mira tu proyecto y cuenta cuántos contratos tienen exactamente una implementación. Cada uno es un escalón de más. Otra señal: si un desarrollador nuevo tarda más de un minuto en contestar "¿qué pasa cuando alguien compra un boleto?", hay demasiada indirección. Cómo corregirlo: la escalera es de costo, no de calidad. El escalón correcto es el más bajo que resuelve tu problema, y quedarse ahí no es conformismo: es la decisión que deja el sistema legible.
Ejercicios
Ejercicio 1 — Aplica las tres preguntas. Para cada dependencia de Boletia, contesta las tres preguntas y di en qué escalón la dejarías. Justifica con datos, no con planes.
(a) slugify — una función de una librería que convierte "Festival Cumbre 2026" en festival-cumbre-2026 para las URLs de los eventos. Se usa en tres archivos. La librería tiene ocho años y una sola función pública.
(b) El servicio de almacenamiento de archivos — donde se guardan los PDF de los boletos y las imágenes de portada. Hoy es un servicio en la nube con su SDK. Se usa en cinco archivos. El equipo quiere poder correr las pruebas sin subir nada, y en desarrollo local usar el disco.
(c) smtplib — el envío de correo de la biblioteca estándar de Python. Boletia lo usa desde EmailChannel, que es uno de los tres canales de notificación.
(d) La API de tipo de cambio — un servicio remoto que Boletia consulta para mostrar precios en dólares a los compradores extranjeros. Se usa en un archivo. El servicio a veces tarda tres segundos y a veces no responde.
Ver solución
(a) No, no, no → escalón 1. Una implementación, es pura y rápida —usarla de verdad en las pruebas es mejor que sustituirla—, y ocho años con una función pública es lo más estable que hay. Tres archivos de uso no cambian la respuesta: piden una función event_slug(name) en un archivo con nombre, no un contrato. Y esa función además compra algo que la librería no da: la política de Boletia sobre qué hacer con los nombres repetidos, que hoy seguramente está resuelta de tres maneras distintas.
(b) Sí, sí, depende → escalón 3, y probablemente 4. La primera es un sí claro: hay dos implementaciones reales y necesarias —nube y disco local— y no es una implementación falsa de pruebas, es una necesidad del entorno de desarrollo. La segunda también: subir archivos en cada prueba es lento y con efectos externos. La tercera depende del SDK, pero es un servicio, así que su comportamiento puede cambiar sin que actualices nada. Un contrato FileStorage con put, get y delete, dos implementaciones y una factory que elige por entorno. El escalón 4 se justifica si además quieres reintento —subir archivos falla más de lo que uno espera—.
(c) No, sí, no → escalón 2 o 3, y la razón no es smtplib. Una sola implementación, API de la biblioteca estándar y estable desde siempre. Pero enviar correo sale a la red y tiene efectos reales, así que hay que poder sustituirlo. Ahora, el detalle importante: la frontera que Boletia necesita no es sobre smtplib, es sobre el concepto "canal de notificación", que tiene tres implementaciones reales —email, SMS, push—. O sea que el contrato existe por la pregunta 1 aplicada a otra abstracción. Es un buen recordatorio de que las tres preguntas se hacen sobre el eje de variación, no sobre la librería.
(d) No, sí, sí → escalón 2 como mínimo, tirando a 3. Una implementación y un solo lugar de uso, así que la primera es no. Pero es un servicio remoto, lento e inestable, y eso hace que la segunda y la tercera sean sí con fuerza: sin costura, cada prueba que toque precios sale a internet y tarda tres segundos. Un módulo exchange.py con usd_rate() y la llamada inyectable alcanza. Sube al 3 si necesitas caché o reintento, que casi seguro vas a necesitar —y ahí ya estás en territorio de la lección 5—.
Por qué funciona: las cuatro respuestas son distintas y ninguna se resuelve con una regla general. Fíjate especialmente en (c), donde la respuesta correcta obliga a corregir la pregunta: antes de decidir cuánto envolver, hay que tener claro qué es lo que varía. Y en (d), donde una sola implementación en un solo lugar igual justifica una costura, porque las preguntas 2 y 3 pesan por sí solas.
Ejercicio 2 — Baja de escalón. El siguiente código existe en Boletia. Aplica las tres preguntas, decide el escalón correcto y reescríbelo.
# ── notifications/formatter.py ────────────────────────────────────────────
class MessageFormatter(Protocol):
def format(self, template: str, context: dict) -> str: ...
# ── notifications/jinja_formatter.py ──────────────────────────────────────
class JinjaFormatter:
def __init__(self, template_dir: str, autoescape: bool = True):
self._env = jinja2.Environment(
loader=jinja2.FileSystemLoader(template_dir),
autoescape=autoescape,
)
def format(self, template: str, context: dict) -> str:
return self._env.get_template(template).render(**context)
# ── notifications/formatter_factory.py ────────────────────────────────────
_FORMATTERS = {"jinja": JinjaFormatter}
def get_formatter(name: str | None = None) -> MessageFormatter:
name = name or settings.TEMPLATE_ENGINE
return _FORMATTERS[name](template_dir=settings.TEMPLATE_DIR)
Ver solución
Las tres preguntas:
¿Más de una implementación? No. _FORMATTERS tiene una entrada y settings.TEMPLATE_ENGINE siempre vale "jinja". Nadie ha escrito un segundo motor de plantillas ni lo va a hacer: cambiar de motor obligaría a reescribir todas las plantillas, así que ni siquiera es un cambio plausible.
¿Sustituir en pruebas? No. Dar formato a una plantilla es puro y rápido —lee archivos del disco, que es local—. Y lo que quieres verificar en las pruebas de notificaciones es que el mensaje dice lo correcto, cosa que un formateador falso no te dice.
¿Interfaz inestable? No. La API de Environment/get_template/render lleva más de una década igual, y es una librería, no un servicio.
Tres "no". Escalón correcto: 1 o 2. Y hay un matiz que decide entre los dos: sí hay estado compartido —el Environment es caro de construir y conviene reutilizarlo—. Eso normalmente empujaría al escalón 3, pero se resuelve con una función memorizada:
# Archivo: notifications/templates.py — DESPUÉS. Un archivo, dos funciones.
import functools
import jinja2
@functools.lru_cache(maxsize=1)
def _environment() -> jinja2.Environment:
"""El entorno de plantillas se construye una sola vez.
Jinja compila y cachea las plantillas por entorno, así que crear uno nuevo
en cada mensaje sería lento. lru_cache es la forma más corta de tener un
único entorno sin escribir una clase para sostenerlo.
"""
return jinja2.Environment(
loader=jinja2.FileSystemLoader(settings.TEMPLATE_DIR),
autoescape=True, # nunca configurable: apagarlo abre un XSS en el correo
)
def render(template: str, **context) -> str:
"""Da formato a una plantilla de notificación.
Único lugar del sistema que sabe que usamos Jinja.
"""
return _environment().get_template(template).render(**context)
Qué se ganó: tres archivos y una opción de configuración menos. La opción TEMPLATE_ENGINE no existía para nadie y su desaparición no la va a extrañar nadie.
Qué se ganó además, y es lo interesante: el autoescape dejó de ser configurable. En la versión anterior era un parámetro del constructor, así que alguien podía construir un formateador con autoescape=False y abrir un agujero de seguridad en los correos. Bajar de escalón eliminó una decisión que nadie debía poder tomar. Menos configuración no es solo menos código: a veces es menos superficie de error.
Qué se perdió: la costura de pruebas. Si algún día hiciera falta, se agrega en una línea —def render(template, *, env=None, **context)— y sigues en el escalón 2.
Si tu solución conservó el Protocol "por si acaso", vuelve a la pregunta 1: un Protocol con una implementación no documenta un contrato, documenta esa clase. Y si conservaste la clase para sostener el Environment, es defendible —es el escalón 3 y es una diferencia de gusto—; lo que no es defendible es la factory con un diccionario de una entrada.
Ejercicio 3 — El caso ambiguo. No todos los casos dan tres "sí" o tres "no". Boletia necesita geocodificar la dirección de las sedes —convertir "Av. Insurgentes Sur 3000, CDMX" en coordenadas— para mostrar el mapa. Los datos:
- Se usa en dos lugares: al crear un evento y en un job que corrige sedes viejas.
- Hoy hay una implementación: una API remota de pago por consulta.
- Las pruebas del creador de eventos no deberían salir a internet ni gastar consultas.
- La API es de un proveedor grande y lleva años estable, pero el equipo comercial está evaluando cambiar a otra más barata el próximo semestre.
Contesta las tres preguntas, decide el escalón y —esto es lo importante— escribe la justificación en dos o tres líneas, como la escribirías en un comentario de revisión.
Ver solución
Las tres preguntas, con honestidad:
¿Más de una implementación? No. Hay una. El cambio de proveedor está siendo evaluado para el próximo semestre, y eso es un plan, no una implementación. Si dejaras que esta pregunta se conteste con el plan, estarías cometiendo el segundo error común de la lección.
¿Sustituir en pruebas? Sí, y con fuerza. Sale a la red y cuesta dinero por consulta. Una suite que geocodifica de verdad es lenta y además genera una factura. Esta pregunta sola justifica una costura.
¿Interfaz inestable? No, la interfaz. Lleva años estable y el proveedor es grande. Pero ojo con la distinción: es un servicio, así que su disponibilidad y su latencia sí son inestables aunque su interfaz no lo sea. Esa parte es real y es un argumento distinto del que la pregunta hace.
El escalón: 2, con un ojo puesto en el 3.
# Archivo: venues/geocoding.py
class GeocodingUnavailable(RuntimeError):
"""Error propio: quien geocodifica no debería conocer los del proveedor."""
def coordinates_for(address: str, *, client=None) -> tuple[float, float]:
"""Convierte una dirección en (latitud, longitud).
Único lugar del sistema que habla con el proveedor de geocodificación.
El cliente se puede inyectar para las pruebas: geocodificar de verdad
cuesta dinero por consulta.
"""
client = client or _default_client()
try:
hit = client.geocode(address, country="MX")
except geoapi.GeoError as err:
raise GeocodingUnavailable(f"No se pudo geocodificar: {address}") from err
return (hit.lat, hit.lng)
La justificación, como iría en una revisión:
Lo dejo como función con el cliente inyectable, no como contrato con factory. Hoy hay una implementación —el cambio de proveedor está en evaluación, no decidido— así que un
Protocoldescribiría esa API en vez de abstraerla. Lo que sí necesitamos ya es la costura para pruebas, porque cada consulta cuesta, y eso se resuelve con un parámetro. Si el cambio de proveedor se aprueba, extraer el contrato con las dos APIs delante nos va a costar una tarde y va a quedar mejor que adivinarlo hoy.
Por qué esta es la respuesta y no "pongamos el contrato ya": porque el argumento del futuro tiene una respuesta concreta, no una apuesta. Si el cambio se aprueba, vas a tener las dos APIs a la vista y vas a poder diseñar un contrato que le quede a las dos. Si diseñas hoy con una, casi seguro modelas conceptos que solo existen en ese proveedor —su forma de reportar la precisión, su noción de "coincidencia parcial"— y el día del cambio lo rediseñas igual, con la diferencia de que ahora hay que migrar código que ya lo usa.
Y lo que sí se hace hoy, porque es barato y no depende del futuro: el error propio. GeocodingUnavailable cuesta tres líneas y evita que geoapi.GeoError se escape al resto del sistema. Fíjate en el patrón: de todo el aparato de la lección 3, lo que casi siempre vale la pena aunque estés en el escalón 2 es traducir los errores. Es lo más barato y lo que más se filtra si no lo haces.
Si tu respuesta fue "escalón 3, contrato desde ya" y la justificaste con el costo de migrar después, es defendible siempre que hayas puesto un número —"si el cambio se aprueba, migrar dos lugares nos cuesta X"—. Lo que no es defendible es "por si acaso" sin costo estimado. La diferencia entre criterio y superstición es si puedes decir cuánto cuesta cada opción.
Resumen y siguiente paso
En esta lección instalaste el freno del módulo: tres preguntas que deciden si una dependencia merece el aparato completo o una función de tres líneas. ¿Hay más de una implementación, hoy, de verdad? ¿Necesitas sustituirla en pruebas? ¿La interfaz externa es realmente inestable? Si las tres son "no", envuelve simple y sigue. Y viste cómo se contesta mal cada una: la primera con planes, la segunda confundiendo "quiero una costura" con "necesito un contrato", la tercera con reglas aprendidas de memoria sobre las dependencias externas.
Viste la idea que sostiene todo lo demás: una función también es una frontera. El ticket_qr de tres líneas te da lo mismo que un adaptador completo —un solo archivo que importa la librería, los detalles decididos una vez, cambiar de librería tocando una función— por el 5% del costo. La discusión nunca fue entre aislar y no aislar; fue entre cuánta maquinaria.
Comparaste dos dependencias de Boletia con la misma vara. qrforge: tres "no", una función. zafiropay: tres "sí" —cuatro implementaciones, red y dinero, versión 3.0 anunciada—, contrato, adaptador, factory y decoradores. La diferencia no está en el tamaño de la librería sino en las respuestas, y esas respuestas se anotan en una tabla que sirve como justificación en cualquier revisión.
Y te llevaste la escalera de cinco escalones —llamada directa, función, módulo con dependencia inyectable, clase con contrato, contrato con factory y decoradores— con sus dos reglas: empieza en el más bajo que resuelva tu problema y sube un escalón por vez, cuando el dolor lo pida. Más la asimetría que hace importante esta lección: subir toma una tarde y bajar toma la misma tarde, pero nadie la hace nunca.
Antes de avanzar deberías poder: contestar las tres preguntas sobre una dependencia real sin usar verbos en futuro; decir en qué escalón está cada frontera de un proyecto y por qué; reconocer las señales de que una función dejó de alcanzar; y —lo más útil en el trabajo— escribir en tres líneas la justificación de por qué elegiste el escalón que elegiste.
La lección 8 es el proyecto, y junta todo el módulo. Vas a tomar la integración de Boletia con el proveedor de pago —la que sigue esparcida en varios archivos— y la vas a aislar detrás de una interfaz propia, eligiendo el patrón mínimo que sirva. La entrega no es solo el código: es el código más la justificación de dónde pusiste la frontera y por qué ahí. Esa segunda parte es la que se juzga, porque es la que demuestra criterio. Y al terminar vas a tener una dependencia externa domesticada, que es exactamente el punto de partida del módulo 6: una vez que las piezas del sistema tienen fronteras limpias, la siguiente pregunta es cómo se avisan las cosas entre ellas.
Recursos
- The Grug Brained Developer — el mejor ensayo que existe sobre la complejidad como enemigo real. Es esta lección contada como comedia, y conviene leerlo entero.
- Martin Fowler — YAGNI — el costo de construir hoy lo que quizá necesites mañana, desarmado con números. Es el fundamento de la primera pregunta.
- Sandi Metz — The Wrong Abstraction — por qué una abstracción equivocada cuesta más que la duplicación que venía a eliminar. Explica mejor que nada por qué bajar de escalón casi nunca ocurre.
- Refactoring — Inline Function y Collapse Hierarchy — los dos refactores que se aplican al bajar de escalón. Existen, están catalogados, y casi nadie los usa.