Skip to content

Value objects ​

Un value object es un tipo definido por su valor, no por su identidad. Dos Money de 10 € son el mismo Money; dos pedidos con los mismos datos son pedidos distintos.

Bases disponibles ​

BasePara
StringValueObjectTexto con reglas
NumberValueObjectNúmeros con rango o precisión
DateValueObjectFechas con reglas temporales
BooleanValueObjectBanderas con significado de dominio
IdValueObjectIdentidades (UUID)
DddEnumConjuntos cerrados de valores
DddValueObject<T>Cualquier otra forma, incluidas las compuestas

Compuestos ​

Cuando el valor tiene varias partes que sólo tienen sentido juntas:

ts
interface IMoneyProps {
  amount: number;
  currency: string;
}

export class Money extends DddValueObject<IMoneyProps> {
  static create(amount: number, currency = 'USD'): Money {
    const money = new Money({ amount, currency });
    if (!money.isValid) {
      throw new BrokenRulesException('Money', money.brokenRules.getBrokenRules());
    }
    return money;
  }

  add(other: Money): Money {
    if (other.props.currency !== this.props.currency) {
      throw new InvalidOperationException(
        `Cannot add ${other.props.currency} to ${this.props.currency}`,
      );
    }
    return Money.create(this.props.amount + other.props.amount, this.props.currency);
  }
}

add() devuelve un Money nuevo. Los value objects son inmutables: si necesitas otro valor, construyes otro objeto. Mutar uno compartido cambiaría el valor bajo los pies de quien lo tuviera guardado.

Y fíjate en lo que hace ese guard: sumar dólares y euros deja de ser un bug silencioso de producción y pasa a ser imposible de escribir.

Comparación por valor ​

ts
Money.create(10, 'USD').equals(Money.create(10, 'USD'));  // true
Money.create(10, 'USD') === Money.create(10, 'USD');      // false

Usa siempre .equals(). La igualdad por referencia sobre un value object es casi siempre un bug.

DddEnum no es un enum de TypeScript ​

Es la fuente de un error concreto que costó tiempo encontrar:

ts
export class ProductStatus extends DddEnum {
  static readonly ACTIVE = new ProductStatus('ACTIVE');
  static readonly INACTIVE = new ProductStatus('INACTIVE');
  static readonly DELETED = new ProductStatus('DELETED');
}

Los miembros estáticos son instancias, no cadenas. Así que esto no funciona nunca:

ts
Object.values(ProductStatus).includes(dto.status);   // siempre false

Estás comparando instancias con un string. El endpoint que hacía esto rechazaba todas las llamadas —incluidas las correctas— con el mensaje maravillosamente contradictorio Expected: ACTIVE, INACTIVE or DELETED. Provided value: 'INACTIVE'.

Usa la búsqueda propia del enum:

ts
const status = ProductStatus.fromValue(dto.status);   // devuelve la instancia, o lanza

El orden de construcción ​

Ya está en Reglas rotas como error 3, pero es específico de los value objects y vale la pena verlo aquí:

ts
export class Price extends NumberValueObject {
  private readonly max = 9_999_999.99;      // ← se asigna DESPUÉS

  override addValidators(): void {
    super.addValidators();
    this.validatorRules.add(new MaxValidator(this, this.max));   // ← undefined
  }
}

El constructor base llama a addValidators() antes de que corra el cuerpo del tuyo. Saca el valor a una constante de módulo o a un static readonly y desaparece.

¿Y si ya está hecho? ​

Antes de escribir un Email, un Money o un Phone, mira ddd-valueobjects: son doce, con sus reglas y sus formateadores, y ya están probados.

Siguiente ​

Publicado con licencia MIT. Hecho en Perú 🇵🇪 · Impulsado por BeyondNetCode