Опирается на правила:
R-VO-1…R-VO-5иR-VO-X1…R-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 в VO | R-VO-X1 | Если есть identity — это Entity, не VO |
Primitive obsession (string email, number amount) | R-VO-X2 | VO с валидацией: Email, Money |
| Мутабельный массив внутри VO | R-VO-X3 | Object.freeze([...items]) в конструкторе |
| Мутирующий метод без возврата нового экземпляра | R-VO-5 | add() → новый Money, не this.amount = ... |
Деньги как number | R-VO-X2 | Big.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>/.