Arquitectura de Software

Domain-Driven Design: Value Objects, conceptos del negocio mediante valores e inmutabilidad

Aprende a representar conceptos con Value Objects que validan sus datos, se comparan por valor y ofrecen operaciones inmutables. Implementa Money, Email y Address en TypeScript, úsalos dentro de una Entity y descubre qué comprobar al revisar código generado por IA.

Cargando tus opciones de aprendizaje…

Una tienda recibe dos importes: 1000 y 500. El backend los suma y devuelve 1500. La operación aritmética es correcta, pero el primer importe estaba en céntimos de euro y el segundo en centavos de dólar.

En otra pantalla, alguien interpreta 1000 como mil euros. En una tercera, se modifica el precio de un producto y cambia también el importe que compartía una línea del pedido.

Un number permite todas esas operaciones porque desconoce moneda, unidad y significado. Un Value Object incorpora ese conocimiento al modelo: representa un valor del negocio junto con las reglas que permiten crearlo, compararlo y utilizarlo.

Qué caracteriza a un Value Object

Un Value Object se define por los valores que representa, sin una identidad conceptual propia que necesites seguir en el tiempo. Dos importes de diez euros son equivalentes para un cálculo aunque provengan de instancias diferentes. Esta distinción aparece en la sección Value Objects de la referencia de DDD de Eric Evans.

Una Entity, en cambio, conserva una identidad: un pedido sigue siendo el mismo aunque cambie su dirección. La dirección acordada para ese pedido puede representarse como un Value Object, porque interesa su contenido y no seguir la trayectoria de una dirección individual.

Para trabajar con Value Objects conviene reunir cuatro propiedades:

  • Compararlos por sus valores significativos.
  • Establecer condiciones válidas de creación.
  • Ofrecer operaciones que respeten su significado.
  • Mantenerlos inmutables: cambiar un valor significa obtener otro, sin alterar el anterior.

Fowler explica la relación entre comparación por valor e inmutabilidad en su artículo sobre Value Object. Las políticas concretas —qué monedas aceptar o cómo comparar correos— siguen perteneciendo a tu modelo.

La ausencia de identidad conceptual no significa que el objeto no tenga una referencia en memoria. Significa que esa referencia no decide si dos valores son equivalentes.

Primitive Obsession: cuando el tipo permite demasiado

Primitive Obsession describe el uso excesivo de tipos primitivos para conceptos que necesitan reglas y significado propios. No todo string debe convertirse en una clase. La señal aparece cuando repites validaciones, confundes unidades o realizas operaciones que el negocio no admite. La refactorización Replace Primitive with Object, de Fowler, propone introducir objetos cuando un dato empieza a necesitar ese comportamiento.

Este fragmento parcial ilustra el problema:

function calculateTotal(price: number, shipping: number): number {
  return price + shipping;
}

calculateTotal(1000, 500); // No expresa moneda ni unidad.

Añadir nombres como priceInCents ayuda, pero no impide sumar monedas distintas. Necesitamos que el programa pueda rechazar esa operación, no solo advertirla en un comentario.

Ejemplo principal: Money con operaciones inmutables

Definir las decisiones antes de implementar

Nuestro ejemplo admite únicamente euros y dólares estadounidenses, representados como EUR y USD. Trabajaremos con unidades menores enteras: 1050 representa 10,50 en cualquiera de esas dos monedas. Esta convención se limita a las monedas elegidas; no presupone que todas tengan dos decimales.

Las reglas serán:

  • El importe es un entero seguro no negativo.
  • Solo se suman cantidades de la misma moneda.
  • Multiplicar requiere una cantidad entera no negativa.
  • Cada operación valida su resultado y devuelve otra instancia.

No modelamos deudas, conversiones, porcentajes ni redondeos. Un negocio que los necesite deberá definir esas operaciones explícitamente. Tampoco aceptaremos decimales para después multiplicarlos por cien: la entrada ya debe expresar unidades menores.

Programa completo

Este bloque es autocontenido. Guárdalo como money.ts y ejecútalo con node money.ts en Node.js 24.21.0. Solo requiere módulos incluidos en Node.js. Los otros dos programas del artículo se ejecutan del mismo modo, cada uno en su propio archivo.

Node ejecuta esta sintaxis mediante type stripping, que elimina las anotaciones de TypeScript; no comprueba estáticamente los tipos. Por eso las condiciones importantes se comprueban también durante la ejecución.

import assert from 'node:assert/strict';

