Опирается на правила:
R-VO-1…R-VO-5иR-VO-X1…R-VO-X3из DDD Tactical Style Guide → раздел 2. Value Object.
Важно знать
- Value Object — это объект без идентичности. Два
Money(100, RUB)— это один и тот жеMoneyдля бизнеса. ID и lifecycle отсутствуют.- Маркер:
implements ValueObjectизddd-building-blocks. Чисто декларативный — позволяет ArchUnit-тестам отличать VO от Entity.- Immutable:
final class, все поляfinal, ни одного сеттера. Javarecordидеально подходит — он immutable by design.- Equals по всем значимым полям. У
recordэто получается автоматически, поэтомуrecord X(...) implements ValueObject— самый компактный путь.- Конструктор / фабрика валидирует инварианты. Невалидный
Email("not-an-email")илиMoney(-1, RUB)не должен существовать.- Мутации возвращают новый экземпляр:
money.add(other)создаёт новыйMoney, не модифицирует существующий.String email,BigDecimal amount,long timestampв API доменных методов — primitive obsession. ЗаводимMoney,Instant.- Коллекции внутри VO — только обёрнутые:
List.copyOf(items)в конструкторе,List<X>снаружи неизменяемый.
Value Object — второй базовый блок DDD, на одном уровне с Entity. Граница простая: если объект важен что в нём, а не который из них — это VO. Money(100, RUB) важен значением, не идентичностью; два таких объекта взаимозаменяемы. Customer с id c-42 — это конкретный клиент, не любой Customer с теми же полями; идентичность важна, это Entity. Раскрытие раздела 2 гайда.
Маркер ValueObject и Java record
R-VO-1, R-VO-2, R-VO-3: реализуем маркер ValueObject, класс immutable, equals по значениям.
Самый короткий путь — Java record:
public record Money(BigDecimal amount, Currency currency) implements ValueObject {
public Money {
Objects.requireNonNull(amount, "amount required");
Objects.requireNonNull(currency, "currency required");
if (amount.scale() > currency.getDefaultFractionDigits()) {
throw new IllegalArgumentException(
"amount scale exceeds currency precision: " + amount);
}
}
}
Что даёт record:
final class— нельзя унаследовать (recordвсегдаfinal).finalполя — компонентные поля record-а неизменяемы.- equals/hashCode по всем полям — генерируются автоматически и канонически.
- canonical constructor с compact form (
public Money { ... }) — место для валидации инвариантов без переписывания всего конструктора. toString— генерируется автоматически в форматеMoney[amount=100, currency=RUB], удобно для логов.
Полнотелый класс остаётся вариантом, если нужны кастомные геттеры или приватный конструктор + фабрика:
public final class Email implements ValueObject {
private final String value;
private Email(String value) {
this.value = value;
}
public static Email of(String raw) {
Objects.requireNonNull(raw, "email required");
String trimmed = raw.trim().toLowerCase(Locale.ROOT);
if (!EMAIL_PATTERN.matcher(trimmed).matches()) {
throw new IllegalArgumentException("invalid email: " + raw);
}
return new Email(trimmed);
}
public String value() { return value; }
@Override public boolean equals(Object o) { /* по полю value */ }
@Override public int hashCode() { /* по value */ }
}
По умолчанию — record. Полнотелый — только когда нужен фабричный метод с нормализацией (как Email.of), приватный конструктор, специфический toString.
Валидация инвариантов в конструкторе
R-VO-4: невалидный VO не существует.
public record Money(BigDecimal amount, Currency currency) implements ValueObject {
public Money {
Objects.requireNonNull(amount);
Objects.requireNonNull(currency);
}
public static Money zero(Currency currency) {
return new Money(BigDecimal.ZERO, currency);
}
public static Money of(long amount, Currency currency) {
return new Money(BigDecimal.valueOf(amount), currency);
}
}
Что валидируется:
- Обязательные поля (
requireNonNull). - Технические инварианты — формат email, диапазон процента, scale денег.
- Согласованность полей — если в
DateRange(from, to)to < from, это невалидный VO.
Что не валидируется в VO: бизнес-правила, которые зависят от внешнего контекста («Money(-100) — это валидный VO, потому что бывает long-position и short-position; запрет отрицательного — это инвариант агрегата BankAccount, не самого Money»).
Мутации возвращают новый экземпляр
R-VO-5: операция, которая «изменяет» VO, на самом деле возвращает новый VO. Существующий не трогаем.
public record Money(BigDecimal amount, Currency currency) implements ValueObject {
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("currency mismatch");
}
return new Money(this.amount.add(other.amount), this.currency);
}
public Money multiply(BigDecimal factor) {
return new Money(this.amount.multiply(factor), this.currency);
}
public Money withCurrency(Currency newCurrency, BigDecimal rate) {
return new Money(this.amount.multiply(rate), newCurrency);
}
}
Использование:
Money total = Money.zero(RUB);
for (OrderItem item : items) {
total = total.add(item.subtotal());
}
add создаёт новый Money на каждой итерации. Из-за immutability Money можно безопасно расшаривать между потоками, класть в Set, использовать как ключ Map — hashCode стабилен.
Используем VO вместо примитивов (борьба с primitive obsession)
R-VO-X2: String email, BigDecimal amount, String orderId в публичной сигнатуре — антипаттерн.
// ПЛОХО
public void registerCustomer(String email, String phone, BigDecimal creditLimit) { ... }
// ХОРОШО
public void registerCustomer(Email email, PhoneNumber phone, Money creditLimit) { ... }
Что даёт замена:
- Невозможно перепутать параметры.
registerCustomer(phone, email)— компиляция упадёт. СоString, String— тихий баг. - Валидация на границе создания, не на использовании.
Email.of(raw)падает один раз, в одном месте. СString email— каждое чтение поля сопровождается «а валидный ли он?». - Бизнес-методы рядом с типом.
email.domain(),money.add(other),phone.maskedForLogs()— методы есть только у VO, не уString. - Документация в типе. Параметр
Money creditLimitсразу говорит о валюте;BigDecimal creditLimit— нет.
Сквозные VO в проекте: Email, PhoneNumber, Money, OrderId, CustomerId, Address, DateRange, Percentage. Каждый ID-агрегата — VO (record OrderId(long value) implements ValueObject), не long. Так в сигнатурах не перепутать OrderId с CustomerId.
Мутабельные коллекции внутри VO — только с обёрткой
R-VO-X3: если VO содержит коллекцию, её нужно защитить от мутации снаружи.
public record Address(String city, String street, List<String> phoneNumbers)
implements ValueObject {
public Address {
Objects.requireNonNull(city);
Objects.requireNonNull(street);
phoneNumbers = List.copyOf(phoneNumbers); // ← defensive copy
}
}
List.copyOf создаёт immutable копию входного списка. Дальше:
- Клиент не сможет мутировать
address.phoneNumbers()—UnsupportedOperationException. - Изменение исходного списка снаружи не отразится на VO.
- Equals по списку работает (стандартный
List.equalsсравнивает поэлементно).
Без List.copyOf (this.phoneNumbers = phoneNumbers) клиент мог бы:
List<String> phones = new ArrayList<>(List.of("+7-000"));
Address addr = new Address("М", "Тверская", phones);
phones.add("+7-666"); // ← мутирует address.phoneNumbers тоже
Это нарушает immutability — equals/hashCode плавают, объект небезопасен в коллекциях.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
Поле id или жизненный цикл в VO | R-VO-X1 | Если есть identity — это Entity, не VO |
Primitive obsession (String email, BigDecimal amount) | R-VO-X2 | VO с валидацией: Email, Money, Percentage |
Мутабельная коллекция внутри VO без List.copyOf | R-VO-X3 | Defensive copy в конструкторе, immutable наружу |
| Setter / non-final field в VO | R-VO-2 | record или final class со final полями |
| Equals только по части полей | R-VO-3 | Java record даёт корректный equals по всем компонентам |
Куда дальше
- DDD Tactical → раздел 2. Value Object — нормативные формулировки
R-VO-*. - Entity — соседняя категория, по identity а не по значению.
- Aggregate Root — почему ссылки на другие агрегаты — это VO-ID, не объекты.
- Validation Style Guide — где валидация VO встречается с валидацией HTTP-input.
- PostgreSQL types — почему
Money↔numeric(p,s), неfloat.