ADR: registros de decisiones de arquitectura
En resumen: Un ADR (architecture decision record) es un documento corto con el contexto, las opciones, la decisión y las consecuencias; una decisión antigua no se reescribe, se reemplaza por una nueva.
Problema
¿Por qué tenemos una cola?
Una colega nueva abre el código del pago. Las notificaciones pasan por una cola, el correo a veces llega con un minuto de retraso y su consumidor tiene que descartar duplicados. «Esto es complicado. ¿Por qué no llamamos al correo directamente?»
Nadie lo recuerda. Quien lo decidió ya no está en la empresa. La discusión fue en un chat que se borró hace tiempo. Quedan dos malas opciones: conservar algo que no entiendes, o quitarlo y, en el próximo Black Friday, pisar justo el rastrillo por el que se puso.
La idea
Un ADR: la decisión y el porqué
Un ADR (architecture decision record) es un documento corto sobre una decisión. Se escribe cuando se toma la decisión y vive en el repositorio junto al código (docs/adr/0007-notifications-queue.md). Tiene seis partes:
- título y número: qué se decidió, con un número correlativo;
- estado: propuesto, aceptado, reemplazado;
- contexto: qué es cierto ahora, qué duele y qué limita;
- opciones: al menos dos que de verdad se consideraron;
- decisión: qué opción y por qué;
- consecuencias: qué se vuelve más fácil y qué se vuelve más difícil.
Este es el ADR que la colega buscaba:
# ADR-0007: Las notificaciones pasan por una cola
## EstadoAceptado. Reemplaza a ADR-0004.
## ContextoADR-0004: el pago llama al correo directamente. En Black Friday elproveedor de correo respondió en 8 segundos y los pagos caducaron.
## Opciones1. Una llamada directa con un timeout corto: el pago pasa, el correo se pierde.2. Una cola: el pago publica un evento y las notificaciones lo leen después.
## DecisiónUna cola. Un correo perdido de un pedido pagado es peor que uno tardío.
## Consecuencias- El pago no espera al correo.- El correo puede llegar un minuto después.- La entrega es «al menos una vez», así que el consumidor es idempotente.- Una pieza más que vigilar: la cola.La segunda mitad es la más valiosa: le dice a la colega por qué hay duplicados y qué se rompe si la cola cae. La cola y el consumidor idempotente son de la lección sobre eventos.
La idea
Los estados y cambiar una decisión
Un ADR pasa por unos cuantos estados:
- propuesto (proposed): está escrito y se discute, normalmente en un pull request;
- aceptado (accepted): desde hoy esta es la regla;
- reemplazado (superseded): un ADR más nuevo lo cambió, y el antiguo apunta a él;
- obsoleto (deprecated): ya no vale, y nada lo reemplaza.
Un ADR aceptado no se reescribe. Cuando la decisión deja de ser correcta, escribes un ADR nuevo que reemplaza al antiguo, y le das al antiguo el estado «reemplazado por ADR-0007». El antiguo era correcto en su contexto: si lo borras, pierdes por qué se eligió y qué pasó después.
Míralo
Una decisión, sus consecuencias, una decisión nueva
const STATUSES = new Set(["proposed", "accepted", "superseded", "deprecated"]);
function check(adrs) { const all = byNumber(adrs); const bad = []; for (const a of adrs) { const n = adrName(a); if (!STATUSES.has(a.status)) bad.push(`${n}: estado desconocido ${a.status}`); if (!a.context) bad.push(`${n}: sin contexto`); if (a.options.length < 2) bad.push(`${n}: menos de dos opciones`); if (!a.decision) bad.push(`${n}: sin decisión`); if (!a.consequences) bad.push(`${n}: sin consecuencias`); if (a.status === "superseded") { const next = all.get(a.supersededBy); if (next === undefined) bad.push(`${n}: reemplazado, pero no dice por cuál`); else if (next.status !== "accepted") bad.push(`${n} → ${adrName(next)}: el reemplazo no está aceptado`); } } return bad;}const adr4 = { number: 4, title: "Las notificaciones se envían de forma síncrona", status: "proposed", context: "Primera versión: el correo es rápido", options: ["llamada directa", "cola"], decision: "El pago llama al correo directamente", consequences: "Simple, pero un correo lento frena el pago", supersededBy: null,};const adr4 = { number: 4, title: "Las notificaciones se envían de forma síncrona", status: "accepted", context: "Primera versión: el correo es rápido", options: ["llamada directa", "cola"], decision: "El pago llama al correo directamente", consequences: "Simple, pero un correo lento frena el pago", supersededBy: null,};const adr4 = { number: 4, title: "Las notificaciones se envían de forma síncrona", status: "accepted", context: "Primera versión: el correo es rápido", options: ["llamada directa", "cola"], decision: "El pago llama al correo directamente", consequences: "Simple, pero un correo lento frena el pago", supersededBy: null,};
const adr7 = { number: 7, title: "Las notificaciones pasan por una cola", status: "proposed", context: "ADR-0004: el correo frena los pagos", options: ["directo con timeout", "cola"], decision: "El pago pone un evento en la cola", consequences: "No espera; un correo puede llegar dos veces", supersededBy: null,};const adr4 = { number: 4, title: "Las notificaciones se envían de forma síncrona", status: "superseded", context: "Primera versión: el correo es rápido", options: ["llamada directa", "cola"], decision: "El pago llama al correo directamente", consequences: "Simple, pero un correo lento frena el pago", supersededBy: 7,};
const adr7 = { number: 7, title: "Las notificaciones pasan por una cola", status: "accepted", context: "ADR-0004: el correo frena los pagos", options: ["directo con timeout", "cola"], decision: "El pago pone un evento en la cola", consequences: "No espera; un correo puede llegar dos veces", supersededBy: null,};const STATUSES = new Set(["proposed", "accepted", "superseded", "deprecated"]);
function check(adrs) { const all = byNumber(adrs); const bad = []; for (const a of adrs) { const n = adrName(a); if (!STATUSES.has(a.status)) bad.push(`${n}: estado desconocido ${a.status}`); if (!a.context) bad.push(`${n}: sin contexto`); if (a.options.length < 2) bad.push(`${n}: menos de dos opciones`); if (!a.decision) bad.push(`${n}: sin decisión`); if (!a.consequences) bad.push(`${n}: sin consecuencias`); if (a.status === "superseded") { const next = all.get(a.supersededBy); if (next === undefined) bad.push(`${n}: reemplazado, pero no dice por cuál`); else if (next.status !== "accepted") bad.push(`${n} → ${adrName(next)}: el reemplazo no está aceptado`); } } return bad;}Paso a paso, en palabras
- ADR-0004 se propone: el contexto, dos opciones y la decisión. El pago llama al correo directamente.
- El equipo lo acepta. El precio también queda escrito: un correo lento frena el pago.
- Black Friday: el proveedor de correo responde en 8 segundos y los pagos caducan. La consecuencia escrita se cumple.
- Una decisión nueva, ADR-0007: las notificaciones pasan por una cola. Su contexto apunta a ADR-0004.
- Las opciones y el precio: el pago ya no espera, pero un correo puede llegar más tarde, y dos veces.
- ADR-0007 se acepta. El antiguo no se borra ni se reescribe: su estado pasa a «reemplazado» y apunta al nuevo.
- La comprobación de la lección: un ADR reemplazado debe apuntar a uno aceptado. ADR-0004 → ADR-0007 pasa.
- Quien pregunta «¿por qué una cola?» lee ADR-0007 y, desde ahí, ADR-0004. La historia queda, con las opciones y el precio.
Míralo
Comprobar los ADR
En un proyecto real los ADR son archivos markdown, y la comprobación lee sus encabezados. Aquí cada ADR es un registro con los mismos campos, para que el programa sea corto:
// adr: { number, title, status, context, options,// decision, consequences, supersededBy }function adrName(adr) { return `ADR-${String(adr.number).padStart(4, "0")}`;}const adr4 = { number: 4, title: "Las notificaciones se envían de forma síncrona", status: "superseded", context: "Primera versión: el correo es rápido", options: ["llamada directa", "cola"], decision: "El pago llama al correo directamente", consequences: "Simple, pero un correo lento frena el pago", supersededBy: 7,};
const adr7 = { number: 7, title: "Las notificaciones pasan por una cola", status: "accepted", context: "ADR-0004: el correo frena los pagos", options: ["directo con timeout", "cola"], decision: "El pago pone un evento en la cola", consequences: "No espera; un correo puede llegar dos veces", supersededBy: null,};La comprobación vigila las dos cosas que se olvidan: las partes de un ADR y la cadena «reemplazado por».
function byNumber(adrs) { return new Map(adrs.map((a) => [a.number, a]));}const STATUSES = new Set(["proposed", "accepted", "superseded", "deprecated"]);
function check(adrs) { const all = byNumber(adrs); const bad = []; for (const a of adrs) { const n = adrName(a); if (!STATUSES.has(a.status)) bad.push(`${n}: estado desconocido ${a.status}`); if (!a.context) bad.push(`${n}: sin contexto`); if (a.options.length < 2) bad.push(`${n}: menos de dos opciones`); if (!a.decision) bad.push(`${n}: sin decisión`); if (!a.consequences) bad.push(`${n}: sin consecuencias`); if (a.status === "superseded") { const next = all.get(a.supersededBy); if (next === undefined) bad.push(`${n}: reemplazado, pero no dice por cuál`); else if (next.status !== "accepted") bad.push(`${n} → ${adrName(next)}: el reemplazo no está aceptado`); } } return bad;}ADR-0005 está marcado como reemplazado, pero no dice por cuál. ADR-0009 tiene una opción y ninguna consecuencia:
const adr5 = { number: 5, title: "El descuento se calcula en el controlador", status: "superseded", context: "Una pantalla, una regla", options: ["en el controlador", "en el dominio"], decision: "La regla está en el controlador", consequences: "Rápido, pero la regla se duplica", supersededBy: null };const adr9 = { number: 9, title: "Caché para las páginas de productos", status: "proposed", context: "La base de datos está al 100 % en las campañas", options: ["Redis"], decision: "Cache-aside con TTL de 5 minutos", consequences: "", supersededBy: null };const problems = check([adr4, adr5, adr7, adr9]);problems.forEach((p) => console.log(p));console.log(`${problems.length} problemas`);ADR-0005: reemplazado, pero no dice por cuálADR-0009: menos de dos opcionesADR-0009: sin consecuencias3 problemasUna comprobación así corre con las pruebas en cada pull request, y un ADR sin consecuencias nunca llega a la rama principal. En JavaScript un ADR es un objeto simple, así que el nombre ADR-0007 sale de una función, no de un método.
Pruébalo
Predice la salida
La comprobación de arriba recibe dos ADR sobre los informes. ¿Qué imprime?
const adr11 = { number: 11, title: "Los informes leen la base principal", status: "superseded", context: "Hay pocos informes", options: ["la principal", "una réplica"], decision: "Los informes leen la principal", consequences: "Simple, pero carga la base de datos", supersededBy: 12 };const adr12 = { number: 12, title: "Los informes leen una réplica", status: "proposed", context: "Los informes frenan los pagos", options: ["una réplica"], decision: "Los informes leen una réplica", consequences: "Los datos tienen un minuto de retraso", supersededBy: null };check([adr11, adr12]).forEach((p) => console.log(p));ADR-0011 está reemplazado por ADR-0012, y ADR-0012 sigue propuesto: la regla falla, porque el reemplazo debe estar aceptado. ADR-0012 además tiene una sola opción.
Míralo
Por qué una cola: a lo largo de la cadena
La colega parte de ADR-0004, que aparece en discusiones antiguas, y llega a la decisión vigente:
function history(all, number) { let a = all.get(number); console.log(`${adrName(a)} ${a.title} [${a.status}]`); while (a.supersededBy !== null) { a = all.get(a.supersededBy); console.log(`→ ${adrName(a)} ${a.title} [${a.status}]`); }}// ¿por qué las notificaciones pasan por una cola?history(byNumber([adr4, adr7]), 4);ADR-0004 Las notificaciones se envían de forma síncrona [superseded]→ ADR-0007 Las notificaciones pasan por una cola [accepted]Dos minutos de lectura en vez de una semana de adivinar. Si el contexto de ADR-0007 deja de ser cierto (los correos van a otro sistema que responde rápido), la colega escribe ADR-0015, que lo reemplaza, y la cadena crece.
Pruébalo
Encuentra el error
Un compañero propone un ADR para la caché de la lección sobre caching. ¿Qué línea hace que el ADR sea inútil para la siguiente persona?
Elige la línea con el error.
Las consecuencias son todas buenas. Una decisión sin precio no es una decisión, es un anuncio. Falta lo que se necesitará dentro de un año: un precio puede tener hasta 5 minutos de retraso; todos los que escriben precios deben borrar las copias; Redis es un sistema más que vigilar; si Redis cae, toda la carga vuelve a la base de datos.
La idea
Cuándo ayuda un ADR y cuándo es burocracia
- Ayuda en decisiones caras de cambiar (una cola, una base de datos, los límites de los módulos, un formato de API) y con más de una respuesta razonable.
- Ayuda cuando se escribe antes de la decisión: las opciones y el precio salen mientras aún puedes elegir otra cosa.
- Perjudica cuando cada cosa pequeña recibe un ADR. El nombre de una clase, o una biblioteca que cambias en una hora, no es una decisión de arquitectura.
- Perjudica cuando se escribe después para justificar lo que ya está hecho: entonces las opciones son inventadas y las consecuencias son todas buenas.
- Es inútil cuando nadie lo encuentra. Pon el número del ADR en un comentario en el lugar del código y en el pull request.
Más precisamente, para curiosos
De dónde viene. El formato es de Michael Nygard (2011). Las plantillas populares son la suya y MADR (Markdown Any Decision Records), que añade «ventajas y desventajas de cada opción».
Herramientas. adr-tools crea un archivo nuevo con el número siguiente y cambia los estados; log4brains construye un sitio a partir de los ADR. Una comprobación como la de esta lección se escribe en una tarde y corre con las pruebas.
ADR, RFC y design doc. Un design doc describe un sistema entero o un cambio grande antes de construirlo. Un RFC es una propuesta para que la discutan muchas personas. Un ADR es una decisión, corta, y se queda como historia. Un design doc suele terminar en unos cuantos ADR.
Quién los escribe. No solo el arquitecto. Quien propone el cambio escribe el ADR en el pull request, y la revisión de código se convierte también en una revisión de la decisión.
La idea
Qué forma y cuándo
El módulo mostró nueve maneras de organizar un sistema. Ninguna es buena por sí sola: cada una resuelve un dolor y trae un precio.
| Forma | Ayuda cuando | Perjudica cuando |
|---|---|---|
| Capas | las reglas deben probarse sin base de datos y sin pantalla | la aplicación son solo formularios sobre tablas y las capas solo pasan llamadas |
| Hexagonal | el dominio vive mucho, mientras el pago y la base de datos cambian | cada clase tiene una interfaz sin segunda implementación |
| Monolito modular | uno o pocos equipos, un deploy | no se respetan los límites y los módulos meten la mano en las tablas de otros módulos |
| Servicios | equipos independientes, escala distinta, deploys separados | un solo equipo: la red, los datos y el monitoreo cuestan más de lo que dan |
| Eventos y colas | una consecuencia lenta no debe frenar la acción principal | la respuesta se necesita enseguida; los duplicados y el orden se resuelven a mano |
| Outbox | un evento no debe perderse en una caída | no hay cola: basta una llamada en el mismo proceso |
| Contrato de API | clientes que no puedes actualizar (la app móvil) | código en un solo proceso que cambia en conjunto |
| Caché | se lee mucho lo mismo y puede estar un poco viejo | el valor debe ser exacto o no se puede invalidar |
| Escalado | un servidor no basta o no debe ser el único | basta un servidor y el cuello de botella es la base de datos |
Primero el monolito. Un sistema nuevo empieza como un monolito modular con límites claros. Un servicio se separa cuando hay una razón concreta: un equipo aparte, otra escala, deploys independientes. La razón va a un ADR, junto con el precio. Lo contrario (servicios desde el primer día) paga el precio de un sistema distribuido antes de que nadie sepa dónde están los límites reales.
Ejercicio
El contenido de la carpeta
Resuélvelo en tu editor.
La carpeta docs/adr/ crece y nadie recuerda qué ADR están vigentes. Escribe toc: imprime todos los ADR ordenados por número con su estado, y en uno reemplazado también el número de su reemplazo. Pásale ADR-0007, ADR-0001, ADR-0009 y ADR-0004 en ese orden.
ADR-0001 Monolito modular en vez de servicios [accepted]ADR-0004 Las notificaciones se envían de forma síncrona [superseded → ADR-0007]ADR-0007 Las notificaciones pasan por una cola [accepted]ADR-0009 Caché para las páginas de productos [proposed]Más tareas
- Los vigentes. Imprime solo los ADR aceptados: eso es «la arquitectura de hoy».
- Un ciclo. ¿Qué le pasa a
historysi ADR-0004 está reemplazado por 0007 y 0007 por 0004? Añade una comprobación que encuentre un ciclo así.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Transactional Outbox · Diseño de API: contrato, versiones, solicitudes repetidas · Caché · Escalado
- Continúa en: Requisitos no funcionales que puedes medir · Trade-offs · Cambiar un sistema en marcha (Strangler Fig, Expand–Contract) · Revisión de arquitectura
- Ejercicios sobre el tema: Arquitectura de software