Apuntes · Capítulo 8Diseño y arquitectura

Repaso: del pedido de un cliente a una arquitectura documentada

Repaso: del pedido de un cliente a una arquitectura documentada

Durante siete semanas fueron bajando de lo general a lo particular. Empezamos por la pregunta de qué distingue a la ingeniería de software de programar, y por cómo se organiza un equipo para trabajar con un cliente. Después vinieron los requisitos, las especificaciones, los módulos, las APIs y la arquitectura. Cada tema tuvo su semana, pero en un proyecto real están todos en el mismo hilo: lo que el cliente dijo se vuelve una historia; la historia, una operación con su contrato; el contrato vive en un módulo; el módulo se expone por una API; y las decisiones que cuesta cambiar quedan en un ADR.

Esta semana no hay temas nuevos. El capítulo es más corto que los anteriores y tiene un solo propósito: prepararlos para aplicar esas ideas a un caso que no han visto, que es lo que pide el examen parcial de la Sesión B. Primero vemos cómo es el examen. Después repasamos cada parte en el orden en que entra, con lo que hay que saber con precisión, y resolvemos un ejercicio tipo examen sobre un caso nuevo: una lavandería de barrio. Al final está lo que toca hacer en el proyecto: cerrar el sprint 1 y entregar P2.

Lectura. Las Preguntas de repaso de las guías de las semanas 1, 2, 3, 4, 5, 6 y 7, y exams/README.md: formato del examen y cómo estudiar. Complementarias: las lecturas principales de cada semana que no hayan alcanzado a leer. Para el examen pesan más Software Engineering at Google, cap. 1 y cap. 10, la Guía de Scrum, Design Docs at Google y el ADR de Nygard. El código de los ejercicios resueltos está en code/week-08 y se prueba con make test dentro del contenedor del curso.

Ruta de lectura.

  • Antes de clase (obligatorio, unos 20 minutos): Cómo es el examen, Un caso nuevo: la lavandería y, en cada una de las seis partes del repaso, la sección Lo que hay que tener preciso.
  • En clase: Sesión A: los Ejercicios resueltos de cada parte, sobre la lavandería. Sesión B: el examen parcial. Laboratorio: no hay; el equipo trabaja en El proyecto: cerrar el sprint 1 y entregar P2.
  • Después de clase: entre la Sesión A y el parcial, el Resumen, los Ejercicios, el parcial de práctica con el reloj corriendo y las Preguntas de repaso de las guías de las semanas 1 a 7.

Cómo es el examen

Qué entra

El parcial cubre las semanas 1 a 7 y se rinde sin herramientas de IA. Las condiciones están en exams/README.md. Mide lo que el proyecto no puede medir individualmente: que cada uno de ustedes entienda las ideas y sepa aplicarlas solo. Entran seis tipos de pregunta:

Parte Qué les piden
Ingeniería de software y procesos Preguntas cortas sobre la ley de Hyrum, trade-offs, Scrum y Kanban, y qué se puede concluir (y qué no) de un estudio de productividad con IA
Requisitos A partir de lo que dice un cliente, escribir historias con criterios verificables y encontrar lo ambiguo o contradictorio
Especificaciones Precondiciones, postcondiciones e invariantes de una operación, y los tipos de TypeScript que impiden un estado inválido
Diseño En un fragmento de TypeScript, señalar módulos superficiales, información que se filtra y acoplamiento, y proponer una interfaz mejor
APIs Rutas, códigos de estado y errores de una funcionalidad; qué es idempotente y cómo hacer idempotente lo que no lo es
Arquitectura Elegir entre alternativas, escribir el ADR con sus consecuencias y un atributo de calidad como escenario medible

Hay un parcial de práctica con este formato: dos horas, sobre otro caso. Resuélvanlo con el reloj corriendo y sin mirar nada; así descubren cuánto les toma cada tipo de pregunta.

Error común. Estudiar memorizando definiciones. El examen da un caso que no han visto y pide aplicar la idea: no basta saber qué es un módulo profundo, hay que reconocer uno superficial en código ajeno y decir cómo cambiar su interfaz.

Cómo estudiar

Tres actividades, en este orden:

  1. Las Preguntas de repaso de las semanas 1 a 7, por escrito y sin mirar las lecturas. Lo que no pueden escribir es lo que no saben. Releer da la sensación de saber, y esa sensación no es una medición.
  2. Su propio P2. Expliquen a otra persona del equipo cada decisión del design doc y de los ADRs, y por qué se descartaron las alternativas. Si solo una persona del equipo puede explicar una decisión, ya encontraron qué estudiar.
  3. Rehacer en papel los ejercicios del laboratorio: las historias del backlog de Kallpa (semana 3), la refactorización del módulo superficial (semana 5), la especificación OpenAPI (semana 6) y el ADR (semana 7).

Con IA. Una herramienta sirve para estudiar si la usan para que les pregunte, no para que les responda: que les plantee un caso, que critique lo que escribieron a mano. Su resumen de un tema puede estar mal con la misma seguridad con que está bien, así que todo lo que les diga sobre la Guía de Scrum o sobre RFC 9110 se comprueba contra el texto. Y hagan por lo menos un examen de práctica completo sin ella: en el parcial no va a estar, y lo que se mide es lo que ustedes escriben.

Idea clave. Casi todas las preguntas piden aplicar y justificar. Usen los datos del caso, digan qué se pierde con cada decisión y sean concretos: un criterio lleva un monto y una hora; un problema de diseño, una línea de código y el cambio que lo hace doler.

Un caso nuevo: la lavandería

Los ejercicios de las siguientes secciones son sobre el mismo caso, para que se vea el hilo completo. Hilda tiene una lavandería en Breña y le pide a un equipo una aplicación. Esto es lo que dijo en la primera conversación:

“El cliente deja su ropa, la peso y le doy un ticket de papel. Cobro S/ 7 por kilo. El servicio normal sale en dos días; el exprés sale el mismo día y cuesta el doble. Se cobra al entregar, siempre. A los clientes frecuentes les hago un descuento y ellos me pagan por adelantado. La gente llama todo el día a preguntar si su ropa ya está, y a veces se me pierden los tickets. Quiero que cuando la ropa esté lista les llegue un aviso rápido por WhatsApp. Un pedido se puede anular, pero no cuando ya se está lavando.”

