Опирается на правила: R-VO-1R-VO-5 и R-VO-X1R-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, ни одного сеттера. Java record идеально подходит — он 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. Заводим Email, 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 или жизненный цикл в VOR-VO-X1Если есть identity — это Entity, не VO
Primitive obsession (String email, BigDecimal amount)R-VO-X2VO с валидацией: Email, Money, Percentage
Мутабельная коллекция внутри VO без List.copyOfR-VO-X3Defensive copy в конструкторе, immutable наружу
Setter / non-final field в VOR-VO-2record или final class со final полями
Equals только по части полейR-VO-3Java record даёт корректный equals по всем компонентам

Куда дальше

  • DDD Tactical → раздел 2. Value Object — нормативные формулировки R-VO-*.
  • Entity — соседняя категория, по identity а не по значению.
  • Aggregate Root — почему ссылки на другие агрегаты — это VO-ID, не объекты.
  • Validation Style Guide — где валидация VO встречается с валидацией HTTP-input.
  • PostgreSQL types — почему Moneynumeric(p,s), не float.