Перейти к содержимому
Программа DDDDDD.3 Тактическое проектирование

Сущности и объекты-значения

Чем сущность с идентичностью и жизненным циклом отличается от неизменяемого объекта-значения, почему Money, Email и Period лучше голых float и str, как писать объекты-значения и сущности на Python и как тестировать правила значений.

УРОКddd/ddd-3-tactical/01-entities-value-objects.mdx2 чУрок 1 из 4
После урока вы сможете
  • Отличать сущность от объекта-значения по вопросу «важно ли, какой именно это экземпляр» и находить в коде одержимость примитивами
  • Писать объекты-значения на Python — dataclass(frozen=True), проверка в __post_init__, операции, возвращающие новое значение
  • Писать сущности с равенством по идентификатору и решать, откуда берётся идентификатор
  • Тестировать правила значений короткими тестами без базы данных, моков и системного времени

В онлайн-школе счёт за запись на поток выставляют из трёх мест: при записи с сайта, при переносе через поддержку и при оплате компанией за сотрудника. Функция одна, и устроена она так, как пишут первый вариант почти всегда, — на строках и числах:

def create_invoice(student_email, cohort_id, amount, currency):
"""Выставить счёт за запись на поток — всё на строках и числах."""
return {"to": student_email, "cohort": cohort_id, "amount": amount, "currency": currency}
# Три вызова из разных мест кода. Python принял все три.
print(create_invoice("anna@example.com", "python-2026-11", 30000, "RUB")) # рубли
print(create_invoice("boris@example.com", "python-2026-11", 3000000, "RUB")) # копейки
print(create_invoice("python-2026-11", "Vera@Example.com", 30000.0, "rub")) # перепутан порядок
{'to': 'anna@example.com', 'cohort': 'python-2026-11', 'amount': 30000, 'currency': 'RUB'}
{'to': 'boris@example.com', 'cohort': 'python-2026-11', 'amount': 3000000, 'currency': 'RUB'}
{'to': 'python-2026-11', 'cohort': 'Vera@Example.com', 'amount': 30000.0, 'currency': 'rub'}

Ни одной ошибки. При этом Анне выставили счёт в рублях, Борису — в копейках (одна из сумм отличается в сто раз), а счёт Веры уйдёт на адрес python-2026-11. Валюта rub не совпадёт с RUB в отчёте бухгалтерии. Всё это всплывёт через месяц, в сверке платежей, и искать причину будут по всему коду.

Беда не в невнимательности программистов. Строка и число не знают ничего о предметной области: строке всё равно, почта она или номер потока, числу всё равно, рубли это или копейки. В глоссарии из урока DDD.1.2 записано: деньги — это сумма в копейках вместе с валютой. Код должен знать это так же твёрдо, как бухгалтер.

Этот урок открывает тактическое проектирование — вторую половину DDD из урока DDD.1.1. Начнём с двух самых маленьких строительных блоков модели: сущностей и объектов-значений. На них стоят агрегаты, события и репозитории из следующих уроков модуля.

Эрик Эванс делит объекты модели по одному вопросу: важно ли, какой именно это экземпляр? Ответ «да» означает сущность, ответ «нет» — объект-значение.

СущностьсущностьEN entityОбъект модели, который определяется идентичностью, а не атрибутами, и живёт во времени: создаётся, меняет состояние, остаётся «тем же». В онлайн-школе — студент, поток, запись на поток, платёж. Сравнивают сущности по идентификатору. (entity) — объект, который определяется своей идентичностьюидентичность (сущности)EN identityТо, что отличает сущность от любой другой, даже с теми же атрибутами, и сохраняется при всех её изменениях. В коде — идентификатор: UUID, номер по правилам бизнеса или ключ из базы. Модель получает его снаружи, чтобы тесты были предсказуемыми. (identity), а не атрибутами. Он живёт во времени: создаётся, меняется, переходит из статуса в статус — и всё это время остаётся «тем же самым».

