Агрегаты и инварианты
Что такое агрегат и его корень, почему граница агрегата — это граница транзакции, четыре правила Вона Вернона (маленькие агрегаты, ссылки по идентификатору, согласованность в конечном счёте снаружи), как находить инварианты из Event Storming и канвы DDD Crew — на примере агрегата «Поток» онлайн-школы с вместимостью, записью, листом ожидания и отменой.
Зачем это нужно
Заголовок раздела «Зачем это нужно»Открытие продаж популярного потока: 30 мест, сотни желающих. Два менеджера набора в одну и ту же секунду записывают двух студентов. Каждый видит в своей вкладке одно свободное место, у каждого проверка «мест хватает» проходит, оба сохраняют. В потоке на 30 мест — 31 запись. Через неделю методист обнаруживает лишнего студента, и школе приходится извиняться и возвращать деньги.
Вторая история тише. В конце урока DDD.1.2 мы честно оставили в коде
строку anna.status = EnrollmentStatus.CANCELLED — «пока вручную». Любой модуль школы может так же поставить записи
любой статус: подтвердить неоплаченную, отменить без расчёта возврата, вернуть отменённую к жизни. Правила записаны
в методах Cohort, но обойти их — одна строка.
Обе истории про одно: у правил нет хозяина. В уроке DDD.3.1 мы научились защищать
правила одного значения — Money не бывает отрицательным. Но правило «записей не больше вместимости» связывает много
объектов сразу: все записи потока. Кто его защищает? Ответ DDD — агрегатагрегатEN aggregateКластер сущностей и объектов-значений, который меняется только целиком через корень и после каждой операции соблюдает свои инварианты. Граница агрегата — граница транзакции: одна транзакция меняет один агрегат. В онлайн-школе — поток с записями и листом ожидания..
Инварианты: правила, которые не нарушаются никогда
Заголовок раздела «Инварианты: правила, которые не нарушаются никогда»ИнвариантинвариантEN invariantПравило предметной области, которое должно выполняться всегда, после каждой завершённой операции: «записей, занимающих место, не больше вместимости потока». Настоящий инвариант защищают внутри одного агрегата; то, что может быть временно нарушено, — забота политик и согласованности в конечном счёте. — правило предметной области, которое должно быть истинным всегда: после каждой завершённой операции, в любой момент, когда систему видит кто-то снаружи.
Возьмём правила Набора из глоссария и спросим про каждое: может ли оно быть нарушено хотя бы на минуту?
| Правило Набора | Инвариант? | Почему |
|---|---|---|
| Записей «ожидает оплаты» и «подтверждена» не больше вместимости | да | лишний студент на занятии — реальный ущерб, исправлять потом дорого |
| Студент не записан на один поток дважды | да | двойной счёт и путаница в местах |
| Запись закрывается в момент старта потока | да | после старта новые записи не принимаются ни на секунду |
| Неоплаченная запись отменяется через 48 часов | нет — это правило времени | запись «просрочена» с момента, когда прошло 48 часов, а отменяет её отдельная команда; минуту между ними бизнес переживёт |
| Отмена за 7 суток и более до старта — полный возврат | нет — это правило операции | оно отвечает на вопрос «сколько вернуть», а не на вопрос «каким может быть состояние» |
| Освободившееся место — первому в листе ожидания, 24 часа на ответ | частично | «место не уходит в обход очереди» — инвариант; «предложение сгорает через 24 часа» — правило времени |
Разница важна. Инвариант должен проверяться внутри той же операции, которая могла бы его нарушить. Правило времени выполняется отдельной командой по расписанию или по событию. Канва проектирования агрегата от DDD Crew называет такие правила корректирующими политикамикорректирующая политикаEN corrective policyТермин канвы проектирования агрегата (DDD Crew): бизнес-логика, которая исправляет расхождения после того, как правило решили не делать инвариантом агрегата. Пример: платёж пришёл после автоматической отмены записи — вернуть деньги или восстановить запись. (corrective policies): логика, которая исправляет расхождение после того, как оно возникло.
На стене Event Storming в уроке DDD.2.3 все правила Набора, включая
48 и 24 часа, записали на один жёлтый стикер «Поток» — для стены это правильно. При проектировании агрегата их
разводят: инварианты проверяет каждая команда, а правила времени становятся отдельными командами вроде
expire_unpaid(now), которые запускает таймер.
Агрегат и его корень
Заголовок раздела «Агрегат и его корень»Определение
Заголовок раздела «Определение»Эрик Эванс определяет агрегат как кластер связанных объектов, который мы рассматриваем как единое целое с точки зрения изменения данных. Авторы «Cosmic Python» говорят проще: агрегат — это объект предметной области, который содержит другие объекты и позволяет обращаться со всей коллекцией как с одним целым. Единственный способ изменить что-то внутри — загрузить агрегат целиком и вызвать метод самого агрегата.
У каждого агрегата есть коренькорень агрегатаEN aggregate rootЕдинственная сущность агрегата, доступная снаружи: через её методы идут все изменения, на неё одну ссылаются другие объекты (по идентификатору). Корень отвечает за инварианты всего агрегата и не отдаёт наружу изменяемые внутренности. (aggregate root) — одна сущность, через которую идёт
всё общение с внешним миром. В Наборе корень — поток Cohort. Внутри него живут записи Enrollment (сущности:
у каждой свой номер и статус) и лист ожидания, а рядом — значения из прошлого урока: цена Money и сроки.
Правила Эванса для агрегата
Заголовок раздела «Правила Эванса для агрегата»- У корня — глобальная идентичность: поток
python-2026-11уникален во всей школе. У сущностей внутри — локальная: номер записи достаточно сделать уникальным внутри потока. - Снаружи можно держать ссылку только на корень. Счёт в контексте Оплаты помнит «запись 3 потока
python-2026-11», но, чтобы подтвердить запись, код загружает поток и вызываетcohort.confirm(...), а не меняет запись напрямую. - Корень может дать наружу ссылку на внутреннюю сущность только на время одной операции — хранить её нельзя; значения отдают свободно, они неизменяемы. В Python мы поступим строже и будем отдавать наружу только копии.
- Из хранилища достают только корни. Загрузить «запись без потока» нельзя.
- После любого изменения, которое фиксируется, должны выполняться все инварианты агрегата целиком.
Авторы «Cosmic Python» предлагают удобную аналогию: агрегаты — это «публичные» классы модели, а сущности и значения внутри них — «приватные», как методы с подчёркиванием в начале имени.
flowchart LR subgraph A["Агрегат «Поток»: граница согласованности"] C["Cohort — корень<br/>cohort_id, starts_at,<br/>capacity, price: Money"] E1["Enrollment<br/>python-2026-11-1<br/>ожидает оплаты"] E2["Enrollment<br/>python-2026-11-2<br/>подтверждена"] W["Лист ожидания<br/>vera, gleb"] C --> E1 C --> E2 C --> W end K["Course<br/>другой агрегат"] S["Student<br/>другой агрегат"] I["Invoice в Оплате<br/>другой агрегат"] C -.->|"course_id"| K E1 -.->|"student_id"| S I -.->|"cohort_id, enrollment_id"| C
Граница агрегата = граница транзакции
Заголовок раздела «Граница агрегата = граница транзакции»Инвариант связывает данные. Правило вместимости связывает вместимость и статусы всех записей потока. Чтобы гарантировать его, все эти данные нужно читать и менять вместе, атомарно — одной транзакцией. Поэтому граница агрегата — это граница согласованностиграница согласованностиEN consistency boundaryГраница, внутри которой все правила выполняются немедленно и одной транзакцией. В DDD её проводят по агрегату: внутри — строгая согласованность, между агрегатами — согласованность в конечном счёте. (consistency boundary): внутри неё все правила выполняются немедленно.
Отсюда рабочее правило: одна транзакция — один агрегат. Сценарий «записать студента на поток» загружает один поток, вызывает один метод корня и сохраняет один поток. Если сценарию хочется в той же транзакции поменять ещё и счёт, и профиль студента, это сигнал: либо граница проведена неверно, либо остальное должно случиться позже — через события. Сценарии и события подробно разбирает урок DDD.3.3, сохранение агрегатов — урок DDD.3.4.
Правила Вона Вернона
Заголовок раздела «Правила Вона Вернона»Вон Вернон в серии статей «Effective Aggregate Design» (2011) и в книге «Реализация методов предметно-ориентированного проектирования» (2013) свёл опыт проектирования агрегатов к четырём правилам.
1. Моделируйте настоящие инварианты в границах согласованности
Заголовок раздела «1. Моделируйте настоящие инварианты в границах согласованности»Граница агрегата определяется не тем, что объекты «связаны» в жизни, а тем, какие правила должны выполняться транзакционно. Поток и его записи — в одном агрегате, потому что вместимость — правило над всеми записями сразу. Курс и поток связаны в жизни, но общего инварианта у них нет: запись на один поток не зависит от записей на другой. Вернон формулирует цель так: правильно спроектированный ограниченный контекст в каждой транзакции меняет только один экземпляр агрегата.
2. Проектируйте маленькие агрегаты
Заголовок раздела «2. Проектируйте маленькие агрегаты»Большой агрегат соблазнителен: «положим всё про курс в один объект, и правила точно будут соблюдены». Цена — две беды. Первая — производительность: чтобы записать одного студента, приходится загружать курс со всеми потоками за пять лет. Вторая — конфликты: два менеджера, которые записывают студентов на разные потоки одного курса, меняют один и тот же агрегат и мешают друг другу.
В первой статье серии Вернон разбирает ровно такую ошибку на приложении для Scrum-команд. Сначала «Продукт» содержал внутри все задачи бэклога, релизы и спринты. Пользователи, которые правили разные задачи одного продукта, получали отказы при сохранении, хотя ничего общего не меняли. Команда разделила большой агрегат на несколько маленьких, которые ссылаются на продукт по его идентификатору. Совет Вернона: оставлять в агрегате корень и минимум того, что нужно для инвариантов, а описательные данные выносить в значения.
3. Ссылайтесь на другие агрегаты по идентификатору
Заголовок раздела «3. Ссылайтесь на другие агрегаты по идентификатору»Поток хранит course_id, а не объект Course; запись хранит student_id, а не объект Student. Ссылка по
идентификатору не даёт случайно изменить чужой агрегат в своей транзакции (у вас просто нет его в руках), делает
агрегат маленьким при загрузке и позволяет хранить разные агрегаты в разных базах и сервисах. Если для решения нужны
данные другого агрегата, сценарий загружает их отдельно и передаёт в метод значением.
4. За границей — согласованность в конечном счёте
Заголовок раздела «4. За границей — согласованность в конечном счёте»Если правило связывает два агрегата, но может быть нарушено на короткое время, его выполняют не в одной транзакции, а в конечном счётесогласованность в конечном счётеEN eventual consistencyДанные в разных местах (агрегат и модель чтения, два контекста) какое-то время расходятся, но без новых изменений сходятся к одному значению. Плата за независимость частей системы; бизнес обычно и так живёт с задержками и исправлениями. (eventual consistency): первый агрегат меняется и публикует доменное событиедоменное событиеEN domain eventЗначимый для экспертов факт, который уже произошёл, в прошедшем времени: «Студент записан», «Платёж получен». В Event Storming — оранжевый стикер, в коде — неизменяемый объект (урок DDD.3.3)., а второй меняется позже, в своей транзакции, когда событие обработано. Платёж получен в Оплате — событие ПлатёжПолучен — через мгновение поток в Наборе подтверждает запись — событие ЗаписьПодтверждена — Обучение открывает доступ к занятиям. Между шагами есть окно, когда платёж уже есть, а запись ещё «ожидает оплаты». Бизнес должен согласиться на это окно — спросите экспертов, сколько секунд или минут допустимо.
Вернон предлагает вопрос для выбора: чья это работа — сделать данные согласованными? Если результата немедленно ждёт тот же пользователь, который выполняет действие, правило стоит держать в одной транзакции (в рамках остальных правил). Если это работа другого пользователя или системы, подходит согласованность в конечном счёте.
Когда правила нарушают
Заголовок раздела «Когда правила нарушают»Вернон перечисляет и причины отступить от правил: удобство интерфейса (создать несколько агрегатов одной пакетной командой), нет средств для асинхронной доставки событий, требование глобальных транзакций, производительность запросов. Отступать можно, если цена понятна и решение записано.
Где провести границу в Наборе
Заголовок раздела «Где провести границу в Наборе»Попробуем четыре варианта границы и посмотрим, какие инварианты каждый умеет защищать.
| Граница агрегата | Что защищено транзакционно | Что плохо |
|---|---|---|
| Курс со всеми потоками и записями | все правила Набора | огромный агрегат; записи на разные потоки конфликтуют; ради одной записи грузятся годы истории |
| Поток с записями и листом ожидания | вместимость, двойная запись, закрытие при старте, очередь | популярный поток в момент открытия продаж — частые конфликты версий, их лечат повтором команды |
| Запись — отдельный агрегат, поток знает только вместимость | статусы одной записи | вместимость между агрегатами не защищена: возможен перебор мест, нужна корректирующая политика «извиниться и вернуть деньги» |
| Студент со всеми своими записями | «не записан дважды» | вместимость потока не видна вовсе |
Для школы выбираем второй вариант: поток на 30 мест — небольшой агрегат, а его главные правила требуют видеть все записи сразу. Третий вариант — не ошибка, а другой компромисс: для массового курса на 10 000 мест, где перебор на одно-два места не страшен, а конфликты мешали бы тысячам людей, эксперты могли бы выбрать маленькие агрегаты-записи и политику исправления. Такой выбор делают вместе с бизнесом.
Канва проектирования агрегата
Заголовок раздела «Канва проектирования агрегата»Чтобы обсуждать такие решения с командой, DDD Crew предлагает канву проектирования агрегатаканва проектирования агрегатаEN Aggregate Design CanvasШаблон DDD Crew на одном листе для обсуждения агрегата: название, описание, переходы состояний, инварианты и корректирующие политики, команды и события, пропускная способность (шанс конфликтов) и размер. Лицензия CC BY 4.0 (в README) / CC BY-SA 4.0 (файл LICENSE.md). (Aggregate Design Canvas) — лист из девяти разделов, которые заполняют по порядку. Вот она для потока (адаптация README канвы, лицензия CC BY-SA 4.0):
| Раздел канвы | Агрегат «Поток» (Cohort) |
|---|---|
| 1. Название | Поток курса. Живёт от открытия набора до старта и ещё немного после — пока идут отмены и возвраты |
| 2. Описание | Отвечает за места в потоке: кто записан, кто ждёт. Граница выбрана, потому что вместимость — правило над всеми записями сразу; цена — возможные конфликты при открытии продаж |
| 3. Переходы состояний | набор открыт → мест нет (есть лист ожидания) → набор открыт (место освободилось) → набор закрыт (старт). У записей: ожидает оплаты → подтверждена → отменена |
| 4. Инварианты | занято (записи «ожидает оплаты» и «подтверждена» плюс действующие предложения) не больше вместимости; студент в потоке не больше одного раза; после старта новых записей нет; освободившееся место не уходит в обход листа ожидания |
| 5. Корректирующие политики | неоплаченная запись отменяется через 48 часов; предложение из листа ожидания истекает через 24 часа, и место предлагают следующему; платёж, пришедший после автоотмены, Оплата возвращает полностью (ПлатёжВозвращён) |
| 6. Команды | записать (enroll), подтвердить (confirm), отменить (cancel), снять неоплаченные (expire_unpaid), встать в лист ожидания (join_waitlist), предложить места (offer_next), принять предложение (accept_offer) |
| 7. События | СтудентЗаписан, ЗаписьПодтверждена, ЗаписьОтменена (с причиной: отменена студентом или не оплачена за 48 часов), МестоОсвободилось, МестоПредложено, ПредложениеИстекло |
| 8. Пропускная способность | обычно единицы команд в час от десятков студентов; в пик открытия продаж — сотни команд за минуты, шанс конфликта высокий |
| 9. Размер | на экземпляр — порядка двух-трёх событий на место (запись, подтверждение, иногда отмена), живёт недели; хранить и загружать легко |
Два последних раздела — самые недооценённые. Пропускная способность оценивает, как часто два клиента будут менять один экземпляр одновременно: README канвы сравнивает корзину интернет-магазина (её меняет один покупатель) с системой бронирования билетов на конференцию (десятки и сотни покупателей сразу). Поток в момент старта продаж похож на второе. Авторы канвы прямо формулируют компромисс: большие агрегаты чаще конфликтуют, но требуют меньше корректирующих политик; маленькие — наоборот.
Канва не обязана быть законченной. Две горячие точки сессии из урока DDD.2.3 — «поток перенесли на другую дату» и «возможен ли частичный возврат» — так и остались открытыми; их записывают рядом с канвой как вопросы к экспертам, а не додумывают в коде.
Как найти инварианты из Event Storming
Заголовок раздела «Как найти инварианты из Event Storming»На стене Event Storming уровня проектирования (Design Level) агрегат появляется сам: между синей командой и оранжевым событием ставят большой жёлтый стикер. Шпаргалка DDD Crew называет его «ограничение» (constraint) и уточняет, что слово «агрегат» с экспертами лучше не использовать. Таблицу «стикер → код» для Набора вы уже видели в уроке DDD.2.3. Чтобы превратить жёлтый стикер в агрегат, задайте у стены четыре вопроса:
- Когда эту команду отклоняют? Проговорите каждую команду фразой «команда X отклоняется, если …». Всё, что
встало на место многоточия, — кандидаты в инварианты и доменные исключения: «Записаться на поток» отклоняется,
если поток стартовал (
EnrollmentClosed), студент уже записан (AlreadyEnrolled), мест нет (NoFreeSeats). - Какие данные нужны, чтобы решить? Это состав агрегата: вместимость, статусы всех записей потока, лист ожидания. Команды, которые решаются по одним и тем же данным, — кандидаты в один агрегат.
- Может ли правило быть нарушено хотя бы на минуту? Нет — инвариант внутри границы. Да — корректирующая политика или согласованность в конечном счёте. Горячая точка «а если двое берут последнее место?» — ровно этот вопрос, и отвечают на него эксперты, а не разработчик.
- Что должно быть правдой после события? Это постусловие метода корня: после «Запись отменена» запись в статусе «отменена», место свободно, опубликовано МестоОсвободилось.
Решения экспертов — в коде агрегата
Заголовок раздела «Решения экспертов — в коде агрегата»Три открытых вопроса глоссария из урока DDD.1.2 эксперты школы закрыли у стены в уроке DDD.2.3. Агрегат превращает каждое решение в проверку или команду:
| Решение экспертов (условие учебного примера) | Как это выглядит в агрегате «Поток» |
|---|---|
| После отмены можно записаться снова: «не дважды» касается только действующих записей | AlreadyEnrolled бросается, только если у студента есть действующая запись («ожидает оплаты» или «подтверждена») либо он уже ждёт в листе ожидания или ответа на предложение; отменённая запись остаётся в истории, новая получает новый номер и новые 48 часов |
| Платёж после автоотмены возвращается полностью, запись не восстанавливается | confirm для отменённой записи бросает InvalidStatusTransition: «отменена» — конечный статус. Платёж и запись — разные агрегаты разных контекстов, возврат делает Оплата по корректирующей политике (событие ПлатёжВозвращён) |
| Студент из листа ожидания подтверждает согласие занять место; 24 часа место закреплено за ним | offer_next(now) закрепляет место (МестоПредложено, SeatOffered) и снимает просроченные предложения (ПредложениеИстекло, SeatOfferExpired); accept_offer создаёт обычную запись «ожидает оплаты» с 48 часами на оплату |
| Одна отмена на обе причины, с причиной в событии | ЗаписьОтменена (EnrollmentCancelled) несёт причину: BY_STUDENT (отменена студентом) или UNPAID (не оплачена за 48 часов); правило возврата 7 суток применяется только к отмене студентом |
Имена методов на стене были черновиком, и при проектировании агрегата их уточнили. Черновое offer(now) стало
offer_next(now): имя говорит, кому предлагают место, — следующим в очереди. Отмену по таймеру удобно поручить
команде expire_unpaid(now): она сама находит записи, которые ждут оплаты 48 часов и больше, и отменяет их с
причиной UNPAID. Причине из вызывающего кода агрегат слепо не доверяет — от неё зависят деньги. Поэтому
cancel(enrollment_id, now, reason) в домашней работе принимает UNPAID только для записи, которая действительно
ждёт оплаты 48 часов и больше.
Агрегат «Поток» в коде
Заголовок раздела «Агрегат «Поток» в коде»Вот как выглядит корень. Поля с подчёркиванием — внутренности, которые снаружи не трогают; читать их можно через свойства, которые отдают копии.
class Cohort: """Поток курса — корень агрегата: записи и лист ожидания меняются только через его методы."""
def __init__(self, cohort_id: str, starts_at: datetime, capacity: int): self.cohort_id = cohort_id self.starts_at = starts_at self.capacity = capacity self._enrollments: dict[str, Enrollment] = {} # внутренние сущности: номер записи → запись self._waitlist: list[str] = [] # студенты по идентификатору, по очереди self._offers: dict[str, datetime] = {} # студент → до какого момента действует предложение
@property def enrollments(self) -> tuple: """Копии записей: правка копии не меняет поток.""" return tuple(replace(e) for e in self._enrollments.values())
def enroll(self, student_id: str, now: datetime) -> str: """Записать студента. Все правила записи проверяет корень — больше некому.""" if now >= self.starts_at: raise EnrollmentClosed(f"Запись на поток {self.cohort_id} закрыта: поток стартовал") if self._is_in_cohort(student_id): raise AlreadyEnrolled(f"Студент {student_id} уже есть в потоке {self.cohort_id}") if self._seats_for_newcomers() <= 0: raise NoFreeSeats(f"На потоке {self.cohort_id} нет свободных мест") return self._new_enrollment(student_id, now)Пять приёмов, которые делают правила необходимыми, а не желательными:
- Методы — глаголы единого языка.
enroll,cancel,confirm, а неset_statusиadd_to_list: метод выражает операцию бизнеса целиком и поэтому может проверить её правила. - Проверка до изменения. Если правило нарушено, агрегат остаётся в прежнем состоянии, а наружу летит доменное исключение с понятным сообщением.
- Внутренности не отдаются.
enrollmentsвозвращает копии,waitlist— кортеж. Python не запрещает обратиться кcohort._waitlist, но подчёркивание — договорённость, которую ловят на ревью. - Время и идентификаторы — снаружи.
now— параметр, как и в прошлом уроке. - Инварианты проверяемы.
check_invariants()пересчитывает правила по состоянию агрегата; в тестах он ловит ошибку сразу после операции, которая её допустила.
Параллельная запись и оптимистическая блокировка
Заголовок раздела «Параллельная запись и оптимистическая блокировка»Вернёмся к двум менеджерам из начала урока. Агрегат защищает инвариант внутри одной копии, но у каждого менеджера своя копия, загруженная из базы. Нужна ещё защита при сохранении. Самый простой способ — оптимистическая блокировкаоптимистическая блокировкаEN optimistic locking, optimistic concurrency controlСпособ поймать параллельные изменения без блокировки на время работы: у агрегата есть номер версии, при сохранении хранилище сверяет его с текущим. Версия выросла — кто-то успел раньше, сохранение отклоняется (ConcurrencyError), команду повторяют на свежих данных. (optimistic locking): у агрегата есть номер версии, и хранилище сохраняет его, только если версия с момента загрузки не изменилась. «Оптимистическая» — потому что мы ничего не блокируем заранее и надеемся, что конфликтов мало, а если конфликт случился, замечаем его и повторяем команду.
import copy
class NoFreeSeats(Exception): """На потоке нет свободных мест."""
class ConcurrencyError(Exception): """Поток изменили параллельно: версия в хранилище новее загруженной."""
class Cohort: """Сокращённый агрегат «Поток»: только вместимость и записанные студенты."""
def __init__(self, cohort_id, capacity): self.cohort_id = cohort_id self.capacity = capacity self.students = [] self.version = 0 # номер версии для оптимистической блокировки
def enroll(self, student_id): if len(self.students) >= self.capacity: raise NoFreeSeats(f"На потоке {self.cohort_id} нет свободных мест") self.students.append(student_id)
class InMemoryStore: """Хранилище в памяти: каждому отдаёт свою копию, как база данных."""
def __init__(self, check_version): self.check_version = check_version self._rows = {}
def add(self, cohort): self._rows[cohort.cohort_id] = copy.deepcopy(cohort)
def get(self, cohort_id): return copy.deepcopy(self._rows[cohort_id])
def save(self, cohort): stored = self._rows[cohort.cohort_id] if self.check_version and stored.version != cohort.version: raise ConcurrencyError(f"Поток {cohort.cohort_id} уже изменили: " f"в хранилище версия {stored.version}, у вас {cohort.version}") cohort.version += 1 self._rows[cohort.cohort_id] = copy.deepcopy(cohort)
for check_version in (False, True): print("С проверкой версии:" if check_version else "Без проверки версии:") store = InMemoryStore(check_version) cohort = Cohort("python-2026-11", capacity=2) cohort.enroll("anna") store.add(cohort)
# Два менеджера одновременно открыли поток, где осталось одно место first = store.get("python-2026-11") second = store.get("python-2026-11") first.enroll("boris") # в своей копии каждый видит одно свободное место, second.enroll("vera") # и проверка инварианта у обоих проходит store.save(first) try: store.save(second) except ConcurrencyError as error: print(" Вера:", error) retry = store.get("python-2026-11") # повторяем команду на свежих данных try: retry.enroll("vera") except NoFreeSeats as error: print(" Повтор для Веры:", error) print(" В потоке:", store.get("python-2026-11").students)Без проверки версии: В потоке: ['anna', 'vera']С проверкой версии: Вера: Поток python-2026-11 уже изменили: в хранилище версия 1, у вас 0 Повтор для Веры: На потоке python-2026-11 нет свободных мест В потоке: ['anna', 'boris']Без проверки версии случилось худшее из возможного — потерянное обновлениепотерянное обновлениеEN lost updateОшибка параллельной работы: два запроса прочитали одну версию данных, оба изменили и сохранили — второй молча затёр изменения первого. Ловится оптимистической блокировкой по номеру версии. (lost update): запись Бориса молча затёрта, хотя ему уже
сказали «вы записаны». С проверкой второе сохранение отклонено, команда повторена на свежих данных, и инвариант честно
отказал Вере. Глава 7 «Cosmic Python» реализует то же самое на настоящей базе данных через номер версии у агрегата
Product. Обратите внимание: версия одна на весь агрегат, и это ещё один довод за маленькие агрегаты — чем больше
агрегат, тем чаще чужие несвязанные изменения отклоняют ваше. Как устроено хранилище агрегатов по-настоящему, разберёт
урок DDD.3.4.
Практика
Заголовок раздела «Практика»Задание в браузере: агрегат «Поток»
Заголовок раздела «Задание в браузере: агрегат «Поток»»Реализуйте команды агрегата Cohort: свободные места, запись, отмену, лист ожидания, предложение места на 24 часа и
согласие на него. Готовы классы исключений, сущность Enrollment, конструктор, свойства для чтения (enrollments,
waitlist, offers возвращают копии) и check_invariants(), которую тесты вызывают после каждого сценария.
Правила, которые проверяют тесты, — те же, что в канве выше:
- занято (записи «ожидает оплаты» и «подтверждена» плюс действующие предложения) не больше вместимости;
- студент не может быть в потоке дважды — ни записанным, ни с предложением, ни в листе ожидания; после отмены можно записаться снова;
- в момент старта потока запись и лист ожидания закрываются;
- отмена освобождает место, но новичок не может его занять, пока в листе ожидания есть люди;
offer_next(now)снимает просроченные предложения и предлагает свободные места первым в очереди, по 24 часа каждому. В работающей системе этот вызов делает политика, которая реагирует на событие МестоОсвободилось (о событиях и политиках — урок DDD.3.3);- отклонённая команда поток не меняет: если согласие пришло слишком поздно,
accept_offerбросаетOfferExpired, а снимает просроченное предложениеoffer_next.
Событий в задании нет — их, вместе с причиной отмены, вы добавите в домашней работе.
from dataclasses import dataclass, replace
from datetime import datetime, timedelta
from enum import Enum
OFFER_PERIOD = timedelta(hours=24) # срок на ответ для студента из листа ожидания (условие учебного примера)
class EnrollmentStatus(Enum):
"""Статус записи на поток — ровно три, как в глоссарии."""
PENDING_PAYMENT = "ожидает оплаты"
CONFIRMED = "подтверждена"
CANCELLED = "отменена"
class DomainError(Exception):
"""Нарушено правило предметной области."""
class NoFreeSeats(DomainError):
"""На потоке нет свободных мест."""
class AlreadyEnrolled(DomainError):
"""Студент уже записан на поток, ждёт ответа на предложение места или стоит в листе ожидания."""
class EnrollmentClosed(DomainError):
"""Запись на поток закрыта: поток уже стартовал."""
class EnrollmentNotFound(DomainError):
"""В потоке нет записи с таким номером."""
class InvalidStatusTransition(DomainError):
"""Такой переход статуса запрещён, например повторная отмена."""
class NoOffer(DomainError):
"""Студенту не предлагали место."""
class OfferExpired(DomainError):
"""Срок предложения места истёк."""
@dataclass
class Enrollment:
"""Запись на поток — сущность внутри агрегата «Поток». Меняет её только корень."""
enrollment_id: str
student_id: str
enrolled_at: datetime
status: EnrollmentStatus = EnrollmentStatus.PENDING_PAYMENT
@property
def takes_seat(self) -> bool:
"""Место занимают записи «ожидает оплаты» и «подтверждена»."""
return self.status != EnrollmentStatus.CANCELLED
def __eq__(self, other):
return isinstance(other, Enrollment) and other.enrollment_id == self.enrollment_id
def __hash__(self):
return hash(self.enrollment_id)
class Cohort:
"""Поток курса — корень агрегата: записи и лист ожидания меняются только через его методы."""
def __init__(self, cohort_id: str, starts_at: datetime, capacity: int):
if isinstance(capacity, bool) or not isinstance(capacity, int) or capacity < 1:
raise ValueError(f"Вместимость потока — целое число от 1, а получено {capacity!r}")
self.cohort_id = cohort_id
self.starts_at = starts_at
self.capacity = capacity
self._enrollments: dict[str, Enrollment] = {} # номер записи → запись
self._waitlist: list[str] = [] # студенты в порядке очереди (FIFO)
self._offers: dict[str, datetime] = {} # студент → до какого момента действует предложение
self._next_number = 1
# ---------- чтение: наружу только копии ----------
@property
def enrollments(self) -> tuple:
"""Копии записей: правка копии не меняет поток."""
return tuple(replace(e) for e in self._enrollments.values())
@property
def waitlist(self) -> tuple:
return tuple(self._waitlist)
@property
def offers(self) -> dict:
return dict(self._offers)
def _active_students(self) -> set:
"""Студенты, чьи записи занимают место («ожидает оплаты» и «подтверждена»)."""
return {e.student_id for e in self._enrollments.values() if e.takes_seat}
def _find(self, enrollment_id: str) -> Enrollment:
if enrollment_id not in self._enrollments:
raise EnrollmentNotFound(f"В потоке {self.cohort_id} нет записи {enrollment_id}")
return self._enrollments[enrollment_id]
def _new_enrollment(self, student_id: str, now: datetime) -> str:
"""Создать запись «ожидает оплаты» с новым номером вида python-2026-11-1 и вернуть номер."""
enrollment_id = f"{self.cohort_id}-{self._next_number}"
self._next_number += 1
self._enrollments[enrollment_id] = Enrollment(enrollment_id, student_id, enrolled_at=now)
return enrollment_id
def check_invariants(self):
"""Проверить инварианты агрегата. Тесты вызывают её после каждой операции."""
active = [e.student_id for e in self._enrollments.values() if e.takes_seat]
assert len(active) + len(self._offers) <= self.capacity, "мест занято больше, чем вместимость"
everyone = active + list(self._offers) + self._waitlist
assert len(everyone) == len(set(everyone)), "студент встречается в потоке дважды"
assert all(e.enrolled_at < self.starts_at for e in self._enrollments.values()), "запись после старта"
# ---------- команды: ваша часть ----------
@property
def free_seats(self) -> int:
"""Свободные места: вместимость минус записи, занимающие место, минус действующие предложения."""
# TODO: ваш код
raise NotImplementedError
def enroll(self, student_id: str, now: datetime) -> str:
"""Записать студента на поток и вернуть номер записи (его создаёт self._new_enrollment).
Проверки по порядку:
1) now >= starts_at → EnrollmentClosed;
2) студент уже занимает место, ждёт ответа на предложение или стоит в листе ожидания → AlreadyEnrolled;
3) свободных мест для новичка нет → NoFreeSeats. Пока лист ожидания не пуст, места принадлежат ему:
новичку доступно free_seats - len(self._waitlist).
"""
# TODO: ваш код
raise NotImplementedError
def cancel(self, enrollment_id: str, now: datetime) -> None:
"""Отменить запись: статус CANCELLED. Нет записи → EnrollmentNotFound (готовый self._find),
запись уже отменена → InvalidStatusTransition. Предлагать место — не здесь, а в offer_next."""
# TODO: ваш код
raise NotImplementedError
def join_waitlist(self, student_id: str, now: datetime) -> None:
"""Встать в конец листа ожидания. Те же проверки 1) и 2), что в enroll."""
# TODO: ваш код
raise NotImplementedError
def offer_next(self, now: datetime) -> tuple:
"""Предложить свободные места листу ожидания и вернуть кортеж студентов, которым предложили.
1) Предложения, у которых now >= срока, сгорают (удалите их из self._offers).
2) Пока поток не стартовал, есть свободные места и лист ожидания не пуст: первый в очереди
покидает её и получает предложение до now + OFFER_PERIOD.
"""
# TODO: ваш код
raise NotImplementedError
def accept_offer(self, student_id: str, now: datetime) -> str:
"""Принять предложение места и вернуть номер новой записи.
Предложения нет → NoOffer; now >= срока → OfferExpired; поток стартовал → EnrollmentClosed.
Отклонённая команда поток не меняет: просроченное предложение снимет offer_next.
Иначе предложение исчезает, а студент получает запись.
"""
# TODO: ваш код
raise NotImplementedError
try:
cohort = Cohort("python-2026-11", starts_at=datetime(2026, 11, 2, 10, 0), capacity=2)
now = datetime(2026, 10, 1, 12, 0)
anna = cohort.enroll("anna", now)
cohort.enroll("boris", now)
cohort.join_waitlist("vera", now)
print("Свободных мест:", cohort.free_seats, "| лист ожидания:", cohort.waitlist)
cohort.cancel(anna, now)
print("Анна отменила запись. Предложено:", cohort.offer_next(now))
vera = cohort.accept_offer("vera", now + timedelta(hours=3))
print("Вера согласилась:", vera, "| свободных мест:", cohort.free_seats)
try:
cohort.enroll("gleb", now)
except NoFreeSeats as error:
print("Глеб:", error)
cohort.check_invariants()
print("Инварианты соблюдены")
except NotImplementedError:
print("Допишите методы с пометкой TODO и запустите снова.")
После решения посмотрите на тесты задания: каждый называется правилом и заканчивается вызовом check_invariants().
Попробуйте сломать решение — например, забудьте вычесть предложения в free_seats — и посмотрите, какой тест упадёт
первым.
Проверь себя
Заголовок раздела «Проверь себя»- Инвариант — правило, которое истинно после каждой операции. Правила времени и правила расчёта — не инварианты: их выполняют корректирующие политики и методы операций.
- Агрегат — кластер сущностей и значений, который меняется только через корень и защищает свои инварианты. Снаружи ссылаются только на корень, внутренности не отдают. Граница агрегата — граница согласованности и транзакции: одна транзакция меняет один агрегат.
- Правила Вернона: настоящие инварианты — внутри границы; агрегаты маленькие; другие агрегаты — по идентификатору; между агрегатами — согласованность в конечном счёте. Нарушать правила можно осознанно и с записанной причиной.
- Для Набора агрегат — поток с записями и листом ожидания. Канва DDD Crew помогает проговорить инварианты, политики, команды, события, шанс конфликтов и размер; стена Event Storming подсказывает инварианты вопросом «когда команду отклоняют?».
- Параллельные изменения одного агрегата ловит оптимистическая блокировка по номеру версии: конфликт — повтор команды на свежих данных.
Дальше: урок DDD.3.3 — доменные события, сервисы и фабрики. Научим поток сообщать о том, что произошло (СтудентЗаписан, МестоОсвободилось), и соберём полный сценарий «записаться на поток» — от команды до публикации событий.