Apuntes · Capítulo 7Diseño y arquitectura

Arquitectura y decisiones de arquitectura

Arquitectura y decisiones de arquitectura

La semana pasada diseñaron la API de Kallpa: qué recursos expone, qué códigos de estado responde y qué promete a quien la llama. Dos semanas antes discutimos cómo repartir el código en módulos para que esconda decisiones. Esta semana subimos un piso más. La pregunta ya no es cómo se escribe un módulo, sino cuáles son las decisiones de todo el sistema que después cuesta mucho cambiar, cómo se eligen con argumentos y cómo se dejan por escrito para quien llegue después.

Kallpa vuelve a ser el caso de estudio, y esta vez con un problema concreto. El complejo quiere correr dos copias del servidor sobre el mismo archivo de base de datos, para que una caída no deje a los vecinos sin reservar. Parece un cambio de configuración. Con mediciones veremos que no lo es: en la Kallpa publicada, con dos copias, la mitad de las peticiones simultáneas puede terminar en un error 500. Y el arreglo que se le ocurre a casi todos, esperar en vez de fallar, hace que desaparezcan los errores y que aparezca algo peor: canchas reservadas dos veces.

El capítulo sigue las dos sesiones de la semana. En la primera definimos arquitectura, y comparamos la arquitectura en capas, la hexagonal, el monolito, el monolito modular y los microservicios, con la ley de Conway y la estrategia monolith first como criterios para elegir. En la segunda escribimos los atributos de calidad como escenarios que se pueden medir, estudiamos el caso de las dos instancias, aprendemos a documentar una decisión en un ADR y dibujamos el sistema con el modelo C4.

Lectura. Sesión A: Martin Fowler y James Lewis, Microservices (2014), las características de un microservicio y, al final, Are Microservices the Future?; Martin Fowler, MonolithFirst (2015); Alistair Cockburn, Hexagonal Architecture. Complementarias: Fowler, PresentationDomainDataLayering y su guía de arquitectura de software; SQLite, Appropriate Uses For SQLite.

Sesión B: Michael Nygard, Documenting Architecture Decisions (2011); MADR, el template de ADR con alternativas consideradas; Simon Brown, The C4 model, en especial el diagrama de contenedores. Complementarias: Architectural Decision Records (adr.github.io); Fowler, TechnicalDebtQuadrant; Mermaid, C4 diagrams, y GitHub, Creating diagrams.

El código de este capítulo está en code/week-07. Todas las salidas que se muestran son de ejecuciones reales en el contenedor del curso.

Ruta de lectura.

  • Antes de clase (obligatorio, unos 20 minutos): de Qué es arquitectura, Las decisiones caras de cambiar y No todo merece la misma ceremonia; de Arquitectura en capas, La regla; de Arquitectura hexagonal: puertos y adaptadores, El problema que resuelve y Definiciones; de Monolito, monolito modular y microservicios, Monolito y Microservicios; de Atributos de calidad, Qué son y Escenarios: estímulo, entorno, respuesta y medida; y de Decisiones de arquitectura: los ADR, El problema que resuelven y El formato.
  • En clase: Sesión A: Qué es arquitectura, Arquitectura en capas, Arquitectura hexagonal: puertos y adaptadores y Monolito, monolito modular y microservicios. Sesión B: Atributos de calidad, El caso: dos instancias sobre un mismo archivo, Decisiones de arquitectura: los ADR y, si hay tiempo, El modelo C4. Laboratorio: Laboratorio: un ADR y un diagrama de contenedores para Kallpa.
  • Después de clase: el Resumen y los Ejercicios. Para profundizar: Un ejemplo completo de ADR, La deuda técnica como decisión, Qué alternativas hay, Diagramas como texto con Mermaid y las lecturas del recuadro Lectura.

Qué es arquitectura

Las decisiones caras de cambiar

La palabra arquitectura se usa con mucha soltura. El propio Martin Fowler, en su guía, dice que cuando la industria habla de arquitectura se refiere a una noción “definida con vaguedad” de los aspectos más importantes del diseño interno de un sistema. Para este curso usamos una definición de trabajo que sirve para tomar decisiones:

Arquitectura es el conjunto de decisiones de un sistema que son caras de cambiar después: cómo se divide en partes, cómo se comunican esas partes, dónde viven los datos y cómo se despliega.

La definición tiene una consecuencia que conviene ver de una vez: lo que es arquitectura depende del costo de cambiarlo, no del tema ni del tamaño del diagrama. El nombre de una función no es arquitectura, aunque la use todo el mundo, porque un editor la renombra en un segundo. “SQLite en un archivo, en el mismo proceso que el servidor” sí lo es, aunque se escriba en una línea.

Michael Nygard, en el artículo que propuso los ADR, da una lista parecida desde otro ángulo. Él llama arquitectónicamente significativas a las decisiones que afectan la estructura, las características no funcionales, las dependencias, las interfaces o las técnicas de construcción. Las dos miradas se complementan: la lista de Nygard dice dónde buscar, y el costo de cambiar dice cuáles importan.

Una prueba con Kallpa

Veamos varias decisiones de Kallpa y qué costaría cambiar cada una:

Decisión ¿Qué cuesta cambiarla? ¿Arquitectura?
El puerto por omisión es 3000 Una variable de entorno No
quotePriceCents se llama así Un renombre automático No
La página no usa frameworks Reescribir public/, unos cientos de líneas Poco: son pocas y están aisladas
Los montos son céntimos enteros Cada columna, cada cálculo, cada prueba y los datos guardados Sí
Fecha como texto y hora como entero, sin timestamps El esquema, los datos, las comparaciones y la API Sí
SQLite en el mismo proceso que el servidor Ver abajo Sí
Todo se despliega como un solo programa Separar datos, redes, despliegues y fallas Sí

La línea entre “poco” y “sí” es de grado. No hay un límite exacto, y no hace falta: lo útil es hacerse la pregunta antes de decidir.

La fila de SQLite merece una mirada de cerca, porque parece que la capa store la esconde. Veamos quién conoce esta decisión:

$ grep -rl "node:sqlite\|DatabaseSync" src
src/http/server.ts
src/services/booking-service.ts
src/store/member-repository.ts
src/store/court-repository.ts
src/store/seed.ts
src/store/database.ts
src/store/booking-repository.ts

Son solo siete archivos, y el SQL está en store. Se podría pensar que cambiar a PostgreSQL es reescribir esa carpeta y ya. No es así. node:sqlite es síncrono: DatabaseSync devuelve el resultado en la misma llamada. Un cliente de PostgreSQL habla por la red y es asíncrono. Con él, todos los métodos de store y de services tendrían que devolver una Promise, todos los handlers tendrían que usar await, y entre revisar que una hora está libre y guardar la reserva habría un punto donde otras peticiones pueden correr. Más adelante en el capítulo medimos exactamente qué pasa cuando eso ocurre.

Es la limitación del ocultamiento de información que vieron en la semana 5: un módulo esconde sus decisiones hasta donde su interfaz lo permite. Que una operación sea síncrona o tarde lo que tarda la red es parte de la interfaz, y por eso “en el mismo proceso” no queda encerrado en store.

Idea clave. Diseño (semana 5) y arquitectura son lo mismo a distinta escala. Los criterios son los mismos: esconder decisiones, reducir dependencias. Lo que cambia es el costo de equivocarse. Por eso a una decisión de arquitectura se le dedica más tiempo, se escribe y se discute con más gente.

No todo merece la misma ceremonia

Una consecuencia práctica: conviene distinguir las decisiones que se pueden revertir fácilmente de las que no. Las primeras se toman rápido y se corrigen si salen mal. A las segundas, como el modelo de datos o el estilo de despliegue, se les dedica un análisis de alternativas. Tratar todas igual es un error en los dos sentidos: un design doc para decidir el nombre de una carpeta hace perder tiempo, y decidir sin pensar dónde viven los datos sale muy caro un año después.

También se puede abaratar una decisión para que deje de ser de arquitectura. Si un módulo esconde cómo se formatea un mensaje, cambiar el formato deja de ser caro. Buena parte del trabajo de arquitectura consiste en eso: reducir la cantidad de decisiones difíciles de revertir.

Arquitectura en capas

La regla

La arquitectura en capas divide el sistema en niveles apilados, donde cada capa usa solo a las de abajo y no sabe nada de las de arriba. La versión más común es la que describe Fowler en PresentationDomainDataLayering: presentación, lógica de dominio y acceso a datos. Su argumento principal es de carga cognitiva: al separarlas, cuando se trabaja en una se puede ignorar casi todo de las otras, y cada una se prueba y se reemplaza por separado.

El README de Kallpa promete esto: http usa services, domain y notifications; services usa store; y ni store ni domain saben nada de HTTP. En un dibujo, agregando que casi todos usan los tipos y reglas de domain:

Cinco cajas, una por carpeta de Kallpa. A la izquierda, apiladas, http, services y store, con una flecha de http a services y otra de services a store. Arriba a la derecha, notifications, con una flecha desde http. En el centro, al costado de la pila, domain, resaltada, a la que llegan flechas desde http, services, store y notifications; de domain no sale ninguna flecha. Cada flecha quiere decir que una capa usa a la otraCinco cajas, una por carpeta de Kallpa. A la izquierda, apiladas, http, services y store, con una flecha de http a services y otra de services a store. Arriba a la derecha, notifications, con una flecha desde http. En el centro, al costado de la pila, domain, resaltada, a la que llegan flechas desde http, services, store y notifications; de domain no sale ninguna flecha. Cada flecha quiere decir que una capa usa a la otra
Lo que promete el README de Kallpa. No es una pila estricta: domain está al costado, casi todos lo usan y él no usa a nadie.

domain está al costado: casi todos lo usan y él no usa a nadie. Por eso no es una pila estricta, donde cada capa solo conoce a la inmediata inferior. Lo habitual es una versión relajada, en la que una capa puede saltarse a otra si la regla lo dice. Lo que importa es que la regla se pueda enunciar y comprobar.

Medir en vez de creer

La regla vale lo que vale su cumplimiento. En la semana 5 lo medimos con layers.ts. Esta semana usamos la forma más directa, que es el paso 1 del laboratorio:

$ make dependencias
== src/http
      6 from '../domain/
      2 from '../notifications/
      2 from '../services/
      1 from '../store/
== src/services
      2 from '../domain/
      3 from '../store/
== src/store
      6 from '../domain/
== src/domain
== src/notifications
      1 from '../domain/

src/domain no tiene líneas: no importa nada de otra capa. Y hay una línea que llama la atención: http importa algo de store, saltándose a services. Es esta:

import type { BookingRow } from '../store/booking-repository.ts';

“Es solo un tipo”, se podría decir. Y es cierto que en ejecución esa línea no existe: Node quita los tipos al cargar el archivo. Pero el código que la usa es este:

export function toBookingJson(row: BookingRow): Booking {
  return {
    id: row.id,
    courtId: row.court_id,
    memberId: row.member_id,
    // ...

El handler sabe cómo se llaman las columnas de la tabla. Si alguien renombra court_id, tiene que cambiar http/handlers.ts. La dependencia es real aunque no aparezca en ejecución: acoplarse es necesitar saber algo del otro, y un tipo es conocimiento. Es lo que la semana 5 llamó una fuga de información.

Convertir la regla en una revisión automática

Una regla que depende de que alguien se acuerde de revisarla se rompe. ESLint tiene una regla que sirve para esto, no-restricted-imports, que se puede activar solo para algunos archivos:

{
  // domain no sabe de la base de datos ni de HTTP.
  files: ['src/domain/**/*.ts'],
  rules: {
    'no-restricted-imports': [
      'error',
      { patterns: ['../store/*', '../http/*', '../services/*', 'node:sqlite', 'node:http'] },
    ],
  },
},
{
  // http llega a los datos a través de services.
  files: ['src/http/**/*.ts'],
  rules: {
    'no-restricted-imports': ['error', { patterns: ['../store/*'] }],
  },
},

Corrida sobre una copia de Kallpa, en capas-eslint.js:

$ make capas
cp capas-eslint.js build/kallpa/capas-eslint.js
cd build/kallpa && eslint -c capas-eslint.js . ; echo "eslint terminó con código $?"

/work/code/week-07/build/kallpa/src/http/handlers.ts
  18:1  error  '../store/booking-repository.ts' import is restricted from being used by a pattern  no-restricted-imports

✖ 1 problem (1 error, 0 warnings)

eslint terminó con código 1

Encontró el import de tipo. Ahora hay que decidir: se corrige el código (la capa services devuelve un tipo que no es una fila) o se cambia la regla (se acepta que http conozca el tipo de la fila). Las dos respuestas se pueden defender, y las dos son mejores que no saberlo. Esa decisión es parte del laboratorio.

Una regla así también tiene límites, y conviene conocerlos para no confiar de más:

  • Revisa lo que se le dijo. http/server.ts importa DatabaseSync de node:sqlite, y la regla no reclama porque para http solo prohíbe ../store/*.
  • Mira las declaraciones import, no todo lo que el código puede hacer. Una prueba de esta semana comprueba que un import('../store/database.ts') dinámico dentro de domain pasa la regla sin aviso.
  • Mira los imports, no el SQL. Si un handler recibe la conexión por parámetro, puede llamar a db.prepare('SELECT ...') sin importar nada de store. Lo único que se vería es que el tipo DatabaseSync aparece en handlers.ts.

Error común. Tratar la arquitectura como un dibujo. Un diagrama de capas que ninguna herramienta comprueba describe la intención del día en que se dibujó. La pregunta útil no es “¿qué dice el diagrama?”, sino “¿qué pasa si alguien lo viola?”. Si la respuesta es “nada”, el diagrama no protege a nadie.

En la industria. Revisar las dependencias entre partes de un sistema con una herramienta es práctica establecida. Software Engineering at Google, cap. 18, describe cómo en Bazel cada módulo declara sus dependencias y usar algo no declarado es un error del build, como vimos en la semana 5. El ejemplo de Kallpa es una versión mínima de lo mismo, con una regla que ya viene en ESLint.

Arquitectura hexagonal: puertos y adaptadores

El problema que resuelve

En un dibujo de capas, la base de datos está abajo y todo apunta hacia ella. Eso sugiere que la lógica del negocio depende de la base de datos, y a menudo es verdad: las reglas terminan mezcladas con el SQL y solo se pueden probar con una base de datos de verdad. Y en la parte de arriba pasa algo parecido: las reglas se filtran a los controladores de la interfaz. Alistair Cockburn escribió el patrón para evitar las dos fugas. Su intención, en sus palabras:

Allow an application to equally be driven by users, programs, automated test or batch scripts, and to be developed and tested in isolation from its eventual run-time devices and databases.

En español: que a la aplicación le dé igual quién la maneja (una persona, otro programa, una prueba automática o un script) y que se pueda desarrollar y probar sin los dispositivos ni las bases de datos con los que va a correr al final.

Definiciones

  • Núcleo o aplicación: las reglas del negocio y los casos de uso. No conoce ninguna tecnología externa.
  • Puerto: una conversación con un propósito, definida por el núcleo en sus propios términos. En código es una interfaz. “Guardar esta reserva si la hora está libre” es un puerto; “ejecutar este INSERT” no lo es.
  • Adaptador: el código que conecta un puerto con una tecnología concreta. Para un mismo puerto suele haber varios: el de producción y el de pruebas, por ejemplo.

Cockburn distingue dos lados. Los puertos primarios son por donde alguien maneja a la aplicación (un handler HTTP, una prueba). Los secundarios son por donde la aplicación usa algo de afuera (la base de datos, el envío de avisos). En la literatura también se les dice driving y driven. La regla es una sola y es la que importa:

Las dependencias apuntan hacia el núcleo. El adaptador de SQLite conoce al núcleo; el núcleo no conoce a SQLite.

¿Y por qué un hexágono? Cockburn lo aclara: no porque el número seis importe, sino para tener espacio donde dibujar los puertos que hagan falta y salir del dibujo de una sola dimensión de las capas. Lo que quiere resaltar es la asimetría entre adentro y afuera.

Un hexágono al centro, el núcleo, con las reglas y los casos de uso. Sobre su borde hay tres puertos: reservar a la izquierda, guardar si está libre arriba a la derecha y avisar abajo a la derecha. A la izquierda, los adaptadores primarios prueba y HTTP, con flechas hacia el puerto reservar. A la derecha, los adaptadores secundarios SQLite y en memoria, con flechas hacia el puerto guardar si está libre, y consola y WhatsApp, este último punteado porque es futuro, con flechas hacia el puerto avisar. Todas las flechas apuntan hacia el núcleo. Una nota aclara que, al ejecutar, el núcleo llama a los adaptadores secundarios, pero solo los conoce por el puerto que definióUn hexágono al centro, el núcleo, con las reglas y los casos de uso. Sobre su borde hay tres puertos: reservar a la izquierda, guardar si está libre arriba a la derecha y avisar abajo a la derecha. A la izquierda, los adaptadores primarios prueba y HTTP, con flechas hacia el puerto reservar. A la derecha, los adaptadores secundarios SQLite y en memoria, con flechas hacia el puerto guardar si está libre, y consola y WhatsApp, este último punteado porque es futuro, con flechas hacia el puerto avisar. Todas las flechas apuntan hacia el núcleo. Una nota aclara que, al ejecutar, el núcleo llama a los adaptadores secundarios, pero solo los conoce por el puerto que definió
Las flechas son dependencias, y todas apuntan hacia el núcleo. El núcleo sí llama a SQLite al ejecutar, pero a través de un puerto que él mismo definió.

Kallpa como hexágono: la versión honesta

Kallpa tiene dos puertos secundarios bien hechos, y las pruebas ya los aprovechan:

Puerto Dónde se define Adaptadores de hoy Adaptador futuro
Clock src/domain/clock.ts systemClock, fixedClock —
Notifier src/notifications/notifier.ts consoleNotifier, el doble de test/helpers.ts WhatsApp Business

Por eso las pruebas de Kallpa fijan la hora en el lunes 9 de marzo a las 10:00 y capturan los avisos sin tocar una línea del código de reservas. El día que el complejo contrate WhatsApp Business, hará falta un adaptador nuevo de Notifier, y ni las reglas de precios ni createBooking tienen por qué cambiar.

Pero Kallpa no es hexagonal en todo, y conviene no contar una historia más bonita que la real:

  • Los datos no tienen puerto. BookingService recibe un DatabaseSync y arma los repositorios de SQLite por su cuenta: la dependencia sigue yendo de services hacia store, no al revés. No hay forma de correr BookingService sin SQLite. Las pruebas usan SQLite :memory:, lo que funciona bien y es rápido, pero no es un adaptador distinto.
  • El caso de uso vive en el adaptador primario. Revisar las reglas, cotizar, guardar y avisar, en ese orden, está en createBooking, dentro de src/http. Por eso domain se prueba sin HTTP y reservar no: test/http.test.ts levanta un servidor. Es lo que el laboratorio de la semana 5 empezó a mover hacia un módulo profundo.
  • Notifier no está en el núcleo. Lo define src/notifications, junto con su adaptador, y http lo importa. Funciona porque el archivo no importa nada externo, pero el puerto y el adaptador de consola viven juntos.

Nada de esto es un error grave para un sistema de este tamaño. Es una descripción. La arquitectura real de un sistema casi nunca es un estilo puro, y describirla bien sirve para decidir cuál de esas fugas vale la pena arreglar.

Un puerto de verdad, con dos adaptadores

La carpeta code/week-07/hexagon tiene un ejemplo mínimo de cómo se vería reservar con un puerto para los datos. El núcleo dice qué necesita, en palabras del negocio:

export type Reservations = {
  // Promete las dos cosas juntas: revisar que la hora esté libre y guardar. Quien
  // implementa el puerto decide cómo; si devuelve null, la cancha estaba ocupada.
  bookIfFree(booking: NewBooking): SavedBooking | null;
};

Y el caso de uso solo conoce el dominio y los puertos:

export function reserve(deps: ReserveDeps, request: BookingRequest): ReserveResult {
  const violation = checkBookingRules(request, limaTime(deps.clock.now()));
  if (violation) return { ok: false, code: violation.code };

  // Para no alargar el ejemplo, el precio usa una cancha de fulbito y un vecino regular.
  const priceCents = quotePriceCents({
    sport: 'fulbito',
    date: request.date,
    startHour: request.startHour,
    hours: request.hours,
    plan: 'regular',
  });
  const booking = deps.reservations.bookIfFree({ ...request, priceCents });
  if (!booking) return { ok: false, code: 'SLOT_TAKEN' };

  deps.notifier.bookingConfirmed(booking);
  return { ok: true, booking };
}

Hay dos adaptadores: uno en memoria, con un arreglo, y otro de SQLite, con una transacción. Y las mismas pruebas corren contra los dos:

for (const [name, make] of [
  ['memoria', memoryReservations],
  ['SQLite', inSqlite],
] as const) {
  describe(`puerto Reservations: adaptador de ${name}`, () => {
    test('una hora libre se reserva y se avisa', () => { /* ... */ });
    test('un cruce con otra hora de inicio también se rechaza', () => { /* ... */ });
    // ...
▶ puerto Reservations: adaptador de memoria
  ✔ una hora libre se reserva y se avisa
  ✔ un cruce con otra hora de inicio también se rechaza
  ✔ otra cancha a la misma hora está libre
  ✔ las reglas del dominio no dependen del adaptador
▶ puerto Reservations: adaptador de SQLite
  ✔ una hora libre se reserva y se avisa
  ✔ un cruce con otra hora de inicio también se rechaza
  ✔ otra cancha a la misma hora está libre
  ✔ las reglas del dominio no dependen del adaptador

