Опирается на правила:
R-VO-1…R-VO-5иR-VO-X1…R-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 obsession—str email,float amount,str order_idв публичных сигнатурах. Заменять на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 или жизненный цикл в VO | R-VO-X1 | Если есть identity — это Entity, не VO |
Primitive obsession (str email, float amount) | R-VO-X2 | VO с валидацией: Email, Money, OrderId |
list внутри @dataclass(frozen=True) | R-VO-X3 | tuple[T, ...] или frozenset[T] |
float для денег | R-VO-2 | Decimal |
@dataclass(eq=True) на Entity | R-ENT-X2 | Обычный класс с identity-equality из Entity[ID] |
Мутация VO через object.__setattr__ вне __post_init__ | R-VO-2 | dataclasses.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).