Опирается на правила: R-VO-1R-VO-5 и R-VO-X1R-VO-X3 из DDD Tactical Style Guide → раздел 2. Value Object.

Важно знать

  • Value Object — объект без идентичности. Два Money(100, 'RUB') — это один и тот же объект для бизнеса. ID и lifecycle отсутствуют.
  • VO — класс, наследующий ValueObject из core/shared/building-blocks.ts. equals() делегирует на components(), который каждый VO реализует сам.
  • Иммутабельность: все поля readonly, Object.freeze(this) в конструкторе. readonly-модификатор TS не защищает в runtime; Object.freeze защищает сам объект.
  • Мутирующие операции возвращают новый экземпляр, не изменяют существующий.
  • Для одно-полевых идентификаторов (OrderId, CustomerId) — branded type: compile-time различимость без runtime-стоимости полного класса.
  • Деньги — Big.js / decimal.js поверх строки из БД, никогда number. Числа с плавающей точкой несовместимы с финансовыми расчётами.
  • Коллекции внутри VO — ReadonlyArray плюс копия в конструкторе; readonly-модификатор TS не защищает вложенные объекты в runtime.

Value Object — второй базовый блок DDD. Граница: если объект важен чем в нём, а не который из них — это VO. Money(500, 'RUB') важен значением; два таких объекта взаимозаменяемы. Order с id o-42 — это конкретный заказ; идентичность важна, это Entity. Раскрытие раздела 2 гайда.

Базовый класс ValueObject и метод components()

R-VO-1, R-VO-2, R-VO-3: VO наследует ValueObject, все поля readonly, equals() сравнивает все значимые поля через components().

// core/shared/building-blocks.ts
export abstract class ValueObject {
  equals(other: ValueObject): boolean {
    return other instanceof this.constructor
      && JSON.stringify(this.components()) === JSON.stringify(other.components());
  }

  protected abstract components(): ReadonlyArray<unknown>;
}
// core/order/value-object/money.ts
import Big from 'big.js';
import { ValueObject } from '../../shared/building-blocks';
import { DomainError } from '../../shared/domain-error';

export class Money extends ValueObject {
  constructor(readonly amount: Big, readonly currency: string) {
    super();
    if (amount.lt(0)) throw new DomainError('amount must be non-negative');
    if (currency.length !== 3) throw new DomainError('currency must be ISO-4217');
    Object.freeze(this);
  }

  add(other: Money): Money {
    if (this.currency !== other.currency) {
      throw new DomainError(`currency mismatch: ${this.currency} vs ${other.currency}`);
    }
    return new Money(this.amount.plus(other.amount), this.currency);
  }

  multiply(factor: number): Money {
    return new Money(this.amount.times(factor), this.currency);
  }

  isGreaterThan(other: Money): boolean {
    if (this.currency !== other.currency) throw new DomainError('currency mismatch');
    return this.amount.gt(other.amount);
  }

  static zero(currency: string): Money {
    return new Money(new Big(0), currency);
  }

  protected components(): ReadonlyArray<unknown> {
    return [this.amount.toString(), this.currency];
  }
}

R-VO-4: конструктор валидирует инварианты — неотрицательность суммы, ISO-4217-формат валюты. R-VO-5: add и multiply возвращают новый Money, не мутируют this.

Почему Big.js, а не number: IEEE 754 даёт 0.1 + 0.2 === 0.30000000000000004. Финансовые расчёты требуют точной десятичной арифметики.

Branded types для ID-объектов

Для одно-полевых идентификаторов полный класс — избыточен. Branded type даёт compile-time различимость без runtime-стоимости:

// core/order/value-object/ids.ts
import { DomainError } from '../../shared/domain-error';

export type OrderId = string & { readonly __brand: 'OrderId' };
export const OrderId = (value: string): OrderId => {
  if (!isUuid(value)) throw new DomainError(`OrderId must be uuid, got: ${value}`);
  return value as OrderId;
};

