Arquitectura de Software

Domain-Driven Design: Entities, identidad, comportamiento y ciclo de vida

Aprende a reconocer una Entity por su identidad y a proteger su evolución mediante operaciones de negocio. Implementa un ejemplo completo en TypeScript, distingue igualdad por identidad de igualdad por referencia y utiliza pruebas para revisar tanto tu modelo como el código generado por IA.

Cargando tus opciones de aprendizaje…

Una persona comunica un problema a soporte. Se crea un ticket con el asunto «No puedo acceder». Después corrige el texto, un agente se hace cargo y finalmente registra una solución. El asunto, el responsable y el estado cambian, pero nadie considera que haya aparecido un ticket distinto.

Ahora imagina otro ticket con exactamente el mismo asunto y responsable. Puede corresponder a un incidente diferente. Comparar sus propiedades no basta para decidir si hablamos del mismo caso.

Esa continuidad es el punto de partida de una Entity: importa reconocer cuál es mientras cambia. El trabajo de diseño consiste en decidir qué identifica esa continuidad y qué operaciones permiten que evolucione de forma válida.

Una Entity se reconoce por su identidad

Una Entity representa algo cuya identidad debe mantenerse durante su ciclo de vida, aunque cambien sus atributos. Evans presenta esta distinción en la sección Entities de su referencia de DDD.

La identidad responde «¿es el mismo ticket?». Sus propiedades responden «¿cómo está ahora?». Son preguntas distintas.

Un identificador como ticket-42 puede hacer explícita la primera respuesta. Pero añadir un campo id a cualquier estructura no demuestra que el concepto necesite modelarse como Entity. La pregunta relevante es si el negocio necesita distinguirlo de otros y seguir su evolución.

También debes definir el alcance de la identidad. Si dos organizaciones pueden tener un ticket número 42, ese número por sí solo no identifica el caso fuera de su organización. Necesitas considerar la organización junto con el número, o utilizar otro identificador inequívoco dentro del alcance acordado.

El identificador no tiene por qué ser una propiedad visible del negocio

Un documento puede tener una referencia comercial; un envío, un código de seguimiento; una persona, un correo electrónico. Algunos de esos valores pueden cambiar o no ser únicos en todos los escenarios.

Por eso conviene distinguir el identificador estable de las referencias externas modificables. Un código de seguimiento corregido no debería convertir un envío existente en otro envío.

Puedes generar identificadores mediante distintas estrategias. Elegir un UUID o una secuencia es una decisión técnica; determinar qué mantiene su continuidad es una decisión del modelo. Ninguna estrategia sustituye los controles de unicidad necesarios en el sistema real.

Igualdad por identidad, por referencia y por estado

En JavaScript, === compara referencias cuando los operandos son objetos. Dos instancias diferentes no son iguales por referencia aunque representen la misma Entity.

En una aplicación puedes tener un ticket en memoria y reconstruir otra instancia a partir de información guardada. Ambas representan el mismo caso si conservan la identidad acordada. Sus atributos incluso pueden diferir si una corresponde a una lectura anterior.

Por eso implementaremos sameIdentityAs(): su nombre expresa exactamente qué compara. Que devuelva true no significa que las dos instancias contengan el estado más reciente ni que sea seguro guardar cualquiera de ellas.

Esta distinción importa al deduplicar resultados, comparar objetos recuperados o revisar cambios. Usar el asunto como identidad fusionaría tickets distintos; usar únicamente la referencia de memoria separaría dos representaciones del mismo ticket.

De una estructura de datos a operaciones de negocio

Este fragmento parcial muestra una clase que permite almacenar datos, pero no protege sus relaciones:

class TicketData {
  id = '';
  subject = '';
  status: 'open' | 'resolved' = 'open';
  assigneeId: string | null = null;
  resolution: string | null = null;
}

const ticket = new TicketData();
ticket.id = 'ticket-42';
ticket.status = 'resolved';
// No hay responsable ni explicación de la solución.

El tipo restringe las palabras que admite status, pero no garantiza que un ticket resuelto tenga responsable y explicación. Un setter genérico como setStatus() puede conservar el mismo problema si solo asigna el valor recibido.

Fowler describe Anemic Domain Model como un diseño con objetos que parecen representar el Domain, pero cuyo comportamiento está concentrado fuera de ellos. Su análisis ayuda a identificar el coste de mantener objetos con identidad y relaciones sin aprovecharlos para expresar las decisiones del modelo.

