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

Важно знать

  • Value Object — объект без идентичности. Два Money(Decimal("100"), "RUB") — это один и тот же Money для бизнеса. ID и lifecycle отсутствуют.
  • Идиома Python: @dataclass(frozen=True) — иммутабельность, hashable, value-equality по всем полям из коробки (R-VO-1..3).
  • Инварианты проверяются в __post_init__. Невалидный Email("not-an-email") не должен существовать (R-VO-4).
  • Мутирующие операции возвращают новый экземпляр: money.multiply(2) создаёт новый Money, исходный не трогает (R-VO-5).
  • Деньги — всегда Decimal, никогда float. Ошибки округления с float проявляются в финансовых расчётах непредсказуемо.
  • Коллекции внутри VO — только tuple или frozenset. list внутри frozen=True остаётся мутабельным — frozen защищает только сами поля, не содержимое.
  • @dataclass(eq=True) по умолчанию — Value Object-семантика. Не применять на Entity: это нарушает identity-equality (R-ENT-X2).
  • primitive obsessionstr email, float amount, str order_id в публичных сигнатурах. Заменять на Email, Money, OrderId.

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

@dataclass(frozen=True) — основная идиома

R-VO-1, R-VO-2, R-VO-3: VO помечен как Value Object, иммутабелен, equality по всем значимым полям.

В Python нет библиотеки ddd-building-blocks — VO реализуется через @dataclass(frozen=True):

# core/order/value_object/money.py
from dataclasses import dataclass
from decimal import Decimal


@dataclass(frozen=True)
class Money:
    amount: Decimal
    currency: str

    def __post_init__(self) -> None:
        if self.amount < 0:
            raise ValueError("amount must be non-negative")
        if len(self.currency) != 3:
            raise ValueError("currency must be ISO-4217 code")

    def add(self, other: "Money") -> "Money":
        if self.currency != other.currency:
            raise ValueError(f"currency mismatch: {self.currency} vs {other.currency}")
        return Money(self.amount + other.amount, self.currency)

    def multiply(self, factor: int) -> "Money":
        return Money(self.amount * factor, self.currency)

Что даёт @dataclass(frozen=True):

  • Иммутабельность: попытка money.amount = Decimal(0) бросает FrozenInstanceError.
  • Value-equality: Money(Decimal("100"), "RUB") == Money(Decimal("100"), "RUB")True без ручного __eq__.
  • Hashable: можно использовать как ключ dict и элемент set.
  • Место для валидации__post_init__ вызывается после __init__.

Инварианты в post_init

R-VO-4: невалидный VO не существует. Конструктор валидирует все инварианты.

# core/customer/value_object/email.py
import re
from dataclasses import dataclass

_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")


@dataclass(frozen=True)
class Email:
    value: str

    def __post_init__(self) -> None:
        if not self.value:
            raise ValueError("email must not be empty")
        normalized = self.value.strip().lower()
        if not _EMAIL_RE.match(normalized):
            raise ValueError(f"invalid email: {self.value!r}")
        object.__setattr__(self, "value", normalized)

object.__setattr__ обходит защиту frozen=True — единственный способ нормализовать значение внутри __post_init__. Используется только для нормализации в __post_init__, не для мутации снаружи.

Что валидируется:

  • Обязательные поляif not self.value.
  • Форматные инварианты — паттерн email, длина кода валюты.
  • Согласованность полей — в DateRange(start, end) проверять end >= start.

Что не валидируется в VO: бизнес-правила, зависящие от внешнего контекста. Правило «заказ не может быть на сумму меньше минимального — инвариант агрегата Order, не самого Money.

Мутации возвращают новый экземпляр

R-VO-5: мутирующая операция создаёт новый VO. Исходный не трогается.

# core/order/value_object/money.py (продолжение)
from dataclasses import replace


@dataclass(frozen=True)
class Money:
    amount: Decimal
    currency: str

    def subtract(self, other: "Money") -> "Money":
        if self.currency != other.currency:
            raise ValueError("currency mismatch")
        result = self.amount - other.amount
        if result < 0:
            raise ValueError("result would be negative")
        return Money(result, self.currency)

    def with_currency(self, new_currency: str, rate: Decimal) -> "Money":
        return replace(self, amount=self.amount * rate, currency=new_currency)

dataclasses.replace(obj, **changes) создаёт новый экземпляр с заменёнными полями — удобная альтернатива явному конструктору. Использование:

price = Money(Decimal("1000"), "RUB")
discounted = price.multiply(Decimal("0.9"))  # новый Money(900, "RUB")
assert price.amount == Decimal("1000")       # исходный не изменился

VO вместо примитивов — борьба с primitive obsession

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

# ПЛОХО
def register_customer(email: str, phone: str, credit_limit: float) -> None: ...

# ХОРОШО
def register_customer(email: Email, phone: PhoneNumber, credit_limit: Money) -> None: ...

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

  • Невозможно перепутать параметры. register_customer(phone, email) — тип упадёт при runtime-проверке Pydantic или при ручном isinstance. С str, str — тихий баг.
  • Валидация однажды, в одном месте. Email(raw) падает при создании. С str email — каждое использование сопровождается «а валидный ли он?».
  • Бизнес-методы рядом с типом. email.domain(), money.add(other), phone.masked() — методы есть только у VO.

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

# core/order/value_object/order_id.py
from dataclasses import dataclass
from uuid import UUID


@dataclass(frozen=True)
class OrderId:
    value: UUID

Так сигнатуры не перепутают OrderId с CustomerId — оба UUID под капотом, но разные типы.

Коллекции внутри VO — только tuple или frozenset

R-VO-X3: list внутри @dataclass(frozen=True) остаётся мутабельным — frozen защищает только ссылку на список, не его содержимое.

# ПЛОХО — list внутри frozen dataclass
@dataclass(frozen=True)
class Address:
    city: str
    phone_numbers: list[str]   # мутабельная коллекция

addr = Address("Москва", ["+7-495-000-0000"])
addr.phone_numbers.append("+7-499-000-0000")  # мутирует VO — нарушает R-VO-2
hash(addr)  # TypeError: unhashable type: 'list'

# ХОРОШО — tuple (иммутабельна, hashable)
@dataclass(frozen=True)
class Address:
    city: str
    phone_numbers: tuple[str, ...]

    def __post_init__(self) -> None:
        if not self.city:
            raise ValueError("city required")
        object.__setattr__(self, "phone_numbers", tuple(self.phone_numbers))

tuple иммутабельна и hashable — VO с tuple можно класть в set и использовать как ключ dict.

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

АнтипаттернПравилоЧто взамен
Поле id или жизненный цикл в VOR-VO-X1Если есть identity — это Entity, не VO
Primitive obsession (str email, float amount)R-VO-X2VO с валидацией: Email, Money, OrderId
list внутри @dataclass(frozen=True)R-VO-X3tuple[T, ...] или frozenset[T]
float для денегR-VO-2Decimal
@dataclass(eq=True) на EntityR-ENT-X2Обычный класс с identity-equality из Entity[ID]
Мутация VO через object.__setattr__ вне __post_init__R-VO-2dataclasses.replace(vo, field=new_value)

Куда дальше

  • DDD Tactical → раздел 2. Value Object — нормативные формулировки R-VO-*.
  • python/entity.md — соседняя категория, идентичность, а не значение.
  • python/aggregate-root.md — почему ссылки на другие агрегаты — это VO-ID.
  • python/domain-event.md — события тоже @dataclass(frozen=True).