export type CustomerId = string & { readonly __brand: 'CustomerId' };
export const CustomerId = (value: string): CustomerId => {
  if (!isUuid(value)) throw new DomainError(`CustomerId must be uuid`);
  return value as CustomerId;
};

export type ProductId = string & { readonly __brand: 'ProductId' };
export const ProductId = (value: string): ProductId => {
  if (!isUuid(value)) throw new DomainError(`ProductId must be uuid`);
  return value as ProductId;
};

function isUuid(value: string): boolean {
  return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
}

В сигнатурах метода Order использует CustomerId, а не string:

constructor(id: OrderId, readonly customerId: CustomerId) { ... }

TypeScript различает OrderId и CustomerId на уровне типов — случайная передача customerId туда, где ждут orderId, не скомпилируется.

Email и другие VO с нормализацией

Когда нужна нормализация или сложная валидация — полный класс:

// core/customer/value-object/email.ts
export class Email extends ValueObject {
  private constructor(readonly value: string) {
    super();
    Object.freeze(this);
  }

  static of(raw: string): Email {
    const trimmed = raw.trim().toLowerCase();
    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
      throw new DomainError(`invalid email: ${raw}`);
    }
    return new Email(trimmed);
  }

  domain(): string {
    return this.value.split('@')[1];
  }

  protected components(): ReadonlyArray<unknown> {
    return [this.value];
  }
}

Приватный конструктор + фабричный метод of() — нормализация и валидация в одном месте, всегда.

Коллекции внутри VO

R-VO-X3: если VO содержит коллекцию — защищаем её от мутации:

// core/customer/value-object/delivery-address.ts
export class DeliveryAddress extends ValueObject {
  readonly phones: ReadonlyArray<string>;

  constructor(
    readonly city: string,
    readonly street: string,
    phones: string[],
  ) {
    super();
    if (!city) throw new DomainError('city required');
    this.phones = Object.freeze([...phones]);
    Object.freeze(this);
  }

  protected components(): ReadonlyArray<unknown> {
    return [this.city, this.street, ...this.phones];
  }
}

Object.freeze([...phones]) — создаёт копию массива и замораживает её. Клиент не сможет мутировать address.phones. Object.freeze(this) без заморозки вложенного массива не защитит его — элементы массива останутся мутабельными в runtime.

Primitive obsession — как лечить

R-VO-X2: string email, number amount, string orderId в публичных сигнатурах — антипаттерн.

// ПЛОХО
function registerCustomer(email: string, phone: string, creditLimit: number) { ... }

// ХОРОШО
function registerCustomer(email: Email, phone: PhoneNumber, creditLimit: Money) { ... }

Что даёт замена:

  • Невозможно перепутать параметры — registerCustomer(phone, email) не скомпилируется.
  • Валидация один раз, при создании VO, а не при каждом использовании.
  • Бизнес-методы рядом с типом: email.domain(), money.add(other).

Сквозные VO в проекте: Email, PhoneNumber, Money, OrderId, CustomerId, ProductId. Каждый ID-агрегата — branded type, не string.

Что запрещено

АнтипаттернПравилоЧто взамен
Поле id или lifecycle в VOR-VO-X1Если есть identity — это Entity, не VO
Primitive obsession (string email, number amount)R-VO-X2VO с валидацией: Email, Money
Мутабельный массив внутри VOR-VO-X3Object.freeze([...items]) в конструкторе
Мутирующий метод без возврата нового экземпляраR-VO-5add() → новый Money, не this.amount = ...
Деньги как numberR-VO-X2Big.js / decimal.js

Куда дальше

  • DDD Tactical → раздел 2. Value Object — нормативные формулировки R-VO-*.
  • node/entity.md — соседний паттерн с идентичностью.
  • node/aggregate-root.md — ссылки между агрегатами через branded-type ID.
  • node/module-structure.md — папка value-object/ в core/<bc>/.