type Currency = 'EUR' | 'USD';

class Money {
  readonly minorUnits: number;
  readonly currency: Currency;

  private constructor(minorUnits: unknown, currency: unknown) {
    if (
      typeof minorUnits !== 'number' ||
      !Number.isSafeInteger(minorUnits) || minorUnits < 0
    ) {
      throw new Error('El importe debe ser un entero seguro no negativo');
    }
    if (currency !== 'EUR' && currency !== 'USD') {
      throw new Error('Moneda no admitida');
    }
    this.minorUnits = minorUnits;
    this.currency = currency;
    Object.freeze(this);
  }

  static create(minorUnits: unknown, currency: unknown): Money {
    return new Money(minorUnits, currency);
  }

  add(other: Money): Money {
    if (this.currency !== other.currency) {
      throw new Error('No se pueden sumar monedas distintas');
    }
    return Money.create(this.minorUnits + other.minorUnits, this.currency);
  }

  multiply(quantity: number): Money {
    if (!Number.isSafeInteger(quantity) || quantity < 0) {
      throw new Error('La cantidad debe ser un entero seguro no negativo');
    }
    return Money.create(this.minorUnits * quantity, this.currency);
  }

  equals(other: Money): boolean {
    return this.minorUnits === other.minorUnits && this.currency === other.currency;
  }

  toDTO(): { minorUnits: number; currency: Currency } {
    return { minorUnits: this.minorUnits, currency: this.currency };
  }
}

const unitPrice = Money.create(1250, 'EUR');
const shipping = Money.create(350, 'EUR');
const subtotal = unitPrice.multiply(2);
const total = subtotal.add(shipping);

assert.equal(total.minorUnits, 2850);
assert.equal(unitPrice.minorUnits, 1250);
assert.equal(subtotal.minorUnits, 2500);
assert.notEqual(total, subtotal);

const sameValue = Money.create(2850, 'EUR');
assert.equal(total === sameValue, false);
assert.equal(total.equals(sameValue), true);
assert.equal(total.equals(Money.create(2850, 'USD')), false);

assert.throws(() => total.add(Money.create(1, 'USD')), /monedas distintas/);
assert.throws(() => Money.create(10.5, 'EUR'), /entero seguro/);
assert.throws(() => Money.create(-1, 'EUR'), /no negativo/);
assert.throws(() => Money.create(1, 'CLP'), /Moneda no admitida/);
assert.throws(() => unitPrice.multiply(1.5), /cantidad/);
assert.throws(() => Money.create(Number.MAX_SAFE_INTEGER, 'EUR')
  .add(Money.create(1, 'EUR')), /entero seguro/);

assert.throws(() => Object.assign(unitPrice, { minorUnits: 1 }), TypeError);
assert.deepEqual(total.toDTO(), { minorUnits: 2850, currency: 'EUR' });
console.log('Money: igualdad, operaciones y límites verificados');

De las entradas al resultado

Las entradas son dos unidades de 12,50 EUR y un envío de 3,50 EUR. multiply(2) produce 25,00 EUR; add(shipping) produce 28,50 EUR. El precio unitario y el subtotal no cambian.

Si el envío estuviera en USD, la suma fallaría. Si una operación supera el rango de enteros seguros de JavaScript, la creación del resultado también falla. Usar enteros elimina la necesidad de sumar decimales en este ejemplo, pero no convierte number en un tipo de precisión ilimitada.

La Invariant, una condición que debe mantenerse para cualquier instancia válida, reúne aquí importe y moneda admitidos. Validar también los resultados evita que una operación parta de valores válidos y produzca otro que el modelo no pueda representar.

Observa la diferencia entre referencia y valor: total === sameValue es false porque son objetos distintos; equals() devuelve true porque coinciden importe y moneda. JavaScript no invoca automáticamente ese método al usar ===. Tampoco un Set de objetos deduplica dos instancias usando tu equals(); si necesitas esa colección, debes definir su criterio de clave o comparación.

Inmutabilidad: readonly no es suficiente

En TypeScript, readonly comunica una restricción al comprobador de tipos, pero no cambia por sí solo el comportamiento durante la ejecución. Además, una propiedad de solo lectura puede apuntar a un objeto cuyo interior sigue siendo modificable. La documentación oficial de TypeScript explica ambas limitaciones.

