Módulo 2: El doble que miente: el problema que motiva los contratos

7. El costo en producción

Descripción

Hasta aquí el módulo ha tratado la divergencia como un fenómeno técnico: por qué ocurre, en cuántas formas, por qué el unit test no la ve. Falta la parte que le importa al negocio y que justifica todo el esfuerzo de las disciplinas que vienen: cuánto cuesta cuando una de estas mentiras cruza el punto ciego y llega a producción. Porque si el costo fuera trivial —un log feo, un caso raro sin consecuencias— podríamos encogernos de hombros y seguir. No lo es. La divergencia que pasa el unit test en verde y explota con la pieza real tiene una factura concreta, y en esta lección la vamos a leer línea por línea, empezando por el traceback crudo del incidente y siguiendo por las tres dimensiones que hacen que este tipo de bug sea de los más caros que existen.

Las tres dimensiones son estas, y las vamos a recorrer con el caso del get→None de la lección 3. Primera: la detección tardía. El verde del unit test no fue neutral; fue una señal activa de "adelante, despliega". El bug no se coló a pesar de los tests: se coló con la bendición de los tests, que es peor, porque nadie sospecha de lo que salió verde. Segunda: el radio de impacto. Un bug que llega a producción no afecta a un test; afecta a cada usuario que pase por esa ruta, en tiempo real, hasta que alguien lo note y lo revierta. Tercera: la depuración confusa. Estos bugs suelen estallar lejos de su causa —un KeyError en el repositorio por una suposición hecha en el servicio—, y el equipo pierde horas buscando el problema donde no está, porque el síntoma y la causa viven en piezas distintas. Detección tardía, radio amplio, depuración confusa: las tres se multiplican.

Conexión con el módulo: esta lección cierra el arco del módulo poniéndole precio al problema que las seis anteriores describieron. Las lecciones 2 a 5 mostraron las divergencias; la 6 explicó por qué son invisibles para el unit test; esta explica por qué esa invisibilidad es cara y no solo incómoda. Es la justificación económica de los módulos 3 a 7: el contrato y la integración tienen un costo —hay que escribirlos y mantenerlos—, y esta lección demuestra que ese costo es una fracción minúscula de lo que cuesta el bug que previenen. Cuando en el módulo 3 te pida escribir una batería de contrato, quiero que recuerdes esta factura y entiendas que no es burocracia: es un seguro barato contra un siniestro caro.

Analogía: el error de imprenta

Imagina una errata —una sola letra mal— en un cartel. Si la cazas en el borrador, en tu pantalla, la corriges en dos segundos y nadie se entera: costo, cero. Si se te pasa y la cazas en la prueba de impresión, antes de la tirada, reimprimes una hoja: costo, unos centavos y un rato. Si se te pasa hasta después de imprimir cien mil carteles y pegarlos por toda la ciudad, la errata ahora vive en cada esquina: hay que reimprimir todo, mandar a un equipo a despegar y repegar, aguantar los memes de la gente que fotografió el error, y explicarle al cliente por qué su marca salió mal escrita en la avenida principal. La misma letra equivocada cuesta cero, centavos o una fortuna, y lo único que cambió es en qué etapa la cazaste. Cuanto más tarde, más cara.

La divergencia de un doble es esa errata, y las etapas son las mismas. Cazada en un contrato, en tu máquina, es el borrador: un test rojo local, lo corriges en minutos, nadie se entera —costo, casi cero—. Cazada en un test de integración en CI, antes de desplegar, es la prueba de impresión: el pipeline se pone rojo, no despliegas, arreglas y reintentas —costo, un rato—. Cazada en producción, con la pieza real y usuarios de verdad, es la ciudad empapelada: cada socio que pase por la ruta rota se topa con el error, alguien tiene que notarlo, diagnosticarlo, revertir el deploy, y explicar el incidente —costo, una fortuna en tiempo, confianza y dinero—. Esta lección es sobre la tercera etapa, la cara, para que entiendas por qué vale tanto la pena cazar la errata en la primera. El contrato y la integración no son más que "revisar el cartel antes de imprimir cien mil".

El incidente: el traceback crudo

Reconstruyamos el momento. La feature de cancelación idempotente —la de la lección 3, con el guard if booking is None: return 0— pasó sus unit tests en verde y se desplegó. En producción, el repositorio no es el fake: es el SqliteBookingRepository real. Un socio abre un enlace viejo, de una reserva que ya se borró, y pulsa "Cancelar". Esto es lo que el sistema hace, capturado como aparecería en el log del servidor:

# prod_incident.py — el handler de produccion, con el repositorio real
repo = SqliteBookingRepository(sqlite3.connect(":memory:"))   # produccion: real
service = IdempotentBookingService(
    FixedClock(datetime(2026, 3, 1, 9)), StubPaymentGateway(ok=True),
    SpyEmailSender(), repo)

# El socio cancela una reserva que ya no existe (doble clic / enlace caduco).
refund = service.cancel("bk-deleted-yesterday")
print(f"Reembolso: {refund}")   # nunca llega aqui

Qué esperar. En mi máquina (Python 3.14.0), corriendo el script directamente, el traceback crudo —el que acabaría en tu log de errores de producción—:

Traceback (most recent call last):
  File "/tmp/prod_incident.py", line 14, in <module>
    refund = service.cancel("bk-deleted-yesterday")
  File "/tmp/reservo/idempotent.py", line 19, in cancel
    booking = self._repo.get(booking_id)
  File "/tmp/reservo/sqlite_repo.py", line 50, in get
    raise KeyError(booking_id)       # el real LANZA si no existe
    ^^^^^^^^^^^^^^^^^^^^^^^^^^
KeyError: 'bk-deleted-yesterday'

Ese KeyError sin capturar es, en una aplicación web real, un error 500: el servidor responde "error interno", el socio ve una pantalla rota en vez de un amable "no había nada que cancelar, reembolso 0", y el evento se escribe en el log de errores. Lo que el desarrollador diseñó como el caso más suave del sistema —cancelar algo inexistente, que debía ser un no-op tranquilo— se convirtió en el caso que tira el servidor. La feature idempotente, cuya razón de ser era manejar con calma el doble clic y el enlace viejo, hace exactamente lo contrario en producción: convierte esos casos benignos en fallos. Con esta imagen concreta —el traceback en el log, el 500 en la pantalla del socio— desglosemos las tres dimensiones del costo.

Dimensión uno: la detección tardía

Lo primero que hay que entender es que el verde del unit test no fue una detección que falló; fue una luz verde que se encendió. Cuando la suite pasó, no dijo "no encontré problemas"; dijo "adelante, esto está bien, despliega con confianza". El deploy no ocurrió a pesar de los tests: ocurrió porque los tests lo autorizaron. Y ahí está lo insidioso de la detección tardía: nadie audita lo que salió verde. El código con un unit test rojo se detiene y se revisa; el código con un unit test verde pasa sin que nadie lo mire dos veces. La divergencia se aprovecha exactamente de esa confianza: se disfraza de verde, obtiene el sello de aprobación, y cruza a producción por la puerta principal, con permiso.

El costo de detectar tarde no es lineal, es exponencial, y es la lección del error de imprenta. En el borrador (contrato local), la divergencia es un test rojo que corriges antes de comitear: minutos, cero usuarios afectados, cero gente involucrada más que tú. En la prueba de impresión (integración en CI), es un pipeline rojo que frena el deploy: un rato, cero usuarios, quizá una conversación con el equipo. En producción, es un incidente: usuarios afectados en tiempo real, alguien de guardia despertado, un diagnóstico bajo presión, un rollback, un post-mortem, y la confianza del equipo en su suite erosionada. La misma divergencia —un carácter, .get() en vez de [...]— cuesta minutos o cuesta un día entero de varias personas, y lo único que cambia es en qué etapa se cazó. Detectar tarde es multiplicar el costo por el número de etapas que la errata logró cruzar sin que nadie la viera.

Dimensión dos: el radio de impacto

Un unit test que falla afecta a una cosa: al test. Un bug en producción afecta a todos los que pasan por la ruta rota, en tiempo real, hasta que alguien lo detiene. Esa es la diferencia de radio, y es enorme. En nuestro caso, cada socio que abra un enlace viejo o haga doble clic en "Cancelar" —que no es un caso raro; es de los más comunes en cualquier aplicación con enlaces que se comparten y botones que se pulsan dos veces— recibe un error 500. No uno; todos, mientras el deploy roto esté vivo. Si el bug está una hora en producción antes de que alguien lo note y revierta, el radio es "todos los que intentaron cancelar algo inexistente en esa hora". Podrían ser decenas, cientos, miles, según el tráfico.

