Arquitectura de Software

Domain-Driven Design: Model-Driven Design, del conocimiento del negocio al código

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.

Cargando tus opciones de aprendizaje…

Una tienda permite modificar pedidos mientras se están preparando. El backend tiene una tabla orders, un campo status y una función que actualiza cualquiera de sus columnas. El panel funciona hasta que alguien cambia la dirección de un pedido que ya salió del almacén.

La base de datos acepta el cambio. La API responde correctamente. El transportista, sin embargo, conserva la dirección anterior. Para soporte, el pedido parece dirigirse a un lugar; para logística, a otro.

Faltaba representar una decisión: después del despacho, cambiar la dirección no es una edición ordinaria del pedido. Requiere un procedimiento distinto, si el negocio lo permite.

El objetivo de Model-Driven Design es que las decisiones importantes del modelo tengan una expresión reconocible en el software que ejecutas. Para entender cómo funciona, seguiremos la conexión entre las reglas del negocio y su implementación.

Qué significa que el modelo guíe el diseño

Un Domain Model selecciona conceptos, relaciones y reglas útiles para resolver un problema del negocio. En Model-Driven Design, el modelo y la implementación se desarrollan vinculados: lo aprendido cambia el código y la experiencia al programar permite revisar el modelo. Eric Evans presenta esta relación en su referencia de DDD, sección Model-Driven Design.

No basta con dibujar un pedido con líneas y luego programar un objeto que admite cualquier modificación. Si el modelo afirma que un pedido despachado no puede cancelarse directamente, la implementación debe proteger esa restricción.

El Ubiquitous Language, el vocabulario compartido con quienes conocen el negocio, aporta nombres y significados. Por ejemplo, «despachar» puede significar entregar físicamente el paquete al transportista, no solo generar una etiqueta. El Domain Model añade relaciones y comportamiento: qué puede hacer un pedido, en qué condiciones y con qué resultado.

El modelo tampoco es una especificación perfecta que alguien termina antes de programar. Puede empezar con tres reglas y evolucionar al descubrir una excepción. Lo importante es corregir conjuntamente la explicación y el comportamiento implementado.

Domain Model y Data Model responden preguntas diferentes

Un Data Model describe cómo se estructuran y relacionan los datos. En una base relacional, incluye decisiones como tablas, columnas, claves y restricciones. Un Domain Model se ocupa del significado y de las operaciones válidas.

Para nuestra tienda, el Data Model podría contener orders, order_lines y shipping_address. El Domain Model necesita responder otras preguntas: ¿cuándo se puede confirmar?, ¿qué queda fijado al confirmar?, ¿cuándo deja de ser posible cancelar?

Ambos deben colaborar. Una restricción de unicidad o una transacción de base de datos puede ser indispensable para sostener una regla. La diferencia es que conocer las columnas no explica por sí solo todas las decisiones del negocio.

Tampoco necesitas una clase por tabla. Un objeto puede componerse a partir de varias tablas, y una consulta de lectura puede devolver una estructura simple sin reconstruir todo el comportamiento. La representación depende de qué operación estás resolviendo.

El problema aparece cuando todas las decisiones se reducen a editar campos. Fowler llama Anemic Domain Model a un diseño que presenta objetos de Domain, pero concentra su comportamiento fuera de ellos. Su análisis del patrón distingue esa situación de elegir deliberadamente una solución más simple para un problema sencillo.

Ejemplo principal: un pedido con operaciones válidas

Estado inicial: podemos guardar datos, pero no expresar la intención

Este fragmento parcial representa una solución centrada en estructuras de datos. No es una implementación completa de persistencia:

type OrderData = {
  status: 'draft' | 'confirmed' | 'dispatched' | 'cancelled';
  items: string[];
};

function updateOrder(order: OrderData, changes: Partial<OrderData>): void {
  Object.assign(order, changes);
}

const order: OrderData = { status: 'dispatched', items: ['book'] };
updateOrder(order, { status: 'cancelled' });