En Money, Object.freeze(this) impide modificar sus propiedades durante la ejecución. Como solo contienen valores primitivos, basta para este diseño. No es una receta general: congelar un objeto no congela recursivamente sus arreglos u objetos anidados, ni vuelve inmutables los datos internos de cualquier clase. La documentación de Object.freeze en MDN precisa estos límites.

Si utilizas campos privados con #, la clase también debe abstenerse de mutarlos; Object.freeze() no convierte esos campos en constantes. La inmutabilidad depende del diseño completo, no de una palabra clave aislada.

La ventaja práctica es poder compartir un valor sin que un consumidor lo altere para otro. Cambiar el precio significa asignar otro Money donde corresponda, no reescribir el anterior.

Email: validar una política y decidir la igualdad

En una plataforma educativa, un correo representado como string puede estar vacío, incluir espacios exteriores o utilizar un formato no admitido. Un Email puede concentrar esa política y ofrecer una comparación consistente.

Pero normalizar no es una tarea neutra. El estándar SMTP distingue la parte local, anterior a @, del nombre del dominio: la primera debe conservar su uso de mayúsculas y minúsculas; el segundo no es sensible a ellas. Así lo establece RFC 5321, sección 2.3.11. No convertiremos toda la dirección a minúsculas ni eliminaremos puntos o etiquetas como +curso.

Este programa autocontenido para email.ts acepta deliberadamente un subconjunto ASCII: parte local con letras, números, puntos y algunos signos; dominio con etiquetas separadas por puntos. Rechaza otros formatos que pueden ser válidos según los estándares. Es una política didáctica, no un validador completo de correo internacional. Una aplicación real debe elegir una validación acorde con sus requisitos.

import assert from 'node:assert/strict';

class Email {
  readonly value: string;

  private constructor(input: unknown) {
    if (typeof input !== 'string') throw new Error('El correo debe ser texto');
    const parts = input.trim().split('@');
    if (parts.length !== 2) throw new Error('Formato no admitido');
    const [local, domain] = parts;
    const validLocal = /^[A-Za-z0-9._%+-]+$/.test(local) &&
      !local.startsWith('.') && !local.endsWith('.') && !local.includes('..');
    const validDomain = domain.split('.').length >= 2 &&
      domain.split('.').every(label =>
        /^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(label));
    if (!validLocal || !validDomain) throw new Error('Formato no admitido');
    this.value = `${local}@${domain.toLowerCase()}`;
    Object.freeze(this);
  }

  static create(input: unknown): Email {
    return new Email(input);
  }

  equals(other: Email): boolean {
    return this.value === other.value;
  }
}

class Student {
  #id: string;
  #email: Email;

  constructor(id: string, email: Email) {
    if (!id.trim()) throw new Error('El estudiante necesita identidad');
    this.#id = id;
    this.#email = email;
  }