Eso no convierte cualquier estructura de datos en un error. Una respuesta de lectura o un formulario pueden necesitar solo datos. El problema aparece cuando una Entity que debería proteger reglas depende de que cada controlador o proceso recuerde aplicarlas por separado.

Ejemplo completo: el ciclo de vida de un ticket

Las reglas que vamos a implementar

Para este caso didáctico acordamos lo siguiente:

  • Un ticket se crea abierto, sin responsable ni solución.
  • Solo un ticket abierto permite cambiar el asunto o asignar un responsable.
  • Resolver exige un responsable y una explicación no vacía.
  • Reabrir solo es válido para un ticket resuelto. Conserva al responsable, retira la solución actual y mantiene la identidad.
  • Un ticket resuelto debe tener responsable y solución; uno abierto no tiene solución actual.

La última condición es una Invariant: una propiedad que debe mantenerse después de cada operación válida. Las reglas de reapertura son decisiones de este ejemplo, no una política universal de soporte.

El ciclo queda expresado así:

Creación -> open -> resolve(...) -> resolved
             ^                        |
             +------- reopen() -------+

Cambiar el asunto y asignar responsable solo se permite en open.

El ejemplo guarda únicamente el estado actual. Retirar la solución al reabrir no representa una política de borrado del historial: una aplicación que necesite auditoría conservaría también las resoluciones anteriores mediante un diseño adicional.

El programa

Este bloque es autocontenido. Guárdalo como entities.ts y ejecútalo con node entities.ts en Node.js 24.21.0. Solo utiliza un módulo incluido en Node.js; no necesita framework, ORM ni base de datos.

La ejecución directa utiliza type stripping, que elimina las anotaciones de TypeScript, pero no comprueba estáticamente los tipos. El programa valida los datos relevantes para sus reglas durante la ejecución.

import assert from 'node:assert/strict';

type TicketSnapshot = Readonly<{
  id: string;
  subject: string;
  status: 'open' | 'resolved';
  assigneeId: string | null;
  resolution: string | null;
}>;

class TicketRuleError extends Error {}

function requireText(value: unknown, label: string): string {
  if (typeof value !== 'string' || value.trim().length === 0) {
    throw new TicketRuleError(`${label} no puede estar vacío`);
  }
  return value.trim();
}

class SupportTicket {
  #state: TicketSnapshot;

  private constructor(state: TicketSnapshot) {
    const id = requireText(state.id, 'El identificador');
    const subject = requireText(state.subject, 'El asunto');
    const assigneeId = state.assigneeId === null
      ? null : requireText(state.assigneeId, 'El responsable');
    const resolution = state.resolution === null
      ? null : requireText(state.resolution, 'La solución');

    if (state.status !== 'open' && state.status !== 'resolved') {
      throw new TicketRuleError('Estado de ticket desconocido');
    }
    if (state.status === 'resolved' && (assigneeId === null || resolution === null)) {
      throw new TicketRuleError('Un ticket resuelto necesita responsable y solución');
    }
    if (state.status === 'open' && resolution !== null) {
      throw new TicketRuleError('Un ticket abierto no tiene solución actual');
    }
    this.#state = { id, subject, status: state.status, assigneeId, resolution };
  }

  static create(id: string, subject: string): SupportTicket {
    return new SupportTicket({
      id, subject, status: 'open', assigneeId: null, resolution: null,
    });
  }

  static restore(snapshot: TicketSnapshot): SupportTicket {
    // Reconstruir conserva la identidad y el estado; no inicia otro ticket.
    return new SupportTicket(snapshot);
  }

  sameIdentityAs(other: SupportTicket): boolean {
    return other instanceof SupportTicket && this.#state.id === other.#state.id;
  }

  changeSubject(subject: string): void {
    this.#requireOpen();
    const validSubject = requireText(subject, 'El asunto');
    this.#state = { ...this.#state, subject: validSubject };
  }

  assignTo(assigneeId: string): void {
    this.#requireOpen();
    const validAssignee = requireText(assigneeId, 'El responsable');
    this.#state = { ...this.#state, assigneeId: validAssignee };
  }