Una franja arriba dice que las semanas 1 y 2, ingeniería de software y procesos, son cómo se trabaja este hilo en el tiempo. Debajo, una cadena vertical de seis pasos, cada uno con su semana y su ejemplo en la lavandería. Lo que dijo el cliente, semana 3: quiero que cuando la ropa esté lista les llegue un aviso rápido por WhatsApp. Historia y criterios, semana 3: HU-02, con criterios dado, cuando, entonces. Operación con contrato, semana 4: deliver con pre, postcondición e invariantes, y una unión discriminada. Módulo que esconde, semana 5: OrderDesk, con markReady(id) en lugar de setEst(id, 3). API, semana 6: POST /api/orders con 201, 400 o 422, y la cabecera Idempotency-Key. Decisión cara de cambiar, semana 7: ADR-0001, los avisos salen de una tabla de pendientes. Al pie, que el parcial cubre las semanas 1 a 7 sobre un caso que no han vistoUna franja arriba dice que las semanas 1 y 2, ingeniería de software y procesos, son cómo se trabaja este hilo en el tiempo. Debajo, una cadena vertical de seis pasos, cada uno con su semana y su ejemplo en la lavandería. Lo que dijo el cliente, semana 3: quiero que cuando la ropa esté lista les llegue un aviso rápido por WhatsApp. Historia y criterios, semana 3: HU-02, con criterios dado, cuando, entonces. Operación con contrato, semana 4: deliver con pre, postcondición e invariantes, y una unión discriminada. Módulo que esconde, semana 5: OrderDesk, con markReady(id) en lugar de setEst(id, 3). API, semana 6: POST /api/orders con 201, 400 o 422, y la cabecera Idempotency-Key. Decisión cara de cambiar, semana 7: ADR-0001, los avisos salen de una tabla de pendientes. Al pie, que el parcial cubre las semanas 1 a 7 sobre un caso que no han visto
El hilo de la primera mitad del curso, con el ejemplo de cada paso en este capítulo. Los ejemplos no son todos de la misma funcionalidad: muestran dónde entra cada idea.

Ingeniería de software y procesos

Lo que hay que tener preciso

Ingeniería de software es programación integrada en el tiempo (SWEG, cap. 1). La pregunta que separa un script de un sistema es cuánto va a vivir el código. Un proyecto es sostenible si puede reaccionar a los cambios que valen la pena a un costo razonable.

Ley de Hyrum: con suficientes usuarios de una API, no importa lo que se prometa en el contrato: todo comportamiento observable va a ser usado por alguien. No dice que haya que evitar cambiar. Dice que el costo de cambiar no lo determina el contrato, sino lo que la gente observa.

Shifting left: encontrar un error al escribir el código cuesta menos que en revisión, en pruebas o en producción. Trade-off: toda decisión tiene costos (tiempo de personas, de máquina, riesgo, deuda), se toma con datos y se revisa cuando los datos cambian.

Procesos. Cada proceso apuesta sobre qué tan estables son los requisitos y qué tan caro es cambiar. La cascada apuesta a requisitos conocidos y estables; ojo, el artículo de Royce de 1970 que se cita como su origen la presenta como riesgosa. Iterativo e incremental: software que funciona, en ciclos cortos, y cada entrega es una pregunta al cliente.

Scrum, según la guía de 2020, sin mezclar palabras:

Tres responsabilidades Product owner, Scrum master, developers
Cinco eventos El sprint (un mes o menos), que contiene a la sprint planning, la daily, la sprint review y la retrospectiva
Tres artefactos y sus compromisos Product backlog → product goal; sprint backlog → sprint goal; incremento → Definition of Done

Un ítem que no cumple la Definition of Done al final del sprint no se puede liberar ni presentar en la sprint review: vuelve al product backlog. No existe “terminado al 90 %”.

Kanban limita el trabajo en curso (WIP) y mide WIP, throughput, work item age y cycle time. La ley de Little dice que, en promedio y en un sistema estable, cycle time = WIP / throughput. No está en la Kanban Guide, pero explica por qué limitar el WIP importa.

Estimación. Estimación (cuánto trabajo creemos que hay), compromiso (lo que el equipo acepta entregar) y objetivo (lo que el negocio quisiera) son tres cosas distintas. Los story points miden tamaño relativo, no horas. La velocity sirve para planificar el siguiente sprint del mismo equipo; no para comparar equipos. Por la ley de Goodhart, si se vuelve meta, los puntos se inflan.

Boeing 737 MAX. Decir “fue un bug” es incorrecto: el MCAS hizo lo que estaba especificado, con un dato equivocado de un solo sensor. Fallaron los requisitos, el análisis de riesgo, la revisión independiente y la honestidad sobre lo que se sabía.

La evidencia sobre IA. Lo que importa no es recordar un número, sino saber leerlo: qué se midió, cómo, a quiénes, cuándo.

  • METR 2025 fue un experimento controlado aleatorio con 16 desarrolladores con experiencia, en 246 issues de sus propios repositorios. Con IA tardaron 19 % más; antes esperaban ir 24 % más rápido y después creían haber ido 20 % más rápido. METR advierte que los resultados están desactualizados, y que su segundo experimento (nota de febrero de 2026) es una señal poco confiable. La lección vigente: la sensación de productividad no es una medición.
  • DORA 2025 es una encuesta a profesionales del software. Describe lo que reportan y las asociaciones entre sus respuestas; no permite concluir que una cosa causa la otra. Su conclusión principal es que la IA es un amplificador de las fortalezas y debilidades que la organización ya tiene.

Ejercicio resuelto: cuatro preguntas cortas

Un buen formato para una pregunta corta son tres o cuatro líneas que apliquen la idea. Estas son del tipo del examen, con la respuesta que daría puntos.

a) La lavandería expone una API con el historial de un cliente. Nunca prometió un orden, pero siempre salen del más reciente al más antiguo. El equipo cambia la consulta y ahora salen en otro orden. ¿Qué pasa y qué ley lo explica?

La ley de Hyrum: el orden es observable, así que alguien depende de él. Por ejemplo, una pantalla que toma orders[0] como el pedido más reciente, y ahora muestra otro. Que el contrato no lo prometiera no evita el daño. Hay dos opciones: prometer el orden (escribirlo en el contrato y probarlo) o hacer que nadie pueda contar con él, y avisar del cambio. No basta con decir “nunca lo prometimos”.

