Al terminar esta lección
- Distinguir un ASR de un NFR común o de una decisión de implementación, y registrarlo con su origen y su prueba.
- Derivar la frontera transaccional de un caso de uso a partir de su garantía mínima, y justificar qué efectos quedan afuera.
- Formular una invariante concurrente, detectar una carrera read-then-write y elegir el mecanismo mínimo que la protege en PostgreSQL.
- Diseñar la semántica de idempotencia (mismo
requestIdcon mismo payload y con payload distinto) y el patrón outbox para efectos posteriores al commit. - Leer y redactar un ADR completo con contexto, alternativas, consecuencias y disparador de revisión.
7. Cuando el negocio empieza a empujar la arquitectura
La arquitectura no empieza con "¿microservicios o monolito?". Empieza cuando un requisito o un riesgo obliga a tomar una decisión estructural que será costosa de cambiar después.
Hasta el módulo 6 trabajaste con evidencia, reglas y especificación. Ahora cada una de esas piezas se vuelve una pregunta técnica concreta: la regla RN-05 (disponible = onHand - reserved, nunca negativo) pregunta dónde se decide la disponibilidad; la RN-06 (una intención de pedido no genera dos pedidos) pregunta cómo se reconoce un reintento; el NFR-04 (una falla fiscal después del commit no duplica pedido ni comprobante) pregunta qué pasa después de la transacción.
Cada decisión estructural tiene que poder recorrer esta cadena: requisito o riesgo → decisión → trade-off → prueba. Si falta un eslabón, no tenés una decisión: tenés una preferencia. Las cuatro secciones centrales (transacción, concurrencia, idempotencia, outbox) son la misma cadena aplicada a cuatro riesgos de Nexo.
7.1 Requisitos arquitectónicamente significativos
El problema. Un equipo que trata todo NFR como decisión de arquitectura termina con un diagrama enorme; uno que no trata ninguno descubre en producción que la estructura no soporta lo que el negocio necesitaba. Hace falta un criterio para separar lo que cambia la forma del sistema de lo que se resuelve dentro de la forma elegida.
Definición operativa. No todos los NFR merecen una decisión arquitectónica explícita. Un ASR es una restricción o cualidad que, si cambia, puede modificar la estructura de la solución. Para decidir si algo es ASR hacé tres preguntas:
- Si este requisito cambiara de valor, ¿tendríamos que mover una frontera (de módulo, de transacción, de proceso, de base)?
- ¿Revertir la decisión después de tener datos y usuarios es caro?
- ¿Hay evidencia de que el riesgo existe en Nexo, o lo estamos trayendo de otro sistema?
Ejemplos de la bibliografía general: multi-depósito con reserva concurrente, integración fiscal obligatoria, operación offline en depósito, auditoría legal, picos de cientos de pedidos concurrentes. Identificar ASR evita diseñar arquitectura por moda.
Ejemplo en Nexo. Nexo tiene alrededor de decenas de usuarios internos (en el caso ficticio: unos treinta, con picos de unos treinta concurrentes) y cientos de clientes B2B, no millones de usuarios públicos. Eso importa: una arquitectura distribuida por prestigio añadiría coordinación, observabilidad y fallos sin que exista una necesidad de escala que la justifique. La reserva concurrente, la integración fiscal y la autoridad de precios sí son ASR porque pueden romper dinero o integridad. El equipo los deja en un registro versionado en el repositorio:
asr:
- id: ASR-01
titulo: Reserva concurrente sin sobreventa por depósito y SKU
origen: [RN-05, AC-06, EV-05]
escenario: >
Dos vendedores confirman pedidos distintos por la última unidad de un SKU
en el mismo depósito. Exactamente uno obtiene la reserva y el
disponible final nunca es negativo.
por_que_es_estructural: >
La decisión de disponibilidad tiene que tomarse en la misma fuente
que la actualiza, de forma atómica. No se puede resolver en la UI ni en un cache.
decisiones: [ADR-002]
prueba: T06
- id: ASR-02
titulo: La misma intención de pedido no produce dos pedidos
origen: [RN-06, AC-07]
escenario: >
El cliente reintenta un POST porque la red cortó después de que el
servidor confirmó. Mismo requestId y mismo payload devuelven el mismo
pedido. Mismo requestId con payload distinto devuelve conflicto.
por_que_es_estructural: >
Exige persistir la intención en la misma transacción que el pedido.
decisiones: [ADR-005, ADR-006]
prueba: [T07, T08]
- id: ASR-03
titulo: Los efectos externos se entregan después del commit y sobreviven a caídas
origen: [NFR-04, AC-11, AC-12]
escenario: >
El pedido se confirma, el sistema de preparación o el proveedor fiscal
no responde. El pedido sigue siendo válido, la entrega queda
pendiente, es observable y se reintenta sin duplicar.
por_que_es_estructural: >
Obliga a un mecanismo de entrega durable y a un proceso que no es el request.
decisiones: [ADR-003, ADR-004]
prueba: [T12]
- id: ASR-04
titulo: El servidor es la autoridad de precio, crédito, estado y permisos
origen: [RN-01, RN-03, NFR-03]
escenario: >
Un cliente web manipulado envía unitPrice, creditApproved o reserved=true.
El servidor los ignora y decide desde fuentes autoritativas.
por_que_es_estructural: >
Define qué datos viven en Nexo y cuáles se leen del ERP en cada request.
decisiones: [ADR-001]
prueba: [T10]
- id: ASR-05
titulo: Toda autorización de crédito es reconstruible
origen: [NFR-02]
escenario: >
Seis meses después alguien pregunta quién autorizó un sobregiro, con qué
regla y por qué motivo. La respuesta está en la base, no en un audio de WhatsApp.
por_que_es_estructural: >
La escritura de auditoría comparte transacción con la decisión.
decisiones: [ADR-001]
prueba: [T04]
restricciones:
- id: RES-01
titulo: Equipo pequeño y operación simple
detalle: >
Cada componente adicional necesita despliegue, backup, alerta y guardia.
El costo operativo cuenta como requisito.
no_son_asr:
- id: NFR-01
motivo: >
p95 < 800 ms para registrar un pedido con 30 usuarios concurrentes se cumple
con índices y consultas razonables dentro de la estructura elegida. Pasaría
a ser ASR solo si una medición mostrara que la estructura no puede alcanzarlo.
El registro tiene una virtud poco glamorosa: obliga a escribir, junto a cada ASR, qué prueba lo defiende. Un ASR sin prueba es un deseo.
Contraejemplo. "Necesitamos escalar" sin número ni escenario. En Nexo, el único número que existe es el de usuarios internos y es chico. Que no haya ASR de escala independiente es un hallazgo tan importante como los ASR que sí hay: es lo que va a sostener el ADR-001.
Cómo se verifica. Para cada fila del registro: ¿tiene origen (RN, NFR, EV)? ¿Tiene escenario con condiciones observables? ¿Tiene decisión asociada y prueba? ¿Hay algún componente del diagrama que no apunte a ningún ASR o restricción?
¿ASR, NFR común, requerimiento funcional o detalle de implementación?
Clasificá cada frase del backlog técnico de Nexo. Pensá si, al cambiar, habría que mover una frontera de la estructura.
Dos reservas concurrentes por la última unidad no pueden dejar el disponible en negativo.
El p95 del registro de pedido debe ser menor a 800 ms con 30 usuarios concurrentes.
Si el proveedor fiscal no responde después del commit, el pedido no se duplica ni se revierte.
Si A-184 está reemplazado por A-184B desde el 2026-05-01, el pedido debe usar A-184B.
Usar Prisma o una librería SQL liviana para hablar con PostgreSQL.
Cada autorización de crédito conserva actor, instante, regla, pedido y motivo, y se escribe junto con la decisión.
El botón Confirmar queda deshabilitado mientras hay una request en vuelo.
7.2 Diseñar transacciones desde la garantía mínima
El problema. El reflejo habitual es envolver "todo el método" en una transacción por seguridad. Eso alarga la transacción, retiene locks mientras se espera a terceros y no garantiza nada sobre lo que realmente importa.
Definición operativa. Las clases de control a menudo encapsulan transacciones; en Eje Z la frontera transaccional se deriva de la garantía mínima del CU. Si "pedido + reserva + movimiento de crédito" deben ser atómicos, la transacción debe contener las mutaciones que hacen verdadera esa garantía, no simplemente "todo el método por seguridad".
La garantía mínima de CU-01 dice: no existe pedido confirmado con precio no autorizado, reserva negativa o decisión de crédito ausente, y ante cualquier fallo antes del commit no queda pedido parcial, débito ficticio ni reserva huérfana. De ahí sale la lista de lo que comparte transacción.
Ejemplo en Nexo. El orden canónico de CU-01 es validar → calcular precio → evaluar crédito → reservar stock, todo en la misma transacción. Para CU-01, una unidad transaccional razonable incluye pedido, líneas con su snapshot de precio, reservas, decisión de crédito, registro de idempotencia y el evento de outbox, dentro de la misma base autoritativa. No incluimos una llamada HTTP al proveedor fiscal ni al sistema de preparación: una red externa puede tardar, caer o responder después de que la base ya decidió. Separar el commit local del efecto externo nos obliga a diseñar el "después" con idempotencia y reintentos.
Antipatrón. La llamada externa dentro de la transacción. Parece inofensiva porque el código queda "en orden", pero la conexión y los locks de stock_level quedan retenidos mientras el proveedor decide cuánto tarda:
await db.transaction(async tx => {
const reserved = await stock.reserve(tx, input);
const order = await orders.insert(tx, input, reserved);
- await fiscalClient.requestInvoice(order.id); // 30 s de timeout con la fila de stock bloqueada
+ await outbox.enqueue(tx, "order.confirmed", { orderId: order.id });
return order;
});
Si el proveedor responde después de 30 segundos, esa fila de stock_level estuvo 30 segundos bloqueada para los otros 29 vendedores. Y si el proveedor facturó pero la transacción después falla, tenés un comprobante de un pedido que no existe.
Cómo se verifica. Para cada mutación del caso de uso, preguntá: "si esto falla, ¿la garantía mínima deja de ser cierta?". Si la respuesta es sí, va adentro. Para cada efecto externo, preguntá: "si esto falla, ¿hay que deshacer el pedido?". Si la respuesta es no, va afuera y necesita un mecanismo de entrega durable.
Ordená la unidad transaccional de CU-01
Usá las flechas para ordenar los pasos que ocurren desde que llega el request hasta que el vendedor ve la respuesta.
- Posición 1 de 8: Resolver el precio autorizado de cada línea en el servidor
- Posición 2 de 8: Validar SKU y cantidades contra el maestro de productos
- Posición 3 de 8: Insertar pedido y líneas con snapshot de precio, y encolar el evento en outbox
- Posición 4 de 8: Reservar stock por depósito con UPDATE condicional, línea por línea
- Posición 5 de 8: COMMIT
- Posición 6 de 8: Responder con número, estado y totales
- Posición 7 de 8: Abrir la transacción y reclamar el requestId en idempotency_keys
- Posición 8 de 8: Evaluar crédito con saldo, límite y política vigentes
7.3 Concurrencia: primero la invariante, después el mecanismo
El problema. "¿Usamos locks?" es una pregunta prematura. Sin una propiedad escrita, cualquier mecanismo parece razonable y ninguno se puede probar.
Definición operativa. Primero se define qué condición debe seguir siendo verdadera cuando dos operaciones ocurren al mismo tiempo. Para la última unidad de stock, la invariante puede ser reservado <= disponible; en columnas de Nexo, on_hand - reserved >= 0 por depósito y SKU. Luego se elige el mecanismo mínimo que pueda demostrarla bajo la carga y topología acordadas.
Una invariante bien escrita se puede leer en voz alta sin mencionar tecnología: para cualquier depósito y SKU, la suma de unidades reservadas nunca supera las unidades físicas registradas. Después se arma el escenario A/B que podría romperla.
Ejemplo en Nexo: la carrera. Dos vendedores, una unidad de COR-118 en el depósito Centro (CEN). Cada request, por separado, es correcto. El defecto vive en el entrelazado:
Para encontrarlo en código, leelo como lo haría un revisor: ¿en qué línea se decide y en qué línea se escribe? Si entre ambas hay un await, otro request puede colarse.
¿Dónde se rompe la garantía de CU-01?
Hacé clic en las líneas que violan RN-05 o la unidad transaccional. Son más de una.
Los mecanismos candidatos. La invariante on_hand - reserved >= 0 puede protegerse de varias maneras. La elección depende del patrón de acceso y del motor de persistencia.
| Mecanismo | Cómo protege | Costo | Para Nexo |
|---|---|---|---|
UPDATE ... WHERE on_hand - reserved >= qty | La condición y la escritura son una sola sentencia atómica. Si 0 filas cambian, no había stock. | Casi ninguno. Hay que ordenar los locks entre líneas para evitar deadlocks. | Elegido (ADR-002). |
SELECT ... FOR UPDATE y después UPDATE | Bloquea la fila mientras se decide. | Dos viajes a la base con la fila retenida; más fácil de usar mal (olvidar el FOR UPDATE). | Válido, pero no aporta nada que el UPDATE condicional no dé. |
Concurrencia optimista (columna version) | Reintenta si otro cambió la fila entre lectura y escritura. | Con contención alta sobre pocas filas (la última unidad de un SKU popular) produce tormentas de reintentos. | Útil en otras operaciones, no en la reserva. |
| Nivel SERIALIZABLE | La base aborta transacciones que no serían serializables. | Obliga a reintentar errores de serialización en toda la aplicación. | Sobredimensionado para una invariante de una fila. |
| Contador en Redis o cola de reservas | Serializa en otro sistema. | Segunda fuente de verdad: hay que sincronizarla con la base, y su caída afecta al stock. | Descartado: sin ASR que lo pida. |
Redis no "resuelve carreras" por existir, y una cola tampoco reemplaza una transacción si dos consumidores pueden observar la misma disponibilidad. Para Nexo, PostgreSQL y una operación condicional o bloqueo focal son suficientes.
El mecanismo elegido, en SQL. La condición viaja dentro de la sentencia, y como defensa en profundidad la base también la declara como CHECK:
CREATE TABLE stock_level (
product_id uuid NOT NULL REFERENCES product(id),
warehouse_id text NOT NULL REFERENCES warehouse(id), -- 'CBA' | 'CEN'
on_hand integer NOT NULL CHECK (on_hand >= 0),
reserved integer NOT NULL DEFAULT 0 CHECK (reserved >= 0),
PRIMARY KEY (product_id, warehouse_id),
CONSTRAINT disponible_no_negativo CHECK (reserved <= on_hand) -- RN-05
);
-- Reserva atómica: la decisión y la escritura son la misma sentencia.
UPDATE stock_level
SET reserved = reserved + $3
WHERE product_id = $1
AND warehouse_id = $2
AND on_hand - reserved >= $3
RETURNING on_hand - reserved AS available_after;
-- 1 fila → Reserved
-- 0 filas → InsufficientStock (o el SKU no existe en ese depósito)
¿Por qué funciona bajo el nivel de aislamiento por defecto de PostgreSQL (READ COMMITTED)? Cuando dos UPDATE apuntan a la misma fila, el segundo espera a que el primero termine; al despertar, PostgreSQL vuelve a evaluar el WHERE contra la versión nueva de la fila. En el escenario de la última unidad, el segundo request ve on_hand - reserved = 0, la condición deja de cumplirse y no actualiza nada. Dos cosas para recordar: la protección depende de que condición y escritura estén en la misma sentencia; y el CHECK es la red de seguridad si alguien escribe un UPDATE manual sin la condición, porque la base rechaza el estado inválido en vez de aceptarlo.
Quedan dos detalles que separan una solución de ejemplo de una solución operable. Primero, un pedido de varias líneas toma varios locks: si dos pedidos piden los mismos SKU en orden distinto pueden quedar esperándose mutuamente (deadlock). La convención es reservar siempre en orden determinista (por ejemplo, ordenando las líneas por SKU). Segundo, la prueba tiene que ejecutar la carrera real: dos transacciones concurrentes sobre una unidad, no dos llamadas secuenciales. Ese test se escribe en el módulo 10 (T06, AC-06).
Escribí la invariante y la carrera
Elegí otra invariante concurrente de Nexo distinta del stock (pista: mirá crédito o autorización). Escribila en una oración sin nombrar tecnología y armá el entrelazado A/B que la rompería, paso por paso.
7.4 Idempotencia: diseñar para que los reintentos sean normales
El problema. Clientes móviles, proxies y agentes pueden reintentar sin saber si el request anterior llegó. Si crear pedido no es idempotente, un timeout puede convertirse en venta duplicada, con su doble reserva y su doble deuda.
Definición operativa. El comando debe llevar una clave de intención (requestId); el sistema la asocia a una semántica y devuelve el mismo resultado ante el mismo payload. Reutilizar la clave con un payload distinto debe producir conflicto, no una segunda operación silenciosa. Eso es la RN-06 escrita como contrato:
| Situación | Resultado | Efectos nuevos |
|---|---|---|
| requestId nuevo | Se procesa normalmente y se registra la intención. | Un pedido, sus reservas, un evento de outbox. |
| Mismo requestId y mismo contenido (ya confirmado) | El mismo resultado que la primera vez (mismo número de pedido). | Ninguno. |
| Mismo requestId y contenido distinto | { kind: "conflict", reason: "request_id_reused" } | Ninguno. |
| Mismo requestId llegando a la vez (dos requests en vuelo) | Uno procesa; el otro espera y luego recibe el mismo resultado. | Uno solo. |
Ejemplo en Nexo, y lo que sale mal con la versión ingenua. Guardar la clave sin pensar el segundo caso deja ambigüedad. Esta es la comparación entre el diseño que la mayoría escribe primero y el que se defiende:
Idempotencia que parece suficiente vs idempotencia que se puede defender
Tabla idempotency_keys(request_id, order_id). Si la clave existe, devuelve el pedido guardado.
- Un cliente (o un bug) reutiliza
req-501con otras líneas: recibe el pedido viejo como si fuera el nuevo. El vendedor cree que cargó 40 unidades y cargó 10. - Primero busca la clave y después inserta: dos requests simultáneos pasan la búsqueda y crean dos pedidos.
- La clave se guarda fuera de la transacción del pedido: si falla el commit, queda una clave apuntando a un pedido que no existe.
Tabla idempotency_keys(request_id PRIMARY KEY, order_id, request_hash, created_at).
- El
INSERT ... ON CONFLICT DO NOTHINGreclama la clave: el segundo request simultáneo espera a que el primero termine y recién ahí ve el resultado. - Si la clave ya existía, se compara
request_hash: igual → mismo pedido; distinto → conflicto. - El reclamo está en la misma transacción que el pedido: si se hace rollback, la clave desaparece y el cliente puede reintentar.
La huella (request_hash) resume el contenido que identifica la intención. Qué entra en ella es una decisión de contrato: cliente, depósito y líneas normalizadas por SKU y cantidad entran; la metadata de tracing (cabeceras, timestamps del cliente) no. Esa definición es la del ADR-006, que vas a ver completo en 7.2. Una advertencia: un hash de "todo el JSON" es una trampa, porque el orden de las propiedades o un campo de tracing convertirían un reintento legítimo en un falso conflicto.
Cómo se verifica. Dos tests de integración (T07, mismo requestId y payload; T08, mismo requestId y payload distinto) y uno concurrente: dos requests con el mismo requestId a la vez producen un solo pedido.
7.5 Fallos externos: qué ocurre después del commit
El problema. Una transacción local no vuelve atómica una llamada a un sistema externo. Si el pedido se confirma en PostgreSQL y luego falla la notificación a logística, revertir la base puede ser imposible o incorrecto. Si en cambio la notificación se envía antes del commit y el commit falla, avisaste de un pedido que no existe. Es el problema de la doble escritura: dos sistemas, ninguna transacción que los abarque.
Definición operativa. El diseño debe separar la consistencia del negocio local de la entrega de efectos externos. El patrón estándar se llama outbox transaccional: en la misma transacción que cambia el estado de negocio se inserta una fila en una tabla de eventos pendientes; un proceso aparte (el worker) lee esas filas y las entrega al sistema externo, marcándolas como entregadas. La entrega es at-least-once: puede repetirse, así que el receptor (o la clave que le mandamos) debe tolerar duplicados.
Ejemplo en Nexo. El primer release tiene que entregar el pedido confirmado a preparación, y deja la intención fiscal registrada: la emisión real del comprobante es una integración posterior, pero el contrato de fallo (NFR-04) se diseña y se prueba ahora contra un fake. En ambos casos, la aplicación confirma el pedido y registra el evento en la misma transacción local. Un worker lo reclama, llama al destino con una clave idempotente (el id del evento) y registra el resultado. Si la red falla, reintenta sin volver a confirmar el pedido. Si el destino dice "ya recibido", reconcilia en lugar de duplicar.
Contraejemplo. Un try/catch alrededor del fetch posterior al commit que "reintenta tres veces" en memoria. Si el proceso se reinicia entre el commit y el fetch, el evento se pierde sin dejar rastro. La durabilidad tiene que estar en la base, no en la memoria del request.
Cómo se verifica. Se inyecta una falla en el destino (timeout antes de procesar, timeout después de procesar, error permanente) y se comprueba que: el pedido sigue válido, el evento queda pendiente y visible, el reintento no duplica el efecto y, tras un error permanente, el evento queda en un estado que alguien ve (no se reintenta infinitamente en silencio).
¿Dónde vive cada efecto de CU-01?
Clasificá cada efecto según cómo debe tratarse en la arquitectura de Nexo.
Reservar el stock de cada línea.
Enviar el pedido confirmado al sistema de preparación del depósito.
Registrar la decisión de crédito y la autorización con actor y motivo.
Dejar registrada la intención de facturar (pedido pendiente de facturación) y entregarla al proveedor fiscal.
Enviar un aviso informativo al vendedor ("tu pedido fue confirmado") por un canal secundario.
Actualizar métricas de volumen de pedidos para el tablero interno.
7.6 Seguridad como autoridad contextual, no como login
Autenticación responde quién sos. Autorización responde qué podés hacer en este contexto. Que exista login y un middleware que verifique JWT no resuelve la política de negocio. En Nexo, un vendedor autenticado no debería poder registrar pedidos para cualquier cliente ni autorizar su propio sobregiro. La decisión depende de cartera asignada, rol, monto, estado del pedido y, si corresponde, sucursal. Esas reglas deben evaluarse del lado servidor y poder probarse. Ocultar un botón no es una política de autorización.
Permisos como bandera vs permisos como política
if (!user.isAdmin && req.method !== "GET") {
return res.status(403).end();
}
// ...cualquier usuario no admin que pase acá
// puede crear pedidos para cualquier clientId
El usuario "no admin" que entra es un vendedor que puede cargar pedidos para clientes ajenos, o un responsable de crédito que ni siquiera puede autorizar. El único dato que se evalúa es el rol, no el recurso.
export function canCreateOrder(actor: Actor, client: ClientRef): Decision {
if (!actor.roles.includes("seller")) return deny("role");
if (!actor.portfolio.includes(client.id) && !actor.permissions.has("orders:any-client"))
return deny("client_not_in_portfolio"); // T10: forbidden sin filtrar datos
return allow();
}
La decisión recibe actor y recurso, devuelve un motivo cuando deniega y se puede probar sin levantar la API. El permiso de autorizar crédito es otro permiso, con motivo obligatorio, y nunca sobre el propio pedido del actor.
La seguridad también incluye qué queda auditado y qué deliberadamente no se registra. Una autorización crítica debería conservar actor, momento, pedido, regla aplicada y motivo (NFR-02). Pero auditar no significa volcar cualquier payload: tokens, secretos, contraseñas, documentos sensibles e información innecesaria no deberían terminar en logs por comodidad. Observabilidad y privacidad se diseñan juntas. El cliente nunca es autoridad de precio, crédito, estado ni permisos: el frontend puede enviar clientId, items y un requestId; no impone unitPrice, creditApproved ni reserved=true.
7.7 Arquitectura mínima propuesta para Nexo
Con los riesgos ya explicitados, el dibujo sirve para responder una pregunta concreta: qué piezas necesitamos separar hoy y cuáles conviene mantener juntas para preservar consistencia, operación simple y trazabilidad.