  resolve(resolution: string): void {
    this.#requireOpen();
    if (this.#state.assigneeId === null) {
      throw new TicketRuleError('Asigna un responsable antes de resolver');
    }
    const validResolution = requireText(resolution, 'La solución');
    this.#state = { ...this.#state, status: 'resolved', resolution: validResolution };
  }

  reopen(): void {
    if (this.#state.status !== 'resolved') {
      throw new TicketRuleError('Solo se puede reabrir un ticket resuelto');
    }
    this.#state = { ...this.#state, status: 'open', resolution: null };
  }

  snapshot(): TicketSnapshot {
    return { ...this.#state };
  }

  #requireOpen(): void {
    if (this.#state.status !== 'open') {
      throw new TicketRuleError('La operación requiere un ticket abierto');
    }
  }
}

const ticket = SupportTicket.create('ticket-42', 'No puedo acceder');
const previousReading = SupportTicket.restore(ticket.snapshot());

ticket.changeSubject('No puedo acceder al área de cursos');
assert.equal(ticket === previousReading, false);
assert.equal(ticket.sameIdentityAs(previousReading), true);
assert.notEqual(ticket.snapshot().subject, previousReading.snapshot().subject);

const anotherTicket = SupportTicket.create('ticket-43', ticket.snapshot().subject);
assert.equal(ticket.sameIdentityAs(anotherTicket), false);

assert.throws(() => ticket.resolve('Se restableció el acceso'), /responsable/);
assert.equal(ticket.snapshot().status, 'open');

ticket.assignTo('agent-7');
const beforeFailure = ticket.snapshot();
assert.throws(() => ticket.resolve('   '), /vacío/);
assert.deepEqual(ticket.snapshot(), beforeFailure);

ticket.resolve('Se restableció el acceso');
assert.equal(ticket.snapshot().status, 'resolved');
assert.throws(() => ticket.assignTo('agent-8'), /ticket abierto/);

ticket.reopen();
assert.equal(ticket.snapshot().id, 'ticket-42');
assert.equal(ticket.snapshot().assigneeId, 'agent-7');
assert.equal(ticket.snapshot().resolution, null);
assert.throws(() => ticket.reopen(), /ticket resuelto/);

const view = ticket.snapshot();
Object.assign(view, { id: 'changed-outside', status: 'resolved' });
assert.equal(ticket.snapshot().id, 'ticket-42');
assert.equal(ticket.snapshot().status, 'open');

assert.throws(() => SupportTicket.restore({
  id: 'broken', subject: 'Estado inconsistente', status: 'resolved',
  assigneeId: null, resolution: null,
}), /responsable y solución/);

console.log('Identidad, reglas y transiciones verificadas');

Cómo protege el comportamiento

La creación establece un estado inicial válido. Las operaciones utilizan verbos del negocio: assignTo, resolve, reopen. No permiten enviar un conjunto arbitrario de propiedades para cambiarlo todo a la vez.

El programa se ejecutó con Node.js 24.21.0 y completó las aserciones incluidas. Esta comprobación cubre los escenarios mostrados; no se realizó análisis estático con el compilador de TypeScript.

Cada método comprueba sus condiciones antes de reemplazar el estado. Por ejemplo, una solución vacía produce un error sin dejar el ticket parcialmente resuelto. Esa conservación del estado tras un rechazo es parte de lo que comprueban las aserciones.

El campo #state es privado en tiempo de ejecución. La sintaxis # de JavaScript restringe el acceso desde fuera de la clase, como documenta MDN. No depende únicamente de las comprobaciones estáticas de TypeScript.

El tipo Readonly expresa una restricción para el comprobador de tipos; no congela por sí solo el objeto durante la ejecución. Por eso snapshot() devuelve una copia. En este ejemplo todos sus campos son valores primitivos y una copia superficial basta. Con arreglos u objetos anidados, tendrías que revisar también esas referencias.

La prueba que modifica view muestra esa diferencia: cambia una representación externa, pero no la identidad ni el estado interno del ticket. La encapsulación protege las operaciones del objeto; no constituye un mecanismo de autorización o una frontera de seguridad para todo el sistema.

Crear y reconstruir no son la misma operación

create() inicia un ticket abierto. restore() reconstruye una representación existente y conserva tanto su identificador como su estado. Ambos caminos validan las condiciones que acepta el modelo.