Студентка Анна сменила фамилию и почту — это всё та же Анна, её записи на потоки и оплаты никуда не делись. Две студентки с именем «Анна Иванова» — два разных человека, даже если все их поля совпадают. Запись на поток проходит путь «ожидает оплаты → подтверждена → отменена», и поддержка спрашивает о конкретной записи: «где запись Анны от 1 октября?». Поток могут перенести на неделю или добавить ему мест — это тот же поток, на него уже записаны люди.

В школе сущности — это Студент (Student), Поток (Cohort), Запись на поток (Enrollment), Счёт (Invoice), Платёж (Payment), Сертификат (Certificate). У каждой есть идентификатор и жизненный цикл: последовательность состояний от создания до архива. Эванс советует держать определение сущности простым: идентичность, жизненный цикл и поведение, которое меняет её состояние. Описательные подробности лучше вынести в объекты-значения.

Объект-значениеобъект-значениеEN value objectОбъект модели без собственной идентичности, который описывает характеристику: деньги, период, адрес почты. Неизменяем, равенство — по всем атрибутам, проверяет себя при создании, операции возвращают новое значение. В Python — обычно @dataclass(frozen=True). (value object) описывает характеристику чего-то и не имеет собственной идентичности. Авторы «Cosmic Python» приводят пример с банкнотой: неважно, о какой именно купюре в 10 фунтов идёт речь, все они стоят одинаково. Так же и в школе: цена потока 30 000 руб. — это просто 30 000 руб., какие именно это рубли, не имеет значения.

У хорошего объекта-значения пять свойств:

  1. Равенство по значению. Две суммы «500 руб.» равны, даже если это два разных объекта в памяти.
  2. НеизменяемостьнеизменяемостьEN immutabilityСвойство объекта, состояние которого нельзя изменить после создания: вместо правки создают новый объект. Обязательное свойство объектов-значений и доменных событий; в Python — @dataclass(frozen=True), кортежи вместо списков. (immutability). Значение не правят, а заменяют целиком. Анна сменила почту — в сущность Student положили новый объект Email, старый не изменился.
  3. Самопроверка. Невалидное значение нельзя создать: Money(-100, "RUB") падает сразу, а не через месяц в отчёте. Если объект существует, он корректен, и проверять его повторно в каждой функции не нужно.
  4. Операции без побочных эффектов. Сложение двух сумм возвращает третью и не трогает слагаемые. Эванс называет такие методы функциями без побочных эффектов (side-effect-free functions): их результат зависит только от аргументов, поэтому их легко понять и проверить.
  5. Целостность. Значение собирает вместе то, что по отдельности не имеет смысла. Сумма без валюты — не деньги, дата начала без даты конца — не период. Уорд Каннингем называл такие объекты «целыми значениями» (Whole Value).

Значения в школе: Деньги (Money), Период (Period), адрес почты (Email), статус записи (EnrollmentStatus — перечисление Enum из урока DDD.1.2 тоже значение). Значениями удобно делать и сами идентификаторы: номер потока python-2026-11 неизменяем и сравнивается по значению. Money и Period одинаково нужны Набору и Оплате, поэтому в учебной школе они лежат в общем ядре school/shared_kernel/ (урок DDD.2.2): маленьким неизменяемым значениям с ясными правилами там самое место.

СущностьОбъект-значение
Главный вопроскто это?что это?
Равенствопо идентификаторупо всем атрибутам
Изменениеменяет состояние, оставаясь собойнеизменяем, заменяется целиком
Жизненный циклесть: создание, статусы, архивнет: значение просто существует
Примеры в школеStudent, Cohort, Enrollment, PaymentMoney, Period, Email, EnrollmentStatus
В Pythonкласс со своими __eq__ и __hash__ по id@dataclass(frozen=True) с проверкой в __post_init__
Тестысценарии: что было, что сделали, что сталобыстрые проверки правил и операций

