Domain-Driven Design: Layered Architecture para organizar el código y separar responsabilidades
Aprende a distribuir responsabilidades entre Presentation, Application, Domain e Infrastructure. Recorre un endpoint completo con TypeScript y Express, distingue llamadas de dependencias y utiliza reglas claras para revisar tanto tu código como el generado por IA.
Una tienda tiene un endpoint para confirmar pedidos. El controlador valida el JSON, consulta la base de datos, comprueba que haya artículos, cambia el estado y devuelve una respuesta. Todo funciona hasta que otra integración necesita confirmar pedidos sin pasar por HTTP.
El equipo copia las comprobaciones. Más tarde cambia una regla y solo actualiza uno de los dos recorridos. Ahora un pedido vacío se rechaza desde el panel, pero se confirma desde la integración.
El problema no es la cantidad de líneas del controlador. Es que las reglas, la coordinación y los detalles técnicos están mezclados. Layered Architecture permite distribuir esas responsabilidades y establecer cómo colaboran, de modo que una nueva entrada no necesite reconstruir el comportamiento del negocio.
Qué organiza Layered Architecture
Una layer agrupa responsabilidades relacionadas y define qué servicios ofrece a otras partes del sistema. En DDD se distinguen tradicionalmente Presentation, también llamada User Interface, Application, Domain e Infrastructure. El propósito central es aislar el modelo y sus reglas de los detalles de interfaz y soporte técnico. Evans desarrolla esta separación en la sección Layered Architecture de su referencia de DDD.
Son separaciones del código, no necesariamente procesos o servidores distintos. Puedes mantener las cuatro layers en una sola aplicación Node.js y desplegarla como una unidad. Tampoco corresponden a etapas que cada dato deba recorrer obligatoriamente: leer un listado puede requerir un recorrido distinto de ejecutar una operación con reglas.
Presentation: adaptar la interacción
Presentation recibe entradas y presenta resultados. En un backend HTTP, incluye rutas y controladores: interpreta parámetros, valida la forma de la petición y selecciona la respuesta HTTP. No decide si un pedido puede confirmarse.
Application: coordinar una operación
Application implementa los Use Cases, las operaciones que ofrece el sistema. Recupera información, invoca comportamiento del Domain y coordina el guardado. Puede establecer el límite de una transacción local, cuando la operación lo requiere. No necesita conocer Request, Response ni sentencias SQL.
Domain: decidir según las reglas
Domain expresa conceptos y comportamiento del negocio. En nuestro ejemplo decide si un pedido puede confirmarse. Esa regla debe seguir aplicándose aunque la operación provenga de HTTP, una tarea programada o una prueba.
Infrastructure: resolver mecanismos técnicos
Infrastructure implementa acceso a almacenamiento y servicios externos. Sabe ejecutar una consulta o una petición HTTP; no debe convertirse en el único lugar donde se decide si el negocio permite una acción.
La ubicación exacta de algunos contratos puede variar. Lo importante es poder explicar qué decisión corresponde a cada responsabilidad y qué dependencias introduce tu elección.
Una llamada no es lo mismo que una dependencia
Al ejecutar un Use Case, Application necesita recuperar datos. Eso no obliga a importar directamente un cliente de base de datos.
En este artículo elegiremos un contrato de Repository definido en Domain y una implementación en Infrastructure. Un Repository ofrece recuperar y guardar el modelo; no profundizaremos aquí en ese patrón. Application conoce el contrato y recibe una implementación desde fuera.
El flujo de ejecución será:
Petición HTTP
-> Presentation: validar y adaptar
-> Application: coordinar ConfirmOrder
-> Infrastructure: cargar mediante el contrato de Repository
-> Domain: ejecutar Order.confirm()
-> Infrastructure: guardar mediante el mismo contrato
<- Presentation: traducir el resultado a HTTP
Las dependencias del código serán diferentes:
Presentation -> Application
Application -> Domain
Infrastructure -> Domain
server.ts conecta las implementaciones concretas.
Domain no importa Express, Application ni Infrastructure.
Una flecha del segundo diagrama significa «importa o conoce tipos definidos en». Infrastructure implementa un contrato de Domain; Application puede invocar esa implementación durante la ejecución sin importar su clase concreta. Esto se conoce como Dependency Inversion.
Esta es la variante elegida para aislar el Domain en el ejemplo. Layered Architecture no determina por sí sola un único grafo de imports. Algunas organizaciones usan dependencias descendentes más directas; otras invierten dependencias técnicas. Por eso «tenemos cuatro carpetas» no describe todavía las reglas arquitectónicas.
Antes: todas las decisiones en una ruta
Este fragmento parcial utiliza un objeto database ficticio. Ilustra el problema; no es un servidor ejecutable:
La regla solo existe dentro de esa ruta. Probarla exige atravesar Express o extraerla artificialmente. Cambiar el almacenamiento también afecta al controlador.
La transformación será progresiva: extraer la regla, crear un Use Case, introducir el contrato de persistencia y dejar en la ruta únicamente la adaptación HTTP. Cada paso debe conservar o comprobar el comportamiento esperado.
Las reglas elegidas son sencillas: un pedido vacío o cancelado no puede confirmarse. Confirmar de nuevo un pedido ya confirmado devuelve su estado sin crear otro efecto. No modelaremos pagos, stock ni notificaciones.
La validación se reparte por propósito. Presentation exige un objeto vacío; Domain comprueba las condiciones del pedido; Application distingue un identificador inexistente de una operación rechazada. Un formato JSON correcto no implica una acción válida para el negocio.
El siguiente proyecto didáctico incluye todos sus archivos. Usa Node.js 24.21.0 y Express 4.x. El almacenamiento en memoria representa Infrastructure, pero no conserva datos al reiniciar ni ofrece coordinación entre procesos. No incluye autenticación: escucha solo en la interfaz local y no debe exponerse como servicio real sin controles de acceso.
Node.js ejecuta directamente la sintaxis TypeScript utilizada a continuación mediante type stripping, que elimina anotaciones de tipos. Esto no realiza comprobación estática de tipos. Para comprobar tipos con tsc necesitarías configurar TypeScript y sus declaraciones correspondientes; este tutorial se limita a ejecución y pruebas de comportamiento.
1. Domain protege la regla
Archivo domain/order.ts:
exporttypeOrderSnapshot = {
id: string;
itemCount: number;
status: 'draft' | 'confirmed' | 'cancelled';
};
exportclassOrderRuleErrorextendsError {}
exportclassOrder {
#state: OrderSnapshot;
constructor(state: OrderSnapshot) {
if (!Number.isSafeInteger(state.itemCount) || state.itemCount < 0) {
thrownewOrderRuleError('Cantidad de artículos inválida');
}
if (state.status === 'confirmed' && state.itemCount === 0) {
thrownewOrderRuleError('Un pedido confirmado no puede estar vacío');
}
this.#state = { ...state };
}
confirm(): void {
if (this.#state.status === 'cancelled') {
thrownewOrderRuleError('Un pedido cancelado no puede confirmarse');
}
if (this.#state.itemCount === 0) {
thrownewOrderRuleError('Un pedido vacío no puede confirmarse');
}
this.#state.status = 'confirmed';
}
snapshot(): OrderSnapshot {
return { ...this.#state };
}
}
El constructor recibe datos confiables de nuestra demostración. Si procedieran de almacenamiento externo, el adaptador tendría que validar también su estructura y los valores admitidos antes de reconstruir el modelo. Los tipos no validan datos en tiempo de ejecución.
Application transforma un rechazo conocido en un resultado explícito. No responde 409: ese código pertenece a HTTP. Un fallo inesperado de persistencia se propaga; no se presenta como si el pedido hubiera incumplido una regla.
Guardar copias evita que modificar un objeto recuperado cambie el almacenamiento antes de llamar a save. Cambiar esta implementación por SQL no debería obligar a reescribir Order.confirm().
Eso no significa que cualquier sustitución sea automática: la implementación real debe sostener el contrato, tratar fallos y proteger conflictos entre escrituras. Este Repository en memoria no demuestra esas garantías.
La ruta valida la forma de la entrada, pero no vuelve a contar artículos. El mapeo HTTP está definido en un único lugar. Usamos try/catch y next(error) porque en Express 4 los rechazos asíncronos necesitan propagarse explícitamente al error handler; la documentación de Express distingue este comportamiento del de Express 5.
Este punto de ensamblaje se llama Composition Root: conoce las implementaciones concretas y las conecta. Pasarlas por el constructor es Dependency Injection; no requiere un framework.
Ejecuta el servidor:
node server.ts
Desde otra terminal puedes enviar la petición. En Windows usa curl.exe si curl corresponde a un alias de PowerShell:
Con order-empty obtendrás 409; con un identificador inexistente, 404. Repetir la confirmación de order-1 mantiene confirmed. Esta repetición no crea otro efecto en el ejemplo; si añades cobros o notificaciones, tendrás que diseñar explícitamente cómo evitar efectos duplicados.
La primera comprueba una regla sin Express ni almacenamiento. La segunda comprueba coordinación y guardado sin HTTP. La tercera recorre el endpoint con el adaptador en memoria. Las tres pruebas se ejecutaron y pasaron con Node.js 24.21.0 y Express 4.22.3. No se realizó comprobación estática con TypeScript ni una prueba contra una base de datos real: al introducirla, necesitarás comprobar también su integración.
La separación no reduce por sí sola el número de errores. Permite localizar decisiones y comprobarlas con dependencias proporcionales a cada prueba.
Otros escenarios: qué cambia y qué se reutiliza
Educación: el mismo Use Case desde una tarea programada
Una academia habilita certificados desde un endpoint y desde una tarea nocturna. Presentation adapta la petición web; la tarea constituye otra entrada. Ambas pueden invocar el mismo Use Case, que obtiene la evidencia y pide al Domain decidir si corresponde emitir.
El aprendizaje aquí es separar entrada de operación. La tarea no debe fabricar un objeto Request para reutilizar la ruta. Tampoco debe copiar la regla de aprobación. El Use Case recibe los datos que necesita, no objetos del framework.
Logística: sustituir una integración sin copiar su modelo
Un proveedor representa entregas con códigos externos. Infrastructure implementa una Anticorruption Layer, o ACL, que valida su respuesta HTTP y la traduce al contrato que necesita el consumidor. Application coordina la consulta; Domain evalúa qué significa esa evidencia para el proceso propio.
Si el proveedor no responde, no conviertas el fallo en «entrega pendiente» como si fuera un hecho confirmado. El contrato debe distinguir ausencia de evidencia e indisponibilidad técnica.
Este caso enseña a separar traducción e interpretación del negocio. Si hay varios Bounded Contexts, límites donde cada modelo mantiene un significado consistente, cada uno conserva sus implementaciones: la integración del ejemplo ocurre por ACL HTTP con contratos del consumidor, sin importaciones entre contextos. HTTP no comparte la transacción local del Repository.
Cómo refactorizar sin trasladar la mezcla a otras carpetas
Empieza por escenarios que describan el comportamiento actual y las reglas acordadas. Extrae una regla concreta hacia Domain y haz que la ruta la invoque. Después introduce Application para coordinar carga, operación y guardado. Finalmente separa los detalles de almacenamiento y conecta las implementaciones en el Composition Root.
Mover un controlador entero a application/service.ts conserva la mezcla si sigue usando req, res y SQL. Del mismo modo, un Domain que importa Express para lanzar errores HTTP mantiene una dependencia del transporte aunque esté en otra carpeta.
Define reglas de imports que puedas revisar. En el ejemplo, Domain no importa otras layers; Application no importa Express ni implementaciones de Infrastructure. Puedes empezar comprobándolo en revisión y automatizarlo cuando el tamaño del proyecto lo justifique.
Layered, Hexagonal, Onion y Clean Architecture
Comparten objetivos de separación, pero destacan criterios distintos:
Layered Architecture organiza responsabilidades en layers y define cómo colaboran. El aislamiento concreto depende también de sus reglas de dependencias.
Hexagonal Architecture, descrita por Alistair Cockburn, distingue el interior de la aplicación de sus interacciones externas mediante ports y adapters: contratos de interacción e implementaciones que conectan tecnologías concretas.
Onion Architecture, presentada por Jeffrey Palermo, coloca el modelo en el centro y dirige las dependencias hacia el interior, dejando detalles técnicos fuera.
Clean Architecture, explicada por Robert C. Martin, separa políticas y detalles mediante límites y una regla de dependencias hacia las políticas internas.
Nuestro contrato de Repository y el ensamblaje externo son compatibles con varias de estas ideas. No convierten automáticamente el proyecto en una implementación completa de todas ellas. Para discutir el diseño, resulta más útil mostrar responsabilidades e imports permitidos que elegir una etiqueta.
Cuándo conviene mantenerlo más simple
Un catálogo interno con dos campos y operaciones CRUD —crear, consultar, actualizar y eliminar— puede resolverse con una ruta pequeña, validación y acceso a datos claramente separado. Crear una clase que solo reenvíe cada llamada a otra clase aporta poco si no existe una responsabilidad distinta.
Layered Architecture implica archivos, contratos y decisiones de ensamblaje. Ese coste se justifica cuando necesitas proteger reglas, reutilizar operaciones o aislar tecnologías. No obliga a utilizar un modelo con comportamiento para cada lectura ni un Repository genérico para todas las tablas.
Prompts para orientar y revisar trabajo con IA
Analizar responsabilidades mezcladas
Analiza este controlador de confirmación. Clasifica validación HTTP, coordinación, reglas del pedido y persistencia. No cambies archivos. Devuelve evidencias y una propuesta mínima de extracción que conserve las respuestas actuales. No añadas frameworks ni reglas de negocio. Una persona confirmará las reglas y revisará qué escenarios deben protegerse antes de refactorizar.
Implementar una separación acotada
Refactoriza la operación con Presentation, Application, Domain e Infrastructure. Domain no importa Express ni infraestructura; Application depende del contrato de Repository; la implementación concreta se conecta fuera. Mantén el formato de respuesta acordado. No añadas autenticación, colas ni nuevas dependencias al alcance. Entrega el cambio y pruebas de pedido vacío, cancelado y confirmación repetida. Una persona ejecutará las pruebas y revisará la conservación del contrato HTTP.
Revisar límites y garantías
Revisa el diff contra estas reglas de imports y responsabilidades. Busca lógica duplicada en Presentation, HTTP en Domain, implementaciones técnicas importadas desde Application y supuestos de transacción compartida por HTTP. No edites archivos. Reporta cada hallazgo con evidencia y efecto observable; separa dudas de incumplimientos. Una persona verificará las garantías del almacenamiento y los escenarios concurrentes que el ejemplo en memoria no cubre.
Un buen prompt puede hacer explícitos los límites. La revisión humana debe comprobar que esos límites existen en el código y que la conducta sigue siendo la acordada; una estructura de carpetas convincente no demuestra ninguna de las dos cosas.
Tres ejercicios con solución razonada
1. Cambiar el error HTTP
El producto quiere responder 422 en lugar de 409 cuando no se puede confirmar. ¿Debe cambiar Order.confirm()?
Solución razonada: en el diseño del ejemplo cambia Presentation, donde se traduce rejected. Domain conserva la regla y Application su resultado. Las pruebas HTTP se actualizan para el contrato nuevo; las de la regla deberían seguir pasando. Cambiar una respuesta pública requiere acordar su impacto con consumidores.
2. Reutilizar una confirmación desde una importación
Una importación administrativa propone llamar al controlador simulando req y res. ¿Qué alternativa usarías?
Solución razonada: que la entrada de importación valide su propio formato e invoque ConfirmOrder.execute. Así reutiliza coordinación y reglas sin depender del transporte HTTP. Si necesita permisos o límites propios, deben definirse explícitamente; reutilizar el Use Case no equivale a autorizar cualquier llamada.
3. Dos personas editan el mismo pedido
Una confirma y otra cancela basándose en el mismo estado leído. El equipo afirma que las layers evitan el conflicto. ¿Es suficiente?
Solución razonada: no. La separación no coordina escrituras. Hay que definir el resultado permitido y aplicar control de concurrencia en la persistencia, por ejemplo mediante versión y escritura condicionada dentro de una transacción local. El Use Case debe tratar el conflicto; la prueba necesita reproducirlo contra el almacenamiento correspondiente.
Cómo reconocer una separación útil
Comprueba si puedes explicar quién recibe la entrada, quién coordina, quién decide y quién resuelve el acceso técnico. Después verifica si una regla puede probarse sin servidor y si otra entrada puede ejecutar el Use Case sin copiar comportamiento.
Cuando esas respuestas coinciden con los imports y las pruebas, las layers están aportando una separación real. Si solo cambian los nombres de las carpetas, vuelve a una operación concreta y revisa sus responsabilidades: ahí empieza una organización del código que puedes mantener y comprobar.
Descubre cómo un Domain Model guía las decisiones del código y en qué se diferencia de un Data Model. Con ejemplos en TypeScript, aprende a representar reglas, reconocer la responsabilidad de los patrones de DDD y dar a la IA criterios concretos para implementar y revisar cambios.
Construye un lenguaje compartido que permita tomar decisiones. Exploraremos reservas, logística y educación para detectar ambigüedades, escribir ejemplos precisos y hacer evolucionar nombres, contratos y pruebas con ayuda de la IA.
Aprende a traducir reglas de negocio en software con Domain-Driven Design. Recorre sus conceptos fundamentales mediante ejemplos en TypeScript y descubre cómo usarlos para orientar y revisar código generado por IA, más allá de que compile o responda correctamente una petición.