Y el radio no se mide solo en usuarios; se mide en consecuencias por usuario. Aquí el daño es un error visible y una acción frustrada —malo, pero recuperable: el socio reintenta o escribe a soporte—. En otras divergencias el radio es peor y más silencioso. Piensa en la de unicidad de la lección 5: si el fake dejó pasar la creencia de que "no puede haber dobles reservas" y el real no tenía el constraint, el radio no es un error visible sino datos corruptos —dos socios con la misma sala a la misma hora, que solo se descubre cuando los dos se presentan a la reunión—. Ese radio es más caro porque el daño ya está hecho en los datos cuando lo detectas, y limpiarlo (decidir quién se queda con la sala, compensar al otro) cuesta mucho más que revertir un deploy. La regla del radio: un bug de producción daña en proporción al tráfico que lo toca y a lo irreversible de su efecto, y las dos cosas escalan con el tiempo que el bug pasa vivo.

Dimensión tres: la depuración confusa

La tercera dimensión es la que se lleva las horas de la gente, y nace de una propiedad de estos bugs: el síntoma y la causa viven en piezas distintas. Mira otra vez el traceback. El error —el KeyError— estalla en sqlite_repo.py, en el get del repositorio. Pero el repositorio está haciendo exactamente lo correcto: lanzar cuando no encuentra, tal como su contrato manda. La causa real del bug no está ahí; está en idempotent.py, en el guard if booking is None que asumió un contrato equivocado, escrito por otra persona, en otro archivo, quizás semanas antes. El traceback te lleva a la escena del síntoma, no a la del crimen.

Ponte en los zapatos de quien recibe la alerta a medianoche. Ve un KeyError en el repositorio. Su primer instinto, razonable, es sospechar del repositorio: "¿está mal el get? ¿por qué lanza?". Pero el get está impecable —lo mires como lo mires, hace lo correcto—. Puede pasar un buen rato defendiendo la inocencia del repositorio antes de levantar la vista y preguntarse quién llama a ese get y con qué expectativa. Y aun cuando llegue a idempotent.py, el guard if booking is None: return 0 se ve bien: es defensivo, es claro. Hace falta el salto conceptual —"este guard supone que get devuelve None, pero el repo real lanza"— para entender que el bug es un desacuerdo entre dos piezas correctas, no un defecto en una. Ese salto es difícil justo porque ninguna pieza está mal por sí sola; el error es la grieta entre ellas, y las grietas no salen en los tracebacks. Esta clase de depuración —donde cada pieza que examinas resulta inocente— es la que quema tiempo y paciencia, y es típica de los bugs de divergencia precisamente porque, como vimos en la lección 3, no son culpa de nadie: son un desacuerdo que nadie tenía la responsabilidad de detectar.

La cuenta final, y por qué el contrato es barato

Sumemos. Un carácter mal en un fake produjo: un incidente de producción (detección tardía), errores 500 a cada socio que canceló algo inexistente mientras duró (radio de impacto), y una sesión de depuración que empieza culpando a la pieza inocente (depuración confusa). Traduce eso a lo que de verdad cuesta: tiempo de ingeniería de guardia, tiempo de diagnóstico de varias personas, un rollback, un post-mortem, socios frustrados, y —el costo más difícil de recuperar— un poco de la confianza del equipo en su propia suite de tests, porque "estaba todo verde y aun así se rompió" es una frase que corroe.

Ahora compara con lo que habría costado cazar la misma errata en el borrador. Una batería de contrato que afirma "get de un id ausente lanza", corrida contra el fake y el real: quince minutos de escribir, se corre en centésimas de segundo, y se pone roja en la máquina del desarrollador —contra el fake descuidado— antes de cualquier commit. El bug nunca llega a CI, mucho menos a producción. La aritmética es abrumadora: minutos de prevención contra un día-persona de siniestro, y eso sin contar la confianza. Por eso el contrato y la integración no son un lujo de equipos meticulosos ni burocracia de proceso: son el cálculo económico más favorable que hay en testing. Pagas centavos de prevención para no pagar una fortuna de incidente. Con esta factura leída, el módulo 3 deja de ser "otra técnica que aprender" y se vuelve lo obvio: la forma barata de no volver a leer este traceback en tu log.

Errores comunes

Descartar el caso como "raro" y bajarle prioridad. Qué pasa: alguien ve "cancelar un id inexistente" y piensa "eso casi no pasa, lo arreglo cuando tenga tiempo". Por qué pasa: el caso suena a borde improbable. Cómo detectarlo: los enlaces viejos y el doble clic son de lo más común en aplicaciones reales; "cancelar algo que ya no está" pasa todo el tiempo. Y aunque fuera raro, un caso raro que tira un 500 sigue siendo un 500. Cómo corregirlo: mide la frecuencia real antes de llamar raro a un caso, y recuerda que la gravedad de un bug es frecuencia × daño; un daño alto (error 500, datos corruptos) merece atención aunque la frecuencia parezca baja.