b) Una historia quedó con el código hecho pero sin revisión, y la Definition of Done pide revisión. Termina el sprint. ¿Se muestra en la sprint review? ¿Qué pasa con la historia y con la velocity?

No se puede presentar: según la Guía de Scrum, un ítem que no cumple la Definition of Done no se libera ni se presenta en la sprint review, y vuelve al product backlog. Sus puntos no cuentan en la velocity de este sprint, porque solo cuenta lo terminado. Sumar “la mitad de los puntos” haría que la velocity dejara de servir para planificar.

c) El tablero de la lavandería tiene 10 tareas en curso y el equipo termina 2 por día. ¿Cuánto tarda en promedio una tarea? ¿Y si limitan el WIP a 6?

Por la ley de Little, cycle time = WIP / throughput = 10 / 2 = 5 días. Con WIP 6 y el mismo throughput, 6 / 2 = 3 días. Es un resultado sobre promedios en un sistema estable, donde lo que entra termina saliendo. Y supone que el throughput no cambia: la ley no dice qué le pasa al limitar el WIP, aunque en la práctica menos cambios de contexto suelen ayudar.

d) Un gerente lee que “en METR, la IA hizo a los desarrolladores 19 % más lentos” y prohíbe las herramientas. ¿Qué conclusión se puede sacar del estudio y cuál no?

Se puede concluir que, en ese experimento de 2025, esos 16 desarrolladores experimentados, en sus propios repositorios grandes, tardaron más con las herramientas de ese momento, y que creyeron lo contrario. No se puede concluir que eso valga para este equipo ni para las herramientas de hoy: el propio METR dice que el resultado está desactualizado. Lo que sigue en pie es que hay que medir en lugar de confiar en la sensación, en una dirección o en la otra.

Error común. Tomar un número sin su diseño. Un experimento aleatorio con 16 personas y una encuesta a miles responden preguntas distintas, y ninguno se convierte en “la IA acelera” o “la IA retrasa” sin más. En el examen no se pide recordar decimales: se pide decir qué midió el estudio y hasta dónde llega.

Requisitos

Lo que hay que tener preciso

Un requisito funcional dice qué hace el sistema; uno no funcional, una cualidad medible de cómo lo hace. Sin número, un requisito no funcional todavía no es un requisito. El cliente suele llegar con una solución; el trabajo es encontrar el problema. En una entrevista se pregunta en abierto y por el último caso concreto (“¿cómo fue la última vez que…?”), sin inducir la respuesta (“¿no sería mejor…?”), y se separa lo que el cliente dijo de lo que el equipo supone.

Una historia de usuario (user story) tiene quién, qué y para qué, y es una promesa de conversación. Sus criterios de aceptación se comprueban con un sí o un no, en la forma dado, cuando, entonces, con datos concretos y describiendo comportamiento, no implementación. INVEST: independiente, negociable, valiosa, estimable, pequeña, testeable; es una lista para revisar, no una ley. Una épica se divide con SPIDR (spikes, caminos, interfaces, datos, reglas), nunca por capas técnicas: “la tabla”, “el endpoint” y “la pantalla” no le sirven solas a nadie. MoSCoW ordena: Must, Should, Could, Won’t.

Ejercicio resuelto: de un párrafo a historias

A partir de lo que dijo Hilda: (a) encuentren lo ambiguo y lo contradictorio y digan qué le preguntarían; (b) escriban dos historias con dos criterios cada una; (c) reescriban “un aviso rápido” como requisito medible.

a) Lo ambiguo y lo contradictorio. Primero la contradicción, que es lo más importante de encontrar: “se cobra al entregar, siempre” choca con “los clientes frecuentes me pagan por adelantado”. Las dos no pueden ser ciertas.

Lo que dijo Qué tiene de malo Qué le preguntarían
“Se cobra al entregar, siempre” y “pagan por adelantado” Se contradicen “La última vez que atendió a un cliente frecuente, ¿cuándo le cobró?”
“S/ 7 por kilo” ¿Y 3,2 kg? “¿Cuánto le cobró al último que trajo un poco más de 3 kilos?”
“Clientes frecuentes”, “un descuento” Ni quién es ni cuánto “¿Cómo sabe hoy quién es frecuente? ¿Cuánto le descontó a la última?”
“El exprés sale el mismo día” ¿Y si lo dejan a las 7 de la noche? “¿Hasta qué hora recibe un exprés?”
“Un aviso rápido” Sin número “¿Cuándo sería demasiado tarde para que llegue?”

Noten que las preguntas son abiertas y sobre casos pasados. “¿Redondeamos hacia arriba?” induce la respuesta. Y hay algo más: el aviso por WhatsApp es una solución. El problema son las llamadas todo el día y los tickets perdidos. Conviene entender cuántas llamadas son y de qué, porque puede haber una solución más simple o un problema más grande.

b) Dos historias. Las escribimos con un supuesto explícito que Hilda tiene que confirmar: el peso se redondea al kilo superior.

HU-01. Como encargada de la lavandería, quiero registrar un pedido con su peso y su servicio, para saber cuánto cobrar sin depender del ticket de papel.

  1. Dado que Ana trae 3,2 kg para servicio normal, cuando la encargada registra el pedido, entonces queda en estado recibido con un precio de S/ 28.00.
  2. Dado que Ana trae 3,2 kg para servicio exprés, cuando la encargada registra el pedido, entonces queda con un precio de S/ 56.00.

HU-02. Como cliente, quiero recibir un aviso cuando mi ropa esté lista, para no tener que llamar a preguntar.

  1. Dado que el pedido 12 de Ana está lavando, cuando la encargada lo marca como listo, entonces a Ana le llega por WhatsApp un mensaje con el número del pedido y el monto a pagar.
  2. Dado que el pedido 12 está recibido, cuando la encargada intenta marcarlo como listo, entonces el sistema lo rechaza, el pedido sigue recibido y no se envía ningún aviso.