  get id(): string { return this.#id; }
  get email(): Email { return this.#email; }

  changeContactEmail(email: Email): void {
    this.#email = email;
  }
}

const original = Email.create('  Ana@EXAMPLE.COM  ');
assert.equal(original.value, 'Ana@example.com');
assert.equal(original.equals(Email.create('Ana@example.com')), true);
assert.equal(original.equals(Email.create('ana@example.com')), false);
assert.throws(() => Email.create('sin-arroba'), /Formato/);
assert.throws(() => Email.create('ana..diaz@example.com'), /Formato/);

const student = new Student('student-1', original);
student.changeContactEmail(Email.create('Ana+curso@example.com'));
assert.equal(student.id, 'student-1');
assert.equal(student.email.value, 'Ana+curso@example.com');
assert.equal(original.value, 'Ana@example.com');
console.log('Email: normalización, igualdad y reemplazo verificados');

El resultado muestra dos decisiones distintas. Email protege una forma y una política de igualdad; Student conserva su identidad mientras reemplaza un valor de contacto por otro. La Entity puede exponer el Email porque este no permite mutar sus propiedades.

La validación no demuestra que el buzón exista, que reciba mensajes ni que pertenezca al estudiante. Verificar control del correo es otro proceso. El ejemplo cambia un dato de contacto; no implementa un cambio seguro de credenciales de acceso ni una política de unicidad de cuentas.

También quedan fuera límites de longitud y otras restricciones del protocolo. Esa omisión está acotada al ejemplo: no presentes su expresión regular como una solución universal.

Address: un valor compuesto que se reemplaza completo

En logística, una dirección puede mezclar calle, ciudad y código postal en un solo string. Separar componentes facilita validar y comparar, pero permitir su modificación arbitraria todavía puede afectar a varios consumidores.

Este programa para address.ts representa una dirección mínima de un flujo local. Exige tres componentes no vacíos y elimina espacios exteriores. No valida códigos postales por país, coordenadas ni existencia física. Tampoco afirma que dos textos equivalentes para una persona sean la misma dirección: la igualdad elegida compara exactamente los componentes normalizados.

import assert from 'node:assert/strict';

type AddressInput = { street: unknown; city: unknown; postalCode: unknown };

function requireText(value: unknown, label: string): string {
  if (typeof value !== 'string' || !value.trim()) {
    throw new Error(`${label} es obligatorio`);
  }
  return value.trim();
}

class Address {
  readonly street: string;
  readonly city: string;
  readonly postalCode: string;

  private constructor(input: AddressInput) {
    this.street = requireText(input.street, 'La calle');
    this.city = requireText(input.city, 'La ciudad');
    this.postalCode = requireText(input.postalCode, 'El código postal');
    Object.freeze(this);
  }

  static create(input: AddressInput): Address {
    return new Address(input);
  }

  withStreet(street: unknown): Address {
    return Address.create({ street, city: this.city, postalCode: this.postalCode });
  }

  equals(other: Address): boolean {
    return this.street === other.street && this.city === other.city &&
      this.postalCode === other.postalCode;
  }

  toDTO(): { street: string; city: string; postalCode: string } {
    return { street: this.street, city: this.city, postalCode: this.postalCode };
  }
}

const destination = Address.create({
  street: '  Calle Central 10 ', city: 'Ciudad Ejemplo', postalCode: '12345',
});
const corrected = destination.withStreet('Calle Central 12');
assert.equal(destination.street, 'Calle Central 10');
assert.equal(corrected.street, 'Calle Central 12');
assert.equal(destination.equals(corrected), false);
assert.equal(destination.equals(Address.create(destination.toDTO())), true);
assert.throws(() => destination.withStreet('   '), /obligatorio/);

const dto = destination.toDTO();
dto.city = 'Otra ciudad';
assert.equal(destination.city, 'Ciudad Ejemplo');
console.log('Address: igualdad y reemplazo inmutable verificados');

Cambiar la calle produce otro valor y mantiene el anterior. La operación conserva ciudad y código postal, pero no verifica si la nueva combinación existe; un proceso de verificación de direcciones podría ser necesario según el negocio.

Si la dirección queda fijada al despachar un pedido, no basta con tener un Value Object inmutable. La Entity que representa ese pedido debe decidir cuándo admite reemplazarlo. La inmutabilidad del valor y las reglas de sustitución de su propietario resuelven problemas distintos.

Los tres programas autocontenidos se ejecutaron con Node.js 24.21.0 y completaron las aserciones incluidas. No se realizó análisis estático con el compilador de TypeScript ni validación contra servicios externos.

Value Object, Entity, primitivo y DTO

El primitivo es la representación básica: number o string. Un Value Object reúne componentes, reglas y operaciones para un significado concreto. Una Entity mantiene identidad mientras evoluciona.

Un DTO, o Data Transfer Object, transporta datos entre límites. El objeto devuelto por Money.toDTO() expresa importe y moneda sin conservar métodos ni garantías del constructor. Tener la misma forma de campos no lo convierte en un Money válido.

Al recibir datos externos, comprueba su estructura y reconstruye el Value Object mediante su creación validada. No uses una conversión como data as Money: cambia lo que TypeScript asume, pero no valida ni construye la instancia.

La igualdad también depende del propósito. Una entrada guardada en una libreta de direcciones puede tener identidad propia y seguir siendo la misma entrada aunque se edite. La dirección copiada en un pedido puede ser un valor. El concepto se modela según la responsabilidad, no por su nombre.

Qué estados inválidos puedes evitar y cuáles no

Los ejemplos impiden construir cantidades negativas para esta política, sumar monedas distintas y aceptar componentes vacíos. Esas garantías alcanzan las reglas implementadas y los caminos normales de creación y uso.

No evitan un precio comercial equivocado que, aun así, sea un entero válido. Tampoco garantizan permisos, stock, entrega de correo ni consistencia entre procesos. Un Value Object representa conocimiento explícito; no adivina reglas ausentes.

Los métodos estáticos hacen legible la creación, pero private en un constructor de TypeScript no constituye una barrera de seguridad durante la ejecución. Mantuvimos las validaciones dentro de los constructores para que no dependan únicamente del método estático.

Cuándo basta un tipo primitivo

Una etiqueta opcional usada una sola vez para mostrar un mensaje puede seguir siendo string. Un contador local de iteraciones no necesita IterationCount si no representa una decisión del negocio.

Introduce un Value Object cuando concentra reglas compartidas, evita confusiones de unidades o aporta operaciones significativas. Si una clase solo envuelve un valor sin aclarar decisiones ni reducir errores concretos, considera el coste de más nombres, conversiones y archivos.

La inmutabilidad también puede implicar crear más objetos. Normalmente conviene priorizar claridad y medir si existe un problema de rendimiento antes de sustituirla por mutaciones compartidas. No necesitas una clase base ni un sistema genérico de igualdad para estos ejemplos.

Prompts para trabajar con IA sin perder significado

Detectar conceptos escondidos en primitivos

Analiza este cálculo de precios. Trabajamos solo con EUR y USD, en unidades menores enteras no negativas. Busca confusiones de moneda, unidades y validaciones duplicadas. No cambies archivos ni añadas conversiones. Devuelve propuestas de Value Objects y contraejemplos que justifiquen cada uno. Una persona confirmará monedas, redondeos y admisión de importes negativos antes de implementar.

Implementar operaciones inmutables

Implementa Money en TypeScript sin dependencias, con creación validada, equals, add y multiply. Acepta solo EUR y USD y rechaza importes o resultados fuera del rango de enteros seguros no negativos. No modifiques instancias existentes. Entrega un programa autocontenido y aserciones de monedas distintas, igualdad por valor, overflow y conservación de operandos. Una persona ejecutará las pruebas y comprobará las reglas monetarias acordadas.

Revisar límites de validación y normalización

Revisa Email y Address contra las políticas adjuntas. Busca normalizaciones que cambien significado, igualdad incompleta, referencias mutables expuestas y DTOs tratados como instancias validadas. No edites archivos ni afirmes que una regex verifica existencia o propiedad de un buzón. Devuelve evidencia, un escenario por hallazgo y dudas pendientes. Una persona validará las políticas con negocio y los requisitos de los sistemas externos.

Una prueba generada por la misma IA puede repetir una decisión equivocada. Revisa los contraejemplos y la política antes de aceptar que una clase es correcta porque todas sus pruebas pasan.

Tres ejercicios con solución razonada

1. Un descuento de diez por ciento

Una propuesta añade multiply(0.9) para aplicar un descuento a Money. ¿Es compatible con el ejemplo?

Solución razonada: no; el método representa una cantidad entera, no un porcentaje. Necesitas otra operación con una política explícita de redondeo y tratamiento de fracciones de unidad menor. Decide también si se redondea por línea o sobre el total y comprueba casos como 101 unidades menores. No elimines la validación de enteros para ocultar esa decisión.

2. Dos cuentas con el mismo correo aparente

Una herramienta propone eliminar puntos y todo lo posterior a + antes de comparar correos. ¿Lo aplicarías a cualquier proveedor?

Solución razonada: no sin una política justificada. Esas transformaciones pueden fusionar direcciones distintas. En el ejemplo solo normalizamos el dominio y los espacios exteriores; una equivalencia específica de un proveedor requiere reglas y alcance explícitos. Además, igualdad de correo y política de unicidad de cuentas no son automáticamente la misma decisión.

3. Un arreglo dentro de Address

Se reemplaza street por lines: string[] y se conserva Object.freeze(this). ¿Basta para mantener inmutabilidad?

Solución razonada: no; el arreglo puede seguir siendo modificable y compartirse con quien lo entregó. Copia y protege la colección interna, controla lo que expones y comprueba que modificar el arreglo de entrada o una lectura no cambie el valor. readonly y congelación superficial no resuelven por sí solos ese aliasing, es decir, varias referencias al mismo objeto mutable.

Cómo decidir dónde aplicar un Value Object

Busca conceptos que necesiten comparación por contenido, validaciones repetidas u operaciones con unidades y restricciones propias. Define primero qué valores consideras equivalentes y qué cambios deben producir una nueva instancia.

Después comprueba tres cosas: que la creación rechace lo no admitido, que las operaciones conserven las reglas y que ningún consumidor pueda modificar indirectamente el valor compartido. Con esos criterios puedes evaluar una implementación concreta, tanto si la escribiste tú como si utilizaste IA para generarla.

Cargando conversación…

Sigue aprendiendo