Код из начала урока страдает болезнью, которую Мартин Фаулер и Кент Бек занесли в каталог «запахов кода» книги «Рефакторинг»: одержимость примитивамиодержимость примитивамиEN primitive obsession«Запах кода» из каталога книги «Рефакторинг» (Мартин Фаулер, Кент Бек): понятия предметной области хранят в str, int, float и словарях вместо маленьких классов. Правила проверки размазываются, значения путают местами и единицами. Лечится объектами-значениями. (primitive obsession). Понятия предметной области хранят во встроенных типах — str, int, float, словарях — вместо маленьких собственных классов.

Примитив в коде школыЧто идёт не такОбъект-значениеКакое правило переехало внутрь
price: float в рубляхошибки округления, рубли путают с копейкамиMoneyцелые копейки, валюта обязательна, сумма не отрицательна
amount и currency отдельными параметрамисуммы в разных валютах складываютMoneyскладывать и сравнивать можно только одну валюту
email: strпять функций проверяют адрес пятью разными способамиEmailформат и приведение к нижнему регистру — в одном месте
start и end отдельноконец раньше начала, границы понимают по-разномуPeriodначало раньше конца, правило «конец не входит»
status == 2магические числа, опечаткиEnrollmentStatusровно три статуса, имена из глоссария
cohort_id: str, student_id: strаргументы меняют местамиCohortId, StudentIdномер потока нельзя передать вместо номера студента

Каждый примитив тянет за собой правило, которое некому проверить. В итоге проверка либо дублируется по всему коду (и копии расходятся, как правило возврата в уроке DDD.1.1), либо забывается. Объект-значение даёт правилу один дом: проверка в конструкторе, операции рядом с данными.

Почему бы не хранить деньги просто целым числом копеек? Целое число решает проблему округления, но не остальные. Голое int можно сложить с номером потока, умножить на другие деньги, сделать отрицательным, потерять валюту. Money разрешает только осмысленные операции: сложить с деньгами той же валюты, умножить на число, сравнить.

Не нужно оборачивать каждое поле. Название курса — строка без правил, и класс CourseTitle ничего не добавит. Объект-значение окупается, когда у значения есть правила (формат, диапазон, единицы измерения), когда его легко спутать с другим значением того же примитивного типа или когда с ним выполняют операции.

Декоратор @dataclass из урока 0.2.3 сам пишет __init__, __repr__ и __eq__, который сравнивает объекты по полям. Параметр frozen=True добавляет неизменяемость: попытка присвоить полю новое значение бросает FrozenInstanceError. Неизменяемый класс с равенством по полям получает и __hash__, поэтому значение годится в ключ словаря и в множество. Три свойства объекта-значения достаются почти даром.

Метод __post_init__ dataclass вызывает сразу после __init__. Это место для правил значения. Вот адрес почты, который приводит себя к единому виду и отказывается существовать в некорректном виде:

from dataclasses import FrozenInstanceError, dataclass
@dataclass(frozen=True)
class Email:
"""Адрес почты. Хранится без пробелов по краям и в нижнем регистре."""
value: str
def __post_init__(self):
normalized = self.value.strip().lower()
local, at, domain = normalized.partition("@")
if not local or not at or "." not in domain or " " in normalized:
raise ValueError(f"Некорректный адрес почты: {self.value!r}")
# frozen=True запрещает self.value = ...; при создании меняем поле через object.__setattr__
object.__setattr__(self, "value", normalized)
@property
def domain(self) -> str:
return self.value.partition("@")[2]
if __name__ == "__main__":
a = Email(" Anna@Example.com ")
b = Email("anna@example.com")
print("Равны?", a == b, "| это один объект?", a is b)
print("Хранится:", a.value, "| домен:", a.domain)
print("Годится в ключ словаря:", {a: "Анна"}[b])
try:
a.value = "boris@example.com"
except FrozenInstanceError as error:
print("Изменить нельзя:", error)
for bad in ["anna.example.com", "anna@localhost", "@example.com"]:
try:
Email(bad)
except ValueError as error:
print(error)
Равны? True | это один объект? False
Хранится: anna@example.com | домен: example.com
Годится в ключ словаря: Анна
Изменить нельзя: cannot assign to field 'value'
Некорректный адрес почты: 'anna.example.com'
Некорректный адрес почты: 'anna@localhost'
Некорректный адрес почты: '@example.com'