Lo que debilita una respuesta de examen:

  • “Dado un pedido, cuando se registra, entonces se calcula bien el precio.” ¿Cuánto es bien? No se puede comprobar con un sí o un no.
  • “Entonces se agrega una fila a la tabla orders.” Es implementación, no comportamiento: Hilda no lo puede verificar.
  • “Como sistema, quiero enviar un WhatsApp.” ¿Para quién tiene valor? Falla la V de INVEST.
  • “Como Hilda, quiero administrar mi lavandería.” Es una épica: falla la S y la E. Se divide por reglas o por caminos: primero registrar y cobrar al entregar, después el descuento y el pago adelantado de los frecuentes, cuando Hilda resuelva la contradicción.

c) “Un aviso rápido”, medible. “Dado un pedido marcado como listo en horario de atención, el aviso sale en menos de 1 minuto para el 95 % de los pedidos, medido sobre un mes”. El número sale de Hilda, no del equipo; si ella no lo tiene, se le propone uno y se le pregunta.

Con IA. Una herramienta puede revisar estas historias y proponer casos que faltan (¿y si el cliente no tiene WhatsApp?). Cada caso nuevo es una pregunta para Hilda, no un requisito. Si le piden requisitos a partir de este párrafo, va a resolver la contradicción por su cuenta y con mucha seguridad, y esa decisión no le toca a ella ni a ustedes.

Especificaciones

Lo que hay que tener preciso

Una especificación dice qué hace un módulo sin decir cómo. Precondición: lo que quien llama tiene que garantizar. Postcondición: lo que la función garantiza si se cumplió la precondición. Invariante: lo que vale siempre entre operaciones. Si la función exige sin revisar, es parcial (precondición fuerte); si revisa todo y responde algo definido para toda entrada, es total (precondición débil). Ambas son válidas; la especificación tiene que decir cuál se eligió. Revisar lo mismo en tres capas “por si acaso” es tan mala señal como no revisarlo en ninguna.

Una postcondición completa dice todo lo que importa: “devuelve horas libres” la cumple una función que devuelve []; “devuelve todas las horas libres, sin repetir, de menor a mayor” no.

Los tipos son una especificación que revisa tsc. Uniones de literales en vez de string; uniones discriminadas para que cada estado tenga exactamente sus datos; un switch exhaustivo con never. Parse, don’t validate: en la frontera se convierte el unknown en un tipo que lleva la garantía, una vez. x as Order no revisa nada. Los tipos no pueden decir “de 1 a 3 horas” ni “el monto es igual al precio”: eso va en revisiones en tiempo de ejecución y en pruebas.

Un design doc se escribe antes de construir: contexto y alcance, objetivos y no objetivos, diseño, alternativas (la parte más útil) y temas transversales. Con un agente, una especificación es la entrada del trabajo (spec-driven development); donde calla, el agente elige por ustedes.

Ejercicio resuelto: una operación y sus tipos

Escriban la especificación de deliver, que entrega un pedido y registra el pago. Después escriban los tipos que impidan representar un pedido entregado sin fecha de entrega.

Los tipos. Un tipo con campos opcionales (deliveredAt?: string, readyAt?: string) permite un pedido recibido con fecha de entrega. Una unión discriminada dice qué datos existen en cada estado:

export type ReceivedOrder = OrderBase & { status: 'received' };
export type WashingOrder = OrderBase & { status: 'washing' };
export type ReadyOrder = OrderBase & { status: 'ready'; readyAt: string };
export type DeliveredOrder = OrderBase & { status: 'delivered'; readyAt: string; deliveredAt: string };
export type CancelledOrder = OrderBase & { status: 'cancelled'; cancelledAt: string };

export type Order = ReceivedOrder | WashingOrder | ReadyOrder | DeliveredOrder | CancelledOrder;

Ahora un pedido entregado sin deliveredAt no se puede construir, y una función que pide un pedido listo no acepta otro. Lo comprobamos con tsc: los archivos de bad/ fallan a propósito.

$ make errores
tsc -p bad --pretty false
bad/deliver-washing.ts(6,24): error TS2345: Argument of type 'WashingOrder' is not assignable to parameter of type 'ReadyOrder'.
  Property 'readyAt' is missing in type 'WashingOrder' but required in type '{ status: "ready"; readyAt: string; }'.
bad/delivered-without-date.ts(4,14): error TS2322: Type '{ id: number; phone: string; weightGrams: number; service: "normal"; priceCents: number; receivedAt: string; readyAt: string; status: "delivered"; }' is not assignable to type 'DeliveredOrder'.
  Property 'deliveredAt' is missing in type '{ id: number; phone: string; weightGrams: number; service: "normal"; priceCents: number; receivedAt: string; readyAt: string; status: "delivered"; }' but required in type '{ status: "delivered"; readyAt: string; deliveredAt: string; }'.
bad/missing-case.ts(17,26): error TS2345: Argument of type 'CancelledOrder' is not assignable to parameter of type 'never'.
make: [Makefile:22: errores] Error 2 (ignored)

El tercer error es el switch sin el caso 'cancelled': si mañana aparece un estado nuevo, tsc señala cada lugar donde falta manejarlo.

La especificación. Hay dos diseños válidos, y conviene saber cuál se elige dónde:

// Parcial, con la precondición en el tipo: solo acepta un pedido listo, y tsc
// rechaza cualquier otro. Postcondición: un pedido nuevo, entregado en `now`,
// con los demás datos iguales; `order` no cambia.
export function markDelivered(order: ReadyOrder, now: string): DeliveredOrder {
  return { ...order, status: 'delivered', deliveredAt: now };
}

Cuando el pedido viene de la base de datos, su estado no se conoce al compilar. Allí hace falta la versión total, con el error en el tipo del resultado:

export type DeliverResult =
  | { ok: true; order: DeliveredOrder }
  | { ok: false; code: 'NOT_READY' | 'ALREADY_DELIVERED' | 'CANCELLED' }
  | { ok: false; code: 'WRONG_AMOUNT'; expectedCents: number };

En el examen se puede escribir la especificación en texto:

deliver(order, paidCents, now)

Precondición: ninguna sobre el estado o el monto: es total respecto de ellos. now es un instante en ISO 8601; esto no se revisa y queda a cargo de quien llama.

Postcondición. Si order está listo y paidCents es igual a su precio, devuelve ok: true y un pedido entregado con deliveredAt = now y los demás datos iguales. Si no, devuelve ok: false con el código: NOT_READY (recibido o lavando), ALREADY_DELIVERED, CANCELLED o WRONG_AMOUNT (con el precio esperado). order no se modifica en ningún caso.

