El diseño de software moderno no es un gigantesco diagrama de arquitectura.
Es un conjunto de piezas que agregas cuando el sistema las necesita: entornos aislados, capas claras, observabilidad, modelos de lectura, flujos de eventos, contratos, autorización, manejo de fallos, rutas de migración y, con el tiempo, flujos de trabajo preparados para IA.
La trampa es agregarlas todas desde el principio. La trampa opuesta es esperar tanto que cada funcionalidad se vuelva riesgosa. La buena arquitectura vive entre esos dos errores.
Este artículo es una guía práctica y progresiva. Comienza con un servidor y una base de datos, y agrega cada pieza de diseño solo cuando aparece una necesidad real.
TL;DR
- Comienza con una aplicación y una base de datos, a menos que el problema ya sea distribuido.
- Separa desarrollo y producción desde temprano. La mayoría de los problemas del tipo «funcionó en la demo» son problemas de entorno.
- Mantén una sola aplicación modular antes de recurrir a microservicios.
- Agrega observabilidad antes de optimizar. No puedes optimizar lo que no puedes ver.
- Separa las rutas de lectura y escritura cuando las lecturas predominen y la estructura de las consultas sea distinta de la de las escrituras.
- Usa eventos cuando las funcionalidades necesiten reaccionar a cambios de estado sin modificar el servicio principal cada vez.
- Trata las API y los esquemas de eventos como contratos. Asigna versiones y pruébalos.
- Mantén la autorización separada del acceso a datos cuando aparezcan roles, equipos, tenants o agentes en el producto.
- Diseña para los fallos con timeouts, reintentos, idempotencia, circuit breakers, colas de mensajes fallidos y degradación controlada.
- Moderniza los sistemas legacy por partes con el patrón strangler fig, no con una reescritura de golpe.
- Trata la IA como otra capa del sistema: modelo, herramientas, memoria, permisos, evaluaciones, observabilidad y comportamiento de respaldo.
En pocas palabras: la arquitectura moderna es la disciplina de agregar límites en el momento en que reducen más riesgos de los que añade su complejidad.
Qué aprenderás aquí
- Cómo un sistema puede crecer desde una aplicación CRUD sencilla hasta una arquitectura de producción sin sobreingeniería.
- Qué problema suele justificar cada patrón arquitectónico.
- Cómo NATS, OpenSearch y OpenFGA se aplican a problemas comunes del diseño de sistemas modernos.
- Dónde encajan las funcionalidades de IA y los agentes de programación con IA en esa misma arquitectura.
- Cómo decidir qué pieza agregar a continuación y cuáles evitar por ahora.
La progresión
Imagina que el sistema avanza por seis etapas:
Act 0 Act 1 Act 2 Act 3 Act 4 Act 5
simple -> organized -> observable -> distributed -> resilient -> evolving
app app data services system system
Cada acto responde a un problema:
| Problema | Pieza de diseño |
|---|---|
| «Probar con datos reales de usuarios da miedo». | Entornos aislados |
| «Nadie sabe dónde debe ir la lógica». | Estructura modular en capas |
| «Los usuarios dicen que es lento, pero estamos adivinando». | Observabilidad |
| «Las lecturas están saturando las escrituras». | CQRS y modelos de lectura |
| «Cada nueva funcionalidad modifica el servicio principal». | Eventos |
| «Cambiar un servicio rompe otro». | Contratos y versionado |
| «Los permisos están copiados por todas partes». | Autorización externalizada |
| «Una caída rompe todo». | Diseño ante fallos |
| «No se puede reemplazar el sistema legacy de forma segura». | Migración con strangler fig |
| «Las funcionalidades de IA necesitan datos, herramientas y controles». | IA como capa de primer nivel |
Esa tabla resume el artículo. El resto explica cómo usarla.
Acto 0: comienza con una aplicación y una base de datos
La mejor arquitectura inicial suele ser aburrida:
Browser -> Server -> Database
Una aplicación desplegable. Una fuente de verdad. Una sola ruta que entender.
Esto no es un juguete. Un monolito bien construido puede sostener un producto serio durante mucho tiempo, especialmente cuando un equipo es responsable de todo el sistema. El problema no es empezar con algo simple. El problema es hacerlo sin dejar una forma de crecer.
En esta etapa, evita:
- un bus de mensajes sin trabajo asíncrono
- microservicios con un solo equipo
- CQRS antes de que las consultas causen problemas
- un servicio de autorización a medida antes de que los roles sean complejos
- la orquestación de IA antes de que exista un problema claro del usuario
La disciplina útil consiste en mantener el primer sistema pequeño, pero no desordenado.
User
|
v
API / Web App
|
v
Database
El primer problema real suele aparecer cuando las personas dependen de la aplicación y necesitas cambiarla de forma segura.
Acto 1: haz que una aplicación sea segura de modificar
Las primeras piezas de diseño no tienen que ver con la escala. Tienen que ver con la seguridad de los cambios.
Aísla los entornos
Desarrollo y producción deben ser copias separadas del sistema:
Development environment Production environment
----------------------- ----------------------
newest code released code
sample or seeded data real user data
test credentials production credentials
safe to break must stay available
The Twelve-Factor App llama a esto paridad entre dev y prod: mantener los entornos similares en estructura, pero con los datos, las credenciales y el estado de ejecución aislados.
Esta distinción explica un malentendido común en producto. «Funciona en dev» y «los clientes pueden usarlo» son hitos diferentes. El paso que falta es la publicación.
Para los PM, esto no es burocracia de ingeniería. Es la diferencia entre una demo, una validación en staging y un lanzamiento a producción.
Mantén la aplicación modular
Antes de dividir en servicios, divide la base de código en capas claras:
Edge routing, auth entry points, rate limits
Application use cases, workflows, commands
Domain business rules and invariants
Data repositories, queries, transactions
Async jobs, queues, outbox, retries
Ops logging, metrics, tracing, health checks
Esas capas pueden vivir dentro de una sola aplicación desplegable. Eso es un monolito modular.
No se trata de seguir un ritual. Se trata de que un ingeniero que llegue después sepa dónde corresponde agregar una nueva regla. Si «quién puede aprobar una factura» aparece en un controlador, una consulta SQL, un componente React y un job en segundo plano, el sistema ya está perdiendo coherencia.
Al final del Acto 1, la aplicación sigue viéndose simple desde fuera:
User -> Edge -> Modular App -> Database
Pero por dentro, cada responsabilidad tiene su lugar. Eso hace posible la siguiente etapa.
Acto 2: mide antes de escalar
Los problemas de escala suelen describirse desde la percepción:
- «La búsqueda se siente lenta».
- «El dashboard se cuelga a veces».
- «El checkout estuvo raro anoche».
- «Los clientes dicen que faltan datos».
Eso todavía no son hechos de ingeniería. La observabilidad los convierte en hechos.
Agrega observabilidad
Para una aplicación en producción, necesitas al menos tres tipos de telemetría:
Logs what happened
Metrics how often, how fast, how many
Traces where a request went across system boundaries
OpenTelemetry los describe como señales de telemetría que se pueden generar, recopilar y exportar mediante un framework independiente de proveedores. Las herramientas importan menos que el hábito: cada flujo importante debe tener evidencia suficiente para poder depurarlo después.
Para cada recorrido crítico del usuario, mide:
- tasa de solicitudes
- tasa de errores
- latencia, especialmente p95 y p99
- saturación, como la profundidad de una cola o el uso del pool de conexiones
- una señal de corrección, como «pedidos creados» o «tareas indexadas»
Es fácil omitir esa última señal, y doloroso descubrirlo después. Un sistema puede devolver HTTP 200 mientras hace algo incorrecto sin que nadie lo note.
Separa las lecturas de las escrituras cuando la estructura de las consultas lo exija
En algún momento, el modelo de escritura y el modelo de lectura necesitan estructuras diferentes.
Las escrituras necesitan corrección:
- tablas normalizadas
- transacciones
- restricciones
- invariantes
Las lecturas necesitan velocidad:
- documentos desnormalizados
- vistas precalculadas
- índices de búsqueda
- agregados en caché
CQRS, o Command Query Responsibility Segregation, es el nombre de esa separación.
Write path
---------
Create todo -> App service -> Write database
|
| project changes
v
Read path Read model
--------- ----------
Search todos -> Read API -> OpenSearch / cache / materialized view
El modelo de lectura no es la fuente de verdad. Es una copia diseñada para un propósito específico.
Este diseño aporta velocidad y flexibilidad para las consultas, pero introduce una nueva realidad: la consistencia eventual. Un usuario puede crear algo y tener que esperar un momento antes de que aparezca en la búsqueda. Eso es aceptable para algunos flujos e inaceptable para otros.
Usa este patrón cuando las necesidades de lectura realmente hayan superado lo que puede ofrecer el modelo de escritura. No lo uses porque un diagrama se vea más moderno con dos bases de datos.
Implementación concreta: un pipeline de modelo de lectura
Esta es una estructura de implementación pequeña que aparece en muchos sistemas reales:
Write database -> Outbox table -> Publisher -> NATS -> Projector -> OpenSearch
|
v
Read API
|
v
OpenFGA check
La transacción de escritura almacena tanto el cambio de negocio como un evento en el outbox:
BEGIN;
INSERT INTO todos (id, user_id, title, completed)
VALUES (:id, :user_id, :title, false);
INSERT INTO outbox_events (id, topic, payload, created_at)
VALUES (
:event_id,
'todo.created',
json_build_object(
'todo_id', :id,
'user_id', :user_id,
'title', :title
),
now()
);
COMMIT;
Un publicador lee las filas del outbox que aún no se han publicado y las envía al bus de eventos:
type OutboxEvent = {
id: string;
topic: string;
payload: unknown;
};
async function publishOutboxBatch(events: OutboxEvent[]) {
for (const event of events) {
await messageBus.publish(event.topic, {
id: event.id,
occurredAt: new Date().toISOString(),
data: event.payload,
});
await markPublished(event.id);
}
}
Un proyector actualiza el modelo de lectura de forma idempotente:
type TodoCreated = {
id: string;
data: {
todo_id: string;
user_id: string;
title: string;
};
};
async function handleTodoCreated(event: TodoCreated) {
if (await alreadyProcessed(event.id)) {
return;
}
await openSearch.index({
index: "todos",
id: event.data.todo_id,
document: {
title: event.data.title,
owner_id: event.data.user_id,
completed: false,
},
});
await markProcessed(event.id);
}
Los detalles cambian según el stack, pero la estructura se mantiene:
- escribe una sola vez en la fuente de verdad
- publica los cambios de forma confiable
- actualiza los modelos de lectura en segundo plano
- haz que los proyectores sean idempotentes
- mide el retraso entre la escritura y su visibilidad en las lecturas
Ese retraso se convierte en una señal operativa.
Acto 3: divide los sistemas según límites reales
Los microservicios no son el siguiente paso después de «tenemos archivos». Son el siguiente paso cuando distintas partes del sistema necesitan equipos responsables, escalado, ciclos de publicación o aislamiento de fallos independientes.
El problema de diseño cambia cuando hay más de un servicio. Las llamadas cruzan límites. Los esquemas divergen. Los permisos se duplican. La depuración se vuelve más difícil.
Usa eventos cuando los servicios necesiten reaccionar
Las llamadas directas acoplan estrechamente los servicios:
Todo service -> Email service
Todo service -> Analytics service
Todo service -> Search projector
Todo service -> Notification service
Los eventos invierten esa relación:
Todo service -> todo.completed event -> Message bus
-> Email worker
-> Analytics worker
-> Search projector
-> Notification worker
El productor anuncia lo que ocurrió. Los consumidores deciden qué hacer.
NATS es una opción práctica de código abierto para esto. Core NATS admite pub/sub y request/reply. JetStream agrega persistencia, consumidores durables, flujos que se pueden reproducir y capacidades clave-valor. Esto lo hace útil cuando necesitas mensajería ligera sin adoptar de inmediato una plataforma de eventos más pesada.
La contrapartida: los eventos hacen que los flujos sean más flexibles, pero menos evidentes. Necesitas convenciones de nombres, responsables de los esquemas, trazabilidad, manejo de mensajes fallidos y procedimientos de reproducción.
Trata los contratos como código
Cuando varios servicios comparten API o eventos, cada límite es un contrato.
Ejemplos:
GET /v1/todos
POST /v1/todos
event todo.created.v1
event todo.completed.v2
Los números de versión no son decoración. Les indican a los consumidores qué puede cambiar de forma segura.
La versión ejecutable de un contrato es una prueba:
- validación de OpenAPI para API HTTP
- validación de esquemas para eventos
- pruebas de contrato entre productores y consumidores
- pruebas de integración para los flujos de mayor riesgo
Esto importa aún más con agentes de programación con IA. Los agentes pueden cambiar código rápidamente, pero siguen necesitando un oráculo. Los contratos y las pruebas son ese oráculo.
Separa la autorización del acceso a datos
La autorización se complica cuando el producto empieza a ser útil.
Al principio, esto puede ser suficiente:
if (user.role !== "admin") {
throw new ForbiddenError();
}
Después, la pregunta pasa a ser:
Can this user, service account, or agent
perform this action
on this workspace, project, document, invoice, or tool
under this task context?
En ese punto, la lógica de permisos dispersa por los servicios se vuelve peligrosa.
Un diseño más limpio separa la recuperación de datos de la autorización:
Read API -> OpenSearch "Which records match the query?"
Read API -> OpenFGA "Which matching records can this subject see?"
Read API -> User "Return only authorized records."
OpenFGA es una implementación de código abierto de control de acceso basado en relaciones, inspirada en el artículo Zanzibar de Google. Almacena tuplas de relaciones, evalúa un modelo de autorización y responde a consultas de permisos como:
Can user:luis view document:roadmap?
Can agent:triage read ticket:123?
Can service:billing refund invoice:456?
La separación te da una ventaja para depurar:
| ¿Existen los datos? | ¿Existe el permiso? | Problema probable |
|---|---|---|
| sí | sí | error en la API o la UI |
| sí | no | error de autorización |
| no | sí/no | error de sincronización o creación de datos |
Esto es especialmente útil en sistemas con modelos de lectura, índices de búsqueda, tenants y agentes. Puedes inspeccionar la ruta de datos y la de permisos de forma independiente.
Acto 4: diseña para los fallos
Los sistemas distribuidos fallan de maneras habituales:
- se cae la red
- una dependencia se vuelve lenta
- un mensaje llega dos veces
- un consumidor falla a mitad del proceso
- un índice de búsqueda se retrasa
- falta una tupla de permisos
- una llamada a un modelo de IA excede el timeout
El diseño para producción asume que esto va a ocurrir.
Las herramientas básicas:
| Pieza | Qué evita |
|---|---|
| Timeouts | Esperar indefinidamente |
| Reintentos | Fallar por un error transitorio |
| Idempotencia | Procesar mensajes dos veces |
| Circuit breakers | Fallos en cascada entre dependencias |
| Colas de mensajes fallidos | Que un mensaje defectuoso bloquee todo el trabajo |
| Degradación controlada | Que las funcionalidades opcionales interrumpan los flujos principales |
| Rollbacks | Que los despliegues defectuosos sigan causando problemas |
La idempotencia es la más importante cuando ya existen eventos:
async function handleEvent(event: Event) {
if (await processedEvents.has(event.id)) {
return;
}
await doWork(event);
await processedEvents.add(event.id);
}
La degradación controlada es igual de importante para la experiencia de producto:
async function createTodo(input: CreateTodoInput) {
const todo = await saveTodo(input);
try {
const suggestions = await aiSuggestions.forTodo(todo);
await saveSuggestions(todo.id, suggestions);
} catch (error) {
logger.warn({ error, todoId: todo.id }, "AI suggestions unavailable");
}
return todo;
}
La acción principal se completa incluso si falla la funcionalidad opcional de IA.
Pero la degradación controlada tiene una trampa: oculta el fallo a los usuarios, así que también puede ocultárselo al equipo. Cada ruta degradada debe emitir logs, métricas o trazas. De lo contrario, la aplicación puede estar fallando sin que nadie lo note.
Acto 5: haz evolucionar un sistema en funcionamiento
Cuando un sistema ya aporta valor, reemplazarlo se vuelve riesgoso. El objetivo cambia de «construir lo nuevo» a «cambiar lo que está funcionando sin romperlo».
Usa el patrón strangler fig para migrar sistemas legacy
El patrón strangler fig coloca una fachada delante del sistema antiguo y luego enruta una capacidad a la vez hacia el sistema nuevo:
Request -> Facade / router
|
+-> legacy system for old routes
|
+-> new system for migrated routes
Por lo general, el sistema legacy debería seguir siendo la opción predeterminada hasta que se compruebe que una parte puede migrarse de forma segura. Algunas técnicas útiles son:
- enrutamiento por endpoint, tenant, feature flag o cohorte
- encabezados
X-Served-Bypara identificar qué sistema atendió una respuesta - tráfico espejo para comparar el comportamiento anterior con el nuevo
- captura de cambios de datos para mantener sincronizados los nuevos modelos de lectura
- despliegue canario antes de una migración amplia
- rutas de rollback para cada parte migrada
Así es como la modernización entrega valor de forma continua en lugar de desaparecer dentro de una reescritura.
Trata la IA como una capa de primer nivel
Las funcionalidades de IA no son simplemente «llamar al modelo aquí».
Una funcionalidad de IA en producción suele tener varias piezas:
User request
|
v
AI orchestration layer
|
+-> model
+-> tools
+-> retrieval / vector store
+-> authorization checks
+-> evaluation and tracing
+-> fallback behavior
El almacén vectorial es solo otro modelo de lectura, optimizado para la recuperación semántica. OpenSearch puede cumplir esta función mediante búsqueda vectorial, y Postgres con pgvector puede ser suficiente cuando mantenerse cerca de la base de datos principal aporta más valor que una infraestructura de búsqueda especializada.
Las mismas reglas de arquitectura siguen aplicando:
- Las llamadas a IA necesitan timeouts y mecanismos de respaldo.
- Las herramientas de IA necesitan autorización.
- La salida de la IA necesita evaluación, no solo verificaciones de disponibilidad.
- Las trazas de IA deben mostrar qué herramientas, documentos, prompts y llamadas a modelos influyeron en el resultado.
- Los datos sensibles deben filtrarse antes de la recuperación y antes de invocar herramientas.
La IA no elimina el diseño de sistemas. Aumenta el costo de definir límites de forma descuidada.
Deja que los agentes ayuden en ingeniería, con controles
Los agentes de programación con IA también encajan en esta arquitectura. Un ciclo útil de agente se ve así:
Task -> Agent reads context -> edits code -> runs tests -> fixes failures -> opens PR
^ |
| v
+------ test feedback <---+
Ese ciclo solo es seguro cuando existen las piezas anteriores:
- entornos de dev aislados
- límites arquitectónicos claros
- pruebas y verificaciones de contratos
- control de versiones
- CI
- revisión humana
- límites de permisos para herramientas y secretos
Un ciclo de autocorrección más sólido agrega evidencia de producción:
Observe regression -> classify cause -> propose patch -> test in dev
^ |
| v
Rollback if unhealthy <- canary release <- human approval <- PR
Esto no es autorreparación mágica. Es arquitectura conectada a un ciclo de retroalimentación. La observabilidad detecta el problema. Los contratos definen el comportamiento esperado. El entorno de dev contiene el experimento. Las pruebas evalúan la posible solución. El despliegue canario limita el riesgo. El rollback detiene el daño.
Cuanto más disciplinado sea el sistema, más útil se vuelve la autonomía.
Toda la arquitectura en una imagen
Una versión madura de la aplicación simple original podría verse así:
One environment: dev, staging, or prod
User / Client
|
v
Edge: auth entry point, routing, rate limits
|
v
Application services
|
+-> Write database
| |
| v
| Outbox events
| |
| v
+-> Message bus: NATS / Kafka / Pub/Sub
|
+-> Search projector -> OpenSearch read model
+-> Email worker
+-> Analytics worker
+-> AI orchestration
|
+-> model
+-> vector store
+-> tools
Read API
|
+-> OpenSearch for candidate data
+-> OpenFGA for permission checks
|
v
Authorized response
Ops across everything:
logs, metrics, traces, alerts, SLOs, deploys, rollbacks
No interpretes esto como el punto de partida. Míralo como el resultado de muchos pasos justificados.
Una matriz práctica de decisión
Usa los síntomas, no las modas, para elegir la siguiente pieza.
| Síntoma | Recurre a | Evita |
|---|---|---|
| Se usan datos de producción para hacer pruebas | Aislamiento de entornos | Depender de más precaución manual |
| La lógica está duplicada entre la UI, la API y los jobs | Capas modulares | Dividir en servicios demasiado pronto |
| Nadie puede explicar una pérdida de rendimiento | Observabilidad | Agregar caché a ciegas |
| Las consultas necesitan búsqueda, filtrado y agregación a escala | Modelo de lectura / CQRS | Hacer que el esquema de escritura atienda todas las lecturas |
| Varias funcionalidades reaccionan al mismo cambio de estado | Bus de eventos | Más llamadas directas entre servicios |
| Los consumidores fallan después de cambios en el productor | Contratos y versionado | Coordinación basada en Slack |
| Los permisos varían por espacio de trabajo, objeto, rol o agente | Autorización externalizada | Comprobaciones if copiadas y pegadas |
| Los mensajes llegan dos veces o las dependencias se vuelven intermitentes | Idempotencia, reintentos, timeouts | Suponer que todo seguirá el camino feliz |
| Reemplazar el sistema legacy es riesgoso | Migración con strangler fig | Reescritura de golpe |
| Las funcionalidades de IA necesitan datos privados o herramientas | Capa de IA con autorización, evaluaciones y trazabilidad | Llamadas directas al modelo desde servicios arbitrarios |
| Los agentes de programación con IA crean diffs riesgosos | Especificaciones, pruebas, CI y controles de revisión | Confiar en el código generado porque compila |
La idea no es usar todos los patrones. Es saber qué problema busca resolver cada uno.
Errores comunes
Dividir servicios antes de separar conceptos
Si el monolito está enredado, los microservicios normalmente distribuirán ese enredo. Primero aclara los límites entre módulos dentro de una sola aplicación. Después separa las partes que necesiten equipos responsables o escalado independientes.
Agregar CQRS sin medir
CQRS agrega un problema de sincronización. Úsalo cuando las lecturas realmente necesiten un modelo diferente, no porque separar lecturas y escrituras suene sofisticado.
Tratar los eventos como llamadas invisibles a funciones
Los eventos necesitan responsables, esquemas, reglas de reproducción y observabilidad. Si nadie es responsable de un contrato de evento, cada consumidor termina lidiando con las consecuencias.
Poner la autorización solo en la UI
Las comprobaciones en la UI mejoran la experiencia. No protegen los datos. La aplicación de permisos corresponde al servidor, cerca de cada lectura, escritura, llamada a herramientas y acción en segundo plano.
Agregar IA como un complemento aislado
Las funcionalidades de IA siguen necesitando seguridad, evaluación, observabilidad y comportamiento de respaldo. Una llamada a un modelo es un punto de integración, no una arquitectura de producto.
Conclusión
El diseño de software moderno es el arte de agregar estructura en el momento adecuado.
Empieza simple. Mantén el código modular. Aísla los entornos. Mide el sistema. Separa las lecturas de las escrituras cuando los datos lo exijan. Usa eventos cuando se multipliquen las reacciones. Trata los contratos y los permisos como límites de primer nivel. Diseña para los fallos. Migra de forma incremental. Agrega la IA como una capa gobernada, no como un atajo para evitar el trabajo de ingeniería.
Ninguna de estas piezas es exótica por sí sola. La habilidad está en ordenarlas.
La arquitectura intimida menos cuando cada caja del diagrama responde una pregunta: ¿qué problema resuelve esto?
Fuentes
- The Twelve-Factor App: paridad entre dev y prod - fuente para el concepto de paridad entre dev y prod.
- Documentación de OpenTelemetry - framework de observabilidad independiente de proveedores para logs, métricas y trazas.
- Documentación de NATS JetStream - persistencia, mensajería durable y flujos que se pueden reproducir en NATS.
- Almacén clave-valor de NATS JetStream - documentación oficial de buckets clave-valor respaldados por JetStream.
- Documentación de búsqueda vectorial de OpenSearch - documentación oficial para la búsqueda vectorial con OpenSearch.
- Documentación de OpenFGA - conceptos de control de acceso basado en relaciones inspirados en Zanzibar.
- Página del proyecto OpenFGA en la CNCF - estado en la CNCF y descripción del proyecto.
- Martin Fowler: aplicación del patrón strangler fig - planteamiento original del patrón de aplicación strangler fig.
- Azure Architecture Center: patrón strangler fig - guía práctica para la migración gradual con el patrón.
- Artículo Zanzibar de Google - artículo fundamental detrás de los sistemas de autorización basados en relaciones a gran escala.