Обратите внимание на object.__setattr__. Внутри __post_init__ замороженного класса обычное присваивание тоже запрещено, а нормализовать значение нужно. Вызов метода базового класса object обходит запрет — это принятый приём, и пользоваться им стоит только при создании объекта. Проверку формата мы упростили: настоящая проверка почты сложнее, но важно, что она живёт в одном месте.

Теперь перепутанный вызов из начала урока невозможен: Email("python-2026-11") бросит ValueError в момент создания, потому что в строке нет @. А Money(30000.0, "rub") из задания ниже откажется и от float, и от строчной валюты.

Объект-значение не меняют, поэтому любая операция над ним создаёт новый объект. Так выглядит сложение денег — почти как в главе 1 «Cosmic Python», только с понятной ошибкой на русском:

@dataclass(frozen=True)
class Money:
amount: int # копейки
currency: str # RUB, USD, ...
def __add__(self, other: "Money") -> "Money":
if other.currency != self.currency:
raise CurrencyMismatch(f"Нельзя сложить {self.currency} и {other.currency}")
return Money(self.amount + other.amount, self.currency)

Остальное — проверки при создании, вычитание, умножение на число, сравнение — вы напишете сами в практике. Для «поправить одно поле» у dataclass есть функция dataclasses.replace(obj, поле=новое): она возвращает копию с изменённым полем и снова вызывает __post_init__, так что правила проверятся и для нового значения.

  • Заморозка неглубокая. Если поле — список, заморозить его frozen=True не может: value.items.append(...) сработает. В объектах-значениях используйте неизменяемые коллекции: tuple, frozenset.
  • bool — подкласс int. isinstance(True, int) истинно, поэтому Money(True, "RUB") пройдёт наивную проверку isinstance(amount, int). Проверяйте bool отдельно.
  • Аннотации типов не проверяются при запуске. amount: int — подсказка для читателя и статического анализатора вроде mypy, а не проверка. Money("100", "RUB") создастся, если __post_init__ молчит.
  • Деньги и float несовместимы. Копейки храним целым числом, а умножение на дробь (скидка 15 %) делаем через Decimal с явным правилом округления. Округление — тоже правило предметной области, его стоит обсудить с бухгалтером.

Для сущности равенство по всем полям — ошибка. Если бы Student был обычным dataclass, Анна в памяти после смены почты перестала бы быть равна Анне, загруженной из базы. Поэтому сущность сравнивают по идентификатору:

from dataclasses import dataclass
@dataclass(frozen=True)
class Email:
"""Адрес почты (сокращённая версия из примера выше)."""
value: str
class Student:
"""Студент — сущность: его отличает student_id, а не имя и не почта."""
def __init__(self, student_id: str, name: str, email: Email):
self.student_id = student_id # идентичность: задаётся один раз и не меняется
self.name = name
self.email = email
def change_email(self, new_email: Email):
"""Студент сменил почту — это всё тот же студент."""
self.email = new_email # значение заменяется целиком, а не правится по буквам
def __eq__(self, other):
return isinstance(other, Student) and other.student_id == self.student_id
def __hash__(self):
return hash(self.student_id)
def __repr__(self):
return f"Student({self.student_id!r}, {self.name!r}, {self.email.value!r})"
anna = Student("st-1024", "Анна Иванова", Email("anna@example.com"))
same_anna = Student("st-1024", "Анна Иванова", Email("anna@example.com"))
namesake = Student("st-2048", "Анна Иванова", Email("anna@example.com"))
anna.change_email(Email("anna.ivanova@example.com"))
print(anna)
print("Это та же Анна, что в базе?", anna == same_anna)
print("Тёзка с той же почтой — та же студентка?", namesake == same_anna)
print("Студентов в множестве:", len({anna, same_anna, namesake}))
Student('st-1024', 'Анна Иванова', 'anna.ivanova@example.com')
Это та же Анна, что в базе? True
Тёзка с той же почтой — та же студентка? False
Студентов в множестве: 2