Invariantes de un pedido. priceCents es un entero positivo y no cambia después de recibirlo. El estado solo avanza recibido → lavando → listo → entregado, o recibido → anulado. Un pedido entregado o anulado ya no cambia.

Cada cláusula es una prueba en order.test.ts.

Idea clave. Los tipos cubren lo que se conoce al compilar: qué datos tiene cada estado. paidCents === priceCents depende de dos valores que solo existen al ejecutar, y por eso es un WRONG_AMOUNT y no un error de tsc. En una pregunta de “tipos que impidan un estado inválido”, digan también qué no pueden impedir.

Error común. Olvidar las postcondiciones de lo que no cambia (el resto de los datos del pedido, que order no se modifica) y escribir como postcondición “funciona correctamente”. También: copiar la implementación como especificación. “Hace un switch sobre el estado” dice cómo, no qué.

Diseño

Lo que hay que tener preciso

Complejidad (Ousterhout): todo lo que hace difícil entender y modificar un sistema. Sus síntomas son la amplificación de cambios, la carga cognitiva y las incógnitas desconocidas (la peor). Sus causas, las dependencias y la oscuridad.

Un módulo es profundo si da mucho con una interfaz pequeña; superficial si su interfaz es casi tan grande como lo que hace. Los métodos que solo reenvían (pass-through) son la señal más clara. Ocultamiento de información (Parnas): cada módulo esconde una decisión que puede cambiar. Hay filtración cuando la misma decisión aparece en varios módulos, y la descomposición temporal la causa con frecuencia.

Acoplamiento: cuánto obliga un cambio en un módulo a cambiar otro. Cohesión: cuánto pertenecen juntas las partes de un módulo. De más a menos dañino: depender de detalles internos, compartir estado global, pasar banderas, pasar estructuras enteras, pasar solo los datos necesarios. DRY es sobre conocimiento, no sobre texto: no se junta código solo porque se parece. Un concepto (Jackson) tiene propósito, estado, acciones y principio operacional.

Ejercicio resuelto: un fragmento con problemas

El siguiente código funciona. Señalen sus problemas de diseño, nombrándolos, y propongan una interfaz mejor.

// est: 1 recibido, 2 lavando, 3 listo, 4 entregado.
export type OrderRow = { id: number; cust_phone: string; kg: number; est: number; amount: number };

export class OrderTable {
  #rows: OrderRow[] = [];

  all(): OrderRow[] {
    return this.#rows;
  }

  insert(row: OrderRow): void {
    this.#rows.push(row);
  }

  setEst(id: number, est: number): void {
    const row = this.#rows.find((r) => r.id === id);
    if (row) row.est = est;
  }
}

export class OrderManager {
  #table: OrderTable;

  constructor(table: OrderTable) {
    this.#table = table;
  }

  all(): OrderRow[] {
    return this.#table.all();
  }

  insert(row: OrderRow): void {
    this.#table.insert(row);
  }

  setEst(id: number, est: number): void {
    this.#table.setEst(id, est);
  }
}

// counter.ts: lo que ve quien atiende el mostrador.
export function readyForPickup(manager: OrderManager, phone: string): OrderRow[] {
  return manager.all().filter((r) => r.cust_phone === phone && r.est === 3);
}

// cash.ts: la caja del día.
export function revenue(manager: OrderManager): number {
  return manager.all().filter((r) => r.est === 4).reduce((sum, r) => sum + r.amount, 0);
}

Los problemas, cada uno con su nombre y su evidencia:

  1. Módulo superficial. Los tres métodos de OrderManager solo reenvían a OrderTable con los mismos parámetros. Quitándolo, nadie pierde nada: su interfaz es igual de grande que lo que hace, y no esconde ninguna decisión.
  2. Información que se filtra. Los códigos de estado y los nombres de columna (est, cust_phone) los conocen el mostrador y la caja. Si est cambia de significado, o se agrega un estado, hay que encontrar a todos los que lo leen.
  3. Acoplamiento a detalles internos. all() entrega la lista privada. #rows es privado solo de nombre, porque cualquiera la puede vaciar desde afuera.
  4. Las reglas no tienen owner. Nada dice que no se pueda pasar de entregado a recibido. La regla de transiciones no está escrita en ningún módulo, y setEst acepta cualquier número.

Las pruebas de design.test.ts muestran que no es teoría, y que la interfaz de abajo los resuelve:

$ node --test design.test.ts | grep -E "✔|✖|ℹ (pass|fail)"
✔ shallow: all() entrega la lista interna y se puede vaciar desde afuera (0.812208ms)
✔ shallow: setEst acepta un código que no existe y saltos de estado (0.123416ms)
✔ shallow: un estado nuevo no le avisa a nadie (0.08325ms)
✔ desk: el camino completo, de recibido a entregado (0.359167ms)
✔ desk: no se puede saltar un estado (0.603791ms)
✔ desk: lo que devuelve es una copia, no el estado interno (0.206958ms)
ℹ pass 6
ℹ fail 0

Una interfaz mejor se piensa desde lo que necesita quien la usa: el mostrador quiere saber qué está listo para un teléfono, y la caja, cuánto se ha cobrado. Ninguno quiere filas.

export type OrderDeskInterface = {
  receive(phone: string, weightGrams: number, service: Service): Order;
  startWashing(id: number): StepResult;
  markReady(id: number): StepResult;
  deliver(id: number, paidCents: number): DeliverResult | { ok: false; code: 'NOT_FOUND' };
  readyFor(phone: string): ReadyOrder[];
  revenueCents(): number;
};

Esas son las firmas, escritas como un tipo para poder mostrarlas: en un examen basta con listarlas. La clase que las cumple está en desk.ts. Lo que importa en la respuesta es decir qué esconde: cómo se guardan los pedidos, qué transiciones existen, cómo se calcula el precio y qué códigos de estado hay. Y el reloj entra como dependencia, por lo que se puede probar con una hora fija. Noten que tiene más métodos que OrderManager y es más profundo: no se cuentan métodos, se cuenta lo que hay que saber para usarlo. setEst(id, 3) obliga a quien llama a saber qué es el 3 y cuándo se puede; markReady(id), no.

