Ir al contenido
wannacode

Tu primera tarea de arquitectura

En resumen: Un proyecto de arquitectura de software: requisitos con números, tres opciones con su costo, una matriz de decisión, un ADR, un plan de migración y un registro de riesgos para una tienda que crece.

Problema

La tarea

El dueño de la tienda escribe:

Desde la primavera vendemos también en Grecia y Croacia. Además dejamos entrar a vendedores externos: suben sus propios productos y nosotros cobramos una comisión. El Black Friday no puede caerse como el año pasado. ¿Cuánto costará y para cuándo?

Esta es la primera tarea real del arquitecto. Nadie te dice «construye un monolito modular». Te cuentan un deseo, y tú tienes que traer de vuelta:

  1. requisitos con números que se puedan medir;
  2. al menos dos opciones con su costo;
  3. una decisión, registrada en un ADR;
  4. un plan de migración para el sistema en marcha, reversible en cada paso;
  5. un registro de riesgos;
  6. una comprobación que vigile las fronteras nuevas.

Los tres países están en la eurozona, así que el dinero sigue en euros. Todo lo demás cambia: el idioma, el IVA, los mensajeros, las leyes de devolución.

La idea

El plan

Cada paso usa una lección del módulo:

  1. Los requisitos con percentiles y un presupuesto de disponibilidad, como en «Requisitos no funcionales que puedes medir».
  2. Las opciones con los trade-offs de «Trade-offs» y las formas de «Monolito, monolito modular, servicios».
  3. Un ADR como en «ADR: registros de decisiones de arquitectura».
  4. La migración con Strangler Fig y Expand–Contract.
  5. Los riesgos como en «Revisión de arquitectura».
  6. Una fitness function para las fronteras, también de la revisión.

Cada paso tiene un resultado con el que comparar el tuyo. Las soluciones están en «Solución» después de cada paso, y al final hay una solución de ejemplo de toda la tarea. Inténtalo tú primero: la arquitectura se aprende decidiendo, no leyendo decisiones.

Ejercicio

Paso 1: requisitos con números

«Hazlo rápido y que no se caiga» no es un requisito. Convierte cada uno de estos deseos en un escenario de calidad: fuente, estímulo, respuesta y una medida con un número.

  • «El Black Friday no puede caerse.»
  • «El pago tiene que funcionar siempre.»
  • «Los vendedores deben ver sus ventas enseguida.»
  • «Lanzar un país nuevo debe ser fácil.»
Escenarios de ejemplo
Calidad Fuente y estímulo Respuesta Medida
escala Black Friday, 5 veces el tráfico habitual la página de pago responde p95 por debajo de 800 ms, p99 por debajo de 2 s
disponibilidad un comprador de cualquiera de los tres países paga el pago se completa 99,7 % de los minutos de un mes, como máximo 130 min de caída
consistencia un vendedor cambia las existencias los compradores lo ven en menos de 2 min; todo lo vendido por encima de las existencias se cancela con una disculpa en menos de 1 hora
aislamiento un vendedor sube 10 000 productos de golpe la tienda funciona como antes el p95 del catálogo sube como máximo un 20 %
extensibilidad un cuarto país se lanza con configuración en 2 semanas, sin cambiar el código de los pedidos

Los números de aquí son ejemplos: en una tarea real vienen del dueño («¿cuánto cuesta una hora de caída en el Black Friday?») y de medir el sistema de hoy.

«Los vendedores deben ver sus ventas enseguida» se convirtió en «en menos de 2 minutos». Es una elección deliberada de consistencia eventual: «enseguida» pondría los sistemas de los vendedores en el camino del pago.

Ejercicio

Paso 1b: el presupuesto de la cadena

El pago pasa por la API de la tienda, la base de datos y el proveedor de pagos. La primera idea para los vendedores es preguntar al sistema del vendedor, en cada pago, si el artículo está en existencia. Las disponibilidades y los p95 de abajo son ejemplos (para los proveedores, del contrato; para los vendedores, de una prueba):

const chain = [
{ name: "API de la tienda", availability: 99.95, p95: 120 },
{ name: "base de datos", availability: 99.95, p95: 40 },
{ name: "pago", availability: 99.9, p95: 450 },
{ name: "existencias en los vendedores", availability: 99.0, p95: 400 },
];
budget("Las existencias en los vendedores están en el camino del pago:", chain, 99.7, 800);
budget("Sin ellas (una copia, actualizada desde eventos):", chain.slice(0, 3), 99.7, 800);