Tratar el incidente como un fallo de la persona, no del proceso. Qué pasa: tras el post-mortem, alguien concluye "el que escribió el fake fue descuidado, que tenga más cuidado". Por qué pasa: buscar un culpable individual es más cómodo que cambiar el proceso. Cómo detectarlo: la lección 6 ya demostró que ningún cuidado individual cierra esta grieta —el fake del datetime era impecable y aun así divergió—. Si tu única defensa contra la próxima divergencia es "que la gente tenga cuidado", no tienes defensa. Cómo corregirlo: los incidentes de divergencia se previenen con mecanismos (contrato, integración en CI), no con reprimendas. El post-mortem correcto no termina en "ten más cuidado" sino en "agregamos un contrato para BookingRepository que se corre en CI".

Creer que el rollback cierra el incidente. Qué pasa: se revierte el deploy, el 500 desaparece, y se da el caso por cerrado. Por qué pasa: el síntoma se fue, así que parece resuelto. Cómo detectarlo: el rollback quita el bug de producción, pero la divergencia sigue ahí —el fake sigue devolviendo None, el guard sigue suponiendo mal—, lista para volver en el próximo intento de desplegar la feature. Y si hubo datos corruptos (como en la divergencia de unicidad), el rollback no los limpia. Cómo corregirlo: el rollback es primeros auxilios, no cura. Cerrar el incidente de verdad es reparar la divergencia (arreglar el guard o el contrato del repo), agregar el mecanismo que la habría cazado (contrato/integración), y limpiar cualquier dato que el bug dañó mientras estuvo vivo.

Ejercicios

Ejercicio 1 — Ubica la etapa y el costo. Para cada momento en que se caza la divergencia del get→None, di en qué "etapa de imprenta" estás y estima el costo relativo: (a) un contrato local se pone rojo antes del commit; (b) un test de integración en CI frena el deploy; (c) el error 500 aparece en el log de producción una hora después del deploy.

Ver solución
  • (a) El borrador. Costo mínimo. El contrato rojo aparece en tu máquina antes de compartir nada; lo corriges en minutos, cero usuarios afectados, cero personas involucradas más que tú. Es cazar la errata en la pantalla: nadie se entera de que existió.
  • (b) La prueba de impresión. Costo bajo. El pipeline rojo frena el deploy antes de que llegue a usuarios; cuesta un rato y quizá una conversación de equipo, pero ningún socio ve el bug. Es cazar la errata en la hoja de prueba: reimprimes una página, no cien mil.
  • (c) La ciudad empapelada. Costo alto. El bug ya está en producción: usuarios afectados en tiempo real durante esa hora, alguien de guardia, diagnóstico bajo presión, rollback, post-mortem. Es la errata en cien mil carteles pegados. La misma divergencia, pero cazada en la etapa más cara.

La moraleja cuantitativa: el costo crece con la etapa, no con la complejidad del bug. Un bug trivial cazado tarde cuesta más que un bug complejo cazado temprano. Por eso mover la detección hacia el borrador (contrato) es la inversión de mayor retorno en testing.

Ejercicio 2 — Compara los radios. Dos divergencias llegan a producción: la del get→None (error 500 al cancelar un id inexistente) y la de unicidad (el sistema aceptó dobles reservas porque faltaba el constraint). Compara sus radios de impacto en dos ejes: cuántos usuarios afecta y qué tan reversible es el daño. ¿Cuál es peor y por qué?

Ver solución

La del get→None: afecta a cada socio que cancele algo inexistente mientras el deploy está vivo. El daño por usuario es un error 500 visible y una acción frustrada: molesto, pero reversible —el socio reintenta después del rollback, no quedó ningún dato dañado—. El radio es "usuarios en la ventana rota", y una vez revertido el deploy, el problema desaparece sin residuo.

La de unicidad: afecta a cada par de socios que reservó la misma sala a la misma hora mientras faltaba el constraint. El daño por usuario es datos corruptos: dos reservas que no debían coexistir, y que el sistema aceptó como válidas. Es irreversible por sí solo —el rollback del código no borra las dobles reservas que ya se escribieron; alguien tiene que encontrarlas, decidir quién se queda con la sala, compensar al otro, y arreglar los datos a mano—. Y es silenciosa: nadie ve un error; el bug se descubre cuando dos socios se presentan a la misma reunión, quizá días después.