__hash__ вычисляется только из идентификатора, поэтому он не меняется вместе с почтой, и Анна остаётся в множестве на своём месте. Если идентификатор может меняться, __hash__ определять нельзя: объект «потеряется» в словаре. Авторы «Cosmic Python» по той же причине советуют для сущностей либо хешировать только неизменяемый идентификатор, либо не делать их хешируемыми вовсе. Можно писать сущности и через @dataclass(eq=False) со своим __eq__ — важно не то, как объявлен класс, а то, по чему его сравнивают.

Источник идентификатораПримерПлюсыМинусы
Естественный ключ из доменапочта как id студентаничего не надо придумыватьпочта меняется, и все ссылки на студента ломаются
Счётчик в базе данныхid SERIALпросто и короткоid появляется только после сохранения, модель и тесты зависят от базы
UUID, созданный приложениемuuid.uuid4()известен сразу, уникален вездедлинный, неудобно диктовать в поддержку
Номер по правилам бизнесапоток python-2026-11, номер сертификатачитается людьми, говорит о смыслеформат и уникальность — тоже правило, его надо проверять

В модели удобнее всего получать идентификатор снаружи: параметром конструктора или от отдельного объекта, который умеет выдавать новые номера. Тогда в тестах номера предсказуемы ("st-1024"), а не случайны. Кто именно выдаёт номера, разберём в уроках о фабриках и репозиториях.

Тот же принцип — для времени. Модель не вызывает datetime.now(), а получает текущий момент параметром now: так тест может проверить правило «отмена ровно за 7 суток до старта» без ожидания и без подмены системных часов. Этот приём будет во всех примерах модуля.

Когда объект-значение становится сущностью

Заголовок раздела «Когда объект-значение становится сущностью»

Одна и та же вещь реального мира бывает значением в одной модели и сущностью в другой. Решает не сама вещь, а то, что о ней важно бизнесу в этом месте.

ПонятиеГде это значениеГде это сущностьЧто изменилось
Деньги30 000 руб. — цена потока в Набореплатёж на 30 000 руб. от 1 октября в Оплатеу платежа есть история, статус, номер; два платежа на одну сумму — разные платежи
Местов онлайн-потоке место — просто единица вместимостина очном интенсиве место в аудитории закрепляют за студентомважно, какое именно место у кого
Периоддаты обучения потокаучебный семестр университета, который переносят и на который ссылаются расписанияу семестра появились жизненный цикл и ссылки
Оценкабалл за задание 0–100проверка задания наставникому проверки есть статус, проверяющий, апелляция
Адрес почтыEmail студента в Наборепочтовый ящик в Уведомлениях с историей доставок и отписокящик отслеживают во времени

Разные части школы — это разные ограниченные контексты (урок DDD.2.1), и каждый вправе моделировать одну вещь по-своему. Деньги — значение в Наборе и часть сущности Payment в Оплате.

Вопросы, которые помогают решить:

flowchart TD
Q1["Нужно ли различать два экземпляра<br/>с одинаковыми атрибутами?"] -->|"да"| E["Сущность"]
Q1 -->|"нет"| Q2["Меняется ли вещь со временем,<br/>оставаясь той же самой?"]
Q2 -->|"да: статусы, история"| E
Q2 -->|"нет"| Q3["Ссылаются ли на неё другие объекты,<br/>ищут ли её по номеру?"]
Q3 -->|"да"| E
Q3 -->|"нет"| V["Объект-значение"]
Сущность или объект-значение: три вопроса к эксперту предметной области