Escribe budget, que multiplica la disponibilidad a lo largo de la cadena, suma los p95 y compara con el presupuesto: 99,7 % (la caída en minutos de un mes de 30 días) y p95 por debajo de 800 ms. Ejecútalo con los vendedores en la cadena y sin ellos:

Salida
Las existencias en los vendedores están en el camino del pago:
disponibilidad 98.80% → 517 min de caída al mes (presupuesto 130) ✗
p95 1010 ms (presupuesto 800) ✗
Sin ellas (una copia, actualizada desde eventos):
disponibilidad 99.80% → 86 min de caída al mes (presupuesto 130) ✓
p95 610 ms (presupuesto 800) ✓
Solución
const MINUTES_PER_MONTH = 30 * 24 * 60;
function budget(title, chain, target, p95Budget) {
let availability = 1;
let p95 = 0;
for (const hop of chain) {
availability *= hop.availability / 100;
p95 += hop.p95; // la suma de los p95 es un límite superior, no el p95 exacto de la cadena
}
const down = Math.round((1 - availability) * MINUTES_PER_MONTH);
const allowed = Math.round((1 - target / 100) * MINUTES_PER_MONTH);
console.log(title);
const mark = down <= allowed ? "✓" : "✗";
console.log(` disponibilidad ${(availability * 100).toFixed(2)}% → ${down} min de caída al mes (presupuesto ${allowed}) ${mark}`);
console.log(` p95 ${p95} ms (presupuesto ${p95Budget}) ${p95 <= p95Budget ? "✓" : "✗"}`);
}

Un solo sistema con 99,0 % baja el pago a 98,80 %: 517 minutos de caída al mes frente a un presupuesto de 130. La latencia también se suma. Por eso las existencias en los vendedores no pueden ser un paso síncrono del pago. Pasan a ser una copia de nuestro lado, actualizada desde eventos (la lección «Eventos y colas»), y el costo es que la copia puede atrasarse. Esta decisión sale de los números, no del gusto.

La suma de los p95 es un límite superior: el p95 de la cadena suele ser menor, porque rara vez todos los pasos están lentos a la vez. Para un presupuesto, el límite superior es el lado seguro.

Ejercicio

Paso 2: opciones con su costo

Describe al menos dos opciones que de verdad funcionarían, y el costo de cada una: dinero, tiempo, personas y lo que pierdes. Tres opciones naturales:

A: tres copias de la tienda B: un monolito modular C: servicios desde el primer día
idea una tienda por país el país es una configuración, los vendedores son un módulo catálogo, pedidos, vendedores y pago por separado
tiempo hasta el lanzamiento el más rápido medio el más lento
costo al mes el triple de infraestructura y mantenimiento un solo sistema muchos servicios, monitoreo, red
lo que pierdes las copias se separan y nunca vuelven a unirse un cambio para un país se publica en los tres el equipo aprende sistemas distribuidos mientras entrega

La comparación se hace en una matriz de decisión: criterios con pesos (cuánto importa cada uno) y una puntuación de 1 a 5 para cada opción. Los pesos y las puntuaciones son del equipo y del dueño:

// tiempo hasta el lanzamiento 3, disponibilidad 3, costo al mes 2, el equipo sabe hacerlo 2, reversibilidad 2
const weights = [3, 3, 2, 2, 2];
const options = new Map([
["A: tres copias de la tienda", [5, 3, 2, 4, 2]],
["B: monolito modular, el país es una configuración", [3, 4, 4, 4, 4]],
["C: servicios desde el primer día", [1, 4, 2, 2, 2]],
]);
for (const [name, scores] of options) console.log(name + ": " + total(scores, weights));
console.log("Elección: " + best(options, weights));

Escribe total (la suma de peso × puntuación) y best:

Salida
A: tres copias de la tienda: 40
B: monolito modular, el país es una configuración: 45
C: servicios desde el primer día: 27
Elección: B: monolito modular, el país es una configuración
Solución
function total(scores, weights) {
return weights.reduce((sum, w, i) => sum + w * scores[i], 0);
}
function best(options, weights) {
let winner = null;
for (const [name, scores] of options)
if (winner === null || total(scores, weights) > total(options.get(winner), weights)) winner = name;
return winner;
}

La matriz no decide por ti. Hace visibles los pesos: si el dueño dice «el tiempo lo es todo», eso es un número sobre el que se puede discutir.

Ejercicio

Paso 2b: qué tan frágil es la decisión

Las puntuaciones son una estimación. Comprueba la sensibilidad: sube el peso del tiempo hasta el lanzamiento desde 3 y encuentra el primer peso con el que gana otra opción:

Salida
Con peso 6 para el tiempo gana A: tres copias de la tienda (55 contra 54)

La elección aguanta hasta que el tiempo pasa a ser el doble de importante que la disponibilidad. Eso es lo que le dices al dueño: «si lanzar para la primavera importa más que todo lo demás, ganan las copias, pero este es su costo».

Ejercicio

Paso 3: el ADR

Registra la decisión en un ADR. La comprobación de la lección de ADR solo mira si están las secciones. Aquí la haces más estricta, porque un ADR sin costo es un anuncio:

  • al menos dos opciones en «Opciones»;
  • al menos una consecuencia que empiece con «- malo:»;
  • «Revisar cuando» contiene un número que se puede medir.

Este es el borrador que te manda un colega:

adr-014-borrador.md
# ADR-014: Tres países y vendedores en el monolito modular
## Estado
propuesto
## Contexto
La tienda sale en Grecia y Croacia y deja entrar a vendedores externos. Pago: 99.7% al mes, p95 por debajo de 800 ms.
## Opciones
- B: monolito modular, el país es una configuración, los vendedores son un módulo
## Decisión
B. Las existencias de los vendedores llegan de forma asíncrona y no están en el camino del pago.
## Consecuencias
- bueno: un solo código para los tres países

Escribe checkAdr y ejecútalo con el borrador y con tu ADR final:

Salida
Borrador: 3
✗ falta la sección «Revisar cuando»
✗ Opciones: 1, hacen falta al menos 2
✗ Consecuencias: ni un solo «malo:»
Definitivo: ✓ OK
Solución
const REQUIRED = ["Estado", "Contexto", "Opciones", "Decisión", "Consecuencias", "Revisar cuando"];
function sections(adr) {
const result = new Map();
let current = null;
for (const line of adr.trim().split("\n")) {
if (line.startsWith("## ")) {
current = [];
result.set(line.slice(3), current);
} else if (current !== null && line.trim() !== "") {
current.push(line);
}
}
return result;
}
function checkAdr(adr) {
const s = sections(adr);
const problems = REQUIRED.filter((name) => !s.has(name)).map((name) => "✗ falta la sección «" + name + "»");
const count = (s.get("Opciones") ?? []).filter((line) => line.startsWith("- ")).length;
if (count < 2) problems.push("✗ Opciones: " + count + ", hacen falta al menos 2");
if (!(s.get("Consecuencias") ?? []).some((line) => line.startsWith("- malo:")))
problems.push("✗ Consecuencias: ni un solo «malo:»");
if (s.has("Revisar cuando") && !/\d/.test(s.get("Revisar cuando").join(" ")))
problems.push("✗ Revisar cuando: no hay ningún número que se pueda medir");
return problems;
}
for (const [name, text] of [["Borrador", DRAFT], ["Definitivo", FINAL]]) {
const problems = checkAdr(text);
console.log(name + ": " + (problems.length === 0 ? "✓ OK" : problems.length));
for (const p of problems) console.log(" " + p);
}

El ADR final está en la solución de ejemplo, al final.

Ejercicio

Paso 4: el plan de migración

La tienda está funcionando ahora mismo. Los pedidos tienen que aprender el país sin parar y sin un gran cambio de golpe. Pon los pasos en un orden en el que cada uno se pueda deshacer hasta el último:

  1. el 1 % del tráfico griego pasa por el camino nuevo (un flag)
  2. añade la columna country (vacía, el código antiguo no la lee)
  3. 10 %, 50 %, 100 % solo con métricas verdes, atrás con rojas
  4. backfill: los pedidos antiguos reciben BG, luego compara la cantidad
  5. el código nuevo escribe country, el antiguo sigue funcionando
  6. apaga el camino antiguo y quita el código antiguo

Las partes (1 %, 10 %, 50 %, 100 %) no se suben según el calendario, sino según las métricas. Escribe rollout: para cada parte lee las siguientes métricas registradas. Con verde (como máximo 5 errores por cada 1000 y p95 por debajo de 800 ms) avanza, con rojo devuelve el tráfico a la parte anterior y vuelve a probar después de la corrección. Las métricas son un escenario grabado, así que la salida es siempre la misma:

const observed = [
new Metrics(2, 610),
new Metrics(9, 700),
new Metrics(3, 640),
new Metrics(4, 690),
new Metrics(3, 700),
];
rollout([1, 10, 50, 100], observed.values());
Salida
1%: errores 2/1000, p95 610 ms ✓
10%: errores 9/1000, p95 700 ms ✗ vuelve a 1%
10%: errores 3/1000, p95 640 ms ✓
50%: errores 4/1000, p95 690 ms ✓
100%: errores 3/1000, p95 700 ms ✓
El camino antiguo queda apagado: el punto sin retorno.
Solución
class Metrics {
constructor(errorsPer1000, p95) {
this.errorsPer1000 = errorsPer1000;
this.p95 = p95;
}
healthy() {
return this.errorsPer1000 <= 5 && this.p95 <= 800;
}
}
function rollout(shares, observed) {
let step = 0;
while (step < shares.length) {
const m = observed.next().value;
const line = `${shares[step]}%: errores ${m.errorsPer1000}/1000, p95 ${m.p95} ms`;
if (m.healthy()) {
console.log(line + " ✓");
step++;
} else {
const back = step === 0 ? 0 : shares[step - 1];
console.log(`${line} ✗ vuelve a ${back}%`);
}
}
console.log("El camino antiguo queda apagado: el punto sin retorno.");
}

Solo hay un punto sin retorno, y va al final: quitar el camino antiguo. Todo lo anterior se deshace con un flag. La columna se añade vacía (expand) y se quita solo cuando nadie la lee (contract).

Pruébalo

Encuentra el error

Un colega escribe la comprobación de las métricas. El despliegue va sin problemas, pero el 10 % arranca con 9 errores por cada 1000:

Salida
1%: errores 2/1000, p95 610 ms ✓
10%: errores 9/1000, p95 700 ms ✓
50%: errores 3/1000, p95 640 ms ✓
100%: errores 4/1000, p95 690 ms ✓
El camino antiguo queda apagado: el punto sin retorno.

Elige la línea con el error.

Ejercicio

Paso 5: el registro de riesgos

Estos son los riesgos que reunió el equipo. Escribe register, que los ordena (por puntuación y, si hay empate, por impacto) y muestra la medida. Un riesgo alto (12 o más) sin medida es un agujero abierto y se muestra con «✗», y uno menor sin medida se acepta de forma consciente.

const risks = [
new Risk("el IVA y las facturas se mezclan entre países", 3, 5, "una factura de prueba para cada país antes del 1%"),
new Risk("el mensajero de Croacia no tiene API de seguimiento", 2, 4, ""),
new Risk("el tráfico del Black Friday en Grecia es 5 veces mayor", 3, 4, ""),
new Risk("la copia de existencias se atrasa y vendemos lo agotado", 4, 3, "una reserva al pagar y una cancelación con disculpa"),
new Risk("el equipo no conoce el módulo de vendedores", 3, 2, "programación en parejas los dos primeros meses"),
];
register(risks);
Salida
15 el IVA y las facturas se mezclan entre países → una factura de prueba para cada país antes del 1%
12 el tráfico del Black Friday en Grecia es 5 veces mayor → ✗ sin medida
12 la copia de existencias se atrasa y vendemos lo agotado → una reserva al pagar y una cancelación con disculpa
8 el mensajero de Croacia no tiene API de seguimiento → (aceptado)
6 el equipo no conoce el módulo de vendedores → programación en parejas los dos primeros meses
Solución
class Risk {
constructor(name, probability, impact, measure = "") {
Object.assign(this, { name, probability, impact, measure });
}
get score() {
return this.probability * this.impact;
}
}
function register(risks) {
const sorted = [...risks].sort((a, b) => b.score - a.score || b.impact - a.impact);
for (const r of sorted) {
const measure = r.measure || (r.score >= 12 ? "✗ sin medida" : "(aceptado)");
console.log(`${String(r.score).padStart(2)} ${r.name} → ${measure}`);
}
}

Para el Black Friday la medida es una prueba de carga con 5 veces el tráfico antes del lanzamiento en Grecia, y un plan de qué se apaga primero (las recomendaciones, la búsqueda por vendedor) para que el pago sobreviva. El escalado está en «Escalado», y apagar es como el circuit breaker de «Cuando el vecino es lento: timeout, retry, circuit breaker».

Ejercicio

Paso 6: una fitness function para las fronteras nuevas

El módulo de vendedores es nuevo. Si en su primer mes empieza a leer el interior de los pedidos, un año después será imposible separarlo. La regla: cada módulo tiene una api (la parte pública) y un internal; a otro módulo solo se le llama a través de su api. Los imports de los módulos:

imports.txt
sellers.internal -> orders.api
sellers.internal -> orders.internal
orders.internal -> payments.api
orders.internal -> sellers.internal
catalog.internal -> sellers.api
orders.internal -> orders.db
Salida
✗ sellers.internal -> orders.internal: llama al interior de orders
✗ orders.internal -> sellers.internal: llama al interior de sellers
Violaciones: 2
if (fromModule !== toModule && toPart !== "")
Solución
// a otro módulo solo se le llama a través de su api
function boundaryViolations(imports) {
const found = [];
for (const line of imports) {
const [source, target] = line.split(" -> ");
const [fromModule] = source.split(".");
const [toModule, toPart] = target.split(".");
if (fromModule !== toModule && toPart !== "api") found.push(`${line}: llama al interior de ${toModule}`);
}
return found;
}
const imports = [
"sellers.internal -> orders.api",
"sellers.internal -> orders.internal",
"orders.internal -> payments.api",
"orders.internal -> sellers.internal",
"catalog.internal -> sellers.api",
"orders.internal -> orders.db",
];
const found = boundaryViolations(imports);
for (const v of found) console.log("✗ " + v);
console.log("Violaciones: " + found.length);

Ejecútala en cada commit con una baseline, como en la revisión. Las dos violaciones de hoy se corrigen antes de publicar el módulo de vendedores.

Míralo

Solución de ejemplo

Compara tu solución con esta. No es la única correcta: vale cualquier solución cuyos números, opciones y riesgos sean honestos y estén registrados.

Los requisitos. Los cinco escenarios del paso 1. Los clave: el pago con 99,7 % y p95 por debajo de 800 ms, y las existencias en los vendedores con consistencia eventual, en menos de 2 minutos.

Las opciones. A, B y C con su costo; se elige B con 45 puntos frente a 40 y 27. La decisión aguanta hasta un peso de 6 para el tiempo.

La arquitectura de B.

  • El país es una configuración: el idioma, el IVA, el mensajero y las reglas de devolución salen de una tabla por país, no de un if en el código.
  • Los vendedores son un módulo del monolito, con sus propios datos y una api pública («Monolito, monolito modular, servicios»).
  • Las existencias en los vendedores llegan como eventos a una copia de nuestro lado (un outbox de su parte o una cola de la nuestra).
  • Todo lo vendido por encima de las existencias se cancela con una Saga: reserva, pago y, si el vendedor lo rechaza, un reembolso.
  • El almacén 2009 y la contabilidad siguen detrás de una anti-corruption layer, como hasta ahora.

El ADR.

adr-014.md
# ADR-014: Tres países y vendedores en el monolito modular
## Estado
aceptado
## Contexto
La tienda sale en Grecia y Croacia y deja entrar a vendedores externos. Pago: 99.7% al mes, p95 por debajo de 800 ms.
## Opciones
- A: tres copias de la tienda, una por país
- B: monolito modular, el país es una configuración, los vendedores son un módulo
- C: servicios desde el primer día
## Decisión
B. Las existencias de los vendedores llegan de forma asíncrona y no están en el camino del pago.
## Consecuencias
- bueno: un solo código para los tres países
- malo: un cambio para un país se publica en los tres
- malo: la copia de existencias puede atrasarse minutos
## Revisar cuando
Si los vendedores pasan de 500 o los equipos pasan de 3.

El plan. La columna country (expand), el código nuevo escribe en ella, un backfill de los pedidos antiguos como Bulgaria, luego 1 %, 10 %, 50 % y 100 % del tráfico griego con métricas en verde y, al final, quitar el camino antiguo (contract). Croacia sigue a Grecia con el mismo plan.

Los riesgos. El registro del paso 5, con una medida para el Black Friday. El responsable de cada riesgo responde por él, y el registro se revisa cada dos semanas hasta el lanzamiento.

Las comprobaciones. Las fronteras del paso 6 y el presupuesto del paso 1 como fitness function: una prueba de carga antes de cada lanzamiento que falla si el p95 del pago pasa de 800 ms. Cómo se ejecutan en el pipeline es tema de ZeroToOps, el curso de DevOps.

Lo que no decidimos. Un servicio aparte para los vendedores. El ADR dice cuándo volver a él: con más de 500 vendedores o más de 3 equipos.

Test

Ponte a prueba

1. ¿Por qué las existencias en los vendedores salen del camino del pago?
2. La matriz elige B. ¿Qué comprueba el análisis de sensibilidad?
3. ¿Qué hace que un ADR siga siendo útil dos años después?
4. El despliegue al 10 % muestra 9 errores por cada 1000, con un límite de 5. ¿Qué hace el plan?
5. ¿Cuándo llega el punto sin retorno en el plan?

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