En el ejemplo pasamos identificadores explícitos para hacer las pruebas legibles. En una aplicación real, el responsable de asignarlos debe garantizar su unicidad dentro del alcance definido. Reconstruir no debería generar otro identificador ni volver a ejecutar efectos propios de una creación nueva.

restore() recibe una estructura conocida; no es un parser completo de JSON arbitrario. El código que recibe datos externos debe comprobar su forma antes de invocarlo. Si un sistema histórico contiene estados incompatibles con las reglas actuales, necesitas decidir cómo interpretarlos o migrarlos: no omitir validaciones silenciosamente.

Las dos instancias del ejemplo representan el mismo ticket, pero contienen asuntos diferentes. La lectura anterior no se actualiza sola. Si guardas dos representaciones concurrentes, necesitas detectar conflictos de escritura mediante mecanismos adicionales. sameIdentityAs() no los resuelve.

Otro escenario: una inscripción conserva su historia

Una academia tiene un estudiante y un curso. La primera propuesta usa ambos identificadores como identidad de la inscripción: «una inscripción es la pareja estudiante-curso».

Esa elección puede ser correcta si el negocio admite una sola inscripción para siempre. Pero si permite repetir el curso en otra convocatoria, la misma pareja identifica dos participaciones diferentes. La segunda puede tener otras fechas, calificaciones y condiciones.

En ese caso, Enrollment necesita distinguir cada participación. Cambiar el correo del estudiante no debería cambiar la identidad de su inscripción, y suspenderla no debería sustituirla por una nueva si el negocio considera que sigue siendo el mismo proceso.

Este ejemplo enseña a revisar la identidad contra el ciclo de vida completo, no solo contra la pantalla de alta. Pregunta por reintentos, renovaciones, reaperturas y nuevas participaciones antes de elegir una clave compuesta.

Comercio: Entity y Value Object no se eligen por la cantidad de campos

Un pedido tiene una dirección de entrega. Para enviar ese pedido quizá solo importen sus valores: calle, número y localidad. Dos direcciones con los mismos componentes pueden ser equivalentes para ese propósito. Esa es la perspectiva de un Value Object, que se define por sus valores, como distingue Evans.

En cambio, una entrada de la libreta de direcciones del cliente puede necesitar identidad: se etiqueta, se edita y se selecciona como favorita a lo largo del tiempo. Aunque sus valores cambien, el cliente reconoce la misma entrada guardada.

No hay contradicción. Son responsabilidades diferentes. La dirección acordada para un pedido puede mantenerse como copia de valores, mientras la entrada de la libreta evoluciona por separado. Editar la libreta no debería reescribir automáticamente dónde se envió un pedido anterior.

Este caso enseña que un concepto no es Entity por naturaleza en todos los modelos. Debes decidir si necesitas seguir su continuidad o solo representar un valor para una operación concreta.

Una Entity tampoco equivale a un registro de persistencia

Un Persistence Model organiza la representación necesaria para guardar y recuperar información. Puede incluir nombres de columnas, claves técnicas o metadatos que no forman parte del comportamiento del negocio.

La Entity representa identidad y operaciones válidas. Puede reconstruirse a partir de datos guardados, pero no necesita heredar de una clase de ORM ni recibir una conexión a la base de datos para resolver un ticket.

Que ambos modelos compartan algunos campos no los hace equivalentes. Tampoco obliga a mantener dos clases idénticas en cualquier proyecto: la separación concreta depende de las necesidades y del acoplamiento que aceptes. Para aprender y comprobar las reglas aquí, mantener la Entity independiente evita introducir detalles que no participan en sus decisiones.

Sus métodos tampoco deberían responder HTTP ni enviar correos. Un Use Case puede coordinar la operación y los efectos necesarios fuera de la Entity, manteniendo dentro las decisiones que corresponden a su estado y sus reglas.

Cuándo una solución más simple es suficiente

Un formulario temporal para filtrar tickets no necesita identidad propia ni un ciclo de vida controlado. Puede ser una estructura con campos opcionales. Una proyección de lectura puede limitarse a id, subject y status para mostrar una lista sin ofrecer operaciones de negocio.

Incluso un registro con identidad y pocas reglas puede requerir muy poco comportamiento. No añadas estados, herencia ni una clase base genérica solo para que parezca una Entity más sofisticada.