Практическое правило: начинайте со значения и делайте объект сущностью, только когда на один из вопросов ответ «да». Значения проще: их не надо отслеживать, их безопасно передавать куда угодно, их тесты — самые дешёвые в проекте. Хорошая модель обычно состоит из небольшого числа сущностей, вокруг которых много объектов-значений.

Обратный переход тоже бывает. Если выяснилось, что «запись в журнале изменений» никто не меняет и не ищет по номеру, её можно сделать значением, и код станет проще.

Напишите два объекта-значения школы:

  • Money — сумма в копейках и валюта. Проверки при создании (целые копейки, не bool, не отрицательная сумма, валюта из трёх заглавных латинских букв), сложение и вычитание только одной валюты (иначе CurrencyMismatch), умножение на int или Decimal с округлением до копейки «половина — вверх», сравнение больше/меньше;
  • Period — промежуток времени, у которого начало строго раньше конца; contains(moment) и overlaps(other). Договоримся, что период — полуинтервал: начало входит, конец нет. Тогда поток, который заканчивается 21 декабря в 10:00, и поток, который в это время начинается, не пересекаются.

__str__, duration и __rmul__ уже готовы. Равенство и хеширование вам даст @dataclass(frozen=True) — один тест пройдёт сразу, и это не ошибка, а демонстрация.

Объекты-значения Money и Period8 тестов
from dataclasses import dataclass
from datetime import datetime, timedelta
from decimal import ROUND_HALF_UP, Decimal
from functools import total_ordering


class CurrencyMismatch(ValueError):
    """Деньги в разных валютах нельзя складывать, вычитать и сравнивать."""


@total_ordering
@dataclass(frozen=True)
class Money:
    """Деньги: сумма в копейках (целое число) и валюта — трёхбуквенный код вроде RUB или USD.

    Объект-значение: неизменяемый (frozen=True), равенство по значению (его даёт @dataclass),
    сумма не бывает отрицательной.
    """

    amount: int
    currency: str

    def __post_init__(self):
        """Проверить значение сразу при создании.

        amount — int, но не bool (bool — подкласс int!), иначе TypeError; amount < 0 → ValueError;
        currency — строка из трёх заглавных латинских букв (isascii, isalpha, isupper), иначе ValueError.
        """
        # TODO: ваш код
        ...

    def __add__(self, other):
        """Сумма двух Money одной валюты — новый Money.

        other не Money → TypeError (Money + 100 — это рубли или копейки?); другая валюта → CurrencyMismatch.
        """
        # TODO: ваш код
        raise NotImplementedError

    def __sub__(self, other):
        """Разность двух Money одной валюты. Отрицательный итог отклонит __post_init__ (ValueError)."""
        # TODO: ваш код
        raise NotImplementedError

    def __mul__(self, factor):
        """Умножить на int или Decimal (но не bool и не float) → TypeError для остального.

        Копейки округляем до целых «половина — вверх»:
        (Decimal(self.amount) * factor).quantize(Decimal(1), rounding=ROUND_HALF_UP).
        """
        # TODO: ваш код
        raise NotImplementedError

    def __rmul__(self, factor):
        """3 * price — то же, что price * 3."""
        return self.__mul__(factor)

    def __lt__(self, other):
        """Меньше — только для Money в той же валюте, иначе CurrencyMismatch (или TypeError не для Money).

        Остальные сравнения (<=, >, >=) достроит декоратор @total_ordering.
        """
        # TODO: ваш код
        raise NotImplementedError

    def __str__(self):
        rubles = f"{self.amount // 100:,}".replace(",", " ")
        return f"{rubles},{self.amount % 100:02d} {self.currency}"