Error común. Responder “falta separar en más clases”. Separar OrderTable y OrderManager es justo lo que produjo el módulo superficial. Otro: decir “tiene alto acoplamiento” sin señalar qué cambio duele. Un buen comentario de diseño tiene un lugar en el código y un cambio concreto: “si se agrega el estado anulado, hay que revisar readyForPickup y revenue, y nada lo avisa”.

Con IA. Un agente trabaja de forma táctica por defecto: agregaría un OrderManagerService que reenvía, y sus pruebas pasarían. Revisen el diseño de lo que genera con las mismas preguntas de esta sección: ¿qué esconde este módulo?, ¿qué cambio abarata? Pruebas en verde no dicen nada sobre eso.

APIs y patrones

Lo que hay que tener preciso

Una API es un contrato con quien la llama, que no está en la sala cuando la cambian. Se diseña orientada a recursos (sustantivos, colecciones), con pocos verbos estándar. Una acción que no encaja es un método propio: POST /api/bookings/12/cancel.

Seguro e idempotente, según RFC 9110. Un método es seguro si quien llama no pide ningún cambio de estado: GET, HEAD, OPTIONS (y TRACE). Es idempotente si el efecto sobre el servidor de N solicitudes iguales es el mismo que el de una: los seguros, más PUT y DELETE. Dos precisiones que se evalúan:

  • Lo que se compara es el efecto, no la respuesta. Un segundo DELETE puede dar 404 y sigue siendo idempotente.
  • El método y la operación son dos cosas. POST y PATCH no están definidos como idempotentes: nadie intermedio puede suponer que repetirlos es inocuo. Una operación concreta sobre POST puede comportarse de forma idempotente, si la API lo promete en su contrato.
Código Cuándo
200, 201 (con Location), 204 Bien; creado; bien y sin cuerpo
400 Mal formado: no es JSON, falta un campo, tipo equivocado
401, 403 No se sabe quién es; se sabe y no puede
404, 405 (con Allow) No existe; la ruta existe con otro método
409 Choca con el estado actual
413, 429 Cuerpo demasiado grande; demasiadas solicitudes
422 Bien formado, pero no cumple una regla
500, 503 Falla del servidor; no disponible por ahora

La frontera entre 400 y 422 es una decisión de diseño: se escribe y se respeta en todos los endpoints. Los errores tienen un código estable que un programa puede comparar, un mensaje para personas y la misma forma en toda la API.

Compatible: agregar un endpoint, un campo opcional en la solicitud o un campo en la respuesta. Incompatible: quitar o renombrar un campo, cambiar su tipo o su significado, volver obligatorio algo opcional, cambiar un código de estado. La versión vieja se anuncia con Deprecation y Sunset, se mide quién la usa y recién entonces se apaga.

Patrones. Un patrón es una solución con nombre a un problema que se repite, con sus trade-offs. Los que siguen vigentes: strategy, dependency injection, adapter, repository, decorator y observer. En TypeScript, una strategy suele ser solo una función. La pregunta en una revisión es siempre: ¿qué cambio futuro abarata este patrón y qué tan probable es? Una fábrica con una sola implementación agrega interfaces sin esconder nada.

Ejercicio resuelto: la API de los pedidos

Diseñen las rutas para registrar un pedido y anularlo, con sus códigos y errores. La aplicación del mostrador trabaja con una conexión inestable: ¿qué pasa al reintentar y cómo lo resuelven?

Primero, la decisión escrita: 400 es forma (no es JSON, falta un campo, tipo equivocado), 422 es una regla sobre un dato bien formado y 409 es un choque con el estado actual.

Método y ruta Éxito Errores
POST /api/orders 201, con Location y { order } 400 INVALID_JSON, 400 VALIDATION_ERROR, 422 INVALID_WEIGHT, 422 IDEMPOTENCY_KEY_REUSED
POST /api/orders/:id/cancel 200 { order } 404 ORDER_NOT_FOUND, 409 ALREADY_CANCELLED, 409 ORDER_IN_PROCESS

El reintento. La encargada registra un pedido, la conexión se corta y la aplicación no sabe si llegó. Tiene dos caminos malos: no reintentar y perder el pedido, o reintentar y quizás cobrárselo dos veces a Ana. POST no es idempotente. La solución habitual es una clave de idempotencia: quien llama genera un valor único por intento lógico y lo manda en la cabecera Idempotency-Key. El servidor guarda la clave con la respuesta y, ante un reintento con la misma clave, devuelve la misma respuesta sin repetir la operación. Lo importante es guardar el pedido y la clave en la misma transacción; si no, una falla entre las dos escrituras deja un pedido sin clave, y el reintento lo duplica.

Dos detalles más. La misma clave con otro cuerpo es un error de quien llama, y se responde 422, como pide el borrador de la IETF (todavía no es un RFC). Y el borrador pide 409 si la primera solicitud se sigue procesando; en este servidor no puede pasar, porque node:sqlite es síncrono y no hay ningún await entre leer la clave y guardarla. En un sistema con varias instancias o con escrituras asíncronas, sí puede pasar, y hay que manejarlo.

El ejemplo corre contra un servidor de verdad, en api.test.ts:

$ node --test api.test.ts | grep -E "✔|✖|ℹ (pass|fail)"
✔ sin clave, repetir el POST crea dos pedidos (24.294208ms)
✔ con clave, el reintento devuelve la misma respuesta y crea un solo pedido (6.249125ms)
✔ la misma clave con otro cuerpo se rechaza con 422 (2.506458ms)
✔ el cuerpo mal formado da 400 y la regla incumplida da 422 (1.935125ms)
✔ anular: el efecto de repetirlo es el mismo, aunque la respuesta cambie (2.640709ms)
✔ un pedido que no existe da 404 (1.065833ms)
ℹ pass 6
ℹ fail 0

¿Y anular? Es un POST, así que HTTP no lo define como idempotente. Pero, tal como lo diseñamos, la operación se comporta así: la primera vez responde 200, la segunda 409, y el pedido queda igual (la quinta prueba lo comprueba). Lo que cambia es la respuesta, no el efecto. Quien reintenta tiene que saber que un 409 ALREADY_CANCELLED después de un timeout probablemente significa que su primer intento funcionó. Eso se escribe en el contrato.

