Ir al contenido
wannacode

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.

README.md
web web/shop
service service/orders service/payments
domain domain/pricing
data data/orders
web → service → domain → data

Tu 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:

  1. ¿Cuáles son los límites reales? Qué módulo llama a cuál, según el código.
  2. ¿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».
  3. ¿Se cumplen los requisitos? Los percentiles y la disponibilidad de «Requisitos no funcionales que puedes medir», medidos en el sistema en vivo, no prometidos.
  4. ¿Qué puede salir mal? Los riesgos, ordenados.
  5. ¿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:

imports.txt
web/shop -> service/orders
service/orders -> domain/pricing
service/orders -> service/payments
service/payments -> service/orders
service/orders -> data/orders
domain/pricing -> web/shop

El 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) =>
Array.IndexOf(Layers, module.Split('/')[0]);
// una capa inferior llama a una superior: una flecha hacia arriba
static List<Dep> Violations(List<Dep> deps) =>
deps.Where(d => Layer(d.From) > Layer(d.To))
.ToList();
void Visit(string at)
{
mark[at] = Mark.Grey; // gris: en el camino actual
path.Add(at);
foreach (var to in next[at])
{
if (!mark.TryGetValue(to, out var m)) Visit(to);
else if (m == Mark.Grey) Cycle(to);
}
path.RemoveAt(path.Count - 1);
mark[at] = Mark.Black; // negro: recorrido
}
void Cycle(string to)
{
var loop = path.Skip(path.IndexOf(to)).Append(to);
Found.Add(string.Join(" -> ", loop));
}
Paso a paso, en palabras
  1. El diagrama del README: cuatro capas, flechas solo hacia abajo. Cada módulo está en su capa.
  2. La herramienta lee los imports: 6 dependencias reales. La mayoría apuntan hacia abajo o se quedan en la misma capa.
  3. domain/pricing importa web/shop: la capa 2 llama a la capa 0. La flecha apunta hacia arriba.
  4. El recorrido en profundidad empieza en web/shop. Cada módulo del camino actual es gris.
  5. domain/pricing apunta de vuelta al web/shop gris: un ciclo, web/shop → service/orders → domain/pricing → web/shop.
  6. El recorrido continúa: service/payments apunta de vuelta al service/orders gris. Un segundo ciclo.
  7. 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 readonly string[] Layers = ["web", "service", "domain", "data"];
static int Layer(string module) =>
Array.IndexOf(Layers, module.Split('/')[0]);
// una capa inferior llama a una superior: una flecha hacia arriba
static List<Dep> Violations(List<Dep> deps) =>
deps.Where(d => Layer(d.From) > Layer(d.To))
.ToList();

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)
{
public override string ToString() => $"{From} -> {To}";
}
enum Mark { Grey, Black }
class Cycles
{
readonly Dictionary<string, List<string>> next = new();
readonly Dictionary<string, Mark> mark = new();
readonly List<string> path = new();
public readonly List<string> Found = new();
public Cycles(List<Dep> deps)
{
foreach (var d in deps)
{
next.TryAdd(d.From, new List<string>());
next.TryAdd(d.To, new List<string>());
next[d.From].Add(d.To);
}
foreach (var d in deps)
if (!mark.ContainsKey(d.From)) Visit(d.From);
}
void Visit(string at)
{
mark[at] = Mark.Grey; // gris: en el camino actual
path.Add(at);
foreach (var to in next[at])
{
if (!mark.TryGetValue(to, out var m)) Visit(to);
else if (m == Mark.Grey) Cycle(to);
}
path.RemoveAt(path.Count - 1);
mark[at] = Mark.Black; // negro: recorrido
}
void Cycle(string to)
{
var loop = path.Skip(path.IndexOf(to)).Append(to);
Found.Add(string.Join(" -> ", loop));
}
}
TryGetValue también te dice si el módulo está. GetValueOrDefault sería una trampa aquí: el valor por defecto de Mark es el primero, Grey, y todo módulo sin visitar parecería gris.

La revisión completa sobre la lista de imports:

var deps = Parse(Imports);
Console.WriteLine("Dependencias: " + deps.Count);
Show("Capas violadas", Violations(deps));
Show("Ciclos", new Cycles(deps).Found);
Salida
Dependencias: 6
Capas violadas:
domain/pricing -> web/shop
Ciclos:
web/shop -> service/orders -> domain/pricing -> web/shop
service/orders -> service/payments -> service/orders

La 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.

deps.Where(d => Layer(d.From)  Layer(d.To)).ToList();

Pruébalo

Encuentra el error

Un compañero «simplificó» visit. Ahora la revisión imprime:

Salida
Dependencias: 6
Capas violadas:
domain/pricing -> web/shop
Ciclos:
ninguno

Elige la línea con el error.

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 baseline
static List<string> Fitness(List<Dep> deps, List<string> baseline)
{
var now = Violations(deps).Select(d => d.ToString()).ToList();
var report = now.Where(v => !baseline.Contains(v))
.Select(v => "✗ nueva dependencia hacia arriba: " + v).ToList();
report.AddRange(baseline.Where(old => !now.Contains(old))
.Select(old => "✓ corregido, quítalo del baseline: " + old));
return report;
}
static void Check(string commit, List<Dep> deps, List<string> baseline)
{
var report = Fitness(deps, baseline);
var fails = report.Any(r => r.StartsWith('✗'));
Console.WriteLine(commit + ": " + (fails ? "FALLA" : "OK"));
foreach (var r in report) Console.WriteLine(" " + 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:

var deps = Parse(Imports);
List<string> baseline = ["domain/pricing -> web/shop"];
var first = deps.Append(new Dep("domain/pricing", "service/payments")).ToList();
Check("Commit 1", first, baseline);
var second = deps.Where(d => d.ToString() != "domain/pricing -> web/shop").ToList();
Check("Commit 2", second, baseline);
Salida
Commit 1: FALLA
✗ nueva dependencia hacia arriba: domain/pricing -> service/payments
Commit 2: OK
✓ corregido, quítalo del baseline: domain/pricing -> web/shop

Una 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)
{
public int Score => Probability * Impact;
public string Level => Score >= 12 ? "alto" : Score >= 6 ? "medio" : "bajo";
}
// por puntuación; si hay empate, primero el mayor impacto
static List<Risk> Ranked(List<Risk> risks) =>
risks.OrderByDescending(r => r.Score)
.ThenByDescending(r => r.Impact)
.ToList();

Los riesgos de la revisión de la tienda:

List<Risk> risks = [
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"),
];
foreach (var r in Ranked(risks))
Console.WriteLine($"{r.Score,2} {r.Level,-6} {r.Name} → {r.Response}");
Salida
15 alto el pago no tiene timeout y frena los pedidos → reducir: un timeout y un circuit breaker
12 alto solo una persona conoce el almacén de 2009 → reducir: una segunda persona y una descripción
12 alto ciclo orders ⇄ payments → reducir: un evento en lugar de una llamada de vuelta
10 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 → aceptar

Hay 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.

OrderByDescending y ThenByDescending son estables: los riesgos con la misma puntuación e impacto quedan en el orden en que se escribieron.

Pruébalo

Predice la salida

Cuatro riesgos de otra revisión, ordenados con el mismo ranked. ¿Qué imprime el programa?

List<Risk> risks = [
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, ""),
];
foreach (var r in Ranked(risks))
Console.WriteLine(r.Score + " " + r.Name);
Respuesta

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.

Salida
✗ nueva dependencia hacia arriba: data/orders -> service/orders
✗ ciclos: 3, baseline: 2
FALLA

Má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/payments no debe llamar directamente a service/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

1. ¿De dónde saca la revisión las dependencias reales entre los módulos?
2. La búsqueda llega a un módulo que está gris. ¿Qué significa?
3. ¿Por qué una fitness function para un sistema heredado usa un baseline?
4. Dos riesgos puntúan 12: probabilidad 4 × impacto 3 y probabilidad 3 × impacto 4. ¿Cuál va primero en el registro de la lección?
5. ¿Cuál de estas es una fitness function?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·