El tipo admite estados conocidos, pero no impide transiciones incorrectas entre ellos. También permite reemplazar los artículos de un pedido ya confirmado.

Un controlador podría añadir comprobaciones. Después las necesitarían una tarea programada y una importación administrativa. La regla acabaría dependiendo de que cada entrada recordara aplicarla.

La decisión: representar el ciclo del pedido

Para este ejemplo acordamos cuatro reglas:

  1. Un pedido nuevo está en draft y solo en ese estado puede recibir artículos.
  2. Confirmar exige al menos un artículo y fija su contenido.
  3. Despachar exige que el pedido esté confirmado.
  4. Cancelar está permitido en draft o confirmed; después del despacho requiere otro proceso y se rechaza aquí.

Son decisiones de este caso, no reglas universales del comercio. No modelaremos cobros, descuentos, stock ni devoluciones. Cada artículo representa una unidad de un producto y el mismo identificador puede aparecer varias veces. Esta simplificación evita introducir cantidades y precios que no necesitamos para explicar las transiciones.

El cambio: operaciones con reglas, en lugar de cambios arbitrarios

El siguiente programa es autocontenido. Guárdalo como model-driven-order.ts y ejecútalo con node model-driven-order.ts en Node.js 24.21.0. No requiere paquetes externos. Node puede ejecutar esta sintaxis de TypeScript eliminando los tipos, pero no realiza comprobación estática de tipos durante esa ejecución.

import assert from 'node:assert/strict';

type OrderStatus = 'draft' | 'confirmed' | 'dispatched' | 'cancelled';

class Order {
  #id: string;
  #status: OrderStatus = 'draft';
  #items: string[] = [];

  constructor(id: string) {
    if (!id.trim()) throw new Error('El pedido necesita un identificador');
    this.#id = id;
  }

  addItem(productId: string): void {
    if (this.#status !== 'draft') {
      throw new Error('Solo se puede modificar un pedido en preparación');
    }
    if (!productId.trim()) throw new Error('El producto necesita un identificador');
    this.#items.push(productId);
  }

  confirm(): void {
    if (this.#status !== 'draft') {
      throw new Error('Solo se puede confirmar un pedido en preparación');
    }
    if (this.#items.length === 0) {
      throw new Error('Un pedido vacío no puede confirmarse');
    }
    this.#status = 'confirmed';
  }

  dispatch(): void {
    if (this.#status !== 'confirmed') {
      throw new Error('Solo se puede despachar un pedido confirmado');
    }
    this.#status = 'dispatched';
  }

  cancel(): void {
    if (this.#status !== 'draft' && this.#status !== 'confirmed') {
      throw new Error('El pedido ya no admite cancelación directa');
    }
    this.#status = 'cancelled';
  }

