Domain-Driven Design: Repositories para persistir Aggregates sin acoplar el dominio
Diseña un OrderRepository independiente del almacenamiento y compáralo con un DAO, un ORM y un Application Service. Implementa adaptadores en memoria y SQLite, recupera Aggregates completos y comprueba guardado, conflictos de versión y rollback con pruebas ejecutables.
Un pedido se confirma correctamente en memoria. El backend actualiza su cabecera y después guarda las líneas, pero la segunda operación falla. La siguiente lectura encuentra un pedido confirmado con datos anteriores o incompletos.
En otro recorrido, un servicio recupera directamente un modelo del ORM y modifica sus propiedades. El resto de la aplicación necesita conocer relaciones, sesiones y métodos particulares de esa herramienta para trabajar con el pedido.
Un Repository busca ofrecer una forma de recuperar y persistir objetos del Domain sin obligar a sus consumidores a conocer esos mecanismos. Para que la separación sea útil, el contrato debe explicar también qué guarda, qué devuelve y qué ocurre cuando existe un conflicto.
Qué representa un Repository en DDD
Un Repository ofrece acceso a objetos del Domain con una interfaz semejante a una colección, ocultando el mapeo y el acceso al almacenamiento. Fowler describe esa mediación en su patrón Repository, y Evans la desarrolla en su referencia de DDD.
En un modelo con Aggregates, normalmente se diseña alrededor de sus Aggregate Roots: los puntos de entrada que controlan las modificaciones del conjunto. OrderRepository recupera un Order listo para ejecutar sus operaciones, no una cabecera sin las líneas necesarias para comprobar sus reglas.
Esto no significa cargar todos los datos relacionados con una venta. El pedido puede conservar referencias a cliente y producto por identidad, sin reconstruir sus perfiles completos.
El contrato puede definirse en Domain o en el límite de aplicación que lo necesita, según la organización elegida. La implementación concreta pertenece a Infrastructure. Lo esencial es que el consumidor dependa de una abstracción propia, no de una sesión, conexión o tipo del ORM.
Repository, DAO, ORM y Application Service
Un DAO, o Data Access Object, abstrae mecanismos de acceso a una fuente de datos. La definición original de Oracle no lo limita necesariamente a una tabla. La diferencia útil es el contrato: en DDD, el Repository se expresa en objetos y necesidades del modelo, mientras un DAO puede ofrecer operaciones centradas en registros o consultas.
Un ORM, u Object-Relational Mapper, es una herramienta de mapeo entre objetos y almacenamiento relacional. Puede utilizarse dentro de un Repository. No sustituye automáticamente el diseño del contrato ni garantiza que los objetos recuperados coincidan con tus Aggregates.
Un Application Service coordina un Use Case. Por ejemplo: recuperar el pedido, pedirle que se confirme y guardar. La regla para confirmar pertenece al modelo; la consulta SQL pertenece al adaptador. El Repository no debería calcular descuentos ni decidir qué pedidos merecen aprobarse.
La separación se ve en este recorrido:
ConfirmOrder -> OrderRepository [contrato]
^
| implementan
MemoryOrderRepository
SqliteOrderRepository
Ambos recuperan Order; Order no conoce SQL ni SQLite.
Un contrato pequeño con garantías explícitas
Usaremos tres operaciones: add, para insertar un Aggregate nuevo; findById, para recuperar uno existente; y save, para guardar una actualización condicionada a la versión leída.
No haremos un save ambiguo que inserte o actualice sin distinguir intención. En este ejemplo, un identificador duplicado al añadir es un error; guardar una versión obsoleta también lo es. Un fallo técnico no se traduce a «pedido inexistente».
La versión viajará junto al Aggregate como metadato de persistencia. No es una columna SQL expuesta al Domain ni una regla comercial del pedido: es un número que el contrato utiliza para detectar actualizaciones incompatibles. Otro diseño podría usar un token opaco equivalente.
Ejemplo completo: el mismo contrato, dos implementaciones
Preparación y alcance
El proyecto incluye todos los archivos siguientes y no requiere instalar paquetes:
Usaremos Node.js 24.21.0 y su módulo integrado node:sqlite. El adaptador relacional utiliza DatabaseSync, que ejecuta consultas de forma síncrona; la interfaz del Repository conserva métodos asíncronos para representar el contrato sin atarlo a ese mecanismo. Esta implementación didáctica no es una recomendación de concurrencia o rendimiento para un servidor de producción. Consulta la documentación de SQLite en Node.js al adaptar las APIs a otra versión.
Las pruebas crean una base SQLite real con :memory:: se ejecuta SQL y se comprueban transacciones, pero los datos no sobreviven al cierre. No se modifica una base de datos existente.
Los precios son unidades menores enteras de EUR y las líneas no se pueden repetir. El modelo mínimo evita repetir el diseño completo del Aggregate. Sus métodos no contienen SQL, conexiones ni decoradores de persistencia.
restore() conserva identidad y estado, en lugar de iniciar otro pedido en draft. Las entradas son estructuras conocidas; no es un parser general de JSON. Un adaptador externo debe validar la forma recibida y el modelo comprueba las reglas que le corresponden.
2. Implementación en memoria que no comparte referencias
Archivo memory-repository.ts:
import {
Order, OrderAlreadyExists, OrderWriteConflict,
typeOrderRepository, typeOrderSnapshot,
} from'./domain.ts';
exportclassMemoryOrderRepositoryimplementsOrderRepository {
#rows = newMap<string, { state: OrderSnapshot; version: number }>();
asyncadd(order: Order): Promise<number> {
const state = order.snapshot();
if (this.#rows.has(state.id)) thrownewOrderAlreadyExists('Pedido duplicado');
this.#rows.set(state.id, { state, version: 0 });
return0;
}
asyncfindById(id: string) {
const row = this.#rows.get(id);
return row ? { order: Order.restore(row.state), version: row.version } : null;
}
asyncsave(order: Order, expectedVersion: number): Promise<number> {
const state = order.snapshot();
const current = this.#rows.get(state.id);
if (!current || current.version !== expectedVersion) {
thrownewOrderWriteConflict('El pedido cambió o ya no existe');
}
const version = expectedVersion + 1;
this.#rows.set(state.id, { state, version });
return version;
}
}
Guardar snapshots y reconstruir instancias evita que order.confirm() cambie silenciosamente el almacenamiento antes de llamar a save. La comparación y la sustitución no contienen un await entre ellas; esta simulación solo coordina el estado de su instancia en un proceso.
Pasar el Repository por el constructor es Dependency Injection sencilla. Cambiar la implementación no exige modificar el Use Case. Un controlador podría traducir sus resultados y errores a HTTP; ese transporte queda fuera del ejemplo.
4. Persistencia relacional y mapeo explícito
Archivo sqlite-repository.ts:
import { DatabaseSync } from'node:sqlite';
import {
Order, OrderAlreadyExists, OrderWriteConflict,
typeOrderRepository, typeOrderSnapshot,
} from'./domain.ts';
exportfunctioncreateDemoDatabase(): DatabaseSync {
const db = newDatabaseSync(':memory:');
db.exec(`
PRAGMA foreign_keys = ON;
CREATE TABLE orders (
id TEXT PRIMARY KEY,
status TEXT NOT NULL CHECK (status IN ('draft', 'confirmed')),
version INTEGER NOT NULL CHECK (version >= 0)
);
CREATE TABLE order_lines (
order_id TEXT NOT NULL REFERENCES orders(id),
line_id TEXT NOT NULL,
product_id TEXT NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0),
unit_price_minor INTEGER NOT NULL CHECK (unit_price_minor >= 0),
PRIMARY KEY (order_id, line_id),
UNIQUE (order_id, product_id)
);
`);
return db;
}
typeJoinedRow = {
order_id: string;
status: OrderSnapshot['status'];
version: number;
line_id: string | null;
product_id: string;
quantity: number;
unit_price_minor: number;
};
exportclassSqliteOrderRepositoryimplementsOrderRepository {
#db: DatabaseSync;
constructor(db: DatabaseSync) { this.#db = db; }
#transaction<T>(work: () => T): T {
this.#db.exec('BEGIN IMMEDIATE');
try {
const result = work();
this.#db.exec('COMMIT');
return result;
} catch (error) {
this.#db.exec('ROLLBACK');
throw error;
}
}
#insertLines(state: OrderSnapshot): void {
const insert = this.#db.prepare(`INSERT INTO order_lines
(order_id, line_id, product_id, quantity, unit_price_minor) VALUES (?, ?, ?, ?, ?)`);
for (const line of state.lines) {
insert.run(state.id, line.id, line.productId, line.quantity, line.unitPriceMinorUnits);
}
}
asyncadd(order: Order): Promise<number> {
const state = order.snapshot();
returnthis.#transaction(() => {
if (this.#db.prepare('SELECT id FROM orders WHERE id = ?').get(state.id)) {
thrownewOrderAlreadyExists('Pedido duplicado');
}
this.#db.prepare('INSERT INTO orders (id, status, version) VALUES (?, ?, 0)')
.run(state.id, state.status);
this.#insertLines(state);
return0;
});
}
asyncfindById(id: string) {
const rows = this.#db.prepare(`
SELECT o.id AS order_id, o.status, o.version,
l.line_id, l.product_id, l.quantity, l.unit_price_minor
FROM orders o LEFT JOIN order_lines l ON l.order_id = o.id
WHERE o.id = ? ORDER BY l.line_id
`).all(id) asunknownasJoinedRow[];
if (rows.length === 0) returnnull;
const first = rows[0];
if (!Number.isSafeInteger(first.version) || first.version < 0) {
thrownewError('Versión persistida inválida');
}
conststate: OrderSnapshot = {
id: first.order_id, status: first.status,
lines: rows.filter(row => row.line_id !== null).map(row => ({
id: row.line_idasstring, productId: row.product_id,
quantity: row.quantity, unitPriceMinorUnits: row.unit_price_minor,
})),
};
return { order: Order.restore(state), version: first.version };
}
asyncsave(order: Order, expectedVersion: number): Promise<number> {
const state = order.snapshot();
returnthis.#transaction(() => {
const result = this.#db.prepare(`UPDATE orders SET status = ?, version = version + 1
WHERE id = ? AND version = ?`).run(state.status, state.id, expectedVersion);
if (result.changes !== 1) thrownewOrderWriteConflict('El pedido cambió o ya no existe');
// Reemplazamos solo las líneas de este pedido, dentro de la misma transacción.this.#db.prepare('DELETE FROM order_lines WHERE order_id = ?').run(state.id);
this.#insertLines(state);
return expectedVersion + 1;
});
}
}
El mapeo traduce unit_price_minor a unitPriceMinorUnits y entrega un Aggregate reconstruido. La consulta conjunta obtiene cabecera y líneas en una sentencia, evitando combinar dos lecturas de momentos diferentes. Las conversiones as describen tipos para TypeScript; no validan datos por sí mismas. El esquema controlado y las comprobaciones del modelo y de versión sostienen este ejemplo.
El orden de líneas se define aquí por identificador al recuperar. Si el negocio necesita un orden de presentación distinto, persiste y mapea una posición explícita; no dependas del orden accidental de una tabla.
Las consultas usan parámetros y no concatenan identificadores recibidos dentro del SQL. El borrado acotado de líneas forma parte de la estrategia de actualización de esta demostración. En un modelo con auditoría, referencias externas o muchas líneas quizá necesites calcular diferencias en vez de reemplazarlas.
BEGIN IMMEDIATE, COMMIT y ROLLBACK siguen las reglas de transacciones de SQLite. Esta conexión no debe tener ya una transacción abierta y no se comparte para operaciones intercaladas. No copies ese control sin adaptarlo al cliente y al manejo de conexiones de tu aplicación.
Las primeras pruebas ejecutan el mismo contrato contra dos mecanismos. El Use Case confirma el pedido sin saber cuál utiliza. La prueba de fallo demuestra que una excepción al insertar la segunda línea no deja confirmada la cabecera ni incrementada la versión.
Las tres pruebas se ejecutaron y pasaron con Node.js 24.21.0 y la biblioteca SQLite integrada en ese entorno. No se realizó análisis estático con el compilador de TypeScript ni se probó otro motor relacional.
La prueba de versión usa dos lecturas lógicamente obsoletas, no una carga concurrente de varios procesos. Comprueba el rechazo condicionado a versión; no mide aislamiento, contención ni rendimiento en producción.
Concurrencia y Unit of Work: qué garantía ofrece el guardado
save(order, expectedVersion) utiliza Optimistic Concurrency Control: solo acepta una actualización si la versión sigue siendo la leída. La comprobación y el guardado de todos los componentes ocurren dentro de la transacción. Un conflicto no debe convertirse en un save sin condición para «hacerlo funcionar».
Si reintentas, vuelve a recuperar el Aggregate y ejecutar la operación con el estado actual. Además, comprueba si el Use Case puede repetirse: una escritura rechazada no deshace efectos externos que ya hayas ejecutado.
Una Unit of Work registra los objetos afectados por una operación y coordina la escritura de cambios y problemas de concurrencia, como define Fowler. No es simplemente otro nombre para BEGIN y COMMIT.
En nuestro diseño, cada add o save completa su propia transacción. Si una operación necesitara coordinar más escrituras locales, tendrías que establecer un límite común y hacer participar a los adaptadores. No basta con invocar varios save que confirman por separado, ni con anidar transacciones que el motor no admite.
Educación: recuperar para decidir, consultar para mostrar
Una academia necesita aprobar una inscripción y listar miles de participantes. La aprobación requiere recuperar el Aggregate con la información necesaria para sus reglas. El listado puede utilizar una consulta paginada que devuelve identificador, nombre visible y estado.
Forzar a EnrollmentRepository a reconstruir cada inscripción para mostrar tres columnas puede añadir trabajo innecesario. Un lector específico puede devolver un DTO, una estructura para transferir datos, sin ofrecer operaciones de modificación.
Separar esos recorridos no exige implantar CQRS ni otro sistema completo. Este ejemplo enseña a distinguir una lectura destinada a ejecutar comportamiento de una proyección destinada a presentación.
Logística: una consulta externa no es persistir un Aggregate local
Una integración consulta el estado de un transportista. El adaptador HTTP puede validar y traducir su respuesta; llamarlo ShipmentRepository no lo convierte automáticamente en una colección de Aggregates propios con las mismas garantías que tu base de datos.
Define si necesitas consultar evidencia externa o recuperar un envío que tu sistema controla. Si pertenecen a Bounded Contexts diferentes, conserva sus implementaciones separadas y utiliza una ACL HTTP con contratos del consumidor cuando esa sea la integración acordada.
Un fallo de consulta no equivale a «envío inexistente». Tampoco existe una transacción compartida entre la llamada HTTP y el guardado local por recibir ambas operaciones el mismo identificador de proceso. Aquí la decisión consiste en nombrar y limitar el contrato según la garantía real que puede ofrecer.
Cuándo no necesitas otro Repository genérico
Una clase GenericRepository<T> con findWhere, include, join y opciones propias del ORM puede conservar el acoplamiento bajo nombres nuevos. Empieza por las operaciones que necesitan tus consumidores: findById, guardar cambios o buscar una selección acotada de pedidos, con criterios comprensibles para el modelo.
Eso no obliga a inventar nombres comerciales para cada consulta ni a duplicar toda la API del ORM. Los detalles técnicos pueden reutilizarse dentro de Infrastructure sin convertirse en el contrato común de todos los Aggregates.
Para un catálogo CRUD pequeño, validación y un acceso a datos sencillo pueden bastar. Si no existe comportamiento de Aggregate que proteger, añadir una interfaz que solo reenvía cada llamada quizá no aporte suficiente valor. La separación debe reducir dependencias concretas, no cumplir una lista de clases.
Prompts para orientar y revisar código generado por IA
Diseñar el contrato necesario
Analiza los Use Cases de pedidos adjuntos y propón un OrderRepository sin tipos del ORM ni consultas arbitrarias del motor. Distingue inserción, recuperación, actualización y proyecciones de lectura. Especifica ausencia, duplicado y conflicto. No implementes ni añadas operaciones por defecto. Una persona validará qué información necesita el Aggregate y qué garantías espera cada consumidor.
Implementar mapeo y guardado
Implementa el contrato existente en memoria y en el adaptador relacional proporcionado. Devuelve Aggregates reconstruidos sin compartir referencias. Guarda cabecera y líneas atómicamente y condiciona la actualización a la versión leída. No añadas reglas de negocio al Repository ni instales otro ORM. Entrega pruebas de contrato, estado no guardado, versión obsoleta y fallo al escribir una línea. Una persona ejecutará las pruebas contra el motor correspondiente.
Revisar filtraciones y transacciones
Revisa este diff buscando tipos o sesiones del ORM en Domain, Aggregates incompletos, errores técnicos tratados como null, escrituras fuera de la transacción y conflictos sobrescritos sin recargar. No cambies archivos. Devuelve evidencia y un escenario observable por hallazgo. Una persona verificará conexiones, aislamiento, consultas emitidas y restricciones reales; no supongas una transacción compartida por HTTP.
Una implementación en memoria puede pasar pruebas aunque la SQL falle por restricciones o aislamiento. Utilízala para comprobar comportamiento y conserva pruebas del adaptador real para las garantías que dependen de él.
Tres ejercicios con solución razonada
1. Recuperar solo la cabecera
La IA optimiza findById() y deja de cargar líneas, pero devuelve un Order al que puede pedirse confirm(). ¿Qué revisarías?
Solución razonada: si el objeto contiene lo necesario para comprobar sus reglas. En el modelo del ejemplo, un pedido requiere líneas; una cabecera incompleta no es un Aggregate recuperado. Puede servir como DTO de listado, con otro contrato, pero no debe fingir un Order listo para modificar.
2. Reintentar después de un conflicto
El adaptador recibe cero filas actualizadas y repite el UPDATE eliminando la condición de versión. ¿Conserva el contrato?
Solución razonada: no. Sobrescribe una versión que no evaluaste. Devuelve el conflicto y decide en Application si procede recuperar, reevaluar y reintentar. Las pruebas deben comprobar que la escritura rechazada no modifica estado ni versión.
3. Pasar una transacción del ORM al modelo
Se propone order.confirm(entityManager) para que la Entity guarde sus cambios. ¿Qué dependencia introduce?
Solución razonada: acopla la operación del Domain a un mecanismo de persistencia. La Entity puede comprobar y cambiar su estado sin ese objeto; el Use Case y el Repository coordinan el guardado. Si hay varias escrituras que deben ser atómicas, establece su límite transaccional fuera del modelo y comprueba que comparten la misma unidad real.
Cómo reconocer un Repository que aporta valor
Comprueba qué objeto recupera, qué significa guardarlo y qué garantías promete ante ausencia, fallo y concurrencia. Revisa después que los tipos públicos no obliguen al consumidor a conocer conexiones, tablas o mecanismos del ORM.
Un Repository útil permite ejecutar el comportamiento del Aggregate y persistirlo mediante un contrato explícito. Esa separación se demuestra con mapeo, transacciones y pruebas del adaptador, además de una interfaz con buenos nombres.
Aprende cuándo usar un constructor, un método estático o una Factory dedicada para crear objetos de dominio completos. Construye un pedido desde una cotización en TypeScript, comprueba sus invariantes y distingue una creación nueva de la reconstitución de un estado persistido.
Diseña un Order Aggregate con una raíz que controla sus líneas y protege reglas comunes. Con TypeScript y pruebas ejecutables, aprende a elegir límites de consistencia, evitar cambios parciales y distinguir las garantías del modelo de las que requiere la persistencia transaccional.
Aprende a definir Modules a partir de responsabilidades del negocio, ofrecer interfaces públicas y controlar sus dependencias. Con un ejemplo ejecutable en TypeScript, distingue un módulo de una carpeta, una layer o un Bounded Context y establece límites útiles para trabajar con IA.