Cuál es peor: la de unicidad, por dos razones. Su daño es irreversible (datos corruptos que sobreviven al rollback) y silencioso (no hay error que dispare una alerta, así que puede vivir mucho más tiempo antes de detectarse, ampliando el radio). Un error 500 al menos grita; una doble reserva se queda callada corrompiendo el estado. La lección: los bugs de divergencia que corrompen datos en silencio son más caros que los que fallan ruidosamente, aunque los segundos se vean más dramáticos.

Ejercicio 3 — Escribe el post-mortem correcto. El incidente del get→None se cerró con "el desarrollador que escribió el fake debe tener más cuidado con .get() vs [...]". Explica por qué ese cierre es insuficiente y redacta las acciones que un post-mortem correcto listaría.

Ver solución

Por qué "ten más cuidado" es insuficiente: es una acción sobre una persona, no sobre el sistema, y la lección 6 ya demostró que ningún cuidado individual cierra la grieta de la divergencia —la del datetime venía de un fake impecable—. "Ten más cuidado" no evita la próxima divergencia por otra causa (el real gana un constraint, un tipo cambia); solo pospone el próximo incidente hasta que alguien, inevitablemente, se equivoque de nuevo o el sistema cambie. Un post-mortem que termina en una reprimenda no deja al equipo más protegido que antes.

Las acciones de un post-mortem correcto, todas sobre el proceso:

  1. Reparar la divergencia concreta: decidir el contrato real de get (lanza para id ausente) y corregir la feature idempotente para que lo respete (try/except KeyError, no if booking is None), y corregir el fake descuidado para que honre el contrato ([...] en vez de .get()).
  2. Agregar el mecanismo que lo habría cazado: una batería de contrato para BookingRepository que afirme sus acuerdos de comportamiento (incluido "get de un id ausente lanza") y se corra contra el fake y el real en CI, de modo que cualquier divergencia futura se ponga roja antes del deploy.
  3. Agregar cobertura de integración en las costuras de más riesgo (el repositorio), para probar los bordes contra la pieza real, no solo contra el fake.
  4. Revisar si hubo datos dañados durante la ventana del incidente y limpiarlos (aquí no los hubo, pero es parte del checklist).
  5. Compartir el aprendizaje: documentar que "todo verde" con dobles no garantiza integración sana, para que el equipo no vuelva a leer el verde como una garantía que no es.

El hilo conductor: cada acción instala un mecanismo o repara un desacuerdo, ninguna pide "más cuidado". Así el próximo desarrollador —o el próximo cambio en el esquema— se topa con un test rojo local, no con un incidente de producción.

Resumen y siguiente paso

En esta lección le pusiste precio al problema del módulo. Leíste el traceback crudo del incidente —un KeyError sin capturar que en una app web es un error 500, en el caso que la feature idempotente debía manejar con más calma— y desglosaste las tres dimensiones que hacen caro a este tipo de bug: la detección tardía (el verde no fue neutral, fue una luz verde al deploy, y detectar tarde multiplica el costo como la errata en cien mil carteles), el radio de impacto (cada usuario de la ruta rota, con daño proporcional al tráfico y a lo irreversible del efecto) y la depuración confusa (el síntoma estalla en la pieza inocente, lejos de la causa, porque el bug es un desacuerdo entre dos piezas correctas). Y sacaste la cuenta que justifica todo lo que viene: minutos de contrato contra un día-persona de incidente.

Antes de avanzar deberías poder: explicar por qué el verde del unit test fue una autorización activa y no una detección fallida; comparar el radio de dos divergencias por usuarios afectados y reversibilidad; y redactar un post-mortem que instale mecanismos en vez de pedir cuidado.

Con esto, el módulo te ha dado el problema completo: qué es la divergencia, por qué es inevitable, en cuántas formas aparece, por qué el unit test no la ve y cuánto cuesta. Falta que lo pongas en práctica tú. La lección 8 es el mini-proyecto: te damos una feature de Reservo con una divergencia plantada, y tu trabajo es escribir el unit test que se queda verde, el de integración que se pone rojo, y el diagnóstico de por qué el verde mentía —además de proponer, sin implementarla aún, la forma del contrato que lo habría cazado—. Es el ensayo final antes de que el módulo 3 te dé, por fin, la solución.

Recursos