Software Systems Research

Las piezas del diseño de software moderno y eficaz

Una guía práctica para hacer crecer un sistema de software desde una aplicación y una base de datos hasta una arquitectura observable, orientada a eventos, con gestión de permisos y preparada para IA.

21 min de lectura

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:

ProblemaPieza 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ínoerror de autorización
nosí/noerror 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:

PiezaQué evita
TimeoutsEsperar indefinidamente
ReintentosFallar por un error transitorio
IdempotenciaProcesar mensajes dos veces
Circuit breakersFallos en cascada entre dependencias
Colas de mensajes fallidosQue un mensaje defectuoso bloquee todo el trabajo
Degradación controladaQue las funcionalidades opcionales interrumpan los flujos principales
RollbacksQue 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-By para 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íntomaRecurre aEvita
Se usan datos de producción para hacer pruebasAislamiento de entornosDepender de más precaución manual
La lógica está duplicada entre la UI, la API y los jobsCapas modularesDividir en servicios demasiado pronto
Nadie puede explicar una pérdida de rendimientoObservabilidadAgregar caché a ciegas
Las consultas necesitan búsqueda, filtrado y agregación a escalaModelo de lectura / CQRSHacer que el esquema de escritura atienda todas las lecturas
Varias funcionalidades reaccionan al mismo cambio de estadoBus de eventosMás llamadas directas entre servicios
Los consumidores fallan después de cambios en el productorContratos y versionadoCoordinación basada en Slack
Los permisos varían por espacio de trabajo, objeto, rol o agenteAutorización externalizadaComprobaciones if copiadas y pegadas
Los mensajes llegan dos veces o las dependencias se vuelven intermitentesIdempotencia, reintentos, timeoutsSuponer que todo seguirá el camino feliz
Reemplazar el sistema legacy es riesgosoMigración con strangler figReescritura de golpe
Las funcionalidades de IA necesitan datos privados o herramientasCapa de IA con autorización, evaluaciones y trazabilidadLlamadas directas al modelo desde servicios arbitrarios
Los agentes de programación con IA crean diffs riesgososEspecificaciones, pruebas, CI y controles de revisiónConfiar 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