Error común. Decir que un método es idempotente “porque devuelve lo mismo”, o que un DELETE repetido no lo es porque el segundo da 404. La definición habla del efecto sobre el servidor. El otro error es creer que basta “revisar si ya existe un pedido igual” en lugar de una clave: Ana puede traer dos veces la misma cantidad de ropa en un mismo día, y esos son dos pedidos.

En la industria. Las guías de Microsoft (Repeatability of requests) y el borrador Idempotency-Key de la IETF tratan este mismo problema, porque en un sistema distribuido los reintentos no son la excepción. Las AIP de Google y las guías de Microsoft tratan como un tema aparte qué cambios son compatibles: AIP-180, por ejemplo, se dedica a eso.

Arquitectura

Lo que hay que tener preciso

Arquitectura: las decisiones de un sistema que son caras de cambiar después. Diseño y arquitectura son lo mismo a distinta escala; la diferencia es el costo de equivocarse.

Estilo Qué es Se gana Se paga
Capas Cada capa usa solo la de abajo Se entiende y se revisa Vale lo que vale su cumplimiento
Hexagonal (Cockburn) El núcleo define puertos; el exterior se conecta con adaptadores; la dependencia apunta hacia el núcleo Probar sin base de datos, cambiar de proveedor Más interfaces
Monolito Un programa que se despliega entero Llamadas locales, transacciones simples, un despliegue Todo se despliega junto
Monolito modular Monolito con fronteras claras Lo anterior, y se puede partir después Hay que cuidar las fronteras
Microservicios (Fowler y Lewis) Servicios que se despliegan por separado, cada uno con sus datos, comunicados por la red Despliegues independientes, escalar por partes Fallas parciales, latencia, consistencia eventual, mucha más operación

Ley de Conway: la estructura de un sistema tiende a copiar la estructura de comunicación de la organización que lo construye. Monolith first: casi todos los sistemas con microservicios exitosos empezaron como monolito; partir antes de conocer las fronteras produce fronteras equivocadas, y moverlas entre servicios cuesta mucho más que moverlas entre módulos.

Un atributo de calidad se escribe como escenario, con cuatro partes: estímulo, entorno, respuesta y medida. Los atributos compiten; decidir es elegir cuál gana, para este cliente, y escribir qué se pierde. Un ADR (Nygard) es un documento corto por decisión. No se edita cuando la decisión cambia: se escribe uno nuevo que lo reemplaza. MADR agrega las alternativas con sus pros y contras.

Ejercicio resuelto: una decisión y su ADR

Cuando la encargada marca un pedido como listo hay que avisar por WhatsApp, a través de un proveedor externo que a veces falla. Decidan cómo se envía el aviso y escriban el ADR. Escriban también el atributo de calidad que manda.

El escenario de calidad, con sus cuatro partes:

Parte
Estímulo El proveedor de WhatsApp deja de responder
Entorno En horario de atención, un sábado, con la encargada atendiendo el mostrador
Respuesta Marcar un pedido como listo sigue funcionando; el aviso queda pendiente y se reintenta
Medida Marcar como listo responde en menos de 1 s, y el 99 % de los avisos pendientes sale dentro de los 5 minutos siguientes a que el proveedor vuelva

El ADR, con las secciones que pide P2:

# ADR-0001: Los avisos se envían desde una tabla de pendientes

## Estado
Aceptada, 14 de marzo de 2026.

## Contexto
Al marcar un pedido como listo hay que avisar por WhatsApp a través de un
proveedor externo, que a veces falla o tarda varios segundos. Hilda no puede
dejar de atender el mostrador porque el proveedor esté caído. Somos dos
personas manteniendo el sistema, con unos 30 pedidos al día y un solo servidor.

## Decisión
Marcar como listo guarda, en la misma transacción, el cambio de estado y una
fila en una tabla de avisos pendientes. Un proceso dentro del mismo monolito lee
esa tabla cada minuto, envía los avisos a través del puerto `Notifier` y reintenta
los que fallan.

## Alternativas consideradas
- **Enviar dentro de la misma solicitud.** Es lo más simple y el aviso sale al
  instante. Pero si el proveedor falla o tarda, la encargada espera o ve un error,
  y si el servidor cae entre guardar y enviar, el aviso se pierde.
- **Un servicio aparte con una message queue.** Separa bien la falla y escala.
  Pero agrega un servicio y una cola que operar, para 30 avisos al día y dos
  personas: pagamos mucha operación por una escala que no tenemos.

## Consecuencias
- Ganamos: el mostrador no depende del proveedor y ningún aviso se pierde si el
  servidor se reinicia.
- Perdemos: el aviso sale hasta un minuto después, y puede salir dos veces si el
  proceso cae justo después de enviarlo y antes de marcarlo como enviado.
- Hay que hacer: mirar cuántos avisos llevan más de 10 minutos pendientes. Si
  llegamos a varios servidores, esta decisión se revisa con un ADR nuevo.

Lo que hace buena la respuesta: las alternativas son de verdad (se podría elegir cualquiera) y cada una tiene algo bueno; el contexto tiene los números que deciden (30 pedidos al día, dos personas); y las consecuencias incluyen lo que se pierde, incluido un efecto incómodo: el aviso puede llegar duplicado.

Error común. “Contexto: necesitamos enviar avisos. Decisión: usamos una cola. Consecuencias: es escalable.” No hay alternativas, no hay datos y no se pierde nada. Quien llegue en un año no puede saber si la decisión sigue vigente. Otro error es elegir microservicios “porque escalan” sin ningún requisito de escala.

En la industria. El ADR nació de un artículo corto de Nygard de 2011, que propone guardarlos en el repositorio y reemplazarlos en lugar de editarlos, porque quien llega después necesita ver por qué se decidió lo que se decidió. Es la misma idea de la documentación como código del capítulo 10 de SWEG: tiene owner, se revisa y vive junto al código que describe.

El proyecto: cerrar el sprint 1 y entregar P2

No hay laboratorio esta semana. El tiempo es para cerrar el sprint, entregar P2 y planificar el siguiente con el enunciado de P3, que se publica esta semana.

Revisar sus decisiones

Antes de la demo, lean su propio P2 con los criterios de este capítulo, como si fuera de otro equipo:

Entrega Pregunta
Historias ¿Cada criterio se comprueba con un sí o un no? ¿Cada historia se puede rastrear hasta algo que dijo o aprobó el cliente?
Design doc ¿## No objetivos dice algo que alguien podría esperar? ¿## Alternativas consideradas tiene dos diseños que de verdad se podrían haber elegido?
Módulos Para cada módulo: ¿qué decisión esconde? Si no pueden responder, quizá sobra.
API ¿La frontera entre 400 y 422 está escrita, y se respeta? ¿Qué pasa si la solicitud más importante se repite?
ADRs ¿Cada uno tiene alternativas, y consecuencias con lo que se pierde? ¿Se escribió antes de decidir, o para justificar?

La demo y la retrospectiva

La sprint review no es una presentación de lo que se trabajó: es una conversación con el cliente sobre lo que ya funciona y sobre qué cambia en el backlog. Muestren solo lo que cumple la Definition of Done y anoten lo que dijo. La retrospectiva termina con acciones concretas, con responsable, para el sprint 2. Sin eso, tiene el costo de Scrum y ninguno de sus beneficios.

Todo eso queda en docs/sprints/sprint-1.md, con las secciones ## Planificación, ## Revisión y ## Retrospectiva. Antes de poner el tag p2, corran la revisión en el contenedor del curso, desde su repositorio:

cd /work/equipo-nombre
make -f /work/projects/p2-diseno/Makefile check

P3 pone el foco en cómo saben que el producto funciona. Al planificar el sprint 2 con él, usen la velocity del sprint 1 con cuidado, porque es un solo dato, y estimen el trabajo hasta la Definition of Done, que ahora crece.

El código de la semana y make test

code/week-08 tiene los ejemplos de los ejercicios resueltos. No usa Kallpa.

order.ts tiene los tipos, quotePriceCents y deliver; bad/ tiene lo que no debe compilar; shallow.ts y desk.ts son el fragmento y la interfaz mejor; api.ts es la API con clave de idempotencia, probada contra un servidor de verdad. Cada uno tiene su prueba.

$ make test | tail -8
ℹ tests 18
ℹ suites 0
ℹ pass 18
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 401.015125

Resumen

  • El examen da un caso que no han visto y pide aplicar la idea, con los datos del caso y diciendo qué se pierde. Estudien escribiendo, no releyendo.
  • Ingeniería de software: programación integrada en el tiempo. Hyrum: todo lo observable termina siendo usado. Scrum: tres responsabilidades, cinco eventos, tres artefactos con sus compromisos. Kanban: limitar el WIP; cycle time = WIP / throughput, en promedio y en un sistema estable. Una estimación no es un compromiso.
  • De un estudio, digan qué midió, a quiénes y cuándo. Un experimento y una encuesta responden preguntas distintas. La sensación de productividad no es una medición.
  • Requisitos: busquen el problema detrás de la solución, y lo ambiguo y lo contradictorio en lo que dice el cliente. Los criterios se comprueban con un sí o un no, y lo que no se sabe es una pregunta para el cliente.
  • Especificaciones: precondición, postcondición (completa) e invariante; parcial o total, dicho de forma explícita. Las uniones discriminadas impiden estados inválidos; digan también lo que los tipos no pueden impedir.
  • Diseño: módulos profundos, información escondida y bajo acoplamiento. Un buen comentario señala un lugar en el código y el cambio que lo hace doler. Una interfaz mejor se piensa desde quien la usa.
  • APIs: recursos, códigos con significado, errores con un código estable. La idempotencia es sobre el efecto, no sobre la respuesta. POST se vuelve repetible con una clave de idempotencia guardada en la misma transacción que la operación.
  • Arquitectura: las decisiones caras de cambiar. Un atributo de calidad tiene estímulo, entorno, respuesta y medida. Un ADR tiene alternativas de verdad y consecuencias con lo que se pierde, y se reemplaza en lugar de editarse.

Ejercicios

Los ejercicios 1 a 7 son de papel y lápiz: resuélvanlos sin mirar el capítulo. El parcial de práctica es el siguiente paso.

  1. Hilda cuenta otra cosa: “Si no recogen en 30 días, regalo la ropa a la parroquia. Bueno, a los frecuentes los llamo antes. Y quiero que la app sea fácil”. Encuentren lo ambiguo y escriban una pregunta abierta por cada cosa. Escriban una historia con dos criterios para el caso de los 30 días.
  2. Revisen estas historias con INVEST y digan qué letra falla en cada una: (a) “Como encargada, quiero una tabla customers con el campo is_frequent”; (b) “Como Hilda, quiero manejar los pagos, los descuentos, las devoluciones y los reportes”; (c) “Como cliente, quiero que la app se vea bonita”. Reescriban las que se puedan arreglar.
  3. Escriban la especificación de cancel(order, now) para la lavandería: precondiciones, postcondiciones, errores. Decidan si la dejan parcial o total, y digan por qué. ¿Qué parte de la regla se puede poner en los tipos de order.ts y qué parte no?
  4. Escriban la especificación de cancel(bookingId) en Kallpa, solo con lo que dice su README y sin mirar el código. Luego compárenla con src/domain/booking.ts. ¿Qué dice el README que el tipo no puede decir?
  5. Hilda agrega un servicio “solo planchado”, que no pasa por lavando. ¿Qué cambia en shallow.ts y qué en OrderDesk? ¿Qué síntoma de complejidad muestra cada uno?
  6. Clasifiquen como compatible o incompatible, y justifiquen: (a) agregar readyAt a la respuesta de un pedido; (b) cambiar weightGrams por weightKg; (c) pasar INVALID_WEIGHT de 422 a 400; (d) agregar un parámetro opcional ?status= a una lista; (e) volver obligatoria la cabecera Idempotency-Key. Para los incompatibles, digan cómo harían la transición.
  7. ¿Por qué un POST repetido después de un timeout es un problema, y un PUT repetido no? ¿Qué cambiarían en la API de Kallpa para resolverlo? Respondan también: ¿es idempotente PATCH /api/orders/12 con { "status": "cancelled" }? ¿Y con { "addGrams": 500 }?
  8. Para el producto de su equipo, ¿qué decisión de P2 fue de arquitectura? Escriban en cinco líneas su contexto, dos alternativas y sus consecuencias. Después escriban su atributo de calidad más importante como escenario, con sus cuatro partes, y digan con qué otro atributo compite.