Un puerto es un contrato (semana 4), y lo que promete tiene que valer para cualquier adaptador. Por eso se prueba el contrato, no una implementación.

Fíjense en el tamaño de la operación. El puerto no ofrece listBookings e insert por separado: ofrece bookIfFree. Si fueran dos operaciones, la regla de “revisar y guardar juntos” quedaría en manos de quien llama, y el puerto sería un módulo superficial con otro nombre. Más adelante verán por qué esta diferencia no es de estilo.

Capas o hexágono

Las dos arquitecturas no son rivales: el hexágono también tiene una forma de capas adentro. La diferencia está en quién define la interfaz y hacia dónde apunta la dependencia de los datos:

En capas (como Kallpa) Hexagonal
¿Quién define la interfaz de los datos? store, la capa de abajo El núcleo, según lo que necesita
¿De quién depende la lógica de negocio? Indirectamente, de la base de datos De nadie
Probar un caso de uso Con base de datos (aunque sea en memoria) Con un adaptador cualquiera
Costo Menos piezas Una interfaz y un adaptador más por cada cosa externa

El costo no es menor. Por cada puerto hay una interfaz más que leer y un salto más al seguir el código, y una interfaz que refleja uno a uno lo que hay detrás es justo el método que solo reenvía de la semana 5. Un puerto se justifica cuando hay de verdad dos adaptadores (producción y pruebas cuenta) o una tecnología externa que pueda cambiar. Si Kallpa va a usar SQLite y sus pruebas corren bien en :memory:, un puerto para cada tabla agrega más complejidad de la que quita.

Error común. Crear una interfaz por cada clase “para que sea hexagonal”. Un IBookingRepository con los mismos ocho métodos que su única implementación no esconde nada ni invierte nada importante: el núcleo sigue pensando en filas. El puerto se escribe en términos de lo que el núcleo necesita.

Monolito, monolito modular y microservicios

Hasta aquí hablamos de cómo se organiza el código. Ahora, de cómo se despliega. Es la decisión más cara de este capítulo.

Monolito

Un monolito es un solo programa que se despliega entero. Kallpa lo es: un proceso de Node.js con la API, los archivos de la página y el acceso a los datos. Que sea uno solo no dice nada de la calidad de su código; dice cómo se ejecuta. Sus ventajas son reales:

  • Una sola base de código. Se busca con grep, se refactoriza con el editor, y una prueba cubre el camino entero.
  • Llamadas locales. Llamar a una función no falla por la red y tarda nanosegundos o microsegundos.
  • Transacciones simples. Guardar la reserva y actualizar otra tabla en una sola transacción es trivial si todo está en la misma base de datos.
  • Un solo despliegue. Una sola cosa que construir, publicar y vigilar.

Fowler y Lewis agregan algo que se suele olvidar: un monolito también escala a lo ancho, “running many instances behind a load-balancer”. Lo que no puede es escalar solo una parte. Sus costos, según el mismo artículo, son que los ciclos de cambio están atados (cambiar algo pequeño obliga a publicar todo) y que con los años cuesta mantener una buena estructura de módulos.

Microservicios

Fowler y Lewis no dan una definición formal, sino una lista de características comunes. Las nueve secciones de su artículo son:

  1. Componentes como servicios, que se despliegan de forma independiente.
  2. Organizados por capacidades del negocio, no por capas técnicas.
  3. Productos, no proyectos: el equipo mantiene lo que construye.
  4. Endpoints inteligentes y canales simples (smart endpoints and dumb pipes).
  5. Gobierno descentralizado: cada equipo elige sus herramientas.
  6. Datos descentralizados: cada servicio tiene los suyos.
  7. Automatización de la infraestructura.
  8. Diseño para las fallas.
  9. Diseño evolutivo.

Lo central es esto: servicios que se despliegan por separado, cada uno con sus datos, comunicándose por la red. Lo que se gana: despliegues independientes, escalar solo una parte, fronteras que el lenguaje no deja cruzar y aislamiento de algunas fallas. Lo que se paga es lo que el monolito regalaba:

Con un monolito Con microservicios
Una llamada a función siempre llega Una llamada puede fallar, tardar o llegar dos veces
Una transacción abarca todo Sin transacción común: hay que aceptar que la consistencia sea eventual y compensar errores
Un log, un proceso Trazar una petición por varios servicios (tracing)
Una prueba de punta a punta Levantar varios servicios o simular los contratos
Una versión Varias versiones conviviendo: cada API tiene que ser compatible hacia atrás
Un despliegue Un pipeline por servicio, y alguien que opere todo eso

Consistencia eventual quiere decir que, después de una actualización, no todos ven el dato nuevo enseguida, pero terminan viéndolo si no hay más cambios. Fowler y Lewis son directos sobre el motivo: las transacciones distribuidas son difíciles de implementar, así que las arquitecturas de microservicios prefieren coordinar sin transacciones y reconocer que la consistencia puede ser solo eventual.

Veamos lo que eso significa con Kallpa partida en tres servicios: canchas, reservas y pagos. Hoy, reservar una hora de fulbito hace esto en un solo proceso, sin red, como en la parte de arriba de la figura:

Dos paneles con los mismos siete pasos numerados: leer la cancha, leer al vecino, revisar reglas, revisar cruces, cotizar, guardar y avisar. Arriba, el monolito: los siete en fila dentro de un solo proceso, sin red. Abajo, tres servicios, cada uno con sus datos: canchas tiene el paso 1, pagos tiene el paso 5 y reservas tiene los pasos 2, 3, 4, 6 y 7. Entre reservas y cada uno de los otros dos hay una flecha punteada de ida y vuelta, rotulada red. Una nota dice que cada llamada por la red puede fallar, tardar o llegar dos veces, y pregunta si se puede reservar con canchas caído y quién deshace la reserva si el cobro fallaDos paneles con los mismos siete pasos numerados: leer la cancha, leer al vecino, revisar reglas, revisar cruces, cotizar, guardar y avisar. Arriba, el monolito: los siete en fila dentro de un solo proceso, sin red. Abajo, tres servicios, cada uno con sus datos: canchas tiene el paso 1, pagos tiene el paso 5 y reservas tiene los pasos 2, 3, 4, 6 y 7. Entre reservas y cada uno de los otros dos hay una flecha punteada de ida y vuelta, rotulada red. Una nota dice que cada llamada por la red puede fallar, tardar o llegar dos veces, y pregunta si se puede reservar con canchas caído y quién deshace la reserva si el cobro falla
Los mismos pasos, en un proceso o en tres servicios. Dos de ellos pasan a ser llamadas por la red, y ya no hay una transacción que abarque todo.

Con tres servicios, leer la cancha y cotizar son llamadas por la red. ¿Qué pasa si el servicio de canchas está caído? ¿Se puede reservar sin saber el deporte? Si reservas guarda y el cobro en pagos falla, ¿quién deshace la reserva, y qué ve el vecino mientras tanto? Son preguntas que hoy no existen. Y Kallpa recibe unas decenas de reservas al día.

Monolito modular

Entre los dos hay una opción que no suele aparecer en las discusiones: un monolito modular, que se despliega como un solo programa, pero con módulos de fronteras claras que se hacen cumplir. Cada módulo expone una interfaz, esconde sus tablas y los demás no entran por la puerta de atrás. Tiene las transacciones y el despliegue único del monolito, y buena parte de la claridad de las fronteras de los microservicios. Si algún día hace falta separar un módulo, la frontera ya está dibujada.

No es gratis: ninguna red impide cruzar la frontera, así que la disciplina tiene que venir de una herramienta. La regla de ESLint de la sección de capas es exactamente este mecanismo, a escala pequeña.

En la industria. Dos casos muy citados, ambos contados por los equipos que los vivieron, así que son experiencia y no comparación controlada. Shopify (Deconstructing the Monolith, 2019) describe una base de código en Ruby on Rails con más de mil desarrolladores y más de una década, y explica que eligieron un monolito modular: un solo código, con fronteras definidas y respetadas entre componentes. Segment (Goodbye Microservices, 2018) cuenta que juntó más de 140 servicios en uno solo porque el costo de operarlos crecía con cada destino nuevo, y lista lo que perdió, como el aislamiento de fallas: un error en un destino puede tumbar el servicio para todos. Ninguno de los dos dice que los microservicios estén mal. Dicen qué pasó con su equipo y su problema.

La ley de Conway

Fowler y Lewis citan a Melvin Conway (1968):

Any organization that designs a system (defined broadly) will produce a design whose structure is a copy of the organization’s communication structure.

En español: la estructura de un sistema tiende a copiar la estructura de comunicación de la organización que lo construye. Si la empresa tiene un equipo de interfaz, uno de lógica y uno de base de datos, un cambio pequeño se vuelve un proyecto entre tres equipos, y la lógica termina donde cada equipo tiene acceso. Es una observación sobre cómo se comportan las organizaciones, no una ley física; pero se ve con frecuencia, y tiene dos consecuencias prácticas:

  1. Los microservicios se organizan alrededor de capacidades del negocio porque buscan que cada servicio coincida con un equipo que puede decidir solo. El artículo habla de equipos de hasta una docena de personas por servicio, y menciona como extremo pequeño a seis personas con seis servicios.
  2. Por lo tanto, un equipo de cinco personas con un cliente no tiene la organización que justifica diez servicios. Las cinco personas hablan entre ellas todos los días. Separar el sistema con una red no refleja ninguna frontera de comunicación: la inventa.

Monolith first

Fowler observó en 2015 un patrón en las historias que escuchaba:

Almost all the successful microservice stories have started with a monolith that got too big and was broken up. Almost all the cases where I’ve heard of a system that was built as a microservice system from scratch, it has ended up in serious trouble.

Da dos razones. La primera es que al empezar no se sabe si el producto le servirá a alguien, y hay que moverse rápido (el costo de los microservicios frena). La segunda es técnica: los microservicios solo funcionan con fronteras buenas y estables, y aun los arquitectos con experiencia se equivocan al trazarlas al inicio. Mover una responsabilidad entre servicios es mucho más difícil que moverla entre módulos.

Vale la pena leer también sus reservas. Fowler aclara que son anécdotas y no un estudio, que sus colegas no están de acuerdo por unanimidad, que incluso enlaza a un artículo con la posición contraria y que “cualquier consejo sobre estos temas debe verse como tentativo”. Advierte también que no cualquier monolito se puede partir: la mayoría acumula demasiadas dependencias entre módulos. Por eso monolith first no es excusa para escribir un monolito desordenado. Y Fowler y Lewis, ya en 2014, cerraban su artículo con la misma prudencia: dijeron que todavía no había pasado tiempo suficiente para un juicio completo, porque las consecuencias de una decisión de arquitectura a veces se ven solo años después.