  snapshot(): { id: string; status: OrderStatus; items: string[] } {
    // Devuelve una copia para que una lectura no permita saltarse las reglas.
    return { id: this.#id, status: this.#status, items: [...this.#items] };
  }
}

const empty = new Order('order-empty');
assert.throws(() => empty.confirm(), /vacío/);
assert.throws(() => empty.dispatch(), /confirmado/);
assert.equal(empty.snapshot().status, 'draft');

const order = new Order('order-1');
order.addItem('book');
order.confirm();
assert.throws(() => order.addItem('notebook'), /modificar/);

const externalCopy = order.snapshot();
externalCopy.items.push('unauthorized-item');
assert.deepEqual(order.snapshot().items, ['book']);

order.dispatch();
assert.throws(() => order.cancel(), /cancelación directa/);
assert.deepEqual(order.snapshot(), {
  id: 'order-1', status: 'dispatched', items: ['book'],
});

const cancellable = new Order('order-2');
cancellable.addItem('notebook');
cancellable.confirm();
cancellable.cancel();
assert.equal(cancellable.snapshot().status, 'cancelled');
assert.throws(() => cancellable.dispatch(), /confirmado/);

console.log('Reglas del ciclo del pedido verificadas');

Resultado observable: el estado ya no cambia por cualquier camino

La primera entrada es un pedido vacío. El intento de confirmación falla y permanece en draft. Después trabajamos con un pedido que contiene book: puede confirmarse y despacharse, pero no admite nuevos artículos tras confirmar ni una cancelación directa después de despachar.

El programa se ejecutó con Node.js 24.21.0 y completó las aserciones incluidas. No se realizó una comprobación estática con el compilador de TypeScript.

La diferencia con updateOrder es concreta: el consumidor solicita una operación y el modelo decide si es válida. Los campos privados impiden modificar directamente el estado; la copia de lectura evita cambiar el arreglo interno desde fuera.

Una Invariant es una condición que debe mantenerse tras cada operación válida. En este modelo, un pedido confirmado no está vacío y su contenido permanece fijado durante el resto del ciclo. Las aserciones comprueban tanto los rechazos como que el estado se conserva cuando una operación falla.

El código usa una clase porque permite mostrar juntas identidad, estado y comportamiento. Model-Driven Design no depende de esa sintaxis: también puedes expresar un modelo con funciones y tipos inmutables, siempre que conserves sus reglas y relaciones.

Qué falta para usarlo en un backend

Un Use Case, la operación que ofrece la aplicación, coordinaría cargar el pedido, ejecutar dispatch() y guardar el resultado. El Domain Model no necesita conocer HTTP ni generar respuestas del transporte.

Este ejemplo termina en memoria. No incluye recuperación desde almacenamiento, autorización ni coordinación de escrituras concurrentes. Dos procesos podrían cargar el mismo estado y tomar decisiones incompatibles si la persistencia no detecta el conflicto. Un control de versión o un bloqueo transaccional debe diseñarse y probarse en el adaptador real.

Encapsular una regla hace explícita la intención; no reemplaza las garantías necesarias para sostenerla cuando el sistema se ejecuta.

Segundo ejemplo: educación y el significado de un certificado

Una plataforma almacena progress = 100 y certificateUrl. Una implementación interpreta que alcanzar el cien por ciento debe crear un certificado inmediatamente.

Al revisar el proceso, aparece otra regla: terminar las lecciones permite presentar la evaluación; aprobarla habilita la emisión del certificado. El porcentaje de consumo no representa ninguna de esas decisiones por sí solo.

Aquí el cambio no consiste en añadir otro estado al pedido anterior. Consiste en reconocer conceptos diferentes: progreso, resultado de evaluación y habilitación para certificar. Un modelo pequeño podría expresar esta condición con una función pura, que calcula un resultado sin modificar estado externo:

// Fragmento didáctico: recibe evidencias ya validadas y de la misma inscripción.
function isEligibleForCertificate(evidence: {
  requiredLessonsCompleted: boolean;
  finalAssessmentPassed: boolean;
}): boolean {
  return evidence.requiredLessonsCompleted && evidence.finalAssessmentPassed;
}

La entrada «lecciones terminadas, evaluación no aprobada» produce false. Con ambas evidencias produce true, pero no afirma que ya exista un documento emitido. El modelo distingue una decisión de elegibilidad de la operación de emisión.

Este ejemplo enseña que un Domain Model puede empezar con una función precisa. No necesitas convertir cada regla en una jerarquía de clases. Sí necesitas garantizar que las evidencias corresponden a la misma inscripción y a sus requisitos vigentes; la función no resuelve esa obtención.

Tercer ejemplo: una integración no debe decidir tu modelo

Una empresa logística recibe del transportista DELIVERED. Su primer adaptador lo convierte en order.status = 'completed'. Sin embargo, el cierre comercial del pedido también requiere revisar una incidencia pendiente.

El formato externo describe un hecho de transporte, no la decisión completa del negocio. La propuesta es traducirlo a «entrega física confirmada» y dejar que el modelo propio determine si corresponde cerrar el pedido.

Una Anticorruption Layer, o ACL, adapta conceptos externos para proteger el modelo consumidor. En este ejemplo puede hacerlo consultando HTTP y validando la respuesta contra un contrato definido por el consumidor. Los Bounded Contexts —límites donde cada modelo conserva un significado consistente— mantienen sus implementaciones separadas, sin importaciones entre ellos.

La integración aporta evidencia; no obliga a copiar sus estados. Además, consultar HTTP y guardar localmente no constituye una transacción compartida: hay que decidir qué hacer ante fallos y cambios entre ambas operaciones.

De este caso puedes aprender a distinguir representación de intercambio y significado interno. Model-Driven Design también consiste en elegir qué conceptos no deben entrar directamente al modelo.

Las piezas de DDD y la responsabilidad de cada una

Estos patrones ayudan a expresar decisiones del modelo. No son una lista de componentes obligatorios. Las siguientes definiciones resumen los building blocks de Evans; los ejemplos son elecciones didácticas:

  • Entity: conserva identidad a través de sus cambios. Order sigue siendo el mismo pedido al despacharse.
  • Value Object: se define por sus valores. Una cantidad monetaria combina importe y moneda, sin identidad propia necesaria.
  • Aggregate: establece un límite de consistencia con una Aggregate Root que controla sus cambios. Un pedido puede proteger sus líneas.
  • Domain Service: expresa una operación del Domain que no encaja naturalmente en una Entity o Value Object. Por ejemplo, una política que compara opciones de transporte.
  • Domain Event: representa un hecho relevante ocurrido. OrderDispatched expresa un despacho, no su solicitud.
  • Factory: concentra creación compleja y garantiza un resultado válido. Podría construir un pedido a partir de una cotización aceptada.
  • Repository: ofrece recuperar y guardar Aggregates sin exponer los detalles de almacenamiento al consumidor.

Estas responsabilidades permiten elegir qué piezas necesita cada modelo. Para el ejemplo principal bastó Order: añadir Factories y Domain Services vacíos habría aumentado el recorrido sin representar nuevas decisiones. Tampoco una Factory que crea objetos nuevos reemplaza necesariamente las reglas para reconstruir objetos históricos desde persistencia.

Cómo saber si el modelo sigue siendo coherente

Un modelo puede tener nombres correctos y comportarse mal. Busca correspondencia entre tres elementos: la regla acordada, la operación que la aplica y el escenario que la comprueba.

Si el negocio dice «después del despacho hay que gestionar una devolución», revisa que no exista una entrada administrativa que cambie directamente el estado a cancelled. Si aparece una excepción legítima, modifica el modelo para representarla en lugar de abrir una puerta genérica que evite sus reglas.

También conviene revisar quién coordina y quién decide. El Use Case puede cargar datos y guardar resultados; las reglas centrales no deberían quedar duplicadas en cada controlador. A la vez, no conviertas todo acceso a datos en una operación compleja: una lista de pedidos puede necesitar una consulta de lectura simple.

Cuándo basta una solución más sencilla

Un catálogo interno de categorías con nombre y descripción quizá solo necesite operaciones CRUD: crear, leer, actualizar y eliminar. Validación de entrada, un servicio pequeño y restricciones de base de datos pueden resolverlo de forma clara.

La estructura de datos no es el problema. El problema es utilizarla como explicación suficiente cuando hay reglas de transición, excepciones y decisiones que no caben en «editar este registro».

Mantener un modelo con comportamiento tiene costes: descubrir reglas, elegir límites y revisar pruebas cuando cambian. Inviértelo donde las decisiones lo justifiquen. Un Domain Model no necesita representar toda la organización ni adelantarse a requisitos que todavía no existen.

Cómo usar el modelo para orientar y revisar código generado por IA

Un esquema de datos permite pedir que se genere una API, pero deja abiertas muchas decisiones. Un modelo explícito añade operaciones válidas, condiciones y contraejemplos. Esa información permite evaluar la respuesta más allá de si compila.

Los siguientes prompts son aplicables a herramientas como Cursor, Claude o Codex. Comparte únicamente contexto autorizado y confirma que las reglas adjuntas estén vigentes.

Analizar un diseño centrado en campos

Contexto: el pedido puede modificarse solo en draft, confirmarse si tiene artículos y despacharse solo después de confirmar. No se cancela directamente después del despacho. Analiza el código adjunto y localiza caminos que permitan saltarse esas reglas. No edites archivos ni inventes políticas de pago. Devuelve evidencia, escenario y una propuesta mínima de operaciones. Una persona validará las excepciones con negocio y comprobará que los caminos señalados existen.

Implementar un modelo acotado

Implementa Order en TypeScript sin frameworks ni paquetes externos. Expresa addItem, confirm, dispatch y cancel con las reglas adjuntas. Evita cambios arbitrarios de estado y devuelve copias en las lecturas. No añadas stock, pagos, eventos ni persistencia. Entrega código autocontenido y aserciones de pedido vacío, modificación tras confirmar y cancelación tras despachar. Una persona ejecutará el ejemplo y verificará que las reglas representen el proceso real.

Revisar un cambio del modelo

Ahora se permite solicitar una devolución tras el despacho, pero eso no equivale a cancelar ni a reembolsar. Revisa la propuesta adjunta contra el modelo actual. No cambies archivos. Señala confusiones entre intención y hecho, reglas duplicadas y pruebas ausentes. No supongas transacciones compartidas por HTTP. Devuelve preguntas pendientes y casos observables. Una persona decidirá qué operación pertenece a cada Bounded Context y validará los contratos.

La IA puede ayudar a detectar contradicciones. Si implementación y pruebas repiten una misma interpretación incorrecta, ambas pueden coincidir y aun así incumplir el negocio. El modelo necesita contraste humano y evidencia de comportamiento.

Tres ejercicios con solución razonada

1. La dirección de un pedido despachado

Una propuesta añade setAddress() y permite usarlo en cualquier estado. ¿Qué revisarías?

Solución razonada: pregunta cuándo la dirección queda fijada y cómo se solicita un cambio posterior. Si después del despacho depende del transportista, no basta con sobrescribir un campo local. La operación debería representar una solicitud con resultado pendiente o un rechazo, según el acuerdo. Comprueba que actualizar la dirección del perfil tampoco reescriba la dirección histórica del envío.

2. Un certificado emitido antes de aprobar

La IA propone renombrar progress a completionProgress, pero mantiene la emisión automática al llegar a cien. ¿Resuelve el problema?

Solución razonada: no cambia la decisión. Separa evidencia de consumo, aprobación y emisión. Añade el caso «todas las lecciones terminadas y evaluación suspensa»: no debe habilitar el certificado bajo las reglas del ejemplo. El nombre debe acompañar una regla correcta.

3. Una Factory sin creación compleja

El equipo propone una Factory, un Domain Service y un Repository para una categoría con nombre obligatorio. ¿Cómo decidirías?

Solución razonada: identifica qué responsabilidad real necesita cada pieza. Si crear la categoría solo requiere validar un nombre, un constructor o una función puede bastar. La persistencia puede justificar su propia separación, pero no convierte automáticamente la creación en compleja. Introduce abstracciones cuando representen decisiones y reduzcan dependencias concretas.

Qué comprobar antes de dar un modelo por útil

Puedes reconocer Model-Driven Design cuando las operaciones importantes tienen un significado compartido, sus restricciones aparecen en el código y existen escenarios que permiten comprobarlas. También cuando un cambio del negocio obliga a revisar conscientemente el modelo, en lugar de acumular excepciones alrededor de sus campos.

Empieza por una operación con reglas relevantes. Expresa sus entradas, condiciones y resultados; implementa lo necesario y contrasta el comportamiento. Ese vínculo entre conocimiento y código es lo que te permite orientar el trabajo de la IA y juzgar si la solución representa el problema que necesitas resolver.

Cargando conversación…

Sigue aprendiendo