Domain-Driven Design: Proyecto práctico, construyendo una aplicación completa de principio a fin
Integra la colección construyendo una API de pedidos con TypeScript, Express, Zod y SQLite. Incluye los archivos del proyecto, reglas del dominio, eventos, control de versiones, pruebas con Vitest y comandos para ejecutarla y verificarla localmente.
Una tienda necesita dejar de gestionar pedidos mediante cambios manuales en una tabla. Quiere crear borradores, confirmarlos cuando tengan productos y cancelar los que todavía no se han confirmado. También necesita saber qué confirmaciones se registraron, incluso después de reiniciar la aplicación.
Vamos a construir esa aplicación local: una API HTTP, un modelo de dominio, persistencia SQLite, procesamiento de eventos y pruebas. El alcance es un backend educativo completo para esas operaciones; no una tienda con pagos, cuentas de usuario y catálogo.
Los archivos siguientes forman una propuesta nueva y autocontenida. No proceden del repositorio de Código con criterio. No se han instalado sus dependencias ni ejecutado su compilación o sus pruebas durante la preparación del artículo. Se incluyen las verificaciones para reproducirlo y comprobarlo localmente, sin atribuirles resultados que todavía no existen.
Imaginemos una conversación con la persona que administra los pedidos:
—¿Confirmar significa que el cliente ya pagó?
—En este ejercicio significa que el pedido queda cerrado para su preparación. El pago pertenece a otro proceso.
—¿Podemos confirmar un pedido vacío?
—No. Puede guardarse vacío como borrador, pero necesita productos antes de confirmarse.
—¿Qué ocurre si alguien vuelve a confirmar?
—Se rechaza. No queremos que una nueva solicitud represente una segunda confirmación.
El resultado del taller reducido es este acuerdo específico del proyecto:
Un pedido nace en DRAFT y contiene entre cero y veinte líneas.
Cada línea identifica un producto mediante un texto no vacío de hasta ochenta caracteres y una cantidad entera entre uno y cien.
No se repite el mismo identificador de producto en dos líneas. Se eliminan espacios exteriores antes de compararlos.
Solo DRAFT puede pasar a CONFIRMED y debe contener al menos una línea.
Solo DRAFT puede pasar a CANCELLED. Un borrador vacío también puede cancelarse.
CONFIRMED y CANCELLED son estados finales para este MVP.
La confirmación repetida se rechaza; no se interpreta como éxito idempotente.
La versión persistida detecta escrituras basadas en un estado obsoleto.
Cada confirmación aceptada genera un evento y un registro consultable en la misma transacción.
Son decisiones del ejemplo, no reglas universales de comercio electrónico. El vocabulario acordado evita mezclar «confirmado», «pagado» y «despachado».
El proceso puede representarse así:
Operador -> Crear pedido -> DRAFT
|
+-----------+-----------+
| |
Confirmar con líneas Cancelar
| |
CONFIRMED CANCELLED
|
OrderConfirmed
|
Registro local de confirmación
No implementaremos edición de líneas, precios, pago, disponibilidad ni despacho. Se crea un borrador con sus líneas definitivas para mantener el proyecto pequeño. Eso no deja incompleta ninguna de las operaciones prometidas.
2. Contextos y decisiones del MVP
Identificamos Sales como dueño del pedido. Catalog sería dueño de la descripción comercial del producto; Inventory, de la disponibilidad; Billing, de facturación. En esta aplicación solo implementamos Sales: productId es una referencia y no una importación de entidades ajenas.
Los otros contextos son candidatos del descubrimiento, no servicios que debamos crear. No consultaremos su base de datos ni fingiremos que conocemos sus reglas. El contrato de confirmación que produciremos permite discutir una futura integración, sin implementar consumidores imaginarios.
Usamos una estructura preparada para un monolito modular porque no necesitamos despliegues ni transacciones distribuidas. La responsabilidad de Sales justifica un modelo explícito; autenticación, correo y otros servicios genéricos quedan fuera del ejercicio. No necesitamos Shared Kernel, un bus genérico ni un contenedor de inyección para resolver esta operación.
La decisión de consistencia es local y verificable:
HTTP -> aplicación -> Order
|
OrderRepository
|
adaptador SQLite
|
BEGIN IMMEDIATE
guardar versión y estado
procesar OrderConfirmed
guardar contrato de confirmación
COMMIT
El procesamiento del evento solo produce datos locales dentro de la transacción. No envía correo ni llama por HTTP a otro sistema. Así evitamos presentar un efecto externo como si SQLite pudiera revertirlo.
3. Entorno, instalación y estructura
Necesitas Node.js 24 y npm. Elegimos node:sqlite para evitar un paquete nativo adicional; DatabaseSync ofrece una conexión SQLite con operaciones síncronas. Este enfoque mantiene el ejemplo compacto, pero bloquea el hilo de JavaScript mientras trabaja: no lo extrapoles a consultas largas o a una API de alta concurrencia sin evaluar alternativas. SQLite en Node.js 24.
Usaremos Express 5, Zod 4, TypeScript 5.9 y Vitest 4. Node 24 satisface los requisitos de las ramas de Express y Vitest seleccionadas. La guía enlazada de Vitest corresponde a la versión 4, no presupone que sea la última disponible. Express: instalación, Vitest 4: instalación.
Crea una carpeta nueva:
mkdir ddd-orders-workshop
cd ddd-orders-workshop
node --version
npm --version
Copia los archivos que siguen respetando sus rutas. Los comandos no modifican ningún repositorio existente.
Los rangos limitan las ramas mayores, pero la primera resolución de dependencias puede cambiar con el tiempo. npm install generará package-lock.json: consérvalo junto al proyecto. Las instalaciones posteriores deben usar npm ci para reproducir esa resolución. Esto no sustituye revisar avisos de seguridad antes de reutilizarlo en un sistema real.
Separamos la compilación de src del chequeo de tipos de src y tests. Vitest ejecuta pruebas, pero no reemplaza ese chequeo estático.
.gitignore
node_modules/
dist/
data/
coverage/
4. Modelo: el pedido protege sus transiciones
Quantity representa un valor válido y Order contiene el comportamiento. La fábrica estática create fija el estado inicial; restore reconstruye un snapshot interno sin emitir eventos nuevos.
No añadimos un Domain Service porque ninguna regla necesita coordinar conceptos que no pertenezcan al pedido. Aplicar la colección significa escoger herramientas justificadas, no introducir todos sus patrones en cada archivo.
Los getters y las copias permiten consultar sin compartir el array interno. El estado usa campos privados de JavaScript. acknowledgePersistence es un protocolo interno del adaptador: no se expone como operación HTTP ni sustituye un método de negocio.
Antes de guardar, version vale cero. La primera persistencia la eleva a uno; cada escritura aceptada incrementa esa versión. restore confía en datos escritos por nuestra propia aplicación. Un importador de archivos o una herramienta administrativa necesitaría su propia validación de snapshots.
El identificador de producto tiene un máximo de ochenta caracteres tanto en HTTP como en el dominio. El transporte protege la forma de la solicitud; el dominio conserva esa regla aunque reciba una llamada desde otro adaptador.
5. Casos de uso y contrato de integración
Para reducir archivos pequeños agrupamos los casos de uso en Orders. Cada método coordina una operación; no reproduce las condiciones de estado. En un proyecto mayor se pueden separar CreateOrder, ConfirmOrder y CancelOrder sin alterar las reglas.
El evento interno OrderConfirmed se convierte mediante un handler en sales.order-confirmed.v1. El contrato contiene identificador, fecha y pedido confirmado. No exporta el agregado ni convierte automáticamente todos sus campos en una API pública.
El reloj y el generador de identificadores se inyectan desde fuera. El dominio recibe esos valores y no necesita importar herramientas de Node.
La lectura confirmations está en el mismo puerto para mantener corto el ejercicio. Con más consultas podría separarse en un puerto de lectura. Hacerlo aquí no cambiaría el comportamiento ni la consistencia.
6. Persistencia real y procesamiento atómico del evento
Guardamos el snapshot como JSON y mantenemos una columna version para la escritura condicional. Es una decisión educativa: simplifica la reconstrucción, a cambio de limitar consultas SQL sobre líneas. Una aplicación con búsquedas o informes complejos podría necesitar tablas normalizadas.
src/sales/infrastructure/sqlite-orders.ts
import { DatabaseSync } from"node:sqlite";
import { Order, typeSnapshot } from"../domain/order.js";
import {
Conflict, handleOrderConfirmed,
typeConfirmationV1, typeOrderRepository,
} from"../application/orders.js";
exportfunctioninitialize(db: DatabaseSync): void {
db.exec(`
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 3000;
CREATE TABLE IF NOT EXISTS orders (
id TEXT PRIMARY KEY,
snapshot TEXT NOT NULL,
version INTEGER NOT NULL CHECK(version >= 1)
);
CREATE TABLE IF NOT EXISTS confirmations (
id TEXT PRIMARY KEY,
order_id TEXT NOT NULL REFERENCES orders(id),
payload TEXT NOT NULL
);
`);
}
exportclassSqliteOrdersimplementsOrderRepository {
constructor(privatereadonlydb: DatabaseSync) {}
find(id: string): Order | null {
const row = this.db.prepare(
"SELECT snapshot FROM orders WHERE id = ?",
).get(id);
if (!row) returnnull;
returnOrder.restore(JSON.parse(String(row.snapshot)) asSnapshot);
}
save(order: Order): void {
const snapshot = order.snapshot();
const next = { ...snapshot, version: snapshot.version + 1 };
const events = order.pendingEvents();
this.db.exec("BEGIN IMMEDIATE");
try {
if (snapshot.version === 0) {
const result = this.db.prepare(
"INSERT OR IGNORE INTO orders(id, snapshot, version) VALUES(?, ?, 1)",
).run(snapshot.id, JSON.stringify(next));
if (Number(result.changes) !== 1) {
thrownewConflict("ORDER_ALREADY_EXISTS");
}
} else {
const result = this.db.prepare(
"UPDATE orders SET snapshot = ?, version = ? WHERE id = ? AND version = ?",
).run(JSON.stringify(next), next.version, snapshot.id, snapshot.version);
if (Number(result.changes) !== 1) {
thrownewConflict("ORDER_VERSION_CONFLICT");
}
}
for (const event of events) {
const contract = handleOrderConfirmed(event);
this.db.prepare(
"INSERT INTO confirmations(id, order_id, payload) VALUES(?, ?, ?)",
).run(contract.id, snapshot.id, JSON.stringify(contract));
}
this.db.exec("COMMIT");
} catch (error) {
this.db.exec("ROLLBACK");
throw error;
}
order.acknowledgePersistence();
}
confirmations(id: string): readonlyConfirmationV1[] {
returnthis.db.prepare(
"SELECT payload FROM confirmations WHERE order_id = ? ORDER BY rowid",
).all(id).map((row) =>JSON.parse(String(row.payload)) asConfirmationV1,
);
}
}
El adaptador implementa el contrato de aplicación y conoce SQLite. Order no conoce SQL. La composición exterior será quien decida usar esta implementación.
Si dos copias cargan version 1, solo una puede actualizarla a 2. La segunda escritura afecta cero filas y se rechaza con conflicto. BEGIN IMMEDIATE ayuda a organizar la escritura local; no reemplaza la condición de versión.
El handler se ejecuta después de actualizar el pedido y antes de COMMIT. Si insertar su resultado falla, ROLLBACK revierte también el cambio de estado. Los eventos pendientes solo se limpian después de confirmar la transacción.
La tabla confirmations conserva el contrato versionado y permite consultarlo. No es un sistema de entrega a otros contextos: no tiene consumidor, confirmación de recepción ni reintentos. Tampoco estamos reconstruyendo pedidos desde eventos, por lo que esto no es Event Sourcing. Si apareciera una integración externa, habría que diseñar su entrega y su fiabilidad como una capacidad adicional.
7. HTTP: validar y traducir resultados
Zod valida la entrada HTTP mediante safeParse, que permite distinguir éxito y error sin lanzar una excepción por cada solicitud inválida. Eso no desplaza las reglas del agregado al esquema. Uso básico de Zod.
El contrato del ejercicio asigna 400 a entradas inválidas, 404 a pedidos inexistentes, 409 a conflictos de versión y 422 a reglas del pedido incumplidas. Son decisiones documentadas de esta API.
Las rutas de confirmación y cancelación no reciben cambios arbitrarios del pedido; su intención está expresada en la operación. Un cliente no puede enviar status para saltarse el agregado.
Este servidor local no implementa usuarios ni autorización. Escuchará únicamente en 127.0.0.1: no lo publiques ni lo conviertas en accesible externamente como si fuera una API productiva. La ausencia de esas capacidades es parte del alcance local, no una recomendación para aplicaciones reales.
La conexión se abre una vez y se cierra al detener el servidor. La base queda en data/orders.sqlite relativa a la carpeta desde la que arrancas. Ejecuta siempre npm start en la raíz del proyecto para usar el mismo archivo.
Después de copiar todos los archivos:
npm install
npm run typecheck
npm test
npm run build
npm start
La primera línea descarga dependencias en esta carpeta nueva. No se ha ejecutado durante la elaboración del artículo. Ante un error detente y revisa su salida antes de seguir; los comandos se muestran por separado para que puedas identificarlo.
Si tu compilación no reconoce node:sqlite, comprueba que ejecutas Node 24 y que la instalación resolvió @types/node de la rama 24. Si el puerto 3000 está ocupado, detén el proceso anterior o cambia explícitamente el puerto del ejemplo.
9. Pruebas del dominio, persistencia y recorrido HTTP
Las siguientes suites están completas como archivos del proyecto. Sus resultados deben obtenerse ejecutando npm test en tu entorno; no se presentan aquí como pruebas aprobadas.
Estas pruebas distinguen reglas: vacío, repetición, cancelado, cantidades y protección de referencias. Cada rechazo conserva el estado anterior y evita eventos adicionales.
tests/sqlite.test.ts
import { DatabaseSync } from"node:sqlite";
import { afterEach, beforeEach, expect, it } from"vitest";
import { Order } from"../src/sales/domain/order.js";
import { initialize, SqliteOrders } from"../src/sales/infrastructure/sqlite-orders.js";
letdb: DatabaseSync;
letrepository: SqliteOrders;
beforeEach(() => {
db = newDatabaseSync(":memory:");
initialize(db);
repository = newSqliteOrders(db);
});
afterEach(() => db.close());
it("persiste estado y contrato, y limpia eventos tras COMMIT", () => {
const order = Order.create("o-1", [{ productId: "p-1", quantity: 1 }]);
repository.save(order);
order.confirm("e-1", "2026-10-23T12:00:00.000Z");
repository.save(order);
expect(repository.find("o-1")?.snapshot().status).toBe("CONFIRMED");
expect(repository.confirmations("o-1")).toEqual([{
id: "e-1",
type: "sales.order-confirmed.v1",
occurredAt: "2026-10-23T12:00:00.000Z",
data: { orderId: "o-1" },
}]);
expect(order.pendingEvents()).toEqual([]);
});
it("rechaza un escritor con versión obsoleta", () => {
repository.save(Order.create("o-1", [{ productId: "p-1", quantity: 1 }]));
const first = repository.find("o-1")!;
const stale = repository.find("o-1")!;
first.confirm("e-1", "2026-10-23T12:00:00.000Z");
repository.save(first);
stale.cancel();
expect(() => repository.save(stale)).toThrow("ORDER_VERSION_CONFLICT");
expect(repository.find("o-1")?.snapshot().status).toBe("CONFIRMED");
expect(repository.confirmations("o-1")).toHaveLength(1);
});
it("revierte el estado si falla el procesamiento del evento", () => {
const order = Order.create("o-1", [{ productId: "p-1", quantity: 1 }]);
repository.save(order);
db.exec(`
CREATE TRIGGER reject_confirmation BEFORE INSERT ON confirmations
BEGIN SELECT RAISE(ABORT, 'TEST_FAILURE'); END;
`);
order.confirm("e-1", "2026-10-23T12:00:00.000Z");
expect(() => repository.save(order)).toThrow("TEST_FAILURE");
expect(repository.find("o-1")?.snapshot().status).toBe("DRAFT");
expect(repository.confirmations("o-1")).toEqual([]);
expect(order.pendingEvents()).toHaveLength(1);
});
Usamos SQLite real en memoria, no un repositorio simulado. La prueba de conflicto carga dos agregados independientes. La prueba con trigger provoca un fallo controlado para comprobar que el estado no queda confirmado sin su registro. Cada prueba crea y cierra su propia conexión.
La instancia en memoria es desechable, pero el adaptador es el mismo utilizado con archivo en el servidor. La persistencia después de reiniciar se comprobará también mediante HTTP.
Estas pruebas abren un puerto local asignado por el sistema, envían peticiones reales y verifican respuestas. Al terminar cierran servidor y conexión. No requieren un servicio externo ni una base compartida.
10. Probarlo manualmente y comprobar la persistencia
Con npm start activo, abre otra terminal. Usa curl en Bash o curl.exe en PowerShell para evitar diferencias con alias.
Repite la confirmación: espera 422 y ORDER_NOT_DRAFT. Consulta otra vez las confirmaciones: debe seguir existiendo una sola. La versión del pedido no debe aumentar.
Para probar cancelación, crea otro borrador y ejecuta:
curl -i -X POST http://127.0.0.1:3000/orders/ID/cancel
Esperas 200 y CANCELLED. Intentar confirmarlo devuelve 422. Un borrador creado con lines vacío se guarda, pero confirmarlo devuelve ORDER_EMPTY.
Detén el servidor con Ctrl+C y ejecuta de nuevo npm start desde la misma carpeta. Consulta el primer UUID: estado, versión y confirmación deben continuar disponibles. No borres la base para realizar esta comprobación.
En PowerShell también puedes crear el pedido y capturar el identificador sin copiarlo manualmente:
No se ofrecen salidas simuladas como resultados reales: los estados anteriores son expectativas que tu ejecución debe confirmar.
11. Documentar y trabajar con IA sobre el proyecto
Conserva este artículo o sus instrucciones en README.md, y registra junto al código las decisiones de alcance. Como mínimo documenta las reglas de la sección 1, comandos, puerto, ubicación de la base, contrato HTTP, política de confirmación repetida y limitaciones.
Una tarea inicial para un agente puede ser:
Inspecciona este proyecto sin modificarlo.
Relaciona cada regla de pedidos con su método y su prueba.
Confirma la dirección de imports y enumera las decisiones
del README que no estén representadas en código.
No agregues infraestructura ni nuevas reglas.
Devuelve evidencia con rutas y preguntas pendientes.
Para una revisión posterior:
Revisa la confirmación de pedidos.
Comprueba que una escritura con versión obsoleta falla,
que el evento se guarda en la misma transacción y que una
confirmación rechazada no añade registros.
Examina también manejo de errores y límites HTTP.
No declares resultados de pruebas que no hayas ejecutado.
No modifiques dependencias ni publiques la aplicación.
Y para comprobar el trabajo de forma reproducible:
Lee los scripts existentes y ejecuta, si el entorno está
autorizado y preparado, typecheck, test y build.
Informa el resultado real de cada comando y sus limitaciones.
Si falla algo, propón la corrección mínima antes de ampliar
el alcance. Conserva los cambios ajenos.
El responsable humano debe validar que las reglas siguen representando el negocio. Que el código pase sus pruebas no demuestra que las decisiones sean correctas para otra tienda.
12. Qué reutilizar y qué cambiar en otros escenarios
En una plataforma educativa, el patrón de transición puede servir para una inscripción. Sin embargo, «completado» podría depender de actividades aprobadas. No copies la regla «contiene una línea» ni la política de cancelación del pedido: define evidencias educativas y límites propios.
En logística, confirmar una preparación podría depender de una reserva de inventario. Nuestro proyecto no realiza esa reserva y una llamada HTTP externa no quedaría incluida en COMMIT. Antes de añadirla hay que acordar coordinación, fallos y responsabilidades.
Para una agenda personal sin reglas relevantes, un CRUD directo puede ser suficiente. La separación de este proyecto se justifica por transiciones, rechazos, consistencia y pruebas; repetir su estructura en una tabla auxiliar sin comportamiento puede añadir trabajo sin beneficio.
13. Ejercicios de revisión, con soluciones
Las funcionalidades esenciales ya están implementadas. Estos ejercicios ayudan a comprobar que comprendes sus límites, sin dejar pasos obligatorios pendientes.
Dos operadores cargan el mismo borrador. Uno confirma y el otro cancela. ¿Qué dato evita que el último sobrescriba silenciosamente al primero?
El handler empieza a enviar un correo antes de COMMIT. Después falla la transacción. ¿Qué inconsistencia aparece?
Un cliente envía quantity igual a cero. Otro proceso interno llama directamente a Order.create con esa cantidad. ¿Dónde debe rechazarse cada entrada?
Soluciones razonadas
1. La versión esperada en el WHERE de UPDATE. Una copia obsoleta no afecta filas y genera ORDER_VERSION_CONFLICT. El segundo operador debe volver a consultar y decidir con el nuevo estado; no se reintenta ciegamente una operación del negocio.
2. El destinatario podría recibir una confirmación que la base terminó revirtiendo. Por eso el handler actual solo transforma y registra datos locales. Un envío real exige otro diseño de entrega; no basta con mover un await dentro de la transacción.
3. HTTP devuelve 400 al fallar Zod. La llamada interna también debe fallar mediante Quantity. El esquema de transporte protege el contrato de entrada y el dominio mantiene su regla aunque no exista HTTP.
Cerrar la colección construyendo con criterio
El recorrido produjo una aplicación pequeña con un comportamiento completo: entrada validada, agregado, casos de uso, repositorio, SQLite, evento procesado, consulta y pruebas. También dejó decisiones visibles: un contexto implementado, un único proceso, transacción local y ninguna dependencia distribuida inventada.
Su límite productivo es explícito: faltan identidad, autorización, operación y controles apropiados a un despliegue real. Su propósito local está cubierto por los archivos y operaciones del tutorial, y su verificación depende de ejecutar los comandos y revisar sus resultados.
DDD aportó el lenguaje y las reglas; la ingeniería conectó contratos, persistencia y pruebas; la IA puede ayudar a inspeccionar y revisar esas decisiones. El criterio profesional consiste en comprobar que las tres partes siguen describiendo el mismo negocio.
Aprende a orientar agentes de IA con reglas del negocio, límites de contexto y criterios verificables. Un caso en TypeScript muestra cómo preparar instrucciones, proteger Order, diseñar pruebas y revisar cambios sin delegar el criterio arquitectónico.
Compara un monolito modular con una separación de Sales, Payments e Inventory. Diseña contratos en TypeScript y Express y comprende fallos parciales, consistencia eventual, idempotencia, Outbox y Sagas sin introducir infraestructura compleja.
Refactoriza la confirmación de pedidos desde un controlador acoplado hacia Order, caso de uso, puerto y adaptadores. Aprende la regla de dependencias, composición manual y pruebas sin infraestructura, con TypeScript y Express.