La figura muestra la forma general. Este diagrama de componentes agrega lo que la figura no dice: qué módulo habla con qué puerto y quién toca la base.
La elección concreta podría ser Next.js para el portal, una API TypeScript modular, PostgreSQL y un worker para integraciones. Lo importante no es el stack. Es la cadena de justificación: volumen moderado + reglas fuertemente transaccionales + equipo pequeño → mantener un único límite de consistencia local; integraciones lentas o fallables → sacar efectos externos del request crítico; necesidad de trazabilidad → outbox, audit log y estados observables.
Un monolito modular no es "todo mezclado en una carpeta". Los módulos exponen puertos (interfaces) y la regla es verificable en CI: orders puede depender del puerto StockPort, pero no importar tablas de stock ni sus funciones internas. Eso es lo que hace barata una separación futura si algún ADR lo exige.
7.8 Estado: cuando un enum deja de ser suficiente
El problema. Un campo status con strings libres permite cualquier transición: un pedido DESPACHADO puede volver a CONFIRMADO por un UPDATE descuidado, y nadie lo nota hasta que depósito imprime dos veces.
Definición operativa. Pedido merece una máquina de estados porque las operaciones válidas dependen de su ciclo de vida. Cada transición corresponde a un evento de negocio o de aplicación. Los guards expresan condiciones de la transición y las acciones muestran efectos asociados. La expiración automática de reservas no aparece todavía porque la política TTL (RN-07) continúa BLOCKED; dibujarla como transición sería convertir un unknown en comportamiento.
![Figura 9. Máquina de estados UML del agregado Pedido para el slice. Las transiciones usan evento [guard] / acción. La expiración automática de reservas no se modela porque su política TTL continúa BLOCKED.](/resources/proyectos-software-ejez/image9.png)
La misma máquina, redibujada para ver el camino de autorización de crédito (CU-02) junto al camino feliz. Nota que no hay flecha de expiración:
Dos aclaraciones. En el camino feliz, BORRADOR → RESERVADO → CONFIRMADO ocurre dentro de una sola transacción: el vendedor nunca observa RESERVADO. Y la reserva blanda de un pedido pendiente de autorización es una decisión de política: la RN-07 dice que expira "pasado un tiempo todavía no definido", y mientras ese tiempo no exista, no hay transición automática que dibujar.
En código, la máquina es una tabla de transiciones permitidas, no un if disperso:
export type OrderStatus =
| "BORRADOR" | "RESERVADO" | "PENDIENTE_AUTORIZACION" | "CONFIRMADO"
| "EN_PREPARACION" | "LISTO_PARA_FACTURAR" | "FACTURADO" | "DESPACHADO" | "CANCELADO";
const allowed: Record<OrderStatus, readonly OrderStatus[]> = {
BORRADOR: ["RESERVADO", "PENDIENTE_AUTORIZACION", "CANCELADO"],
PENDIENTE_AUTORIZACION: ["RESERVADO", "CANCELADO"],
RESERVADO: ["CONFIRMADO", "CANCELADO"],
CONFIRMADO: ["EN_PREPARACION", "CANCELADO"],
EN_PREPARACION: ["LISTO_PARA_FACTURAR"],
LISTO_PARA_FACTURAR: ["FACTURADO"],
FACTURADO: ["DESPACHADO"],
DESPACHADO: [],
CANCELADO: [],
};
export function assertTransition(from: OrderStatus, to: OrderStatus): void {
if (!allowed[from].includes(to)) {
throw new IllegalTransition(from, to); // T11: rechazo, datos intactos
}
}
Y la base lo respalda con una actualización condicional: UPDATE orders SET status = $2 WHERE id = $1 AND status = $3 (el estado de origen forma parte del WHERE). Si 0 filas cambian, otro proceso movió el pedido antes y el caso de uso lo informa en vez de pisarlo: es la misma idea de la reserva, aplicada al estado.
Un agente propone resolver la última unidad con un lock en memoria de la aplicación (un Map de SKU a promesa). El despliegue es una sola instancia. ¿Qué respuesta de revisión es la más sólida?
7.9 ADR: conservar por qué elegimos algo
El problema. Seis meses después, nadie recuerda por qué el stock se reserva con un UPDATE condicional ni por qué no hay colas. Un desarrollador nuevo (o un agente) "mejora" la decisión sin saber qué alternativa ya se descartó y por qué.
Definición operativa. Un ADR (Architecture Decision Record, formato popularizado por Michael Nygard) es un documento corto, versionado junto al código, que registra una decisión: su contexto (qué fuerzas la empujan), la decisión en una oración activa, las alternativas consideradas y por qué se descartaron, las consecuencias (positivas y negativas) y un disparador de revisión (la evidencia que haría cambiarla). Un ADR no se edita para reescribir la historia: si la decisión cambia, se acepta un ADR nuevo que reemplaza al anterior.
En Nexo el primer release queda gobernado por seis ADR. Esta lección presenta ADR-001, ADR-002 y ADR-003, los que definen la forma del sistema y la protección del stock. ADR-004, ADR-005 y ADR-006 nacen de los incidentes de 7.2 y los vas a ver completos ahí.
| ADR | Decisión | ASR / riesgo | Dónde |
|---|---|---|---|
| ADR-001 | Monolito modular con worker como estilo de despliegue | ASR-04, ASR-05, RES-01 | 7.1 |
| ADR-002 | PostgreSQL como fuente transaccional de stock y reserva, con actualización condicional | ASR-01 | 7.1 |
| ADR-003 | Procesamiento asíncrono de efectos posteriores al commit en un worker | ASR-03 | 7.1 |
| ADR-004 | Outbox transaccional para el handoff a preparación | ASR-03 | 7.2 |
| ADR-005 | Idempotencia por requestId | ASR-02 | 7.2 |
| ADR-006 | Fingerprint del request para detectar conflicto | ASR-02 | 7.2 |
# ADR-001 · Monolito modular con worker como estilo de despliegue
Estado: aceptada
Decide: Responsable del Desarrollo. Revisan: Sergio (depósito) y Laura (crédito) por impacto operativo.
Relacionada: ADR-003 (worker), ADR-002 (PostgreSQL como fuente de stock).
## Contexto
- Equipo de desarrollo pequeño y operación a cargo del mismo equipo (RES-01).
- Volumen moderado: unos treinta usuarios internos con picos de unos treinta
concurrentes y cientos de clientes B2B. Son cifras de ficción del caso, no un
requisito medido. Ningún ASR pide escala independiente por componente.
- Las operaciones críticas cruzan pedidos, stock y crédito y exigen una garantía
transaccional única: nada de pedido parcial, reserva huérfana ni decisión
de crédito ausente (ASR-01, ASR-04, ASR-05).
- El ERP legacy sigue siendo maestro de productos, clientes y listas base en la
etapa 1. Nexo es autoridad de pedidos, reservas, estados y auditoría.
## Decisión
Construimos una aplicación modular en un único deploy lógico para request y base
de datos, más un proceso worker desplegado desde el mismo repositorio. Los módulos
(catalog, pricing, credit, orders, stock, authorization, audit) se comunican por
puertos en proceso. Un módulo no importa tablas ni funciones internas de otro, y
esa regla se verifica con una regla de lint de imports en CI. Qué trabajo sale del
request hacia el worker lo decide ADR-003.
## Alternativas consideradas
1. Microservicios por dominio (pedidos, stock, crédito, notificaciones).
Rechazada: la garantía de CU-01 pasaría de una transacción local a una saga
con compensaciones, y sumaría contratos de red, autenticación entre servicios,
despliegues, tracing y fallos parciales. Ningún ASR exige escala independiente.
2. Monolito sin módulos (capas por tipo técnico, todo importa todo).
Rechazada: no protege los límites que hacen barata una futura separación y
vuelve riesgoso el cambio, también el que hace un agente de IA.
3. Funciones serverless por endpoint.
Rechazada para el primer release: transacciones largas con pool de
conexiones efímeras, mayor dificultad de tracing y cold starts sin beneficio
para una carga interna estable.
## Consecuencias
Positivas
- Una transacción local cubre pedido, líneas, reserva, crédito, idempotencia y outbox.
- Menor costo operativo: un artefacto de aplicación, una base, un worker.
- Trazabilidad simple: un request, un correlation id, un log.
Negativas
- Escala menos granular: si stock se carga mucho, se escala toda la aplicación.
- Requiere disciplina para mantener los límites de módulos (por eso la regla de lint).
- Despliegue acoplado: un defecto en un módulo puede afectar a todos y las
migraciones de base se coordinan en un solo lugar.
## Disparador de revisión
Un módulo requiere escalado o aislamiento operativo materialmente distinto, o
aparece un SLA incompatible con compartir proceso. Hasta que haya medición que
lo muestre, esta decisión no se reabre por preferencia.
## Verificación
- Regla de lint de dependencias entre módulos en CI.
- Prueba de integración de CU-01 contra una sola base (T01, T09).
# ADR-002 · PostgreSQL como fuente transaccional de stock y reserva
Estado: aceptada
Decide: Responsable del Desarrollo. Revisa: Sergio (depósito), por impacto en la operación de stock.
Relacionada: ADR-001 (monolito con worker), ADR-005 (idempotencia en la misma transacción).
## Contexto
- RN-05: una reserva nunca puede dejar el disponible en negativo, por depósito y SKU.
- Dos vendedores pueden pedir la última unidad de un SKU al mismo tiempo (EV-05, ASR-01).
Cada request, leído por separado, es correcto. El defecto está en el entrelazado.
- Pedidos, líneas, reservas, idempotencia y auditoría ya viven en PostgreSQL (ADR-001).
La decisión de disponibilidad tiene que tomarse en la misma fuente que la actualiza.
- Son dos depósitos (CBA y CEN) y decenas de usuarios internos. La contención esperable
sobre una fila de stock es baja.
## Decisión
La disponibilidad se protege en PostgreSQL con una sentencia de actualización condicional
dentro de la transacción de CU-01: `UPDATE stock_level SET reserved = reserved + $3 WHERE
product_id = $1 AND warehouse_id = $2 AND on_hand - reserved >= $3`. Una restricción CHECK
declara la misma invariante en la tabla. Las líneas de un pedido se reservan en orden
determinista por SKU.
## Alternativas consideradas
1. `SELECT ... FOR UPDATE` seguido de `UPDATE`. Rechazada: llega al mismo resultado con dos
viajes a la base y la fila retenida entre ambos; es fácil de usar mal si alguien olvida el
`FOR UPDATE`.
2. Contador de reservas en Redis o cola de reservas. Rechazada: crea una segunda fuente de
verdad que hay que sincronizar con la base, su caída afecta el stock y ningún ASR lo pide.
3. Concurrencia optimista con columna `version`. Rechazada para la reserva: con contención alta
sobre la última unidad de un SKU popular produce tormentas de reintentos. Sirve en otras
operaciones.
4. Nivel SERIALIZABLE en toda la aplicación. Rechazada: obliga a manejar errores de
serialización en cada flujo para proteger una invariante de una sola fila.
## Consecuencias
Positivas
- La invariante vive donde están los datos y protege a cualquier escritor, incluido un script
de ajuste que no pase por la aplicación.
- La prueba concurrente de T06 puede reproducir la carrera real con dos transacciones.
- No se agrega ninguna pieza operativa nueva.
Negativas
- El stock queda acoplado a la carga de la base principal.
- Un pedido de varias líneas toma varios locks: sin orden determinista aparecen deadlocks.
La convención de orden por SKU es obligatoria y tiene que tener una prueba.
- Un SKU caliente serializa sus reservas. A este volumen es aceptable, pero hay que medirlo.
## Disparador de revisión
La medición de contención sobre `stock_level` empuja el p95 del registro de pedido por encima
del presupuesto, o aparece un canal de escritura de stock que no puede usar la misma sentencia.
## Verificación
- T06: dos transacciones concurrentes sobre una unidad; exactamente una reserva.
- T09: falla inyectada antes del commit; la reserva desaparece junto con el pedido.
- El CHECK rechaza un `UPDATE` manual que deje `reserved > on_hand`.
# ADR-003 · Procesamiento asíncrono de efectos posteriores al commit en un worker
Estado: aceptada
Decide: Responsable del Desarrollo.
Relacionada: ADR-001 (estilo de despliegue, incluye el worker), ADR-004 (outbox).
## Contexto
- Hay tres integraciones externas con latencia y disponibilidad distintas:
ERP legacy, sistema de preparación y proveedor fiscal.
- El pedido ya confirmado no debe revertirse porque un tercero esté caído
(NFR-04, ASR-03).
- Una llamada externa dentro del request retiene conexión y locks, y bloquea
al vendedor.
## Decisión
Los efectos que ocurren después del commit (handoff a preparación, intención fiscal y,
en la etapa 1, sincronizaciones con el ERP) no se ejecutan dentro del request. Un worker
lee la tabla outbox (ADR-004), entrega a cada destino con clave idempotente y registra
el resultado. Es un proceso aparte del web; su despliegue sigue lo que fija ADR-001. No
expone HTTP de negocio (solo health) y puede reiniciarse o pausarse sin tocar la API.
## Alternativas consideradas
1. Ejecutar el trabajo asíncrono dentro del proceso web (timers en memoria).
Rechazada: un reinicio o deploy de la API interrumpe entregas, y el trabajo
compite por CPU y conexiones con los requests de los vendedores.
2. Cola o broker externo (RabbitMQ, SQS, Kafka).
Rechazada para el primer release: segunda fuente de verdad que hay que
mantener consistente con la base (vuelve el problema de doble escritura) y un
componente más para operar. No hay volumen que lo justifique.
3. Librería de jobs sobre PostgreSQL.
Viable. No se adopta ahora porque son pocos tipos de evento y el patrón de
reclamo con SKIP LOCKED cubre el caso con menos dependencias. Se revisa si
crece la variedad de trabajos.
## Consecuencias
Positivas
- Aísla fallos de terceros del camino crítico del vendedor.
- Despliegue y reinicio independientes; el worker puede pausarse sin afectar ventas.
Negativas
- Un proceso más para desplegar, monitorear y alertar.
- Entrega at-least-once: todo consumidor debe tolerar duplicados.
- Latencia de entrega acotada por el intervalo de polling.
## Disparador de revisión
Aparecen más tipos de trabajo, necesidad de programación diferida compleja, o el
polling sobre la base se vuelve una carga medible. Entonces se reevalúa la
alternativa 3 o un broker.
Qué puede hacer el agente: comparar opciones arquitectónicas contra ASR, enumerar consecuencias, auditar una máquina de estados, redactar el primer borrador de un ADR a partir de tus notas y proponer experimentos técnicos.
Qué no debe decidir: elegir tecnología por popularidad, inventar un volumen futuro, asumir que Redis, microservicios o colas son obligatorios o decidir política de retención sin requisito. Tampoco escribe en el ADR "consecuencias" que no puede sostener con evidencia del caso.
Contexto mínimo útil: ASR, restricciones, volumen real, presupuesto operativo, skills del equipo, RN e integraciones conocidas.
Cómo verificar: cada propuesta debe responder "qué riesgo mitiga" y "qué evidencia necesitaría para preferirla a una opción más simple".
Prompt de trabajo:
Actuá como revisor de arquitectura. No propongas tecnología todavía.
Contexto: asr-register.yaml (adjunto), RN-05, RN-06, NFR-04.
Restricciones: equipo pequeño, unos 30 usuarios internos, ERP legacy como maestro.
Tarea: para ASR-01 proponé tres mecanismos distintos. Para cada uno:
1) qué invariante protege y qué carrera NO cubre,
2) costo operativo (piezas nuevas, fallos nuevos),
3) qué evidencia haría que lo prefieras al más simple.
Formato: una sección por mecanismo. Si algo depende de un dato que no tenés,
escribilo como pregunta; no lo inventes.
Cómo revisar la salida: tachá toda propuesta sin eslabón ASR → riesgo → prueba; buscá cifras que no están en el contexto (si aparece "10.000 pedidos por minuto", el agente inventó el volumen); comprobá que haya al menos una consecuencia negativa por opción; y pedí que marque qué parte es hecho del caso y cuál es su suposición.
7.10 Revisión de arquitectura: las preguntas antes de admirar el diagrama
Cuando termines de diseñar, recorré esta lista contra tu diagrama. Es la misma que se usa en el Sprint de Arquitectura:
- ¿Qué requisito o riesgo justifica cada componente que puede desplegarse o fallar de forma independiente?
- ¿Dónde vive la autoridad de cada dato crítico: precio, saldo, stock, estado, identidad?
- ¿Cuál es la unidad transaccional mínima y qué efectos quedan deliberadamente fuera?
- ¿Qué invariantes pueden romperse por concurrencia y qué mecanismo concreto las preserva?
- ¿Qué operación puede repetirse y cómo reconoce el sistema la misma intención?
- ¿Qué pasa después del commit si falla un proveedor externo?
- ¿Qué observación necesitarías para reconstruir un incidente sin entrar a producción a adivinar?
- ¿Qué parte de esta arquitectura podrías eliminar hoy sin dejar de cumplir un ASR? Si la respuesta es mucha, probablemente hay sobrearquitectura.
Una arquitectura defendible no es la que tiene más capas. Es la que puede explicar qué riesgo controla cada decisión, qué costo introduce y qué evidencia haría cambiarla.
Vocabulario del módulo
Vocabulario de arquitectura en Nexo
Frente
ASR
Errores frecuentes
- Elegir la tecnología antes de escribir la invariante. Síntoma: el ADR dice "usamos Redis para concurrencia" y nadie puede decir qué propiedad protege. Causa: se partió del patrón, no del daño. Corrección: escribir primero la invariante y la carrera A/B; la tecnología entra recién como mecanismo.
- Transacción alrededor de todo el método. Síntoma: locks de
stock_levelretenidos 30 segundos y vendedores esperando. Causa: llamada HTTP al proveedor dentro deBEGIN ... COMMIT. Corrección: sacar el efecto externo a outbox y dejar en la transacción solo lo que hace verdadera la garantía mínima. - Idempotencia que guarda la clave y nada más. Síntoma: un reintento con otras líneas devuelve el pedido anterior sin avisar. Causa: no se definió el caso de payload distinto. Corrección: huella del contenido y respuesta de conflicto, más reclamo atómico de la clave.
- Decidir fuera de la sentencia que escribe. Síntoma: el test pasa con requests secuenciales y falla en producción en horas pico. Causa: lectura,
ify escritura separados. Corrección: condición dentro delUPDATEy test con dos transacciones concurrentes. - ADR que vende la decisión. Síntoma: solo consecuencias positivas y ninguna alternativa seria. Causa: el ADR se escribió para convencer, no para recordar. Corrección: exigir al menos una consecuencia negativa real y un disparador de revisión.
- Autorización como bandera
isAdmin. Síntoma: un vendedor carga pedidos para clientes de otra cartera. Causa: se modeló el rol, no el recurso ni el contexto. Corrección: política con actor, recurso y motivo de denegación, probada por separado.
Para llevarte
- Un ASR se reconoce por esta pregunta: si cambia, ¿hay que mover una frontera? Registralo con origen, escenario, decisión y prueba.
- La frontera transaccional sale de la garantía mínima del caso de uso: adentro lo que hace verdadera la garantía, afuera lo que depende de terceros.
- Primero la invariante en lenguaje verificable, después el mecanismo. Para Nexo:
UPDATE ... WHERE on_hand - reserved >= qtymás unCHECK, y una prueba que ejecute la carrera real. - Idempotencia es clave más huella más reclamo atómico, con tres respuestas definidas: mismo resultado, conflicto, espera.
- Lo que ocurre después del commit se diseña con outbox y worker; el consumidor tolera duplicados porque la entrega es at-least-once.
- Un ADR sin alternativas, sin consecuencias negativas y sin disparador de revisión es publicidad, no memoria técnica.
¿Cómo valoras el contenido técnico de esta lección?
Tu feedback nos ayuda a mejorar los laboratorios prácticos de Eje Z.