Сущности и объекты-значения
Чем сущность с идентичностью и жизненным циклом отличается от неизменяемого объекта-значения, почему Money, Email и Period лучше голых float и str, как писать объекты-значения и сущности на Python и как тестировать правила значений.
Зачем это нужно
Заголовок раздела «Зачем это нужно»В онлайн-школе счёт за запись на поток выставляют из трёх мест: при записи с сайта, при переносе через поддержку и при оплате компанией за сотрудника. Функция одна, и устроена она так, как пишут первый вариант почти всегда, — на строках и числах:
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 руб., какие именно это рубли, не имеет значения.
У хорошего объекта-значения пять свойств:
- Равенство по значению. Две суммы «500 руб.» равны, даже если это два разных объекта в памяти.
- НеизменяемостьнеизменяемостьEN immutabilityСвойство объекта, состояние которого нельзя изменить после создания: вместо правки создают новый объект. Обязательное свойство объектов-значений и доменных событий; в Python — @dataclass(frozen=True), кортежи вместо списков. (immutability). Значение не правят, а заменяют целиком. Анна
сменила почту — в сущность
Studentположили новый объектEmail, старый не изменился. - Самопроверка. Невалидное значение нельзя создать:
Money(-100, "RUB")падает сразу, а не через месяц в отчёте. Если объект существует, он корректен, и проверять его повторно в каждой функции не нужно. - Операции без побочных эффектов. Сложение двух сумм возвращает третью и не трогает слагаемые. Эванс называет такие методы функциями без побочных эффектов (side-effect-free functions): их результат зависит только от аргументов, поэтому их легко понять и проверить.
- Целостность. Значение собирает вместе то, что по отдельности не имеет смысла. Сумма без валюты — не деньги, дата начала без даты конца — не период. Уорд Каннингем называл такие объекты «целыми значениями» (Whole Value).
Значения в школе: Деньги (Money), Период (Period), адрес почты (Email), статус записи (EnrollmentStatus —
перечисление Enum из урока DDD.1.2 тоже значение). Значениями удобно делать и сами идентификаторы: номер потока
python-2026-11 неизменяем и сравнивается по значению. Money и Period одинаково нужны Набору и Оплате, поэтому в
учебной школе они лежат в общем ядре school/shared_kernel/ (урок DDD.2.2):
маленьким неизменяемым значениям с ясными правилами там самое место.
| Сущность | Объект-значение | |
|---|---|---|
| Главный вопрос | кто это? | что это? |
| Равенство | по идентификатору | по всем атрибутам |
| Изменение | меняет состояние, оставаясь собой | неизменяем, заменяется целиком |
| Жизненный цикл | есть: создание, статусы, архив | нет: значение просто существует |
| Примеры в школе | Student, Cohort, Enrollment, Payment | Money, 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 ничего не добавит.
Объект-значение окупается, когда у значения есть правила (формат, диапазон, единицы измерения), когда его легко
спутать с другим значением того же примитивного типа или когда с ним выполняют операции.
Объекты-значения в Python
Заголовок раздела «Объекты-значения в Python»dataclass(frozen=True)
Заголовок раздела «dataclass(frozen=True)»Декоратор @dataclass из урока 0.2.3 сам пишет __init__, __repr__ и
__eq__, который сравнивает объекты по полям. Параметр frozen=True добавляет неизменяемость: попытка присвоить
полю новое значение бросает FrozenInstanceError. Неизменяемый класс с равенством по полям получает и __hash__,
поэтому значение годится в ключ словаря и в множество. Три свойства объекта-значения достаются почти даром.
Проверка в post_init
Заголовок раздела «Проверка в post_init»Метод __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с явным правилом округления. Округление — тоже правило предметной области, его стоит обсудить с бухгалтером.
Сущности в Python
Заголовок раздела «Сущности в Python»Для сущности равенство по всем полям — ошибка. Если бы 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 и Period
Заголовок раздела «Задание в браузере: Money и Period»Напишите два объекта-значения школы:
Money— сумма в копейках и валюта. Проверки при создании (целые копейки, неbool, не отрицательная сумма, валюта из трёх заглавных латинских букв), сложение и вычитание только одной валюты (иначеCurrencyMismatch), умножение наintилиDecimalс округлением до копейки «половина — вверх», сравнение больше/меньше;Period— промежуток времени, у которого начало строго раньше конца;contains(moment)иoverlaps(other). Договоримся, что период — полуинтервал: начало входит, конец нет. Тогда поток, который заканчивается 21 декабря в 10:00, и поток, который в это время начинается, не пересекаются.
__str__, duration и __rmul__ уже готовы. Равенство и хеширование вам даст @dataclass(frozen=True) — один тест
пройдёт сразу, и это не ошибка, а демонстрация.
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, и почему с двумя строками так не получалось.
Проверь себя
Заголовок раздела «Проверь себя»- Объекты модели делятся по вопросу «важно ли, какой именно это экземпляр». Сущность определяется идентичностью и живёт во времени (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 — агрегаты и инварианты. Соберём сущности и значения Набора в агрегат «Поток», который сам следит за вместимостью, повторной записью и закрытием записи при старте, и разберём, где проходит граница одной транзакции.