Error común. Elegir microservicios “porque escalan” sin un requisito de escala. Kallpa recibe unas decenas de reservas al día; su problema de escala no es el tráfico. La documentación de SQLite dice que un sitio con menos de 100 mil visitas al día debería funcionar bien, y aclara que es un estimado conservador, no un límite. Y al revés: “es un monolito” no es excusa para que todo dependa de todo. Lo contrario de un microservicio no es un desorden.

Cómo elegir

Para decidir, sirven más estas preguntas que el nombre del estilo:

Pregunta Empuja hacia monolito Empuja hacia separar
¿Cuántas personas? ¿Pueden hablar todos los días? Un equipo Varios equipos que se estorban
¿Conocemos las fronteras del dominio? No todavía Sí, y son estables
¿Alguna parte necesita escalar mucho más que el resto? No Sí, con números
¿Hay quien opere varios servicios? No Sí, con pipelines y monitoreo listos
¿Hay partes que deben publicarse a ritmos muy distintos? No Sí

Para los proyectos de este curso, con un equipo, un cliente y un semestre, la respuesta casi siempre será un monolito con buenos módulos. Lo importante en P2 no es elegir eso, sino poder explicar por qué.

Con IA. Un agente que agrega una funcionalidad elige el camino más corto, que a menudo cruza capas. El archivo cruce-de-capas.diff es un cambio escrito para esta clase, como los que suele proponer un agente: para listar las reservas de una cancha, agrega la conexión a las dependencias de los handlers y crea un BookingRepository dentro del handler. Funciona, y las revisiones habituales no tienen nada que decir:

$ make cruce
...
ℹ tests 40
ℹ suites 6
ℹ pass 40
ℹ fail 0
...
cd build/cruce && eslint -c capas-eslint.js . ; echo "eslint terminó con código $?"

/work/code/week-07/build/cruce/src/http/handlers.ts
  19:1  error  '../store/booking-repository.ts' import is restricted from being used by a pattern  no-restricted-imports

✖ 1 problem (1 error, 0 warnings)

eslint terminó con código 1

El make check de Kallpa pasa. Solo la regla de capas lo detecta, aunque nadie lea esa línea en la revisión. Dos cosas ayudan juntas: la arquitectura escrita (ADRs, un diagrama, el README) le da al agente el contexto para no cruzar, y la regla automática detecta el cruce cuando igual ocurre. Y como vimos arriba, la regla tiene huecos: en este mismo cambio, handlers.ts también empezó a importar node:sqlite y eso no se reporta. Por eso la revisión humana sigue haciendo falta.

Atributos de calidad

Qué son

Los atributos de calidad son los requisitos no funcionales de la semana 3, vistos desde la arquitectura. Describen qué tan bien hace el sistema lo que hace:

Atributo Pregunta En Kallpa
Disponibilidad ¿Cuánto tiempo responde? ¿Se puede reservar si el servidor se cae?
Rendimiento ¿Cuánto tarda, cuánto aguanta? ¿Qué pasa el lunes a las 8:00?
Seguridad ¿Quién puede hacer qué? Hoy, cualquiera puede reservar a nombre de otro
Mantenibilidad ¿Cuánto cuesta cambiarlo? Agregar un deporte, cambiar una tarifa
Facilidad de prueba ¿Cuánto cuesta comprobarlo? Las reglas se prueban sin base de datos
Facilidad de despliegue ¿Cuánto cuesta publicarlo? Un proceso, un archivo
Costo ¿Cuánto cuesta operarlo? Lo paga un complejo comunal

La norma ISO/IEC 25010 los clasifica en un modelo con características y subcaracterísticas, y sirve como lista para no olvidarse de ninguno. Pero una lista no decide nada. Lo que importa en la práctica es que cada atributo que le interesa al cliente se pueda medir.

Escenarios: estímulo, entorno, respuesta y medida

“Kallpa debe ser rápida” no se puede verificar. ¿Rápida para quién, cuándo y comparada con qué? La forma estándar de precisarlo, popularizada por Bass, Clements y Kazman en Software Architecture in Practice, es el escenario de calidad. La versión que usamos en este curso tiene cuatro partes:

  • Estímulo: qué pasa.
  • Entorno: en qué condiciones.
  • Respuesta: qué hace el sistema.
  • Medida: con qué número se comprueba.

(El libro agrega dos más, la fuente del estímulo y la parte del sistema afectada. Si hacen falta, se agregan.)

Parte “Kallpa debe ser rápida”, reescrito
Estímulo 20 vecinos reservan a la vez
Entorno Operación normal, lunes a las 8:00
Respuesta Cada uno recibe confirmación o que la hora está ocupada
Medida El 99 % responde en menos de 500 ms, y ninguna cancha queda reservada dos veces

Otros ejemplos, de mal a bien:

Vago Medible
“Siempre disponible” Con el proceso del servidor caído, el servicio vuelve en menos de 1 minuto, y en un mes no está disponible más de 43 minutos en total (99.9 %)
“Seguro” Un vecino que manipula la petición no puede cancelar la reserva de otro: la API responde 403 en el 100 % de los casos
“Fácil de mantener” Agregar un deporte nuevo con su tarifa toca a lo sumo 2 archivos y se publica en menos de un día
“Fácil de desplegar” Una persona nueva levanta el sistema siguiendo el README en menos de 15 minutos

Fíjense en que la disponibilidad se debe negociar con el cliente. Un complejo que abre de 7:00 a 22:00 quizá no necesite 99.9 % a toda hora, y cada nueve adicional cuesta más. El número sale de lo que le duele al cliente, no de lo que suena bien.

Unas palabras sobre las medidas, porque es fácil engañarse:

  • Un percentil dice qué porcentaje de los casos queda por debajo de un valor. Que el p99 sea 500 ms quiere decir que el 99 % tarda eso o menos. Se usa en vez del promedio porque un promedio bueno puede esconder a muchos usuarios mal atendidos.
  • Hacen falta suficientes muestras. Con 20 peticiones, el p99 es simplemente la más lenta (una prueba de medir.test.ts lo comprueba). Por eso medimos con 100 rondas de 20.
  • Importa dónde y cómo se mide. Un número de una laptop vale para comparar alternativas en esa laptop, no para prometer lo que pasará en producción.

Medir un escenario de verdad

medir-escenario.ts levanta Kallpa de verdad y manda rondas de 20 reservas simultáneas, cada ronda por una hora distinta. Probemos el escenario de arriba, con una instancia y con dos:

$ make medir
Una instancia:
  100 rondas, 2000 peticiones: 100×200  1900×409
  latencia: p50 3.7 ms, p99 7.5 ms, máximo 11.1 ms
  horas reservadas dos veces: 0
  rondas sin ninguna reserva: 0
  reservas guardadas sin responder 200: 0
  rondas en que una instancia solo respondió 500: 0
Dos instancias, sobre la Kallpa publicada:
  100 rondas, 2000 peticiones: 100×200  893×409  1007×500
  latencia: p50 2.4 ms, p99 7.3 ms, máximo 11.2 ms
  horas reservadas dos veces: 0
  rondas sin ninguna reserva: 0
  reservas guardadas sin responder 200: 0
  rondas en que una instancia solo respondió 500: 99

Lean bien la segunda parte. Con dos instancias, el escenario se cumple al pie de la letra: el p99 es de 7.3 ms, muy por debajo de los 500 ms, y ninguna cancha quedó reservada dos veces. Y sin embargo, la mitad de las peticiones terminó en un error 500. De hecho, por eso la latencia es menor: fallar es rápido.

Esta es una lección sobre escenarios. Nuestra medida estaba incompleta: decía cuánto tarda una respuesta, pero no qué respuesta. Un escenario mejor agrega lo que acabamos de aprender:

Con 20 vecinos reservando a la vez el lunes a las 8:00, en operación normal, el 99 % de las reservas responde en menos de 500 ms, ninguna responde 5xx y ninguna cancha queda reservada dos veces.

Idea clave. Un escenario de calidad se escribe para poder fallar. Si la medida permite que el sistema “pase” cuando el usuario sale perdiendo, la medida está mal. Medir suele enseñar cuál debió ser la medida.

Los atributos compiten

Casi siempre, mejorar un atributo empeora otro:

Se mejora Se empeora En Kallpa
Disponibilidad con dos instancias Consistencia, simplicidad, costo Hay que coordinar los datos y poner algo delante que reparta las peticiones
Seguridad con autenticación Facilidad de uso Un vecino ya no reserva en dos clics; hay que recuperar contraseñas
Rendimiento con cache Consistencia La página puede mostrar libre una hora ya tomada
Facilidad de prueba con puertos Simplicidad Una interfaz más que leer por cada cosa externa

Una decisión de arquitectura es elegir qué atributo gana, para este cliente, y dejar escrito qué se pierde. No hay una arquitectura mejor en general; hay una mejor para un conjunto de prioridades. Por eso sin saber qué le importa al cliente (semana 3) no se puede decidir nada aquí.

El caso: dos instancias sobre un mismo archivo

La administración del complejo pide correr dos copias del servidor sobre el mismo archivo, para que una caída no deje a los vecinos sin reservar. Es el caso que resuelven en el laboratorio. Aquí se estudia lo que pasa técnicamente. La decisión será de ustedes.

Lo que dice el código

Este es el corazón de createBooking en src/http/handlers.ts, con los números de línea reales:

 79    const request = parseBookingRequest(await readJson(req));
 81    const court = deps.service.findCourt(request.courtId);
 92    const sameDay = deps.service.bookingsForCourt(court.id, request.date).filter(...);
 93    if (sameDay.some((b) => overlaps(...))) { throw new HttpError(409, 'SLOT_TAKEN', ...); }
104    const id = deps.service.saveBooking({ ... });
114    const booking = toBookingJson(deps.service.findBooking(id)!);

El invariante “una cancha no tiene dos reservas confirmadas que se crucen” (semana 4) se cumple con dos pasos separados: en la línea 92 se revisa, y en la 104 se guarda. Este patrón se llama revisar y después actuar (check-then-act). Si otro puede actuar entre los dos pasos, lo que se revisó ya no es cierto cuando se actúa. A ese tipo de error, en el que el resultado depende del orden en que se intercalan las operaciones, se le llama condición de carrera (race condition).

Por qué en un solo proceso no pasa

Node.js ejecuta el JavaScript de un proceso en un solo hilo. Una función corre hasta el siguiente await sin que nadie la interrumpa. En createBooking, el único await es el de la línea 79, leer el cuerpo de la petición. De la línea 81 a la respuesta todo es síncrono, porque DatabaseSync lo es. Así que entre revisar y guardar no puede correr ninguna otra petición. Lo comprueba una prueba de un-proceso.test.ts: con 20 reservas simultáneas por la misma hora hay exactamente un 200 y diecinueve 409.

Es una propiedad valiosa, pero nadie la decidió ni la escribió: es un efecto de cómo está hecho el código. Y es frágil. El archivo await-intermedio.diff agrega una sola espera asíncrona entre revisar y guardar, como la que aparecería al consultar a un servicio externo:

await new Promise((resolve) => setTimeout(resolve, 10));

La segunda prueba de un-proceso.test.ts aplica ese cambio y repite el experimento, en un solo proceso:

assert.equal(confirmed, 20);
assert.deepEqual(statuses, Array<number>(20).fill(200));

Las 20 peticiones revisan, todas ven la hora libre, todas esperan y todas guardan. Veinte reservas confirmadas para la misma hora de la misma cancha, y las 40 pruebas de Kallpa no se enteran, porque ninguna manda peticiones simultáneas.

Error común. Creer que “Node.js tiene un solo hilo” elimina las condiciones de carrera. Elimina las que ocurrirían dentro de un tramo síncrono. Cada await es un punto donde otras peticiones pueden correr. Y con dos procesos no hay un solo hilo que valga.

Con dos procesos: el entrelazado

Con dos instancias, cada una tiene su propio hilo y su propia conexión al archivo. Nada impide este orden, donde el tiempo avanza hacia abajo:

Instancia A Instancia B En la base de datos
Revisa: las 9:00 están libres Sin reservas
Revisa: las 9:00 están libres Sin reservas
Guarda “Equipo A” 1 reserva
Guarda “Equipo B” 2 reservas a la misma hora

No hace falta creer el dibujo ni esperar a tener suerte. doble-reserva.test.ts abre dos conexiones a un mismo archivo, con el BookingService real de Kallpa, y ejecuta exactamente ese orden:

// Los dos revisan antes de que cualquiera guarde: los dos ven la hora libre.
assert.equal(isFree(instanceA), true);
assert.equal(isFree(instanceB), true);
instanceA.saveBooking({ ...row, team_name: 'Equipo A' });
instanceB.saveBooking({ ...row, team_name: 'Equipo B' });

assert.equal(confirmedCount(a), 2);

Pasa: quedan dos reservas. Esta forma de probar una condición de carrera, forzando el orden en vez de lanzar muchas peticiones y esperar, da el mismo resultado siempre.

Lo que pasa cuando se corre de verdad

El script del laboratorio, dos-instancias.sh, levanta dos servidores y manda 20 reservas repartidas entre los dos:

$ make dos-instancias
cd build/kallpa && bash ../../dos-instancias.sh
Respuestas (cantidad y código HTTP):
      1 200
     13 409
      6 500
Reservas confirmadas de la cancha 3 el 2026-11-05 a las 9:00:
16|Prueba 2
Logs de los servidores: /tmp/a.log y /tmp/b.log

No hubo doble reserva en esta corrida, sino seis errores 500. En seis corridas seguidas obtuvimos 3, 8, 2, 6, 10 y 3 errores: el número cambia cada vez. El log de uno de los servidores dice por qué:

Error: database is locked
    at CourtRepository.findById (file:///work/code/week-07/build/kallpa/src/store/court-repository.ts:16:80)
    at BookingService.findCourt (file:///work/code/week-07/build/kallpa/src/services/booking-service.ts:25:25)
    at Object.handler (file:///work/code/week-07/build/kallpa/src/http/handlers.ts:81:32)
    at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
    at async Server.<anonymous> (file:///work/code/week-07/build/kallpa/src/http/server.ts:59:7) {
  code: 'ERR_SQLITE_ERROR',
  errcode: 5,
  errstr: 'database is locked'
}
POST /api/bookings 500

Falló un SELECT, la lectura de la cancha, ni siquiera una escritura. Para entenderlo hace falta saber cómo se coordina SQLite entre procesos.

Los locks de SQLite

SQLite no tiene un servidor que ordene las peticiones. Los procesos se coordinan con locks sobre el archivo. En el modo por omisión, que es el de Kallpa (el journal delete), hay varios niveles, de menor a mayor:

Lock Quién lo tiene Qué permite a los demás
SHARED Cualquiera que esté leyendo; pueden ser muchos Leer
RESERVED Uno solo, que se prepara para escribir Leer, pero no escribir
PENDING y EXCLUSIVE Uno solo, mientras escribe en el archivo Nada nuevo: quienes ya leían terminan y nadie más entra

Por eso una lectura puede fallar: mientras otro proceso confirma su escritura, tiene el lock exclusivo y nadie puede ni leer. Además, si busy_timeout es 0, como en Kallpa, SQLite no espera: responde database is locked de inmediato. doble-reserva.test.ts comprueba cada una de estas afirmaciones con dos conexiones.

Esto también explica un número impactante que vimos antes, los 1007 errores 500. En cada ronda, las 20 peticiones llegan a la vez, y cada proceso atiende sus 10 una tras otra. Mientras un proceso confirma su INSERT, lo que incluye esperar al disco, el otro ya atendió todas las suyas y cada una se encontró el archivo bloqueado en su primera consulta. La última línea de la medición lo confirma: en 99 de 100 rondas, una de las instancias respondió 500 a todo lo que recibió. Con el script de curl salen menos errores: cada curl es un proceso que arranca por su cuenta, y las peticiones llegan más repartidas. El número depende de cómo llegan las peticiones, y por eso un escenario tiene que decir cómo se mide.

La casi doble reserva

Si la Kallpa publicada puede reservar dos veces, ¿por qué no lo vimos? En más de 40 corridas de medir-escenario.ts, más de 4000 rondas, no apareció ninguna. Pero miren esta otra traza, de otra corrida del mismo script:

Error: database is locked
    at BookingRepository.insert (file:///work/code/week-07/build/kallpa/src/store/booking-repository.ts:54:8)
    at BookingService.saveBooking (file:///work/code/week-07/build/kallpa/src/services/booking-service.ts:53:27)
    at Object.handler (file:///work/code/week-07/build/kallpa/src/http/handlers.ts:104:29)

Línea 104: esta petición ya pasó la revisión de la línea 92, y falló al guardar. Vio la hora libre, y lo único que le impidió guardar una segunda reserva fue el lock. Lo que nos está protegiendo es un error que nadie pidió, no una decisión de diseño.

Ahora viene la parte importante. Al ver database is locked, el arreglo obvio es que SQLite espere en vez de fallar. node:sqlite lo permite con una línea, que es todo lo que hace solo-espera.diff:

const db = new DatabaseSync(path, { timeout: 5000 });

Midamos:

Dos instancias, con solo-espera.diff:
  100 rondas, 2000 peticiones: 172×200  1828×409
  latencia: p50 5.6 ms, p99 15.1 ms, máximo 17.4 ms
  horas reservadas dos veces: 72
  rondas sin ninguna reserva: 0
  reservas guardadas sin responder 200: 0
  rondas en que una instancia solo respondió 500: 0

Los errores 500 desaparecieron y las pruebas pasan. Quien mire solo los logs dirá que se arregló. Y 72 de las 100 horas quedaron reservadas dos veces, con 172 peticiones que recibieron un 200 y su aviso por WhatsApp. El número varía mucho de una corrida a otra: en veinte corridas salió desde 1 hasta 79. Nunca salió 0.

Este es el centro del capítulo. El error 500 molesta y se ve. La doble reserva es silenciosa y la descubre un equipo que llega a la cancha y la encuentra ocupada. El cambio reemplazó un error visible por uno invisible, y el escenario de calidad, si no hubiera medido las dobles reservas, habría dado por bueno el cambio.

Idea clave. Para que un invariante valga entre procesos, revisar y guardar tienen que ser una sola operación indivisible para la base de datos. Esperar el lock no une los dos pasos: solo hace que el segundo paso espere y después se ejecute igual, con una revisión que ya venció.

Qué alternativas hay

Sin decidir por ustedes, estos son los hechos sobre cada alternativa, con lo que se midió o comprobó.

Una sola instancia, y aceptar el riesgo. Es lo de hoy: sin errores 500, sin dobles reservas (salvo que alguien agregue un await). Lo que se pierde es que, mientras el proceso esté caído, nadie reserva. ¿Cuánto es eso? Lo podemos medir:

$ make reinicio
10 reinicios: mínimo 95 ms, mediana 101 ms, máximo 103 ms

Eso es lo que tarda en volver a responder después de matar el proceso, si algo lo vuelve a levantar en el acto. No incluye lo que tarde en darse cuenta quien lo vigila, ni sirve si se cae la máquina o el disco, y es de una laptop con un archivo pequeño. Aun así, da una referencia para discutir: el problema que dos instancias resolverían dura muy poco si hay un supervisor. La deuda de esta alternativa es que el invariante sigue dependiendo de que ningún cambio agregue un await en ese tramo.

Una transacción que empieza con BEGIN IMMEDIATE. Toma el lock de escritura antes de revisar, de modo que mientras una instancia revisa y guarda, la otra no puede ni empezar. transaccion.diff lo hace en Kallpa, junto con la espera:

atomically<T>(work: () => T): T {
  this.#db.exec('BEGIN IMMEDIATE');
  try {
    const result = work();
    this.#db.exec('COMMIT');
    return result;
  } catch (error) {
    this.#db.exec('ROLLBACK');
    throw error;
  }
}
Dos instancias, con transaccion.diff:
  100 rondas, 2000 peticiones: 100×200  1900×409
  latencia: p50 4.8 ms, p99 12.4 ms, máximo 13.9 ms
  horas reservadas dos veces: 0
  rondas sin ninguna reserva: 0
  reservas guardadas sin responder 200: 0
  rondas en que una instancia solo respondió 500: 0

Exactamente un 200 por ronda, en todas las corridas que hicimos. Las 40 pruebas de Kallpa siguen pasando. Hay tres detalles que se suelen pasar por alto. Tiene que ser IMMEDIATE: con un BEGIN a secas el lock se toma recién al escribir, y el segundo en escribir falla con database is locked (hay una prueba de eso). Mientras SQLite espera, Node está bloqueado, porque DatabaseSync es síncrono: el proceso no atiende a nadie más durante esa espera. Y el diff pone la transacción en el handler a través de atomically, para que el cambio se lea en pocas líneas, lo cual es discutible: según lo que vieron en la semana 5, el lugar natural es un módulo profundo con una operación como book.

Animación en tres etapas, con dos instancias, A y B, a los lados del archivo SQLite, que muestra cuántas reservas hay a las 9:00 y quién tiene el lock. Etapa 1, Kallpa publicada: A revisa y ve libre, B revisa y ve libre, A guarda y confirma con el lock tomado, y B falla al guardar con database is locked; queda 1 reserva, A responde 200 y B responde 500. Etapa 2, solo-espera.diff: el mismo orden, pero B espera el lock y después guarda; quedan 2 reservas y las dos responden 200. Etapa 3, transaccion.diff: A toma el lock con BEGIN IMMEDIATE antes de revisar; B espera; A revisa, guarda y confirma; B toma el lock, ve las 9:00 ocupadas y hace ROLLBACK; queda 1 reserva, A responde 200 y B responde 409. Abajo se va llenando una tabla con una corrida de make medir, 2000 peticiones en 100 rondas: publicada, 100 respuestas 200, 893 respuestas 409, 1007 respuestas 500 y 0 horas reservadas dos veces; solo-espera, 172, 1828, 0 y 72; transacción, 100, 1900, 0 y 0Animación en tres etapas, con dos instancias, A y B, a los lados del archivo SQLite, que muestra cuántas reservas hay a las 9:00 y quién tiene el lock. Etapa 1, Kallpa publicada: A revisa y ve libre, B revisa y ve libre, A guarda y confirma con el lock tomado, y B falla al guardar con database is locked; queda 1 reserva, A responde 200 y B responde 500. Etapa 2, solo-espera.diff: el mismo orden, pero B espera el lock y después guarda; quedan 2 reservas y las dos responden 200. Etapa 3, transaccion.diff: A toma el lock con BEGIN IMMEDIATE antes de revisar; B espera; A revisa, guarda y confirma; B toma el lock, ve las 9:00 ocupadas y hace ROLLBACK; queda 1 reserva, A responde 200 y B responde 409. Abajo se va llenando una tabla con una corrida de make medir, 2000 peticiones en 100 rondas: publicada, 100 respuestas 200, 893 respuestas 409, 1007 respuestas 500 y 0 horas reservadas dos veces; solo-espera, 172, 1828, 0 y 72; transacción, 100, 1900, 0 y 0
El mismo entrelazado con tres versiones. Esperar el lock cambia los errores 500 por dobles reservas; tomarlo antes de revisar une los dos pasos. Los números son de una corrida, y el de dobles reservas varía entre corridas.

Una restricción en la base de datos. Aquí hay una trampa. La idea natural es un índice único parcial:

CREATE UNIQUE INDEX one_booking_per_start ON bookings (court_id, date, start_hour)
  WHERE status = 'confirmed';

Detiene dos reservas que empiezan a la misma hora, pero no un cruce. Una reserva de 9:00 a 11:00 y otra de 10:00 a 11:00 tienen distinta start_hour, y el índice las deja pasar. Dos pruebas de doble-reserva.test.ts muestran las dos cosas. Para que una restricción exprese la regla completa, cada hora ocupada tiene que ser una fila, por ejemplo una tabla booking_hours con clave primaria (court_id, date, hour). Eso exige una migración, y hay que mantener esas filas al cancelar. A cambio, la regla la hace cumplir la base de datos para todo el que escriba, incluso quien entre por otro camino.

Cambiar a una base de datos con servidor, como PostgreSQL. Ordena las peticiones, admite varias máquinas y además tiene exclusion constraints, restricciones pensadas para intervalos que se cruzan. Es también la alternativa que más cuesta: un servidor más que instalar, respaldar, pagar y vigilar; y como su cliente es asíncrono, todas las capas cambian y aparecen los await de los que venimos hablando, así que también necesita transacciones bien puestas.

Modo WAL (PRAGMA journal_mode = WAL). Es una mejora, no una solución: los lectores ya no se bloquean durante una escritura, pero sigue habiendo un solo escritor. No une la revisión con el guardado. La prueba correspondiente lo muestra.

Y dos preguntas que se deben hacer sobre el pedido mismo, sea cual sea la alternativa:

  • ¿De qué caída protege? Dos procesos en la misma máquina protegen de que se caiga un proceso, no de que se caiga la máquina o el disco, y en esos casos el archivo también se pierde o queda inaccesible. Con dos máquinas haría falta compartir el archivo por la red, y la documentación de SQLite lo desaconseja: la lógica de locks tiene errores en muchos sistemas de archivos de red, y recomienda evitar SQLite cuando la misma base de datos se accede directamente y a la vez desde muchas computadoras.
  • ¿Quién reparte a los vecinos? La página apunta a una sola dirección. Para repartir entre dos instancias hace falta un load balancer, que es una pieza más que puede caerse. El script del laboratorio esquiva este tema mandando a cada puerto a mano.

La documentación de SQLite también dice que solo permite un escritor a la vez, que los escritores hacen cola y que para muchas situaciones eso no es problema. Para unas decenas de reservas al día, no lo es.

En la industria. Los equipos resuelven el check-then-act en la base de datos, no en la aplicación: con una restricción, una transacción con el nivel de aislamiento adecuado o un lock. La regla práctica es que un invariante que cruza varias filas tiene que tener un guardián que vea todas las peticiones, y la base de datos es el único lugar por donde pasan todas. Lo que cambia entre equipos es cuánto de la regla se puede expresar como restricción.

Decisiones de arquitectura: los ADR

El problema que resuelven

Imaginen que en un año entra una persona nueva a Kallpa y ve que solo corre una instancia. Nygard describe sus dos opciones cuando no sabe por qué se decidió así:

  1. Aceptar a ciegas. Quizá la razón ya no vale, y el equipo le termina teniendo miedo a cambiar cualquier cosa.
  2. Cambiar a ciegas. Quizá esa decisión sostenía algo que nadie está probando, como la regla de los cruces.

Los documentos largos no resuelven esto, porque nadie los lee ni los mantiene al día. La propuesta de Nygard, de 2011, es un documento corto para cada decisión importante: un ADR (architecture decision record).

El formato

Un ADR, según Nygard:

  • Título: una frase corta. ADR 9: LDAP para multi-tenant.
  • Estado: propuesto, aceptado, o deprecado o reemplazado, con referencia al que lo reemplaza.
  • Contexto: las fuerzas en juego (técnicas, políticas, sociales, del proyecto), descritas como hechos.
  • Decisión: lo que se va a hacer, en voz activa y en oraciones completas.
  • Consecuencias: todo lo que resulta de la decisión, no solo lo positivo.

Y estas reglas:

  • Vive en el repositorio y se revisa en un pull request, igual que el código (docs/adr/0001-...md).
  • Se numera en orden y los números no se reutilizan.
  • Tiene una o dos páginas como máximo.
  • Se escribe como una conversación con quien lea en el futuro.
  • Si la decisión cambia, no se reescribe. Se escribe uno nuevo y el viejo queda marcado como reemplazado. “Sigue siendo relevante saber que esa fue la decisión, aunque ya no lo sea.” En el ADR viejo solo cambia su estado.

MADR (Markdown Architectural Decision Records) es un template que agrega lo que a Nygard le quedaba implícito: las alternativas consideradas con sus pros y contras. Sus secciones principales son el contexto y problema, los factores de decisión (decision drivers), las opciones consideradas, el resultado de la decisión con sus consecuencias, cómo se confirmará, y los pros y contras de cada opción. El proyecto MADR guarda sus propias decisiones en ADR, por si quieren ver ejemplos reales.

En este curso usamos esa idea con títulos en español, los que pide P2 y que su revisión automática comprueba:

Sección en P2 Equivalente en MADR
# ADR-NNNN: título Título
## Estado status (en el encabezado del archivo)
## Contexto Context and Problem Statement, Decision Drivers
## Decisión Decision Outcome
## Alternativas consideradas Considered Options, Pros and Cons of the Options
## Consecuencias Consequences

Un ejemplo completo

Para que no sea el mismo caso del laboratorio, este ejemplo trata otra decisión de Kallpa: cómo enviar los avisos de verdad. adr-ejemplo/0002-aviso-por-whatsapp.md lo tiene completo, y adr.test.ts comprueba que cumple lo que P2 pide:

# ADR-0002: Avisar por WhatsApp con una tabla de avisos pendientes

## Estado

Propuesta (ejemplo del apunte de la semana 7).

## Contexto

Hoy Kallpa "avisa" escribiendo en la consola: `consoleNotifier` implementa el
puerto `Notifier`. El complejo va a contratar WhatsApp Business y el aviso tiene
que salir de verdad, por una llamada HTTP a un proveedor externo que puede
tardar o caerse.

Las fuerzas en juego:

- El aviso es un complemento: el vecino necesita la reserva, y la ve en la página
  aunque el mensaje no llegue. Un aviso caído no debe impedir reservar.
- El servidor procesa cada reserva sin ceder el control entre revisar que la hora
  esté libre y guardarla. Esa propiedad evita los cruces en un solo proceso, y un
  `await` a un proveedor externo en ese tramo la rompería.
- El equipo son dos personas a tiempo parcial y el complejo no paga más servidores.
  Kallpa recibe unas decenas de reservas al día.

Escenario de calidad: *con el proveedor de WhatsApp caído durante 10 minutos, en
operación normal, el 99 % de las reservas responde en menos de 500 ms y, cuando
el proveedor vuelve, cada aviso sale en menos de 2 minutos.*

## Decisión

Vamos a guardar cada aviso en una tabla `pending_notifications`, en la misma
transacción que la reserva. Un temporizador dentro del mismo proceso del servidor
la revisa cada 30 segundos, llama al proveedor y marca lo que salió.

## Alternativas consideradas

### Llamar al proveedor dentro de la petición

**A favor.** Es lo más simple: un adaptador nuevo del puerto `Notifier` y nada más.
El vecino sabe enseguida si el aviso salió.

**En contra.** Si el proveedor tarda, tarda la reserva; si se cae, hay que decidir
entre fallar la reserva o perder el aviso. No cumple el escenario. Además, el
puerto `Notifier` es síncrono: hacerlo asíncrono invita a poner un `await` en el
handler.

### Tabla de avisos pendientes y temporizador en el mismo proceso (elegida)

**A favor.** Cumple el escenario. La reserva y su aviso se guardan juntos: si el
proceso se cae, el aviso sigue pendiente. No agrega piezas para desplegar ni
operar. Se prueba con un reloj fijo y un doble del proveedor.

**En contra.** El aviso sale hasta 30 segundos después. Si el proceso se cae entre
llamar al proveedor y marcar el aviso, se repite al volver: un vecino puede recibir
dos veces el mismo mensaje. Hay que escribir y mantener el temporizador.

### Una cola de mensajes externa y un proceso aparte que envía

**A favor.** Es la forma habitual cuando hay mucho volumen o varios consumidores.
El envío escala y se despliega por separado del servidor.

**En contra.** Son dos piezas más que desplegar, vigilar y pagar, para unas decenas
de mensajes al día. La reserva y el mensaje ya no se guardan en una sola
transacción, así que aparece el caso de "se guardó la reserva, pero no el mensaje".
Nadie del equipo ha operado una cola.

## Consecuencias

- Lo que se gana: reservar deja de depender del proveedor, y cambiar de proveedor
  toca un solo adaptador.
- Lo que se pierde: el aviso ya no es inmediato, y puede llegar repetido. Lo
  aceptamos porque un mensaje repetido molesta menos que uno perdido. Hay que
  decírselo al complejo.
- Lo que ahora hay que hacer: una migración para la tabla, el temporizador y un
  límite de reintentos, para que un aviso imposible no se reintente para siempre.
- Deuda que queda, a propósito: el temporizador supone una sola instancia. Si dos
  instancias revisan la misma tabla, los dos pueden mandar el mismo aviso. Si el
  complejo decide correr dos instancias (ver ADR-0001), hay que revisar esta
  decisión.
- Cuándo revisarla: si el volumen pasa de unos cientos de avisos al día, o si otro
  sistema también necesita enterarse de las reservas.

Cosas que conviene notar:

  • El contexto trae números y un escenario. Quien lea en un año puede comparar: si ya no son decenas al día, sabe que la decisión debe revisarse.
  • La alternativa elegida también tiene contras, y la descartada, pros. Si una opción solo tiene ventajas, no se analizó.
  • Las consecuencias incluyen lo malo: un vecino puede recibir el mensaje dos veces.
  • Dice cuándo revisarla. Esa frase le ahorra a alguien la duda de si la decisión sigue vigente.
  • Es corto. Un ADR no explica cómo implementar el temporizador: para eso están el código y, si hace falta, un design doc.

Error común. Un ADR sin alternativas: “Decidimos usar SQLite.” Si no se escribe qué más se consideró y por qué se descartó, quien llegue en un año no sabe si la decisión sigue vigente cuando cambie el contexto. Y otro: escribir el ADR después, para justificar lo que ya se hizo. El valor está en tomar la decisión con el contexto a la vista; un ADR escrito al final solo adorna. P2 lo advierte también: pide decisiones que de verdad hayan tenido alternativas, con consecuencias honestas, incluidas las malas.

ADR y design doc

Se complementan. El design doc de la semana 4 describe cómo se va a construir una cosa, en un momento dado, y envejece. El ADR registra una sola decisión y su motivo, y se conserva aunque cambie. Un design doc enlaza sus ADR, como pide P2. Un cambio mediano suele necesitar un design doc y producir uno o dos ADR.

¿Qué merece un ADR? La prueba del inicio del capítulo: lo que sea caro de cambiar y tenga alternativas reales. La arquitectura y el stack, el modelo de datos, cómo se autentica, dónde se despliega. No hace falta uno para elegir un nombre de variable.

En la industria. El propio artículo de Nygard es un ADR: tiene contexto, decisión, estado (Accepted) y consecuencias. Su formato sobrevive porque es barato. La comunidad en adr.github.io mantiene templates y herramientas, y en MADR se puede ver el formato aplicado a las decisiones de su mismo proyecto. Nygard cuenta que, en los pocos meses que llevaban usándolos, entre seis y diez desarrolladores rotaron por esos proyectos y todos valoraron el contexto que recibían al leerlos. Es un reporte temprano de quien propuso el formato, no una medición.

La deuda técnica como decisión

“Una sola instancia, sin restricción de cruces en la base de datos” es una deuda. La metáfora de la deuda técnica dice que un atajo de diseño se paga como un préstamo: se gana tiempo ahora y cada cambio futuro paga intereses. Fowler la divide en un cuadrante según dos preguntas: ¿se sabía que se estaba contrayendo (deliberada o inadvertida)? ¿Se pensó bien (prudente o imprudente)?

Imprudente Prudente
Deliberada “No hay tiempo para diseñar” “Publiquemos ahora y asumamos las consecuencias”
Inadvertida “¿Qué son las capas?” “Ahora sabemos cómo debimos hacerlo”

Según Fowler, la distinción útil no es entre tener deuda y no tenerla, sino entre la prudente y la imprudente. Una deuda prudente puede no valer la pena pagarse si está en una parte que casi no se toca.

Un ADR es lo que convierte la deuda en deliberada y prudente. Si Kallpa decide quedarse con una instancia y deja escrito “el invariante depende de que el tramo entre revisar y guardar no tenga await; si alguna vez hace falta una segunda instancia o una llamada externa ahí, se paga con una transacción”, ya no es un defecto escondido, sino una apuesta con su condición de pago. El laboratorio pide exactamente esta frase.

Con IA. Los ADR y los diagramas son parte del contexto que se le da a un agente, para que no “rediseñe” sin querer: si el repositorio dice que hay una sola instancia y por qué, el cambio propuesto puede respetarlo. Y un modelo de lenguaje puede redactar el primer borrador de un ADR, pero las alternativas y las consecuencias tienen que salir del equipo y del cliente. El modelo no sabe cuántas personas mantendrán el sistema ni cuánto puede pagar el complejo por un servidor. Para comprobar un borrador: revisen si cada número del contexto tiene de dónde salir (una entrevista, una medición), si cada alternativa la conoce alguien del equipo y si las consecuencias malas son de este proyecto y no genéricas. Un ADR con consecuencias que servirían para cualquier sistema no dice nada. Y en el caso de este capítulo, un borrador que proponga timeout para resolver el database is locked se vería razonable: solo la medición muestra que es peor.

El modelo C4

Cuatro niveles de zoom

El modelo C4 de Simon Brown propone dibujar un sistema como se mira un mapa: con distintos niveles de acercamiento, cada uno para un público.

Nivel Qué muestra En Kallpa ¿Para quién?
1. Contexto El sistema como una caja, sus usuarios y los sistemas externos Vecinos, administración, WhatsApp Cualquiera, incluido el cliente
2. Contenedores Las piezas que se ejecutan o guardan datos por separado Página, servidor Node.js, archivo SQLite Quien desarrolla u opera
3. Componentes Las partes de un contenedor http, services, store, domain, notifications Quien desarrolla
4. Código Clases, funciones Casi nunca se dibuja a mano La lee quien la necesite, en el código

La ventaja frente a un diagrama único, en el que se mezcla de todo, es que cada nivel responde una sola pregunta. La página de C4 recomienda el diagrama de contenedores para todos los equipos.

Contenedor no es Docker

Error común. Confundir el contenedor de C4 con un contenedor de Docker. La página de C4 lo advierte de entrada: “Not Docker!”. En C4, un contenedor representa una aplicación o un almacén de datos: algo que tiene que estar corriendo (o existir) para que el sistema funcione. Puede desplegarse o no en Docker. Kallpa tiene tres contenedores C4, y en el curso todos corren dentro de un solo contenedor Docker o del navegador.

Los de Kallpa:

Contenedor Tecnología Responsabilidad
Página web HTML y JavaScript, en el navegador Mostrar la disponibilidad y reservar
API Node.js 26, node:http Reglas de negocio, JSON
Base de datos Un archivo SQLite Canchas, vecinos, reservas

Un detalle sutil: la página web y la API son contenedores distintos aunque la API sirva los archivos, porque la página se ejecuta en otro lugar. Y el archivo SQLite es un contenedor aunque viva dentro de la misma máquina y no tenga un proceso propio: guarda datos por separado y sin él nada funciona. El diagrama de contenedores no muestra el despliegue: su página aclara que dice muy poco sobre clusters, load balancers, replicación o failover, y que eso se dibuja en un diagrama de despliegue. Por eso “dos instancias” no agrega un contenedor nuevo a ese diagrama; se ve en el de despliegue.

Diagramas como texto con Mermaid

Mermaid describe diagramas con texto, y GitHub los dibuja al ver un archivo Markdown con un bloque de código mermaid. Eso significa que el diagrama vive en el repositorio, se revisa en un pull request, y su historia queda en Git.

El diagrama de contexto de Kallpa:

C4Context
  title Kallpa: diagrama de contexto
  Person(vecino, "Vecino", "Reserva y cancela canchas")
  Person(admin, "Administración", "Consulta la caja del día")
  System(kallpa, "Kallpa", "Reservas de canchas del complejo")
  System_Ext(whatsapp, "WhatsApp", "Por ahora, la consola del servidor")
  Rel(vecino, kallpa, "Reserva y cancela", "navegador")
  Rel(admin, kallpa, "Consulta la caja", "navegador")
  Rel(kallpa, whatsapp, "Avisa la reserva confirmada")

El de contenedores de Kallpa lo dibujan ustedes en el laboratorio. Como modelo de la sintaxis, este es el de otro sistema, un sistema de citas para una posta médica con PostgreSQL y un proveedor de SMS:

C4Container
  title Citas de la posta: diagrama de contenedores
  Person(paciente, "Paciente", "Pide y cancela citas")
  Person(enfermera, "Admisión", "Ve la agenda del día")
  System_Boundary(citas, "Citas de la posta") {
    Container(web, "Página web", "HTML y JavaScript", "Corre en el navegador")
    Container(api, "API", "Node.js", "Reglas de citas y turnos")
    ContainerDb(db, "Base de datos", "PostgreSQL", "Pacientes, médicos y citas")
  }
  System_Ext(sms, "Proveedor de SMS", "Manda recordatorios")
  Rel(paciente, web, "Pide una cita", "HTTPS")
  Rel(enfermera, web, "Revisa la agenda", "HTTPS")
  Rel(web, api, "Citas en JSON", "HTTPS")
  Rel(api, db, "Lee y escribe citas", "SQL/TCP")
  Rel(api, sms, "Texto del recordatorio", "HTTPS")
Diagrama de contenedores de las citas de la posta. Arriba, dos personas, Paciente y Admisión, con flechas a la página web: Pide una cita y Revisa la agenda, por HTTPS. Dentro de una frontera punteada, el sistema Citas de la posta con tres contenedores en columna: página web, en HTML y JavaScript, que corre en el navegador; API, en Node.js, con las reglas de citas y turnos; y base de datos, en PostgreSQL, con pacientes, médicos y citas. De la web a la API viajan citas en JSON por HTTPS; de la API a la base de datos, lee y escribe citas por SQL sobre TCP. Fuera de la frontera, el proveedor de SMS, un sistema externo, recibe de la API el texto del recordatorio por HTTPSDiagrama de contenedores de las citas de la posta. Arriba, dos personas, Paciente y Admisión, con flechas a la página web: Pide una cita y Revisa la agenda, por HTTPS. Dentro de una frontera punteada, el sistema Citas de la posta con tres contenedores en columna: página web, en HTML y JavaScript, que corre en el navegador; API, en Node.js, con las reglas de citas y turnos; y base de datos, en PostgreSQL, con pacientes, médicos y citas. De la web a la API viajan citas en JSON por HTTPS; de la API a la base de datos, lee y escribe citas por SQL sobre TCP. Fuera de la frontera, el proveedor de SMS, un sistema externo, recibe de la API el texto del recordatorio por HTTPS
Lo que describe el bloque de Mermaid de arriba. Cada flecha dice qué viaja y por qué protocolo.

Comprobamos que estos dos bloques son sintaxis válida con el analizador de Mermaid, pero GitHub puede dibujar distinto de lo que esperan: abran el archivo en GitHub y mírenlo. Y si el diagrama de C4 de Mermaid queda amontonado, un flowchart simple con las mismas cajas y flechas también es válido: C4 es una forma de pensar los niveles, no una notación obligatoria.

Lo más útil de un diagrama está en las flechas: cada una debe decir qué viaja y por qué protocolo. “Usa” no le dice nada a nadie; “Citas en JSON, por HTTPS” sí. Una flecha sin etiqueta suele esconder una decisión que nadie tomó.

Con IA. Un agente puede generar un diagrama de Mermaid en segundos leyendo el código, y lo que dibuja es lo que el código hace hoy, incluidos sus accidentes. Eso sirve para detectar diferencias con lo que el equipo cree, pero no reemplaza la decisión de qué debería ser. Para comprobarlo, comparen las flechas con make dependencias: si el diagrama no tiene una flecha de http a store y el código sí, el diagrama está diciendo una intención.

Laboratorio: un ADR y un diagrama de contenedores para Kallpa

El laboratorio se hace en parejas, en su repositorio de Kallpa y en una rama nueva desde main. Aquí van las indicaciones para cada paso, sin las respuestas.

Paso 1: mapear las dependencias. Ya vieron la salida de make dependencias, que corre sobre la Kallpa del curso. Córranla sobre su copia, con el comando de la guía, y compárenla con lo que promete el README. El README dice menos de lo que el código hace, y eso no siempre es una violación: decidan cuáles importan.

Paso 2: la regla de ESLint. Copien los bloques de la sección de capas dentro de su eslint.config.js (antes del paréntesis final) y corran make lint. El error es el que vimos. Decidan si corrigen el código o cambian la regla, y escriban en el pull request por qué. Si deciden corregirlo, make check tiene que seguir pasando.

Paso 3: los diagramas. En docs/arquitectura.md: contexto, contenedores y componentes de la API, en Mermaid. Cada flecha dice qué viaja y por qué protocolo. Abran el archivo en GitHub para comprobar que se dibuja.

Paso 4: las dos instancias. Detengan make start si está corriendo (el script se niega a correr si encuentra un servidor en los puertos 3000 o 3001). Desde la carpeta de Kallpa:

bash /work/code/week-07/dos-instancias.sh

La ruta supone que abrieron el contenedor desde la carpeta del curso, con su repositorio adentro. Si lo abrieron desde su repositorio, /work/code no existe: copien el script. Corran el script varias veces y anoten cuánto varían los números. Miren /tmp/a.log y /tmp/b.log, y busquen en qué línea de handlers.ts falla cada error. Si encuentran una que falla en saveBooking, ya saben qué significa.

Paso 5: explicar. Respondan las tres preguntas de la guía con los números de línea de su copia.

Paso 6: el ADR. En docs/adr/0001-instancias-y-cruces-de-reservas.md, con el formato de P2. Se puede defender más de una respuesta: lo que se evalúa es el razonamiento, no la opción. Pregúntense:

  • ¿Qué caída quiere evitar el complejo? ¿Con qué frecuencia y cuánto le cuesta?
  • ¿Cuál es el escenario de calidad, con su medida completa?
  • ¿Qué números de este capítulo o de su propia medición lo sostienen? make medir, desde code/week-07, les da los suyos. Recuerden sus límites: es una máquina y un patrón de llegada.
  • ¿Qué deuda queda, y qué condición obligaría a pagarla?

Paso 7: el pull request. Con el diagrama, el ADR y el cambio de ESLint. La revisión responde una sola pregunta: ¿alguien que no estuvo en el laboratorio entendería por qué se decidió así?

Sin entregas esta semana, pero P2 se entrega la próxima y pide al menos tres ADR: los de este laboratorio son el modelo.

El código de la semana y make test

La carpeta code/week-07 tiene:

Archivo Qué es
dos-instancias.sh, dos-instancias.test.ts El script del paso 4 del laboratorio
capas-eslint.js, capas.test.ts Las reglas de capas sobre una copia de Kallpa
cruce-de-capas.diff Un cambio escrito para la clase que cruza capas
doble-reserva.test.ts El entrelazado, los locks de SQLite y las restricciones
un-proceso.test.ts, await-intermedio.diff Por qué un proceso no reserva dos veces, y cómo un await lo rompe
medir-escenario.ts, medir.test.ts El escenario de calidad medido contra Kallpa
solo-espera.diff, transaccion.diff Dos variantes para medir
medir-reinicio.ts Cuánto tarda Kallpa en volver después de una caída
hexagon/, hexagon.test.ts Un puerto con dos adaptadores y las mismas pruebas para los dos
adr-ejemplo/, adr.test.ts El ADR de este capítulo y su revisión con las reglas de P2

Dentro del contenedor del curso:

$ cd code/week-07
$ make test
tsc -p .
eslint .
node --test "*.test.ts"
...
ℹ tests 35
ℹ suites 7
ℹ pass 35
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0

make test no afirma cuántos errores 500 salen con dos instancias, porque cambia entre corridas; una prueba cuyo resultado depende de la suerte es un flaky test (semana 9). Afirma lo que sí es determinista: el entrelazado forzado, el comportamiento de los locks y que la transacción no deja dobles reservas. Los números de este capítulo salen de make medir, make reinicio y make dos-instancias. Nada modifica kallpa/: todo se hace en copias, y make clean borra build/.

Resumen

  • Arquitectura son las decisiones de un sistema que son caras de cambiar: cómo se divide, cómo se comunican las partes, dónde viven los datos y cómo se despliega. Diseño y arquitectura son lo mismo a distinta escala; cambia el costo de equivocarse.
  • En una arquitectura en capas, cada capa usa solo a las de abajo. Un import de tipo también acopla. La regla vale lo que vale su cumplimiento: una revisión como no-restricted-imports la hace cumplir, dentro de lo que sabe mirar.
  • En la arquitectura hexagonal, el núcleo define puertos en sus términos, los adaptadores conectan la tecnología y las dependencias apuntan hacia el núcleo. Kallpa la aplica en Clock y Notifier, no en los datos. Un puerto se justifica si tiene adaptadores reales.
  • Un monolito se despliega entero y da transacciones y llamadas simples. Los microservicios dan despliegues y escalado independientes a cambio de fallas parciales, consistencia eventual y mucha más operación. Un monolito modular se queda en el medio.
  • La ley de Conway dice que el sistema copia la comunicación de la organización. Monolith first dice que las fronteras se aprenden y que moverlas entre servicios es mucho más caro. Es experiencia acumulada, con la evidencia y las reservas que declara el propio Fowler.
  • Un atributo de calidad se escribe como escenario: estímulo, entorno, respuesta y medida. Si la medida deja pasar lo que le duele al usuario, está mal: dos instancias de Kallpa cumplían un escenario con la mitad de las peticiones en 500. Los atributos compiten, y decidir es elegir cuál gana.
  • Revisar y después guardar es una condición de carrera si otro puede actuar entre los dos pasos. En un proceso de Node, lo evita que el tramo sea síncrono: un await lo rompe. Entre procesos, hay que hacer que sea una sola operación para la base de datos. Esperar el lock sin una transacción cambia los errores 500 por dobles reservas.
  • Un ADR es un documento corto por decisión, con estado, contexto, decisión, alternativas y consecuencias, también las malas. Vive en el repositorio y, si la decisión cambia, se reemplaza.
  • La deuda técnica puede ser deliberada y prudente si se escribe con la condición que obligará a pagarla.
  • C4 dibuja el sistema en cuatro niveles. Su contenedor es algo que debe correr o existir para que el sistema funcione, no un contenedor de Docker. En los diagramas, las flechas dicen qué viaja y por qué protocolo.

Ejercicios

  1. Clasifiquen cada decisión como de arquitectura o no, y digan qué costaría cambiarla: (a) las horas se guardan como enteros; (b) formatSoles está en pricing.ts; (c) los avisos salen por consola detrás de un Notifier; (d) la API responde errores con { error: { code, message } }; (e) las pruebas usan node:test. ¿Alguna depende de quién usa ya el sistema?
  2. Reescriban como escenario de calidad, con sus cuatro partes y una medida que el usuario reconozca: (a) “la caja del día debe ser confiable”; (b) “el sistema debe ser fácil de usar para los vecinos mayores”; (c) “las reservas deben funcionar aunque haya problemas”. ¿Con qué atributo compite cada uno?
  3. Dibujen Kallpa como hexágono, con todos sus puertos primarios y secundarios y los adaptadores de hoy. ¿Qué adaptador nuevo haría falta para avisar por WhatsApp de verdad y qué parte del código no cambiaría?
  4. Agreguen a capas-eslint.js un bloque para que src/services/** no importe ../http/*. Corran make capas. Después agreguen node:sqlite a lo prohibido en src/http/**. ¿Qué archivo reporta? ¿Lo corregirían o dejarían la excepción? Justifíquenlo.
  5. Un compañero propone separar Kallpa en tres microservicios (canchas, reservas y pagos) para el proyecto del curso. Escriban dos argumentos a favor y tres en contra, usando la ley de Conway y monolith first. Después escriban cómo sería un monolito modular con esas mismas tres fronteras y qué regla de ESLint las haría cumplir.
  6. Qué le falta a este ADR: “Contexto: necesitamos una base de datos. Decisión: usamos SQLite. Consecuencias: es fácil.” Reescríbanlo completo para Kallpa, con tres alternativas. No pueden usar la palabra “fácil”.
  7. En doble-reserva.test.ts, escriban una prueba que fuerce este otro entrelazado: A revisa, A guarda, B revisa, B guarda. ¿Cuántas reservas confirmadas quedan? Con el resultado, expliquen qué tiene que pasar exactamente para que haya una doble reserva.
  8. El índice único parcial de este capítulo no detiene los cruces. Una reserva de 1 hora a las 10:00 pasa si ya hay una de 9:00 a 11:00. Implementen en una copia de Kallpa la tabla booking_hours (una migración nueva, nunca editar las anteriores), insértenla junto con la reserva en una transacción y bórrenla al cancelar. Corran make check y node medir-escenario.ts <su copia> 2. ¿Qué diferencia encuentran en los errores que ve el vecino, comparado con transaccion.diff?
  9. Lean createBooking de nuevo: después de guardar, la línea 114 vuelve a leer la reserva. ¿Qué pasaría si esa lectura falla con database is locked? ¿Qué recibe el vecino, qué queda en la base de datos y qué pasa con el aviso? ¿Vieron este caso en las mediciones? ¿Por qué el contador reservas guardadas sin responder 200 existe y por qué que salga 0 no prueba que no pueda pasar?
  10. Repitan la medición con otro patrón de llegada: cambien medir-escenario.ts para que las peticiones de una ronda lleguen separadas por 1 ms. ¿Qué cambia en los 500? ¿Qué aprendieron sobre lo que su escenario debe decir de cómo se mide?
  11. Dibujen el diagrama de contexto y de contenedores del producto de su cliente, en Mermaid, y escriban los tres ADR más importantes de su P2 en una línea cada uno: decisión, alternativa descartada y qué condición los haría cambiar de opinión.
  12. Tomen una deuda técnica real de su proyecto y ubíquenla en el cuadrante de Fowler. Si es inadvertida o imprudente, escriban lo que haría falta para convertirla en deliberada y prudente. ¿Qué condición obligaría a pagarla, y quién se daría cuenta de que se cumplió?