Revisión de arquitectura
En resumen: Una revisión de arquitectura lee las dependencias reales del código, encuentra flechas en la dirección equivocada y ciclos, y convierte las reglas en una fitness function que las vigila en cada commit.
Problema
El diagrama dice «capas»
Heredas la tienda en línea. El README tiene un diagrama: cuatro capas, flechas solo hacia abajo.
web web/shopservice service/orders service/paymentsdomain domain/pricingdata data/orders
web → service → domain → dataTu primera tarea es pequeña: el precio en la página debe tener dos decimales. Cambias formatPrice en web/shop, y los tests de pagos fallan. Resulta que domain/pricing usa formatPrice de web/shop, y que pedidos y pagos se llaman entre sí.
El diagrama no mintió a propósito. Era verdad hace dos años, y después nadie lo volvió a comprobar.
La idea
La verdad está en los imports
Una revisión de arquitectura responde unas pocas preguntas sobre un sistema que ya funciona:
- ¿Cuáles son los límites reales? Qué módulo llama a cuál, según el código.
- ¿Se cumplen las reglas? Las capas de «Capas», la dirección de las dependencias de «Arquitectura hexagonal y clean architecture», los límites de los módulos de «Monolito, monolito modular, servicios».
- ¿Se cumplen los requisitos? Los percentiles y la disponibilidad de «Requisitos no funcionales que puedes medir», medidos en el sistema en vivo, no prometidos.
- ¿Qué puede salir mal? Los riesgos, ordenados.
- ¿Qué decisiones no registró nadie? Para esas escribes ADRs después del hecho, para que quede claro por qué el sistema es como es.
Aquí empezamos con las dos primeras. Cada import es una flecha de un módulo a otro. Una herramienta las saca del código como una lista, una dependencia por línea:
web/shop -> service/ordersservice/orders -> domain/pricingservice/orders -> service/paymentsservice/payments -> service/ordersservice/orders -> data/ordersdomain/pricing -> web/shopEl programa convierte la lista en un grafo y busca dos cosas. Una flecha hacia arriba: una capa inferior llama a una superior. Un ciclo: un módulo que vuelve a alcanzarse a sí mismo a través de otros módulos. Un ciclo significa que los dos módulos no se pueden cambiar, probar ni publicar por separado. Los ciclos se encuentran con una búsqueda en profundidad con colores.
Míralo
Declarado frente a real
static int layer(String module) { return LAYERS.indexOf(module.split("/")[0]);}
// una capa inferior llama a una superior: una flecha hacia arribastatic List<Dep> violations(List<Dep> deps) { List<Dep> found = new ArrayList<>(); for (Dep d : deps) if (layer(d.from()) > layer(d.to())) found.add(d); return found;}
void visit(String at) { mark.put(at, Mark.GREY); // gris: en el camino actual path.add(at); for (String to : next.get(at)) { if (mark.get(to) == null) visit(to); else if (mark.get(to) == Mark.GREY) cycle(to); } path.removeLast(); mark.put(at, Mark.BLACK); // negro: recorrido}
void cycle(String to) { int start = path.indexOf(to); var loop = path.subList(start, path.size()); found.add(String.join(" -> ", loop) + " -> " + to);}Paso a paso, en palabras
- El diagrama del README: cuatro capas, flechas solo hacia abajo. Cada módulo está en su capa.
- La herramienta lee los imports: 6 dependencias reales. La mayoría apuntan hacia abajo o se quedan en la misma capa.
- domain/pricing importa web/shop: la capa 2 llama a la capa 0. La flecha apunta hacia arriba.
- El recorrido en profundidad empieza en web/shop. Cada módulo del camino actual es gris.
- domain/pricing apunta de vuelta al web/shop gris: un ciclo, web/shop → service/orders → domain/pricing → web/shop.
- El recorrido continúa: service/payments apunta de vuelta al service/orders gris. Un segundo ciclo.
- Todos los módulos están recorridos (negros). Declarado: capas, flechas solo hacia abajo. Real: 1 flecha hacia arriba y 2 ciclos.
La regla de las capas es una sola comparación: el índice de la capa crece hacia abajo, así que una flecha hacia arriba es layer(from) > layer(to).
static final List<String> LAYERS = List.of("web", "service", "domain", "data");
static int layer(String module) { return LAYERS.indexOf(module.split("/")[0]);}
// una capa inferior llama a una superior: una flecha hacia arribastatic List<Dep> violations(List<Dep> deps) { List<Dep> found = new ArrayList<>(); for (Dep d : deps) if (layer(d.from()) > layer(d.to())) found.add(d); return found;}Una búsqueda en profundidad encuentra los ciclos. Un módulo gris está en el camino actual, uno negro está completamente explorado. Una flecha a un módulo gris vuelve por el camino: eso es un ciclo, y el ciclo es la parte del camino desde ese módulo hasta ahora.
record Dep(String from, String to) { @Override public String toString() { return from + " -> " + to; }}enum Mark { GREY, BLACK }
class Cycles { final Map<String, List<String>> next = new HashMap<>(); final Map<String, Mark> mark = new HashMap<>(); final List<String> path = new ArrayList<>(); final List<String> found = new ArrayList<>();
Cycles(List<Dep> deps) { for (Dep d : deps) { next.computeIfAbsent(d.from(), k -> new ArrayList<>()).add(d.to()); next.computeIfAbsent(d.to(), k -> new ArrayList<>()); } for (Dep d : deps) if (!mark.containsKey(d.from())) visit(d.from()); }
void visit(String at) { mark.put(at, Mark.GREY); // gris: en el camino actual path.add(at); for (String to : next.get(at)) { if (mark.get(to) == null) visit(to); else if (mark.get(to) == Mark.GREY) cycle(to); } path.removeLast(); mark.put(at, Mark.BLACK); // negro: recorrido }
void cycle(String to) { int start = path.indexOf(to); var loop = path.subList(start, path.size()); found.add(String.join(" -> ", loop) + " -> " + to); }}path.removeLast() existe desde Java 21. mark.get(to) == null significa «todavía no visitado», porque el mapa no tiene entrada.La revisión completa sobre la lista de imports:
List<Dep> deps = parse(IMPORTS);System.out.println("Dependencias: " + deps.size());show("Capas violadas", violations(deps));show("Ciclos", new Cycles(deps).found);Dependencias: 6Capas violadas: domain/pricing -> web/shopCiclos: web/shop -> service/orders -> domain/pricing -> web/shop service/orders -> service/payments -> service/ordersLa violación y los dos ciclos no son tres problemas separados. domain/pricing -> web/shop es a la vez la violación y la flecha que cierra el primer ciclo: arréglala, y desaparecen los dos.
Pruébalo
Completa
¿Qué comparación atrapa la flecha hacia arriba? La capa web tiene índice 0 y data tiene índice 3.
for (Dep d : deps)
if (layer(d.from()) layer(d.to())) found.add(d);>: el módulo del que sale la flecha está en una capa inferior (índice mayor) que el módulo al que apunta. Una flecha dentro de la misma capa (service/orders -> service/payments) no rompe las capas, pero puede ser parte de un ciclo.
Pruébalo
Encuentra el error
Un compañero «simplificó» visit. Ahora la revisión imprime:
Dependencias: 6Capas violadas: domain/pricing -> web/shopCiclos: ningunoElige la línea con el error.
El módulo se vuelve negro al entrar en lugar de gris. Entonces ningún módulo llega a ser gris, la comprobación de la flecha de vuelta nunca se cumple, y los ciclos desaparecen. Lo peor es que la salida parece tranquila: «ninguno». Una comprobación que calla también hay que probarla con una entrada donde sabes que hay un ciclo.
La idea
Fitness function: la revisión en cada commit
Una revisión es una foto. Mañana alguien añade un import nuevo y la foto queda desactualizada. Una fitness function es una comprobación automática de una característica de la arquitectura: se ejecuta en cada commit, como un test, y falla cuando se rompe la regla. El nombre viene del libro «Building Evolutionary Architectures» (Ford, Parsons, Kua).
El sistema heredado ya tiene una violación. Si la comprobación falla por ella, fallará todos los días y el equipo la apagará. Por eso la deuda antigua va a un baseline: la comprobación falla solo por una violación nueva, y una corregida debe salir del baseline para que no vuelva sin que nadie lo note. Así la deuda solo se reduce.
// se ejecuta en cada commit: la deuda antigua está en el baselinestatic List<String> fitness(List<Dep> deps, List<String> baseline) { List<String> report = new ArrayList<>(); List<String> now = new ArrayList<>(); for (Dep d : violations(deps)) { now.add(d.toString()); if (!baseline.contains(d.toString())) report.add("✗ nueva dependencia hacia arriba: " + d); } for (String old : baseline) if (!now.contains(old)) report.add("✓ corregido, quítalo del baseline: " + old); return report;}
static void check(String commit, List<Dep> deps, List<String> baseline) { List<String> report = fitness(deps, baseline); boolean fails = report.stream().anyMatch(r -> r.startsWith("✗")); System.out.println(commit + ": " + (fails ? "FALLA" : "OK")); for (String r : report) System.out.println(" " + r);}El commit 1 añade domain/pricing -> service/payments: pricing quiere la comisión del pago. El commit 2 mueve formatPrice al dominio y quita la violación antigua:
List<Dep> deps = parse(IMPORTS);List<String> baseline = List.of("domain/pricing -> web/shop");
List<Dep> first = new ArrayList<>(deps);first.add(new Dep("domain/pricing", "service/payments"));check("Commit 1", first, baseline);
List<Dep> second = new ArrayList<>(deps);second.removeIf(d -> d.toString().equals("domain/pricing -> web/shop"));check("Commit 2", second, baseline);Commit 1: FALLA ✗ nueva dependencia hacia arriba: domain/pricing -> service/paymentsCommit 2: OK ✓ corregido, quítalo del baseline: domain/pricing -> web/shopUna fitness function no es solo para dependencias. La misma idea vigila otras características:
- el p95 del pago en la prueba de carga se queda por debajo del presupuesto de «Requisitos no funcionales que puedes medir»;
- el módulo de los vendedores llama solo a la API pública de pedidos, no a sus interioridades;
- el bundle de JavaScript de la página pesa menos de 200 KB (un límite de ejemplo);
- cada ADR tiene una sección «Consecuencias».
Cómo se ejecuta exactamente la comprobación en el pipeline (CI) es un tema de ZeroToOps, el curso de DevOps. Aquí está la parte que escribe el programador: la comprobación misma.
La idea
Registro de riesgos
La revisión encuentra más de lo que da tiempo a arreglar. Un registro de riesgos lo pone en orden. Por cada riesgo anotas:
- qué puede pasar;
- qué tan probable es, de 1 a 5;
- cuánto duele si pasa (impacto), de 1 a 5;
- qué haces al respecto y quién es el responsable.
La puntuación es probabilidad × impacto. Ordenas por ella, y en un empate por el impacto: el evento raro pero grave va antes que el frecuente y leve.
record Risk(String name, int probability, int impact, String response) { int score() { return probability * impact; }
String level() { return score() >= 12 ? "alto" : score() >= 6 ? "medio" : "bajo"; }}// por puntuación; si hay empate, primero el mayor impactostatic List<Risk> ranked(List<Risk> risks) { List<Risk> sorted = new ArrayList<>(risks); sorted.sort(Comparator.comparingInt(Risk::score) .thenComparingInt(Risk::impact) .reversed()); return sorted;}Los riesgos de la revisión de la tienda:
List<Risk> risks = List.of( new Risk("ciclo orders ⇄ payments", 4, 3, "reducir: un evento en lugar de una llamada de vuelta"), new Risk("el pago no tiene timeout y frena los pedidos", 3, 5, "reducir: un timeout y un circuit breaker"), new Risk("el dominio llama a web", 4, 2, "aceptar: se arregla con el próximo cambio de precios"), new Risk("nunca se probó restaurar desde el backup", 2, 5, "reducir: una restauración de prueba cada mes"), new Risk("solo una persona conoce el almacén de 2009", 3, 4, "reducir: una segunda persona y una descripción"), new Risk("la biblioteca de PDF para facturas no tiene mantenimiento", 2, 2, "aceptar"));for (Risk r : ranked(risks)) System.out.printf("%2d %-6s %s → %s%n", r.score(), r.level(), r.name(), r.response());15 alto el pago no tiene timeout y frena los pedidos → reducir: un timeout y un circuit breaker12 alto solo una persona conoce el almacén de 2009 → reducir: una segunda persona y una descripción12 alto ciclo orders ⇄ payments → reducir: un evento en lugar de una llamada de vuelta10 medio nunca se probó restaurar desde el backup → reducir: una restauración de prueba cada mes 8 medio el dominio llama a web → aceptar: se arregla con el próximo cambio de precios 4 bajo la biblioteca de PDF para facturas no tiene mantenimiento → aceptarHay cuatro respuestas:
- evitar: quitar la causa, por ejemplo no publicar la funcionalidad;
- reducir: la probabilidad o el impacto, por ejemplo con un timeout y un circuit breaker de «Cuando el vecino es lento: timeout, retry, circuit breaker»;
- transferir: a otro, por ejemplo con un seguro o un contrato con el proveedor;
- aceptar: de forma consciente y por escrito, con una fecha para volver a mirar.
Los números de 1 a 5 son un juicio, no una medición. 12 no es «el doble de malo» que 6. Sirven para ordenar y empezar la conversación, no para hacer cuentas con ellos.
thenComparingInt(Risk::impact).reversed() invierte todo el orden: tanto la puntuación como el impacto van de mayor a menor.Pruébalo
Predice la salida
Cuatro riesgos de otra revisión, ordenados con el mismo ranked. ¿Qué imprime el programa?
List<Risk> risks = List.of( new Risk("el proveedor de correo cambia su API", 3, 2, ""), new Risk("el certificado del sitio se renueva a mano", 3, 3, ""), new Risk("el almacén se cae en el Black Friday", 2, 5, ""), new Risk("los informes mensuales se retrasan", 5, 1, ""));for (Risk r : ranked(risks)) System.out.println(r.score() + " " + r.name());Las puntuaciones son 6, 9, 10 y 5. De arriba abajo: el almacén en el Black Friday (2 × 5 = 10), el certificado (3 × 3 = 9), los correos (3 × 2 = 6), los informes (5 × 1 = 5). Los informes son lo más probable, pero duelen menos si se retrasan.
La idea
Cuándo la revisión hace daño
Una revisión es una herramienta, no un ritual. Hace daño cuando:
- es un examen para el equipo. Una revisión a la que no se invita a quienes escribieron el código encuentra culpables, no riesgos. Ellos saben por qué las cosas son como son.
- la fitness function falla por ruido. Una comprobación inestable (una prueba de carga en una máquina compartida) falla sin motivo y se apaga. Una regla estable vigila más que diez inestables.
- el baseline solo crece. Entonces es una lista de excusas. Vigila su tamaño con el tiempo.
- el registro se queda en una carpeta. Un riesgo sin responsable y sin fecha para volver a mirar es un riesgo olvidado.
- los imports son toda la verdad. No lo son. Una tabla compartida de la base de datos, una llamada por HTTP y una cola conectan módulos sin un import. Los módulos que leen las tablas de otros («Monolito, monolito modular, servicios») no aparecen en el grafo de imports.
Más precisamente, para curiosos
Las herramientas. Nadie escribe la comprobación de dependencias desde cero. Java tiene ArchUnit (las reglas son tests de JUnit) y jdeps en el JDK; C# tiene ArchUnitNET y NetArchTest; Python tiene import-linter; JavaScript tiene dependency-cruiser y madge (para ciclos). Todas sacan el grafo del código y comprueban reglas como las de esta lección, también con un baseline: en ArchUnit se llama FreezingArchRule.
¿Cuántos ciclos hay? La búsqueda de esta lección encuentra una flecha de vuelta por cada ciclo con el que se topa. Eso no es el número de todos los ciclos: si ejecutas la revisión después de domain/pricing -> service/payments, el camino a los pagos pasa por pricing, y el ciclo pequeño «orders ⇄ payments» no se cuenta aparte. Las herramientas buscan componentes fuertemente conexos (el algoritmo de Tarjan): los grupos de módulos en los que cada uno alcanza a todos los demás. La pregunta «qué módulos están en un ciclo» es más útil que «cuántos ciclos hay».
Métodos de revisión. ATAM (Architecture Tradeoff Analysis Method, del SEI) es una revisión formal con escenarios de calidad y las partes interesadas. El modelo C4 es una forma de dibujar la arquitectura en cuatro niveles de detalle, para que el diagrama responda a la pregunta del lector.
Ejercicio
Los ciclos también en la comprobación
Resuelve este en tu editor.
Añade una segunda regla a la fitness function: el número de ciclos no debe crecer por encima del baseline de ciclos (2 hoy). Escribe checkAll(deps, baseline, cycleBaseline), que imprima todos los hallazgos y al final si falla. Ejecútala con un commit que añada data/orders -> service/orders: la capa de datos llama al servicio para enviar un correo después de guardar.
✗ nueva dependencia hacia arriba: data/orders -> service/orders✗ ciclos: 3, baseline: 2FALLAMás ejercicios
- Quién está en un ciclo. En lugar del número de ciclos, guarda el conjunto de módulos que están en algún ciclo. ¿Qué regla es más difícil de esquivar?
- Módulos en una capa. Añade una regla:
service/paymentsno debe llamar directamente aservice/orders, solo mediante un evento (la lección «Eventos y colas»). ¿Cómo la pones en el baseline hasta que se arregle el ciclo? - El registro. Dale a cada riesgo un responsable y una fecha para volver a mirar. Imprime los riesgos con puntuación de 12 o más que no tienen responsable.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Eventos y colas · ADR: registros de decisiones de arquitectura · Requisitos no funcionales que puedes medir · Cuando el vecino es lento: timeout, retry, circuit breaker
- Continúa en: El panorama de la empresa y sus capacidades · Tu primera tarea de arquitectura
- Ejercicios sobre el tema: El arquitecto en acción