Resumen teórico
Piensa en una casa. Antes de que los albañiles pongan el primer ladrillo, alguien dibuja el plano: dónde van las paredes, por dónde pasan las tuberías, qué habitaciones quedan una al lado de otra. Ese plano es difícil de cambiar después. Mover una pared es fácil en el papel y muy caro con la casa terminada. La arquitectura de software es el plano de un programa. Es el conjunto de las grandes decisiones que le dan forma al programa y que cuestan caro de cambiar después.
La mayoría de los programas empieza pequeña y crece. Si nadie piensa en la forma, el código se vuelve un ovillo enredado: tiras de un hilo y se mueven diez más. Una buena arquitectura mantiene las piezas separadas, y un cambio en un lugar no rompe los demás. Esto importa todos los días en la vida real. Por eso una tienda puede cambiar de empresa de pagos sin reescribir todo el sitio, y una aplicación de celular puede usar una base de datos hoy y otra mañana.
En este resumen aprenderás qué es la arquitectura y cómo la juzgamos, y luego una familia de estilos que responden a una pregunta: ¿cómo proteger las reglas de negocio (las reglas que hacen útil al programa, como "el total del carrito es la suma de sus artículos") de los detalles (la base de datos, el framework web, la pantalla)? Conocerás las capas, el hexágono, la arquitectura limpia y su regla de dependencia, un programa único frente a muchos servicios pequeños, los eventos y el vocabulario del domain-driven design. Al final verás por qué toda elección es un trade-off y cómo los equipos dejan por escrito sus decisiones.
Qué es la arquitectura y atributos de calidad
Imagina que armas una ciudad de juguete con bloques. Algunas decisiones son baratas de deshacer: el color de un techo. Otras son caras: por dónde pasa la calle principal, porque todas las casas se colocan alrededor de ella. En el software, la arquitectura está hecha de las decisiones caras. Autores como Martin Fowler y Ralph Johnson la describen como el conjunto de las decisiones de diseño que importan, las difíciles de cambiar. También vive en el entendimiento compartido del equipo, no solo en un documento.
Robert Martin añade que no hay una línea clara entre arquitectura y diseño detallado. Las decisiones pequeñas sostienen a las grandes, así que todo es un continuo. También dice cuál es el objetivo: minimizar las personas y el esfuerzo necesarios para construir y mantener el sistema. Una buena arquitectura mantiene bajo el costo de cada cambio durante toda la vida del programa. Una manera de lograrlo es mantener las opciones abiertas, es decir, no elegir un detalle demasiado pronto.
Requisitos funcionales y no funcionales
Un requisito funcional dice qué hace el programa, como "un cliente puede hacer un pedido". Un requisito no funcional dice qué tan bien lo hace: qué tan rápido, cuánto tiempo está en línea, qué tan seguro, qué tan fácil de cambiar. Las partes individuales implementan las funciones, pero las propiedades no funcionales dependen de la arquitectura como un todo. Por eso dos tiendas en línea con los mismos botones pueden necesitar arquitecturas muy distintas: una para 50 usuarios en una oficina, otra para 5 millones de usuarios en todo el mundo que debe estar disponible el 99,99 % del tiempo.
Los atributos de calidad necesitan un escenario medible
Un atributo de calidad es una palabra como rendimiento, disponibilidad, seguridad o mantenibilidad. "El sistema debe ser rápido" no sirve, porque ninguna prueba puede reprobarlo. Un buen requisito es un escenario: bajo qué condición, qué respuesta se mide y cuál es el límite. Por ejemplo: "con 1.000 usuarios a la vez, 95 de cada 100 búsquedas responden en menos de 300 milisegundos". Describe el resultado que queremos, no la solución técnica.
| Atributo | Qué le pide a la estructura |
|---|---|
| Rendimiento | El trabajo crítico concentrado en pocas partes grandes que se comunican lo menos posible |
| Seguridad | Capas, con los datos más valiosos en la más interna |
| Disponibilidad | Redundancia: copias de reserva que toman el relevo cuando una parte falla |
| Mantenibilidad | Partes pequeñas y fáciles de reemplazar |
Mira la tabla: el rendimiento quiere pocas partes grandes, la mantenibilidad quiere muchas pequeñas. Tiran hacia lados opuestos, así que una arquitectura es siempre un compromiso entre los atributos que más importan para ese sistema.
Ocultamiento de información
David Parnas tuvo una idea sencilla en los años 1970: dividir el programa en módulos de modo que cada módulo oculte una decisión que probablemente va a cambiar, como la forma de guardar los datos. Los demás módulos solo conocen una interfaz pequeña y estable (la lista de lo que el módulo ofrece). Así, cuando la decisión cambia, el cambio queda dentro de un módulo. Si seis archivos leen la misma lista global de pedidos y esperan que esté ordenada por fecha, cambiar esa lista rompe seis archivos. Es lo contrario de ocultar.
Vistas, carpetas y equipos
Un solo dibujo no lo muestra todo. El modelo de vistas 4+1 de Kruchten usa cuatro vistas: lógica (las ideas principales), de procesos (lo que corre al mismo tiempo), de desarrollo (los módulos que manejan los programadores) y física (qué máquinas ejecutan qué). El "+1" es un conjunto de escenarios que une a las cuatro. La vista sobre procesadores y máquinas es la física.
Robert Martin también habla de una arquitectura que grita (screaming architecture). El plano de una biblioteca "grita" que es una biblioteca. De la misma forma, las carpetas de primer nivel de un programa deben gritar qué hace el programa (pedidos, pagos), no qué framework usa. Si las carpetas dicen solo controllers, models y views, el framework es quien decide la forma.
Grita el framework Grita el negocio
src/ src/
controllers/ pedidos/
models/ pagos/
views/ envios/
helpers/ catalogo/La ley de Conway (1968) dice que las organizaciones construyen sistemas cuya estructura copia la manera en que la organización se comunica. Tres equipos que escriben un compilador tienden a hacer un compilador de tres pasadas. Describe lo que suele ocurrir, no es una regla de diseño. La maniobra de Conway inversa usa la ley a propósito: primero organiza los equipos como quieres la arquitectura, y luego déjalos construirla. Dibujar servicios nuevos en el papel y mantener los equipos viejos suele traer de vuelta la división antigua con nombres nuevos.
↑ Volver al índiceArquitecturas en capas y hexagonal
Piensa en una escuela. El alumno le pregunta al profesor, el profesor le pregunta a la secretaría, y la secretaría busca en el archivo. Nadie salta del alumno directo al archivo. Esa es la arquitectura en capas: el programa se corta en niveles, del más cercano al usuario al más cercano a los datos. Cada capa ofrece servicios a la capa de arriba y usa solo los servicios de la capa inmediatamente inferior. Por eso una capa se puede reemplazar sin molestar a las demás, siempre que su interfaz siga igual.
+------------------------+
| Presentación | pantallas, páginas web
+------------------------+
| usa
v
+------------------------+
| Negocio | reglas
+------------------------+
| usa
v
+------------------------+
| Persistencia | acceso a la base de datos
+------------------------+Capa y tier son palabras distintas
Una capa (layer) es una división lógica: un grupo de código con una tarea, como "presentación". Un tier es una división física: dónde se ejecuta el código, por ejemplo el navegador, el servidor de aplicación y el servidor de base de datos. Puedes tener tres capas corriendo en una sola máquina, o dos capas repartidas en cinco máquinas. Las dos cuentas no dependen una de la otra.
Capas estrictas y relajadas
En la división en capas estricta (cerrada) una capa llama solo a la capa inmediatamente inferior. Esto aísla los cambios, pero a veces una capa tiene métodos cuya única tarea es pasar la llamada. En la división relajada (abierta) una capa puede llamar a cualquier capa de abajo. Eso elimina el paso inútil, pero sube el acoplamiento (cuánto depende una parte de otra), porque más capas pasan a depender de la misma capa inferior.
Cuando la mayoría de las solicitudes atraviesa una capa que solo reenvía, lo llamamos antipatrón sinkhole (un antipatrón es un mal hábito común). Unos pocos casos así son normales. Cuando son la mayoría, conviene repensar las capas o abrir algunas, aceptando el acoplamiento extra.
Arquitectura hexagonal: puertos y adaptadores
Alistair Cockburn tuvo otra imagen. Piensa en una laptop con tomas en sus costados. A la laptop no le importa qué conectes, mientras el enchufe encaje en la toma. En la arquitectura hexagonal (también llamada de puertos y adaptadores) la aplicación está en el centro. Solo conoce puertos: interfaces que la propia aplicación declara, diciendo qué necesita o qué ofrece. Cada tecnología externa se conecta mediante un adaptador, una pieza que traduce entre el puerto y la tecnología. La palabra puerto aquí no tiene nada que ver con los puertos de red.
Controller HTTP --+ +-- Adaptador PostgreSQL
Línea de comandos +--> [PUERTO] <+
Prueba automática + APLICACIÓN +-- Adaptador en memoria
adaptadores de entrada adaptadores de salida
(inician la llamada) (son llamados)¿Por qué un hexágono? Solo porque es una figura con muchos lados. Cockburn quería mostrar muchos puertos equivalentes, sin un "arriba" y un "abajo" como en el dibujo de las capas. Lo que importa es la diferencia entre el adentro (la aplicación) y el afuera (los adaptadores).
| Tipo de adaptador | ¿Quién inicia la llamada? | Ejemplos |
|---|---|---|
| De entrada (driving, primario) | El mundo exterior llama a la aplicación | Controller HTTP, herramienta de línea de comandos, una prueba automática |
| De salida (driven, secundario) | La aplicación llama al exterior mediante un puerto que ella declaró | Repositorio, enviador de correo, reloj |
Un puerto puede tener muchos adaptadores: la base de datos real, uno que guarda los datos en memoria, un doble de prueba. Como la aplicación depende solo del puerto, el adaptador se elige desde fuera. Así las reglas de negocio se pueden probar sin base de datos, y el adaptador real se prueba por separado.
La gran diferencia con las capas clásicas
En las tres capas clásicas las dependencias apuntan hacia abajo, así que la capa de negocio depende de la persistencia y termina moldeada por la base de datos. En la arquitectura hexagonal el núcleo declara el puerto, y la persistencia lo implementa. Las llamadas siguen yendo del núcleo a la base de datos, pero la dependencia en el código fuente apunta hacia el núcleo.
// En capas: el negocio importa la capa de base de datos
import { NoteTable } from "../persistence/note-table";
// Hexagonal: el núcleo importa solo su propio puerto,
// y el adaptador de la base de datos importa el puerto
import type { NoteRepository } from "./ports/note-repository";El patrón BCE de Ivar Jacobson tiene el mismo espíritu con tres tipos de objeto: boundary (habla con los actores de afuera), control (coordina los pasos de un caso de uso) y entity (guarda datos y reglas del dominio). Es una de las influencias de la arquitectura limpia, junto con la hexagonal y DCI: entity corresponde a las entidades, control a los casos de uso, boundary a los adaptadores de interfaz. Si la clase que lee la solicitud HTTP también guarda la secuencia del flujo, mezcla boundary y control.
↑ Volver al índiceArquitectura limpia y la regla de dependencia
Imagina una cebolla. En el medio está la parte más preciosa, y cada capa de alrededor la protege. La arquitectura limpia de Robert Martin dibuja el programa como círculos dentro de círculos. Del centro hacia afuera: entidades (las reglas de negocio más generales), casos de uso (reglas de esta aplicación en particular), adaptadores de interfaz (controllers, presenters, repositorios) y, por fuera, frameworks y drivers (el framework web, la base de datos, las herramientas). Cuanto más adentro, más alto el nivel de la política.
+----------------------------------------+
| Frameworks y drivers |
| +----------------------------------+ |
| | Adaptadores de interfaz | |
| | +----------------------------+ | |
| | | Casos de uso | | |
| | | +----------------------+ | | |
| | | | Entidades | | | |
| | | +----------------------+ | | |
| | +----------------------------+ | |
| +----------------------------------+ |
+----------------------------------------+
toda flecha de dependencia apunta hacia adentroLa regla de dependencia
La regla de dependencia dice: las dependencias en el código fuente apuntan solo hacia adentro. El código de un círculo interno no conoce ningún nombre de un círculo externo, ni clase, ni función, ni tipo. Como resultado, las reglas de negocio no cambian cuando reemplazas la base de datos o el framework web.
Martin llama detalles a la base de datos y a la web. Son maneras de traer datos, enviarlos y guardarlos, pero no son la razón de que el sistema exista. Las reglas están en el centro, y los detalles se conectan desde fuera, como plug-ins. Esto permite posponerlos y reemplazarlos. También da las características principales que Martin enumera: independencia de los frameworks, de la interfaz de usuario y de la base de datos, y reglas que se pueden probar sin ninguno de ellos.
Ser independiente de los frameworks no significa no usar nunca uno. Significa que las reglas de negocio no dependen de él. El framework queda en el círculo más externo y se trata como una herramienta. Además, el código de afuera cambia a menudo y el de adentro rara vez, y como las dependencias apuntan hacia adentro, lo que cambia mucho depende de lo que cambia poco, nunca al revés.
Las llamadas van hacia afuera, las dependencias hacia adentro
Aquí está el acertijo. Un caso de uso necesita guardar una nota en la base de datos, así que en tiempo de ejecución llama a código de un círculo externo. ¿Rompe esto la regla? No, porque el flujo de control y la dependencia en el código fuente son cosas distintas. La solución es la inversión de dependencias (la D de SOLID, un conjunto de cinco principios de diseño). El caso de uso declara una interfaz propia, el puerto NoteRepository. La clase externa PostgresNoteRepository la implementa. Así la llamada va hacia afuera, pero la dependencia en el código fuente apunta hacia adentro.
Orden de las llamadas en tiempo de ejecución:
NoteController
-> CreateNote
-> PostgresNoteRepository
-> base de datos
Dónde se declara cada nombre:
adapters/ NoteController
use-cases/ CreateNote, NoteRepository (interfaz)
drivers/ PostgresNoteRepository (la implementa)| Palabra | Qué es |
|---|---|
| Inversión de dependencias | Un principio: el código de alto nivel y el de bajo nivel dependen ambos de una abstracción |
| Inyección de dependenciasun objeto recibe desde fuera lo que necesita, normalmente por el constructor | Un mecanismo que entrega el objeto real a quien lo necesita |
| Contenedor de IoC | Una herramienta opcional que crea y conecta objetos automáticamente |
Cómo encontrar una violación
Para comprobar la regla, lee las líneas de import de cada archivo y compara la capa de quien importa con la capa de lo importado. Importar algo más adentro está permitido, incluso saltándose círculos. Importar algo más afuera es la violación. Un tipo también es un nombre. En TypeScript un import type desaparece al compilar, pero el acoplamiento queda en el código fuente, porque un cambio en el tipo externo rompe el archivo interno. Los nombres externos también pueden esconderse sin una línea de import: el tipo global Response o un código de estado HTTP dentro de un caso de uso ya lo atan a la web.
// use-cases/create-note.ts
import { Note } from "../entities/note"; // hacia adentro: permitido
import { PostgresNoteRepository }
from "../drivers/postgres-note-repository"; // hacia afuera: VIOLACIÓNComo la regla trata del código fuente, una máquina puede comprobarla sin ejecutar el programa. Un script o una regla de lint asocia cada carpeta a una capa y rechaza cualquier import de una capa interna hacia una externa. Ejecutado en el build o en la integración continua (las comprobaciones automáticas en cada cambio), detiene la violación antes de que el código se fusione.
↑ Volver al índiceEntidades y casos de uso
Piensa en un restaurante. Algunas reglas pertenecen a la comida misma: una ensalada lleva una verdura. Otras pertenecen a cómo funciona este restaurante: los pedidos se anotan en este orden y luego se avisa a la cocina. El primer tipo vive en las entidades, el segundo en los casos de uso.
En la arquitectura limpia, la entidad está en el círculo más interno y guarda las reglas de negocio más generales, las que valdrían aunque el trabajo se hiciera a mano, en papel. No depende de ninguna base de datos, framework ni pantalla. Por eso cambia poco y puede servir a más de una aplicación de la misma empresa. Un caso de uso (también llamado interactor) guarda una regla específica de la aplicación: la secuencia de pasos de una operación, como "crear una nota". Orquesta las entidades y solo llega a la base de datos, al reloj o a la red mediante puertos que él mismo declara. No sabe quién lo llama ni dónde se guardan los datos.
| Pregunta | Regla de dominio (entidad) | Regla de aplicación (caso de uso) |
|---|---|---|
| Vale en | Cualquier aplicación que use el concepto | Solo esta aplicación |
| Ejemplo | Un título no puede estar vacío ni tener más de 80 caracteres | Un usuario no puede tener dos notas con el mismo título |
| ¿Necesita otros datos? | No, mira solo el propio objeto | A menudo sí: consulta las notas ya guardadas |
| Cambia | Rara vez | Con más frecuencia |
Las entidades guardan las reglas cerradas que definen el concepto. Una regla que es decisión de un producto concreto, que todavía puede cambiar, o que necesita mirar otros datos guardados va en un caso de uso. Así la entidad sigue siendo reutilizable y la regla queda fácil de encontrar en el código de la aplicación. El protocolo, la paginación y las tablas son detalles de las capas externas, no son reglas.
Objetos de valor que no pueden estar mal
Un objeto de valor (value object) es un objeto pequeño definido solo por sus valores, como un Title. Si su constructor es privado y la única entrada es un método de fábrica create, nadie puede construir uno inválido. Quien recibe un Title como parámetro puede confiar en él, y la regla está escrita en un solo lugar. El estado inválido se vuelve imposible de representar.
export class Title {
private constructor(public readonly value: string) {}
static create(raw: string): Result<InvalidTitleError, Title> {
const trimmed = raw.trim();
if (trimmed.length === 0 || trimmed.length > 80) {
return fail(new InvalidTitleError(raw));
}
return ok(new Title(trimmed));
}
}Fíjate en que create devuelve un Result (también llamado Either): o un éxito con el valor, o un fallo con un error. Los errores esperados, como un mal título, forman parte del contrato de la operación, así que un Result los muestra en la firma y obliga a quien llama a decidir qué hacer. Las excepciones se reservan para fallos que nadie esperaba, como una base de datos caída.
El modelo de dominio anémico
Martin Fowler dio nombre a un antipatrón: el modelo de dominio anémico. Las clases de dominio solo guardan datos, y todas las reglas viven en servicios procedurales separados a su alrededor. Se pierde la idea central de la orientación a objetos, mantener juntos datos y reglas. El resultado habitual son reglas duplicadas y objetos que pueden terminar en un estado inválido.
Qué cruza la frontera
La entrada y la salida de un caso de uso son estructuras de datos simples, sin comportamiento, definidas por la propia capa de casos de uso. El controller convierte la solicitud en esa entrada, y el presenter convierte la salida en lo que la pantalla necesita. Así ni el framework ni la entidad se filtran al otro lado. Un caso de uso tampoco debe saber si lo llamó HTTP, una línea de comandos o una prueba: devuelve un resultado en términos de la aplicación, y cada adaptador lo traduce. Los códigos de estado, el SQL y console.log no le pertenecen.
Puertos para el tiempo y el azar
La hora actual y los valores aleatorios cambian en cada ejecución. El código que los lee directamente (new Date(), crypto.randomUUID()) no es determinista: no da la misma respuesta para la misma entrada. Por eso el caso de uso recibe un IdGenerator y un Clock como puertos. En las pruebas le damos versiones fijas, en producción las reales, y el caso de uso nunca cambia.
Un puerto pertenece al código que lo usa. Por eso NoteRepository se declara junto a los casos de uso, y PostgresNoteRepository lo implementa en una capa externa. Una prueba de CreateNote puede entonces usar un repositorio en memoria, un reloj falso y un generador de ids falso, sin servidor y sin base de datos. Corre en milisegundos y verifica solo la regla de la aplicación. El SQL, las rutas y el cableado los verifican las pruebas de cada adaptador.
Adaptadores de interfaz
Cuando viajas a otro país, puedes llevar un adaptador de enchufe para que tu cargador encaje en una pared distinta. La capa de adaptadores de interfaz hace el mismo trabajo con los datos. Está entre los casos de uso y el mundo exterior y convierte los datos de la forma mejor para casos de uso y entidades a la forma mejor para la web o la base de datos, y de vuelta. Aquí viven los controllers, los presenters y los gateways.
solicitud -> Controller -> dato de entrada -> Caso de uso
|
dato de salida
v
pantalla <- View <- view model <- Presenter| Pieza | Tarea | Qué no debe hacer |
|---|---|---|
| Controller | Recibe una solicitud externa, extrae los datos, llama al caso de uso, pasa el resultado | Decidir reglas de negocio, acceder a la base de datos, conocer detalles de presentación |
| Presenter | Convierte la salida del caso de uso en un view model: textos ya formateados, banderas, etiquetas | Decidir cuál es la regla de negocio |
| View | Copia los valores del view model a la pantalla o a la terminal | Tener lógica alguna |
| Gateway o repositorio | Traduce entre el formato de almacenamiento y las entidades | Ser importado por el caso de uso |
Un buen controller es delgado: simple de probar y simple de reemplazar. La misma salida de un caso de uso puede tener presenters distintos, por ejemplo uno para HTTP y otro para la línea de comandos.
El objeto humilde
Algo de código es difícil de probar, como dibujar píxeles en una pantalla. El patrón Humble Object divide el comportamiento en dos partes. La parte difícil de probar va a un objeto "humilde" con la menor lógica posible. Todo lo demás va a un objeto común, fácil de probar. En la arquitectura limpia la view es el objeto humilde, y el presenter es la parte comprobable. Como el presenter ya preparó cada texto y bandera, la view solo los copia.
Mantener el framework fuera
Si el controller recibiera el objeto de contexto del framework web, la capa de adaptadores pasaría a depender de un detalle de la capa externa. En cambio, los adaptadores declaran tipos simples de solicitud y respuesta. El framework convierte su contexto a ellos y llama al controller. El controller se puede probar entonces con objetos literales simples. Por la misma razón, ni la solicitud del framework ni una fila de la base de datos se pasan hacia adentro: eso haría que la capa interna conociera algo de la externa. El controller separa los campos y arma la estructura de entrada.
// adapters/http.ts: tipos propios, sin importar el framework
export type HttpRequest = { body: unknown; params: Record<string, string> };
export type HttpResponse = { statusCode: number; body: unknown };Traducir errores
Un caso de uso dice qué pasó con un error de dominio o de aplicación, sin nombrar ningún protocolo. Por ejemplo, RemoveNote devuelve NoteNotFound. Decidir que eso significa el estado HTTP 404 es trabajo del adaptador: una entrada inválida se vuelve 400, un recurso inexistente se vuelve 404, un fallo inesperado se vuelve 500. Un adaptador de línea de comandos traduciría los mismos errores de otra manera, como un mensaje y un código de salida.
Dos tipos de validación
Son dos comprobaciones, en lugares distintos. La validación sintáctica pregunta si la entrada externa tiene la forma esperada: ¿están los campos, son correctos los tipos? Ocurre en la frontera, donde se puede usar una biblioteca de esquemas como Zod. La validación de negocio protege las invariantes del dominio, por ejemplo que un título no esté en blanco y tenga como máximo 80 caracteres. Vive en las entidades y en los objetos de valor, para valer por cualquier camino de entrada, no solo por la web.
¿Dónde vive el repositorio?
La interfaz NoteRepository pertenece a la capa de casos de uso, que define lo que necesita. La implementación es un adaptador. En la división en capas que presenta Otávio Lemos, un adaptador que habla directamente con un driver o una biblioteca externa, como el cliente de PostgreSQL, se coloca en la capa más externa, donde importar la biblioteca está permitido. La capa de adaptadores de interfaz queda así libre de dependencias externas. El caso de uso nunca debe importar el archivo del adaptador, ese es el defecto que hay que evitar.
Frameworks, drivers y la raíz de composición
En una casa, las paredes y las habitaciones son el plano, pero los enchufes, las llaves de agua y la pintura son cosas que puedes cambiar. El círculo más externo de la arquitectura limpia guarda estas cosas reemplazables: el framework web, el driver de la base de datos, las bibliotecas de entrada y salida y el pequeño código de pegamento que las conecta con los adaptadores. Normalmente se escribe poco código propio allí. Martin llama detalles a este círculo, porque nada en él define qué hace el sistema, solo cómo se entrega y se guarda.
Aconseja mantener el framework "a un brazo de distancia". Un framework lo escriben personas que no conocen tu problema y no tienen compromiso con él. Si tus entidades y casos de uso extienden las clases base del framework, el compromiso es de un solo sentido: tu código depende del framework, y una versión nueva o un reemplazo significa reescribir el centro. Mantenido en el borde, detrás de adaptadores, el framework se usa sin que el sistema se case con él.
La raíz de composición
Alguien tiene que crear los objetos reales y conectarlos entre sí. Piensa en el electricista que conecta todo en una casa nueva: debe conocer todas las habitaciones, pero ninguna habitación necesita conocerlo a él. Ese lugar es la raíz de composición (composition root; Martin la llama Main). Es el punto de entrada del programa, el componente más sucio y más externo. Conoce todas las capas y todas las herramientas, así que nada puede depender de él. Concentrar el montaje en un solo lugar deja que el resto del código dependa solo de abstracciones.
main/ (raíz de composición)
lee la configuración
new PostgresNoteRepository(url) --+
new UuidGenerator() --+--> new CreateNote(...)
new SystemClock() --+
entrega el objeto listo a los puntos de entradaInyección por constructor
Con la inyección por constructor, una clase dice en su constructor qué necesita y recibe un objeto ya listo. Si en cambio la clase crea la dependencia con new, o la busca en un service locator (un estante global donde se buscan objetos), queda atada a una implementación y esconde ese vínculo de quienes la usan. Con la inyección, la dependencia es visible en la firma y se puede reemplazar en las pruebas.
// A: oculta, atada a PostgreSQL
private readonly notes = new PostgresNoteRepository();
// B: visible, reemplazable
constructor(private readonly notes: NoteRepository) {}El experimento del cambio
Una buena prueba de todo el diseño: añade una clase nueva SqliteNoteRepository que implemente el puerto. ¿Qué código existente debe cambiar para que la aplicación corra con ella? Si todo lo de adentro depende de puertos y el montaje está en fábricas de la raíz de composición, la respuesta es solo la raíz de composición: una fábrica lee la configuración al arrancar y elige la implementación. Si otro archivo tiene que cambiar, hay un acoplamiento indebido en algún lugar.
Configuración y secretos
Las variables de entorno, como MAX_NOTES, son un detalle del entorno de ejecución. Por eso se leen en el borde, por la raíz de composición, que las valida y convierte una sola vez y las pasa hacia adentro como valores simples por los constructores. Las capas internas nunca tocan process.env, y una prueba configura un caso de uso con solo pasar otro número.
La configuración que cambia según el entorno, y sobre todo los secretos como la contraseña de la base de datos, queda fuera del código y fuera del repositorio. El archivo .env real se lista en .gitignore. Un archivo como .env.example se sube al repositorio, con los nombres de las variables y valores obviamente falsos. Quien llega nuevo sabe qué configurar, y ninguna credencial queda en el historial.
ORMs
Un ORM (mapeador objeto-relacional) es una herramienta que convierte filas de tablas en objetos. Es un detalle de persistencia y pertenece al borde. Los decoradores en la entidad, o una entidad que hereda de una clase del ORM (el estilo Active Record), hacen que el centro dependa de la herramienta. La alternativa compatible es el estilo Data Mapper: un esquema separado en la capa externa y un repositorio que traduce entre filas y entidades.
↑ Volver al índiceMonolito frente a microservicios
Un monolito es como una lonchera grande: todo está en una sola caja y lo llevas todo junto. En el software, un monolito es un sistema entregado como una única unidad desplegable (algo que publicas completo). La palabra trata de cómo se empaqueta y se publica el sistema, no de cómo está organizado el código por dentro. Así, un monolito puede estar bien organizado, y un grupo de servicios puede ser un desorden.
Un microservicio es un servicio que se puede desplegar solo, es dueño de sus datos y se construye alrededor de una capacidad de negocio, como pagos o catálogo. Su tamaño en líneas de código no forma parte de la definición. La pregunta útil es: ¿puedo cambiar y publicar este servicio sin pedirles permiso a los demás?
| Característica | Monolito | Microservicios |
|---|---|---|
| Publicación | Una unidad, todo junto | Cada servicio por separado |
| Datos | Normalmente una base de datos compartida | Cada servicio tiene su propia base de datos |
| Llamadas entre partes | Llamadas de función locales | Llamadas de red, que pueden fallar o tardar |
| Bueno para | Simplicidad, cambios fáciles al comienzo | Equipos independientes y escalado independiente |
| Riesgo principal | Un ovillo de código enredado | Los costos de un sistema distribuido |
Una base de datos por servicio
Si dos servicios leen y escriben las mismas tablas, el esquema de la base de datos se vuelve un contrato entre ellos que nadie escribió. Cualquier cambio estructural debe acordarse y publicarse en conjunto, y eso anula el despliegue independiente. Por eso cada servicio guarda sus datos y los muestra a los demás solo mediante su API o mediante eventos.
Monolito modular y monolito distribuido
El monolito modular tiene un único despliegue con fronteras fuertes por dentro: cada módulo es un área de negocio, expone una interfaz pública y no deja que los demás alcancen sus detalles. Da buena parte del orden de los microservicios sin el costo de un sistema distribuido. Si algún día hay que extraer un servicio, la frontera ya existe.
El error opuesto es el monolito distribuido. El sistema se cortó en servicios, pero siguen fuertemente atados: deben publicarse juntos, comparten una base de datos o forman largas cadenas de llamadas síncronas. Tiene los problemas de los dos mundos. Una prueba práctica: ¿se puede publicar un servicio solo? Si cada publicación exige los seis servicios a la vez, en un orden fijo, es un monolito distribuido.
Monolito primero
El consejo asociado a Martin Fowler es empezar con un monolito. Al comienzo de un producto nadie sabe todavía dónde están las fronteras correctas. En un monolito, corregir una frontera equivocada es una refactorización local (reorganizar el código sin cambiar lo que hace). Entre servicios, la misma corrección significa cambiar contratos de red, migrar datos y coordinar publicaciones.
Cortar por negocio, no por capa
Los servicios deben cortarse por capacidad de negocio (o contexto delimitado), no por capa técnica. Una funcionalidad suele atravesar todas las capas: un campo en el formulario, una regla, una columna. Si los servicios son ui, business-logic y data-access, una funcionalidad cambia tres servicios y tres equipos. Si son catalogo, pedidos y pagos, lo que cambia junto queda junto.
Por capa (malo) Por negocio (mejor)
servicio-ui servicio-catalogo
servicio-reglas-negocio servicio-pedidos
servicio-acceso-datos servicio-pagos
servicio-enviosLa red no es una llamada de función
Una llamada local termina o falla junto con quien la hizo. Una llamada de red tiene un tercer resultado: desconocido. Si una solicitud agota el tiempo de espera, quizá el otro lado hizo el trabajo y solo se perdió la respuesta. Esto es el fallo parcial, y "la red es confiable" es una de las falacias clásicas de los sistemas distribuidos. Reintentar a ciegas puede cobrar dos veces una tarjeta. La defensa es hacer la operación idempotente (hacerla dos veces tiene el mismo efecto que hacerla una), por ejemplo enviando una clave de idempotencia en cada intento.
Las cadenas de llamadas síncronas también dañan la disponibilidad, porque la solicitud necesita a todos los servicios al mismo tiempo, así que las disponibilidades se multiplican. Con cada servicio disponible el 99 % del tiempo y fallos independientes:
- 1 servicio
- 99 %
- 2 en cadena
- 98.01 %
- 3 en cadena
- 97.03 %
- 6 en cadena
- 94.15 %
Para tres servicios la cuenta es 0,99 x 0,99 x 0,99 = 0,970299, cerca del 97,03 %. Para evitar cadenas largas, los equipos usan comunicación asíncrona, caché o respuestas degradadas.
Sagas
Hacer un pedido debe reservar existencias en un servicio y cobrar una tarjeta en otro, cada uno con su base de datos. No existe una transacción única para ambos. Una saga divide la operación en transacciones locales, una por servicio. Si un paso falla, los pasos anteriores se deshacen con acciones compensatorias: transacciones nuevas con el efecto contrario, como liberar la reserva o reembolsar el cobro. Entre el inicio y el final el sistema está temporalmente inconsistente. Esto se llama consistencia eventual.
↑ Volver al índiceArquitectura orientada a eventos y CQRS
En la escuela suena el timbre y todos saben qué hacer, aunque el timbre no hable con nadie uno por uno. En una arquitectura orientada a eventos, partes de un sistema anuncian que algo ocurrió, y otras partes reaccionan. Quien anuncia no necesita saber quién escucha, así que el acoplamiento es bajo.
¿Evento o comando?
Un evento es la noticia de un hecho que ya ocurrió, por eso se nombra en pasado: OrderPlaced. Quien lo publica no lo envía a nadie en particular. Un comando es una petición en imperativo: PlaceOrder. Va dirigido a un único manejador, que puede aceptarlo o rechazarlo. La diferencia está en la intención del mensaje, no en el transporte.
¿Tópico o cola?
| Característica | Tópico publish/subscribe | Cola punto a punto |
|---|---|---|
| ¿Quién recibe el mensaje? | Cada suscriptor recibe su propia copia | Solo uno de los consumidores que compiten |
| Se usa para | Avisar a varias partes del mismo hecho | Repartir trabajo |
| Añadir más consumidores | Más reacciones al mismo evento | Más rendimiento |
Garantías de entrega e idempotencia
Los brokers (los programas que transportan mensajes) suelen prometer entrega al menos una vez (at-least-once): ningún mensaje se pierde, pero algunos pueden llegar más de una vez, por ejemplo cuando la confirmación del consumidor no llega al broker. Por eso el consumidor debe ser idempotente: procesar un mensaje dos veces debe dar el mismo resultado que una. La técnica habitual es guardar el identificador de cada evento procesado e ignorar los repetidos.
async function onOrderPaid(event: OrderPaid) {
// Sin la comprobación, un evento repetido suma los puntos dos veces
if (await alreadyProcessed(event.eventId)) return;
await addPoints(event.customerId, event.points);
await markProcessed(event.eventId);
}El orden en un log particionado
Convención: un log particionado como Apache Kafka. Un tópico tiene varias particiones, y la clave del mensaje elige la partición. La garantía de orden vale por partición, no para todo el tópico. Para mantener en orden los eventos de una cuenta (AccountOpened, MoneyDeposited, AccountClosed), dales a todos la misma clave, como el id de la cuenta, para que vayan a la misma partición. Los eventos de cuentas distintas se procesan en paralelo, sin un orden definido entre ellos.
¿Coreografía u orquestación?
En una coreografía, los servicios reaccionan a los eventos de los demás y ninguno conoce el flujo completo, como bailarines que se miran entre sí. En una orquestación, un coordinador central le dice a cada servicio qué hacer y en qué orden, como un director de orquesta. La coreografía baja el acoplamiento, pero hace que el flujo sea difícil de ver y depurar. La orquestación mantiene el flujo visible, al costo de un componente que conoce a todos.
El problema de la doble escritura y el outbox
Supón que un programa guarda un pedido en la base de datos y después publica OrderPlaced en el broker. Son dos sistemas y dos pasos separados, así que no son atómicos: el programa puede caerse entre ellos y dejar un pedido guardado pero nunca anunciado. El patrón transactional outbox lo resuelve. El evento se guarda en una tabla de la misma base de datos, en la misma transacción que el cambio de estado. Un relay lee esa tabla y publica en el broker. Como el relay puede publicar la misma fila dos veces, la entrega es al-menos-una-vez, y los consumidores deben ser idempotentes.
una transacción después, en segundo plano
+--------------------+ +-------+ +--------+
| INSERT pedido | | relay | ----> | broker |
| INSERT fila outbox | -------> | lee la| +--------+
+--------------------+ | tabla |
+-------+CQRS
CQRS (Command Query Responsibility Segregation) separa el modelo que cambia el estado (comandos) del modelo que lo lee (consultas). Eso es todo lo que exige el patrón. El event sourcing, las bases de datos separadas, la mensajería y los microservicios son elecciones adicionales que aumentan la ganancia en algunos casos y el costo en todos. En un CRUD simple (crear, leer, actualizar, borrar), donde las lecturas y las escrituras tienen la misma forma, la separación es complejidad sin retorno.
Si el modelo de lectura se actualiza de forma asíncrona a partir de eventos, queda desactualizado por un corto tiempo después de cada escritura. Un usuario renombra un proyecto, el comando tiene éxito, y la lista cargada justo después aún muestra el nombre antiguo. Segundos más tarde muestra el nuevo. Es el problema de read-your-own-writes (leer lo que uno mismo escribió). Salidas comunes: mostrar el valor que devolvió el propio comando, enviar la siguiente lectura al modelo de escritura, o esperar hasta que la proyección alcance la versión escrita.
↑ Volver al índiceFundamentos de domain-driven design
Imagina a una doctora y un programador que deben construir un sistema para un hospital. Si la doctora dice "historia clínica" y el código del programador dice Record, se van a malentender una y otra vez. El domain-driven design (DDD), de Eric Evans, es una manera de construir software alrededor de una comprensión profunda del dominio, el área de negocio de la que trata el software.
El lenguaje ubicuo es el vocabulario que desarrolladores y expertos del dominio construyen juntos y usan en todas partes: reuniones, documentos, pruebas y código. Si el experto dice "póliza" y el código dice Contract, cada conversación necesita una traducción, y los malentendidos se cuelan. El lenguaje vale dentro de un contexto delimitado.
Diseño estratégico: el mapa general
Un contexto delimitado (bounded context) es la frontera dentro de la cual un modelo y sus palabras tienen un único significado. En una tienda en línea, ventas puede usar "producto" para un artículo con precio y descripción, y envíos puede usar "producto" para un paquete con peso y tamaño. Forzar un solo modelo para toda la empresa produce clases hinchadas y palabras confusas. El DDD acepta varios modelos, cada uno coherente en su contexto, y deja explícitos los vínculos entre ellos.
El mapa de contextos muestra cómo se relacionan los contextos y quién tiene poder sobre quién. Cuando dependes de un contexto que no puedes influir y cuyo modelo no encaja con el tuyo, como un sistema de facturación heredado, construyes una capa anticorrupción. Traduce entre los dos modelos en la frontera. Cuesta trabajo escribirla y mantenerla, pero tu modelo nuevo no hereda los problemas del viejo.
El diseño estratégico también divide el dominio en subdominios y decide dónde gastar esfuerzo. Una startup de logística que gana por su optimización de rutas debe poner ahí a sus mejores personas y el modelado más cuidadoso.
| Subdominio | Qué es | Qué hacer |
|---|---|---|
| Dominio central (core) | Lo que diferencia a la empresa (optimización de rutas) | Las mejores personas, modelado cuidadoso |
| De apoyo (supporting) | Necesario, pero propio de la empresa y sin ser un diferenciador | Soluciones simples |
| Genérico | Funciona igual en cualquier empresa (autenticación, facturas) | Comprar o reutilizar |
Diseño táctico: las piezas dentro de un contexto
Una entidad se define por una identidad continua, no por los valores de sus atributos. Un Customer puede cambiar de dirección y de correo con los años y sigue siendo el mismo cliente, así que dos entidades se comparan por su identificador. Un objeto de valor se define solo por sus valores: no tiene identificador, es inmutable (nunca cambia después de construido) y se compara campo por campo. Money (un monto y una moneda) es el ejemplo clásico. Las operaciones sobre él devuelven un objeto nuevo, lo que evita efectos secundarios cuando se comparte una instancia.
Un agregado es un grupo de objetos tratado como una unidad de consistencia, con una entidad raíz como única puerta de entrada. Para un Order con sus objetos OrderItem, la raíz es Order. Los objetos de afuera guardan una referencia solo a la raíz, preferiblemente por identificador. Cada transacción modifica un agregado, lo que mantiene las reglas simples de garantizar y las transacciones cortas.
Agregado Order
+---------------------------------+
| Order (raíz) |
| +-- OrderItem |
| +-- OrderItem |
| +-- ShippingAddress |
+---------------------------------+
^
| solo la raíz se referencia desde afuera
Customer (otro agregado, por id)El repositorio le da al dominio la ilusión de una colección de agregados en memoria: añadir, buscar por identidad, quitar. Hay un repositorio por raíz de agregado. Los objetos internos se cargan y se guardan junto con la raíz, y cuántas tablas haya detrás es un detalle de implementación. Si un modelo tiene los agregados Order y Customer, tiene dos repositorios, aunque los datos usen cuatro tablas.
Un servicio de dominio aloja una operación de negocio que no es la tarea natural de ninguna entidad u objeto de valor, normalmente porque involucra a varios, como una comisión de transferencia que depende de dos cuentas. No guarda estado y habla el lenguaje ubicuo. El servicio de aplicación está una capa más arriba: coordina el caso de uso, la transacción y los repositorios, sin regla de negocio propia.
Un evento de dominio registra algo relevante para el negocio que ya ocurrió: pedido realizado, pago confirmado. Como describe el pasado, es inmutable y tiene un nombre en pasado. Otras partes pueden reaccionar a él sin que quien lo emite las conozca, lo que baja el acoplamiento entre agregados y contextos.
↑ Volver al índiceTrade-offs y registros de decisiones de arquitectura
Cuando preparas la mochila del colegio, una mochila grande carga más, pero pesa más. No existe la mochila perfecta. De la misma manera, un dicho conocido en arquitectura es que no hay mejores prácticas, solo trade-offs. Toda decisión renuncia a algo: más flexibilidad suele costar más indirección, y más rendimiento suele costar facilidad de mantenimiento. Una solución no es buena o mala en sí, es adecuada o no para un conjunto de requisitos y límites. El trabajo del arquitecto es dejar explícito lo que se gana y lo que se pierde.
Mantén las opciones abiertas
Robert Martin aconseja que una buena arquitectura te permita posponer las decisiones sobre los detalles, como la base de datos o el framework, porque cuanto más tarde decides, más sabes. El último momento responsable es el punto después del cual no decidir empieza a costar más que decidir. Posponer no es no hacer nada: es construir de modo que el detalle se pueda elegir y cambiar más tarde. Si el equipo aún no sabe cuántas lecturas y escrituras tendrá el sistema, oculta la base de datos detrás de un puerto y elige cuando lleguen los hechos.
Puertas de dos sentidos y puertas de un solo sentido
Algunas decisiones son como una puerta por la que puedes pasar y volver: una puerta de dos sentidos (two-way door). Elegir una biblioteca para formatear fechas, escondida detrás de una función propia, es una. Equivocarse es barato, así que decide rápido y aprende probando. Otras decisiones son una puerta de un solo sentido (one-way door): difíciles de revertir. El formato de los identificadores de una API pública usada por miles de clientes es una. Esas merecen análisis, un prototipo y un registro escrito. Lo que define el tipo es el costo de volver atrás, y un buen aislamiento convierte muchas decisiones en puertas de dos sentidos.
| Característica | Puerta de dos sentidos | Puerta de un solo sentido |
|---|---|---|
| Costo de un error | Pequeño | Grande |
| Cómo decidir | Rápido, luego aprender | Analizar, hacer un prototipo, escribir un ADR |
| Ejemplo | Una biblioteca de formato detrás de una función | El formato de los identificadores de una API pública |
Registros de decisiones de arquitectura
Años después, nadie recuerda por qué el sistema es como es. Un registro de decisión de arquitectura (ADR) es un documento corto que registra una decisión arquitectónica y la razón de ella. El formato de Michael Nygard tiene un título, un estado (propuesto, aceptado, reemplazado), un contexto, la decisión y sus consecuencias.
# ADR 7: Usar PostgreSQL en el servicio de pedidos
Estado: aceptado
Contexto: los pedidos necesitan transacciones; evaluamos MongoDB.
Decisión: usaremos PostgreSQL.
Consecuencias: consistencia fuerte (bueno); escalar las
escrituras en horizontal es más difícil (desventaja aceptada).Un ADR útil muestra el intercambio: qué opciones se consideraron, por qué se rechazaron las otras y qué consecuencias negativas aceptó el equipo. Un registro que lista solo ventajas es publicidad, no documentación. Las desventajas aceptadas permiten que alguien, más adelante, note que el contexto cambió y que la decisión debe revisarse.
Los ADR son solo de adición (append-only), como un diario. Cuando una decisión cambia, por ejemplo al pasar de PostgreSQL a otra base de datos, no editas ni borras el registro viejo. Escribes un ADR nuevo con el nuevo contexto, y el viejo recibe el estado reemplazado (superseded), con una referencia al nuevo. El historial muestra entonces lo que se sabía en cada momento.
No toda decisión necesita un ADR. Escribe uno para decisiones estructuralmente importantes y difíciles de revertir: el estilo arquitectónico, las fronteras entre módulos o servicios, el modelo de persistencia, los protocolos entre las partes. Las decisiones locales y baratas de deshacer ya las registran el código y el historial de commits. Registrar todo entierra las decisiones que importan.
Sobreingeniería
Las capas, los puertos y la inversión de dependencias son un intercambio: añaden indirección (pasos extra entre dos cosas) para abaratar los cambios futuros y proteger las reglas de negocio. Un script que una persona ejecuta una vez al mes, con una implementación de cada interfaz y sin plan de tener otra, paga el costo y nunca recibe el beneficio. Usar todos los elementos de la arquitectura limpia sin necesidad es sobreingeniería (overengineering).
Mira el video: Visualising software architecture with the C4 model (Simon Brown)↑ Volver al índiceCómo se conectan todas las ideas
Todos estos estilos responden a la misma pregunta: ¿cómo mantener a salvo las reglas que importan de los detalles que cambian? Las capas cortan el programa por niveles. El hexágono y la arquitectura limpia van más lejos y dan vuelta a las dependencias hacia las reglas. Los microservicios aplican el mismo razonamiento a despliegues enteros, y los eventos dejan que las partes se comuniquen sin conocerse. El DDD te dice dónde deben estar las fronteras, y los ADR recuerdan por qué las elegiste.
| Idea | En una línea | Qué cuesta |
|---|---|---|
| Capas | Cada capa usa solo la de abajo | Métodos que solo reenvían, acoplamiento a las capas inferiores |
| Hexagonal | La aplicación declara puertos, los adaptadores se conectan | Más interfaces y archivos |
| Arquitectura limpia | Las dependencias en el código apuntan solo hacia adentro | Indirección, traducción extra de datos |
| Monolito modular | Un despliegue, fronteras internas fuertes | Disciplina para mantener las fronteras |
| Microservicios | Despliegue independiente y datos propios | Fallos de red, consistencia eventual |
| Eventos y CQRS | Reaccionar a hechos, separar lecturas de escrituras | Duplicados, lecturas desactualizadas, depuración más difícil |
| DDD | Modelar el negocio con un lenguaje compartido | Tiempo con los expertos del dominio |
Errores comunes
- Creer que los nombres de las carpetas o el número de círculos hacen limpia a una arquitectura. Lo hace la regla de dependencia.
- Confundir el orden de las llamadas en tiempo de ejecución con la dirección de los imports.
- Dividir en servicios antes de entender el dominio y terminar con un monolito distribuido.
- Poner códigos de estado, SQL o tipos del framework dentro de un caso de uso o de una entidad.
- Usar "rápido" o "seguro" como requisito sin un escenario medible.
- Usar eventos sin hacer idempotentes a los consumidores.
- Escribir un ADR que esconde las desventajas, o editar un ADR viejo en lugar de reemplazarlo.