@dataclass(frozen=True)
class Period:
    """Период времени [start, end): начало входит в период, конец — нет."""

    start: datetime
    end: datetime

    def __post_init__(self):
        """start и end — datetime (иначе TypeError), start строго раньше end (иначе ValueError)."""
        # TODO: ваш код
        ...

    @property
    def duration(self) -> timedelta:
        return self.end - self.start

    def contains(self, moment: datetime) -> bool:
        """Входит ли момент в период: start <= moment < end."""
        # TODO: ваш код
        raise NotImplementedError

    def overlaps(self, other: "Period") -> bool:
        """Есть ли у периодов общий момент. Соседние периоды встык (1–10 и 10–20) не пересекаются."""
        # TODO: ваш код
        raise NotImplementedError


try:
    price = Money(3_000_000, "RUB")                   # 30 000 руб. в копейках
    print("Цена потока:", price)
    print("Три места:", price * 3, "| то же:", 3 * price)
    print("Скидка 15 %:", price - price * Decimal("0.15"))
    try:
        price + Money(500, "USD")
    except CurrencyMismatch as error:
        print("Ошибка:", error)
    study = Period(datetime(2026, 11, 2, 10, 0), datetime(2026, 12, 21, 10, 0))
    print("Длительность потока:", study.duration.days, "дней")
    print("2 ноября 10:00 внутри?", study.contains(datetime(2026, 11, 2, 10, 0)))
    print("21 декабря 10:00 внутри?", study.contains(datetime(2026, 12, 21, 10, 0)))
except NotImplementedError:
    print("Допишите методы с пометкой TODO и запустите снова.")

Объекты-значения — самая приятная часть модели для тестирования: ни базы данных, ни моков, ни времени. На каждое правило — короткий тест, имя которого читается как правило. Сохраните Email из примера выше в файл values.py, а рядом — test_values.py:

from dataclasses import FrozenInstanceError
import pytest
from values import Email
def test_email_is_normalized():
"""Адрес хранится без пробелов и в нижнем регистре."""
assert Email(" Anna@Example.COM ").value == "anna@example.com"
def test_emails_with_same_value_are_equal():
"""Равенство по значению: регистр и пробелы не делают адрес другим."""
assert Email("ANNA@example.com") == Email("anna@example.com")
assert len({Email("anna@example.com"), Email(" anna@example.com")}) == 1
@pytest.mark.parametrize("bad", ["", "anna", "anna.example.com", "@example.com", "anna@localhost", "an na@example.com"])
def test_invalid_email_is_rejected(bad):
"""Некорректный адрес нельзя даже создать — проверять его потом негде и некому."""
with pytest.raises(ValueError):
Email(bad)
def test_email_is_immutable():
"""Объект-значение не меняют, а заменяют целиком."""
email = Email("anna@example.com")
with pytest.raises(FrozenInstanceError):
email.value = "boris@example.com"
$ pytest -q test_values.py
......... [100%]
9 passed in 0.01s

Девять тестов, потому что parametrize превращает один тест в шесть — по одному на каждый некорректный адрес. Хороший набор тестов для любого объекта-значения отвечает на пять вопросов: создаётся ли корректное значение; отказывается ли некорректное (особенно на границах — ноль, пустая строка, равные даты); равны ли равные значения и работают ли они в словаре; нельзя ли значение изменить; верно ли работают операции, включая запрещённые сочетания. Тесты задания про Money и Period устроены ровно так — откройте их после решения и сравните со своими.

Упражнение на 20 минут. Напишите объект-значение CohortId — номер потока в формате «курс-год-месяц» (python-2026-11): латинские буквы и дефисы, год из четырёх цифр, месяц от 01 до 12. Добавьте свойства year и month. Напишите тесты по пяти вопросам выше. Подумайте, что сломается, если передать CohortId туда, где ждут Email, и почему с двумя строками так не получалось.