La profundidad de implementación debe responder al problema. En nuestro ticket, la relación entre responsable, solución y estado justifica proteger cambios. En una estructura usada únicamente para transportar datos, esos mismos métodos serían una responsabilidad ajena.

Cómo orientar y revisar Entities generadas con IA

Pedir «crea una entidad Ticket» deja abiertas identidad, estados y transiciones. Una herramienta puede entregar una clase con getters y setters sin resolver ninguna de esas decisiones.

Proporciona el alcance de identidad, estado inicial, operaciones, precondiciones y ejemplos de rechazo. Indica también qué no debe hacer la Entity. Estos prompts sirven como punto de partida para herramientas como Cursor, Claude o Codex; la persona responsable debe validar las reglas antes de convertirlas en código.

Analizar la identidad

Contexto: una academia permite que un estudiante repita un curso en convocatorias diferentes. Analiza si estudiante-curso identifica una inscripción o si necesitamos distinguir participaciones. Devuelve alternativas, contraejemplos y preguntas abiertas; no implementes ni inventes políticas. Una persona de coordinación comprobará qué proceso conserva continuidad y cuándo comienza otro.

Implementar comportamiento y rechazos

Implementa SupportTicket en TypeScript sin dependencias. La identidad no cambia al modificar el asunto o reabrir. Resolver exige responsable y solución no vacía; reabrir conserva responsable y retira la solución actual. No uses setters genéricos, HTTP, ORM ni efectos externos. Entrega código autocontenido y aserciones de igualdad por identidad, rechazo sin cambio parcial y reconstrucción inválida. Una persona ejecutará las pruebas y validará la política de reapertura y auditoría.

Revisar una propuesta existente

Revisa esta Entity contra las reglas adjuntas. Busca identidad modificable, igualdad basada en atributos, estado interno expuesto, transiciones sin precondiciones y validaciones ejecutadas después de mutar. No cambies archivos. Devuelve evidencia y un escenario observable por hallazgo; distingue dudas de errores. Una persona revisará también concurrencia, autorización y persistencia, que no quedan garantizadas por encapsular la clase.

La IA puede generar pruebas que repitan sus propias suposiciones. Contrástalas con escenarios acordados: una prueba que considera válido resolver sin responsable no protege la regla de este ejemplo, aunque pase junto con la implementación.

Tres ejercicios con solución razonada

1. Dos lecturas del mismo envío

Dos instancias tienen el mismo identificador de envío, pero una conserva el código de seguimiento anterior. ¿Son la misma Entity? ¿Puedes guardar cualquiera?

Solución razonada: representan la misma Entity si ese identificador es estable y único en el alcance acordado. Eso no garantiza el mismo estado ni su vigencia. Antes de guardar hay que tratar la posible lectura desactualizada; comparar identidad no evita sobrescribir cambios recientes.

2. Resolver y asignar después

Una propuesta cambia primero status a resolved y luego comprueba si existe responsable. Si falta, lanza un error. ¿Qué comportamiento debe proteger una prueba?

Solución razonada: comprobar que el rechazo deja el ticket abierto y conserva los demás datos. La validación debe ocurrir antes de mutar, o la operación debe construir y validar un nuevo estado antes de reemplazar el anterior. Lanzar una excepción no deshace por sí solo las asignaciones ya realizadas.

3. Reabrir creando otro identificador

El equipo propone implementar reopen() llamando a create() con un identificador nuevo. ¿Cuándo sería correcto?

Solución razonada: solo si el negocio define esa acción como abrir un caso diferente, quizá relacionado con el anterior. Si reabrir significa continuar el mismo ticket, hay que conservar su identidad y controlar su transición. El nombre y el comportamiento deben expresar cuál de los dos procesos se acordó.

Cómo reconocer una Entity bien planteada

Busca una razón para seguir su continuidad, un alcance claro de identidad y operaciones que expliquen cómo puede cambiar. Comprueba qué estados son válidos, qué ocurre cuando una operación falla y si una lectura externa permite saltarse las reglas.

Una Entity útil conserva quién es mientras controla cómo evoluciona. Esa combinación de identidad, comportamiento y ciclo de vida te permite revisar el modelo con criterios concretos, tanto si lo escribiste tú como si utilizaste IA para implementarlo.

Cargando conversación…

Sigue aprendiendo