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:
- requisitos con números que se puedan medir;
- al menos dos opciones con su costo;
- una decisión, registrada en un ADR;
- un plan de migración para el sistema en marcha, reversible en cada paso;
- un registro de riesgos;
- 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:
- Los requisitos con percentiles y un presupuesto de disponibilidad, como en «Requisitos no funcionales que puedes medir».
- Las opciones con los trade-offs de «Trade-offs» y las formas de «Monolito, monolito modular, servicios».
- Un ADR como en «ADR: registros de decisiones de arquitectura».
- La migración con Strangler Fig y Expand–Contract.
- Los riesgos como en «Revisión de arquitectura».
- 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):
chain = [ Hop("API de la tienda", 99.95, 120), Hop("base de datos", 99.95, 40), Hop("pago", 99.9, 450), Hop("existencias en los vendedores", 99.0, 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[: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:
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
@dataclassclass Hop: name: str availability: float # % p95: int # msMINUTES_PER_MONTH = 30 * 24 * 60
def budget(title, chain, target, p95_budget): availability = 1.0 p95 = 0 for hop in 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 down = round((1 - availability) * MINUTES_PER_MONTH) allowed = round((1 - target / 100) * MINUTES_PER_MONTH) print(title) mark = "✓" if down <= allowed else "✗" print(f" disponibilidad {availability * 100:.2f}% → {down} min de caída al mes (presupuesto {allowed}) {mark}") print(f" p95 {p95} ms (presupuesto {p95_budget}) {'✓' if p95 <= p95_budget else '✗'}")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 2weights = [3, 3, 2, 2, 2]options = { "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 name, scores in options.items(): print(f"{name}: {total(scores, weights)}")print("Elección:", best(options, weights))Escribe total (la suma de peso × puntuación) y best:
A: tres copias de la tienda: 40B: monolito modular, el país es una configuración: 45C: servicios desde el primer día: 27Elección: B: monolito modular, el país es una configuraciónSolución
def total(scores, weights): return sum(w * s for w, s in zip(weights, scores))
def best(options, weights): return max(options, key=lambda name: total(options[name], weights))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:
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: Tres países y vendedores en el monolito modular## Estadopropuesto## ContextoLa 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ónB. 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ísesEscribe check_adr y ejecútalo con el borrador y con tu ADR final:
Borrador: 3 ✗ falta la sección «Revisar cuando» ✗ Opciones: 1, hacen falta al menos 2 ✗ Consecuencias: ni un solo «malo:»Definitivo: ✓ OKSolución
REQUIRED = ["Estado", "Contexto", "Opciones", "Decisión", "Consecuencias", "Revisar cuando"]
def sections(adr): result, current = {}, None for line in adr.strip().split("\n"): if line.startswith("## "): current = result[line[3:]] = [] elif current is not None and line.strip(): current.append(line) return result
def check_adr(adr): s = sections(adr) problems = [f"✗ falta la sección «{name}»" for name in REQUIRED if name not in s] count = sum(1 for line in s.get("Opciones", []) if line.startswith("- ")) if count < 2: problems.append(f"✗ Opciones: {count}, hacen falta al menos 2") if not any(line.startswith("- malo:") for line in s.get("Consecuencias", [])): problems.append("✗ Consecuencias: ni un solo «malo:»") if "Revisar cuando" in s and not any(ch.isdigit() for ch in " ".join(s["Revisar cuando"])): problems.append("✗ Revisar cuando: no hay ningún número que se pueda medir") return problemsfor name, text in [("Borrador", DRAFT), ("Definitivo", FINAL)]: problems = check_adr(text) print(f"{name}: {len(problems) if problems else '✓ OK'}") for p in problems: print(" " + 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:
el 1 % del tráfico griego pasa por el camino nuevo (un flag)añade la columna country (vacía, el código antiguo no la lee)10 %, 50 %, 100 % solo con métricas verdes, atrás con rojasbackfill: los pedidos antiguos reciben BG, luego compara la cantidadel código nuevo escribe country, el antiguo sigue funcionandoapaga 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:
observed = [ Metrics(2, 610), Metrics(9, 700), Metrics(3, 640), Metrics(4, 690), Metrics(3, 700),]rollout([1, 10, 50, 100], iter(observed))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
@dataclassclass Metrics: errors_per_1000: int p95: int # ms
def healthy(self): return self.errors_per_1000 <= 5 and self.p95 <= 800def rollout(shares, observed): step = 0 while step < len(shares): m = next(observed) line = f"{shares[step]}%: errores {m.errors_per_1000}/1000, p95 {m.p95} ms" if m.healthy(): print(line + " ✓") step += 1 else: back = 0 if step == 0 else shares[step - 1] print(f"{line} ✗ vuelve a {back}%") print("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:
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.
La condición es «o» en vez de «y»: basta con que una de las dos métricas esté en verde. Pasan 9 errores por cada 1000 porque el p95 es bueno. Una condición de seguridad es «y»: avanzas solo cuando todo está en verde.
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.
risks = [ Risk("el IVA y las facturas se mezclan entre países", 3, 5, "una factura de prueba para cada país antes del 1%"), Risk("el mensajero de Croacia no tiene API de seguimiento", 2, 4, ""), Risk("el tráfico del Black Friday en Grecia es 5 veces mayor", 3, 4, ""), Risk("la copia de existencias se atrasa y vendemos lo agotado", 4, 3, "una reserva al pagar y una cancelación con disculpa"), Risk("el equipo no conoce el módulo de vendedores", 3, 2, "programación en parejas los dos primeros meses"),]register(risks)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 medida12 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 mesesSolución
@dataclassclass Risk: name: str probability: int impact: int measure: str = ""
@property def score(self): return self.probability * self.impactdef register(risks): for r in sorted(risks, key=lambda r: (r.score, r.impact), reverse=True): measure = r.measure or ("✗ sin medida" if r.score >= 12 else "(aceptado)") print(f"{r.score: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:
sellers.internal -> orders.apisellers.internal -> orders.internalorders.internal -> payments.apiorders.internal -> sellers.internalcatalog.internal -> sellers.apiorders.internal -> orders.db✗ sellers.internal -> orders.internal: llama al interior de orders✗ orders.internal -> sellers.internal: llama al interior de sellersViolaciones: 2if from_module != to_module and to_part != "":api: a otro módulo se llega solo por su parte pública. Dentro de tu propio módulo (orders.internal -> orders.db) todo está permitido.
Solución
# a otro módulo solo se le llama a través de su apidef boundary_violations(imports): found = [] for line in imports: source, target = line.split(" -> ") from_module, _ = source.split(".") to_module, to_part = target.split(".") if from_module != to_module and to_part != "api": found.append(f"{line}: llama al interior de {to_module}") return foundimports = [ "sellers.internal -> orders.api", "sellers.internal -> orders.internal", "orders.internal -> payments.api", "orders.internal -> sellers.internal", "catalog.internal -> sellers.api", "orders.internal -> orders.db",]found = boundary_violations(imports)for v in found: print("✗ " + v)print("Violaciones:", len(found))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
ifen el código. - Los vendedores son un módulo del monolito, con sus propios datos y una
apipú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: Tres países y vendedores en el monolito modular## Estadoaceptado## ContextoLa 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ónB. 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 cuandoSi 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
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Integrar sistemas antiguos y ajenos · Cambiar un sistema en marcha (Strangler Fig, Expand–Contract) · Revisión de arquitectura · El panorama de la empresa y sus capacidades
- Ejercicios sobre el tema: El arquitecto en acción