Проверь себя6 вопросов
1 Что из перечисленного в контексте Набора — сущности? Выберите все верные варианты.Можно выбрать несколько ответов
2 Студент — обычный @dataclass (сравнение по всем полям). Анна сменила почту. Что вернёт сравнение её объекта в памяти (уже с новой почтой) с копией, загруженной из базы до изменения?
3 Сколько копеек будет в Money(1001, "RUB") * Decimal("0.5") при округлении «половина — вверх» (ROUND_HALF_UP)? (целое число)
4 Зачем в Money.__post_init__ отдельно проверять, что сумма не bool?
5 Периоды — полуинтервалы [начало, конец). Пересекаются ли поток с 1 по 10 ноября (10:00–10:00) и поток с 10 по 20 ноября?
6 Какие фрагменты кода — признаки одержимости примитивами? Выберите все верные варианты.Можно выбрать несколько ответов
  • Объекты модели делятся по вопросу «важно ли, какой именно это экземпляр». Сущность определяется идентичностью и живёт во времени (Student, Cohort, Enrollment, Payment); объект-значение определяется атрибутами, неизменяем и взаимозаменяем с равным (Money, Period, Email, EnrollmentStatus).
  • Одержимость примитивами — понятия домена в str, int, float и словарях. Правила размазываются и забываются, значения путают местами и единицами. Объект-значение даёт правилу один дом.
  • В Python объект-значение — @dataclass(frozen=True) с проверкой в __post_init__ и операциями, которые возвращают новый объект. Помните о неглубокой заморозке, bool как подклассе int и о том, что аннотации не проверяются.
  • Сущность сравнивают по идентификатору (__eq__ и __hash__ по id). Идентификатор и текущее время модель получает снаружи — так тесты предсказуемы.
  • Одна вещь бывает значением в одном контексте и сущностью в другом. Начинайте со значения; тесты значений — самые быстрые и дешёвые в проекте.

Дальше: урок DDD.3.2 — агрегаты и инварианты. Соберём сущности и значения Набора в агрегат «Поток», который сам следит за вместимостью, повторной записью и закрытием записи при старте, и разберём, где проходит граница одной транзакции.

Закончили урок?Урок пройден

Отметьте его — и 5 карточек из урока попадут в колоду повторения. · 5 карточек — в колоде повторения

Первоисточники 5

  • EN
    Eric Evans — Domain-Driven Design Reference (2015), шаблоны Entities и Value Objects
    краткие определения: сущность отличает идентичность, а не атрибуты; объект-значение описывает характеристику, неизменяем, его операции — функции без побочных эффектов. Подробно — глава 5 «синей книги» (рус. пер. «Предметно-ориентированное проектирование (DDD)», 2010)пересказ
  • EN
    Harry Percival, Bob Gregory — Architecture Patterns with Python («Cosmic Python»), глава 1
    файл chapter_01_domain_model.asciidoc, разделы «Dataclasses Are Great for Value Objects» (Money на dataclass, тесты сложения и умножения) и «Value Objects and Entities» (равенство сущности по ссылке, __eq__ и __hash__)пересказ · CC-BY-ND
  • EN
    Python — документация модуля dataclasses
    параметр frozen, исключение FrozenInstanceError, метод __post_init__, функция replace, правила генерации __eq__ и __hash__; для денег — модули decimal (quantize, ROUND_HALF_UP) и functools (total_ordering)ссылка
  • EN
    Влад Хононов — Learning Domain-Driven Design (O’Reilly, 2021), глава 6
    глава 6 «Tackling Complex Business Logic»: объекты-значения, сущности и агрегаты в шаблоне «модель предметной области», одержимость примитивами; на русском книга вышла как «Изучаем DDD — предметно-ориентированное проектирование»пересказ
  • EN
    Martin Fowler — ValueObject (bliki)
    короткая заметка: равенство по значению, неизменяемость и почему значения безопасно передавать и разделятьссылка

Лицензия текста урока: CC-BY-SA-4.0 · обновлено 2026-10-06