Интеграция контекстов: события, outbox, процессы
Как ограниченные контексты обмениваются данными: синхронные вызовы против событий, интеграционные события против доменных, надёжная публикация через transactional outbox, доставка «как минимум один раз» и идемпотентный получатель, саги и менеджеры процессов (хореография против оркестрации), модульный монолит против микросервисов. Сквозной пример — «оплата прошла → запись подтверждена → сертификат выдан».
Зачем это нужно
Заголовок раздела «Зачем это нужно»Анна оплатила поток по Python — 30 000 рублей. Платёжная система прислала уведомление, Набор подтвердил запись. А в личном кабинете занятий нет: Обучение так и не узнало о подтверждении. Разработчики находят причину: сервер перезапустился ровно между сохранением записи и отправкой события. Через неделю обратная беда — после сбоя сети событие «курс пройден» пришло дважды, и Сертификация выдала Анне два сертификата с разными номерами.
Внутри одного контекста порядок наводит агрегат: изменение и проверка правил — одна транзакция (урок DDD.3.2). Между контекстами общей транзакции нет. У Набора, Оплаты, Обучения и Сертификации свои модели, свои данные, а в будущем, возможно, и свои серверы. Связать их так, чтобы ничего не терялось и не задваивалось, — отдельное ремесло.
В этом уроке разберём, как контексты обмениваются данными и чем интеграционные события отличаются от доменных; как надёжно опубликовать событие (шаблон outbox) и безопасно его получить (идемпотентный получатель); как провести процесс через несколько контекстов (саги и менеджеры процессов) и когда контексту пора стать отдельным сервисом. Сквозной пример — цепочка «оплата прошла → запись подтверждена → сертификат выдан». В конце урока — домашняя работа ddd-hw02-integration, где всё это нужно собрать и проверить автотестами.
Как контексты разговаривают
Заголовок раздела «Как контексты разговаривают»Три вида сообщений
Заголовок раздела «Три вида сообщений»Контексты обмениваются сообщениями трёх видов — так их делит и техника Domain Message Flow Modelling от DDD Crew:
| Вид | Что это | Пример в школе | Можно ли отказать |
|---|---|---|---|
| Команда | просьба сделать, у неё один адресат | «Выставить счёт по записи enr-1» | да: получатель проверяет правила |
| Событие | факт в прошедшем времени, рассылается всем интересующимся | ЗаписьПодтверждена (EnrollmentConfirmed) | нет: это уже случилось |
| Запрос | вопрос, на который ждут ответ | «Сколько стоит курс python?» | — |
DDD Crew предлагает рисовать для каждого сценария диаграмму: кто кому что отправляет, в каком порядке и с какими данными. Совет из README техники — держать на одной диаграмме от 5 до 9 сообщений; больше — делите сценарий.
Синхронный вызов или событие
Заголовок раздела «Синхронный вызов или событие»Самый прямолинейный способ — синхронный вызов: метод enroll в Наборе вызывает по HTTP Оплату («выстави счёт»),
та — платёжную систему, и только после всех ответов студент видит «вы записаны». Работает, пока работают все.
Если Оплата перезапускается, записаться нельзя, хотя Набор исправен.
Авторы «Cosmic Python» называют это временной связанностьювременная связанностьEN temporal couplingЗависимость, при которой операция удаётся, только если все участники работают в один и тот же момент: цепочка синхронных вызовов падает, если упало любое звено. Асинхронные сообщения ослабляют её. (temporal coupling): каждая часть системы должна работать в тот же момент, иначе не работает ни одна. Чем длиннее цепочка, тем выше шанс, что какое-то звено сейчас лежит. Лекарство — асинхронные сообщения: Набор фиксирует факт «студент записан», а Оплата выставит счёт, когда получит событие, — через секунду или через минуту после своего перезапуска.
| Синхронный вызов | Событие | |
|---|---|---|
| Отказ получателя | ломает операцию отправителя | событие подождёт, пока получатель поднимется |
| Связь | отправитель знает адрес и интерфейс получателя | отправитель не знает, кто подписан |
| Новый участник | правка кода отправителя | ещё один подписчик |
| Согласованность | сразу | в конечном счётесогласованность в конечном счётеEN eventual consistencyДанные в разных местах (агрегат и модель чтения, два контекста) какое-то время расходятся, но без новых изменений сходятся к одному значению. Плата за независимость частей системы; бизнес обычно и так живёт с задержками и исправлениями. |
| Видимость процесса | вся цепочка в одном месте кода | процесс размазан по подписчикам |
Синхронный вызов уместен, когда ответ нужен прямо сейчас и пользователь его ждёт: показать цену на витрине, проверить промокод, перенаправить студента на страницу оплаты. А вот «записался → выставить счёт → отправить письмо» — классический случай для событий.
Ещё один совет из той же главы «Cosmic Python»: делить систему по глаголам, а не по существительным. Сервисы «студенты», «курсы», «заказы», которые на каждый шаг синхронно спрашивают друг друга, авторы называют распределённым комом грязи: тот же «большой ком грязи» из урока DDD.1.1, только теперь с сетевыми задержками. Контексты школы — «набор», «оплата», «обучение» — как раз про действия.
Доменные и интеграционные события
Заголовок раздела «Доменные и интеграционные события»Доменное событиедоменное событиеEN domain eventЗначимый для экспертов факт, который уже произошёл, в прошедшем времени: «Студент записан», «Платёж получен». В Event Storming — оранжевый стикер, в коде — неизменяемый объект (урок DDD.3.3). из урока DDD.3.3
живёт внутри контекста. Его поля — объекты модели (Money, EnrollmentStatus), его меняют вместе с моделью, о нём
знают только свои. Интеграционное событиеинтеграционное событиеEN integration eventСобытие, которое контекст публикует для других контекстов: стабильный публичный контракт с версией, простыми типами и только нужными получателям полями. Доменные события внутри контекста могут меняться свободно, интеграционные — только с оглядкой на подписчиков. — то, что контекст объявляет миру. Это
уже общедоступный язык (published language) из урока DDD.2.2: контракт,
на который опираются чужие команды.
| Доменное событие | Интеграционное событие | |
|---|---|---|
| Кто читает | свой контекст | другие контексты, другие команды |
| Формат | объект Python | сериализованный документ (JSON) с типом и версией |
| Поля | объекты модели | id, суммы в копейках, даты ISO 8601 — простые типы |
| Изменения | вместе с моделью, в одном коммите | только с оглядкой на подписчиков: новая версия, период поддержки старой |
| Сколько | столько, сколько нужно модели | только то, что интересно снаружи |
Не каждое доменное событие выходит наружу. SeatReleased нужен политике листа ожидания внутри Набора, а Обучению до
него дела нет. «Cosmic Python» советует держать разницу между внутренними и внешними событиями явной. На границе
стоит маленький переводчик:
import jsonfrom dataclasses import dataclassfrom datetime import datetime, timezone
@dataclass(frozen=True)class EnrollmentConfirmed: # доменное событие Набора (урок DDD.3.3) cohort_id: str enrollment_id: str student_id: str occurred_at: datetime
def to_integration_event(event, event_id): """Доменное событие → интеграционное: публичный контракт с типом, версией и простыми полями.""" return json.dumps({ "type": "enrollment.confirmed", # имя для чужих: контекст и факт "version": 1, # версия контракта, а не агрегата "event_id": event_id, # по нему получатели отсеивают повторы "occurred_at": event.occurred_at.isoformat(), "data": {"enrollment_id": event.enrollment_id, "student_id": event.student_id, "cohort_id": event.cohort_id}, }, ensure_ascii=False)
event = EnrollmentConfirmed("python-2026-11", "enr-1", "anna", datetime(2026, 10, 1, 15, 0, tzinfo=timezone.utc))print(to_integration_event(event, "python-2026-11#4")){"type": "enrollment.confirmed", "version": 1, "event_id": "python-2026-11#4", "occurred_at": "2026-10-01T15:00:00+00:00", "data": {"enrollment_id": "enr-1", "student_id": "anna", "cohort_id": "python-2026-11"}}Сколько данных класть в событие — компромисс. Тонкое событие-уведомление («запись enr-1 подтверждена») заставит получателя спросить подробности синхронным запросом — и временная связанность вернётся. Событие, несущее данные (студент, поток), избавляет от лишних вопросов, но каждое поле становится частью контракта. Мартин Фаулер разбирает эти варианты в статье «What do you mean by “Event-Driven”?» (2017).
Принимающая сторона не обязана жить в чужих терминах. Обучение переводит enrollment.confirmed в свою команду
«открыть доступ к занятиям» — тот же предохранительный слойпредохранительный слойEN Anticorruption Layer (ACL)Шаблон карты контекстов: нижестоящий контекст ставит изолирующий слой, который переводит модель чужой системы в термины собственной модели (и обратно). Чужие поля, статусы и слова не проходят дальше слоя. Встречаются переводы «предохранительный уровень» и «антикоррупционный слой»., что вы писали для
платёжной системы в уроке DDD.2.2, только на входе из соседнего контекста.
Надёжная публикация: outbox
Заголовок раздела «Надёжная публикация: outbox»Проблема двойной записи
Заголовок раздела «Проблема двойной записи»В уроке DDD.3.3 мы договорились публиковать события после сохранения агрегата. Но «сохранить в базу» и «отправить в брокер сообщений» — две разные системы, и общей транзакции у них нет. Это проблема двойной записипроблема двойной записиEN dual write problemСохранить изменение в базе и отправить сообщение в брокер — две независимые операции; сбой между ними оставляет систему в противоречивом состоянии (запись подтверждена, а событие не ушло, или наоборот). Решается шаблоном outbox.:
| Порядок | Где упало | Что получилось |
|---|---|---|
| сохранить, потом отправить | между ними | запись подтверждена, а Обучение не узнало — доступа нет |
| отправить, потом сохранить | сохранение не прошло (конфликт версий) | Обучение открыло доступ к записи, которой нет |
| отправить внутри транзакции базы | транзакция откатилась после отправки | то же: мир узнал о том, чего не было |
Распределённые транзакции через базу и брокер сразу (двухфазная фиксация) существуют, но многие брокеры их не поддерживают, а там, где поддерживают, они медленные и хрупкие. Поэтому на практике делают иначе.
Шаблон transactional outbox
Заголовок раздела «Шаблон transactional outbox»Transactional outboxtransactional outbox (исходящие сообщения в той же транзакции)EN Transactional OutboxШаблон из каталога microservices.io Криса Ричардсона: события пишут в таблицу outbox в той же транзакции, что и изменение агрегата, а отдельный процесс-ретранслятор (relay) потом публикует их в брокер и помечает отправленными. Даёт доставку «как минимум один раз». («исходящие» в той же транзакции) описан в каталоге
шаблонов Криса Ричардсона microservices.io. Идея простая: не отправлять событие в брокер, а записать его в таблицу
outbox той же базы, в той же транзакции, что и изменение агрегата. Либо зафиксируется и запись, и событие, либо
ничего. Отдельный процесс-ретранслятор (relay) потом читает неотправленные строки, публикует их и помечает
отправленными.
flowchart LR subgraph TX["Одна транзакция базы Набора"] A["запись enr-1:<br/>статус CONFIRMED"] B["строка outbox:<br/>ЗаписьПодтверждена"] end B --> R["Ретранслятор<br/>опрос таблицы или<br/>журнал транзакций"] R -->|"как минимум один раз"| Q["Брокер или шина"] Q --> L["Обучение<br/>журнал обработанных"] Q --> U["Уведомления<br/>журнал обработанных"]
Ретранслятор бывает двух видов, и оба есть в каталоге Ричардсона и в его фреймворке Eventuate Tram. Опрос таблицы (polling publisher): раз в доли секунды выбрать неотправленные строки — просто и работает с любой базой. Чтение журнала транзакций (transaction log tailing): ретранслятор читает журнал изменений самой базы (в PostgreSQL — WAL, в MySQL — binlog) и не нагружает её запросами. Второй способ называют также захватом изменений данных (change data capture); известный открытый инструмент для него — Debezium.
В модульном монолите с одной базой outbox тоже полезен: подписчики получают событие после фиксации транзакции, а не посреди неё, и границы модулей не размываются общими транзакциями.
Гарантии доставки
Заголовок раздела «Гарантии доставки»Ретранслятор может упасть после публикации, но до отметки «отправлено». После перезапуска он отправит то же сообщение ещё раз. Поэтому outbox даёт доставку «как минимум один раз»доставка «как минимум один раз»EN at-least-once deliveryГарантия: сообщение не потеряется, но может прийти повторно. Другие режимы — «не более одного раза» (может потеряться, без повторов) и «ровно один раз» (между независимыми системами по сети недостижим; на практике его заменяют повторной доставкой и идемпотентным получателем)..
| Гарантия | Что значит | Как получается |
|---|---|---|
| не более одного раза (at-most-once) | может потеряться, повторов нет | отметить «отправлено», потом отправить |
| как минимум один раз (at-least-once) | не потеряется, но может повториться | отправить, потом отметить; повторять до подтверждения |
| ровно один раз (exactly-once) | ни потерь, ни повторов | между независимыми системами по сети недостижимо |
Последняя строка — не пессимизм, а известный результат: отправитель не может отличить «сообщение потерялось» от «потерялось подтверждение», поэтому вынужден либо повторять (и рисковать дублем), либо не повторять (и рисковать потерей). «Cosmic Python» советует на эту тему статью Тайлера Трита «You Cannot Have Exactly-Once Delivery». Некоторые брокеры обещают режим «ровно один раз», но в пределах собственной системы; как только обработчик пишет в свою базу или отправляет письмо, задача снова ваша. Рабочая формула индустрии: доставка как минимум один раз плюс идемпотентная обработка = эффект ровно один раз.
С порядком похожая история. События одного агрегата упорядочены его версиями (вспомните журнал из урока DDD.4.1), а между разными агрегатами общего порядка нет. Брокеры обычно гарантируют порядок только внутри одной очереди или раздела, поэтому события агрегата отправляют с его id как ключом, чтобы они попали в один раздел.
Идемпотентный получатель
Заголовок раздела «Идемпотентный получатель»Раз повторы неизбежны, получатель должен их переносить. Идемпотентный получательидемпотентный получательEN idempotent consumerОбработчик сообщений, для которого повторная доставка того же сообщения ничего не меняет. Обычно хранит журнал обработанных id в той же транзакции, что и изменения, или опирается на операции, которые естественно идемпотентны. (idempotent consumer) — обработчик, для которого вторая доставка того же сообщения ничего не меняет. Способов три, и их часто сочетают.
Естественная идемпотентность. «Перевести запись в CONFIRMED, если она ждёт оплаты» — повтор ничего не изменит. «Увеличить счётчик оплат на единицу» или «отправить письмо» — изменит. Хорошо, когда действие можно сформулировать как установку состояния, а не как приращение.
Журнал обработанных сообщений. Получатель хранит id обработанных сообщений и в одной транзакции проверяет журнал, выполняет действие и делает отметку. Если отметку делать отдельно — до действия или после, — сбой между ними даст либо потерю, либо повтор. Уникальный ключ в журнале страхует от гонки двух одновременных доставок. Такую таблицу иногда называют inbox — зеркальной к outbox.
Уникальность в предметной области. Правило «у пары студент — поток не больше одного сертификата» записывается уникальным ключом, и второй сертификат не появится, даже если сообщение пришло с другим id.
Повторы, перестановки и неудобные случаи
Заголовок раздела «Повторы, перестановки и неудобные случаи»Платёжная система может прислать уведомление дважды — в уроке DDD.2.2
это был вопрос для размышления. Ответ: обработчик PaymentReceived в Наборе ведёт журнал обработанных платежей, и
второе уведомление о том же платеже ничего не меняет.
Хуже, когда сообщения приходят не по порядку или опаздывают. Что делать, если платёж пришёл после автоматической
отмены записи через 48 часов? В уроке DDD.2.3 эксперты решили: вернуть
платёж полностью, запись не восстанавливать — место могло уже уйти первому из листа ожидания. Значит, обработчик не
падает и не подтверждает отменённую запись молча, а запускает возврат: публикует «ВозвратЗапрошен», Оплата
возвращает деньги и сообщает ПлатёжВозвращён (PaymentRefunded). Неудобный случай превращается в явный бизнес-шаг, а
не в исключение в логах.
Для перестановок у получателя два приёма. Если у событий есть версии агрегата, он помнит последнюю учтённую версию и откладывает событие «из будущего», пока не придут пропущенные, — так работает проекция в задании урока DDD.4.1. Если версий нет, он проверяет состояние: «доступ уже закрыт из-за отмены — подтверждение, пришедшее позже, не открывает его снова».
Наконец, бывают сообщения, которые не обрабатываются никогда: битый формат, ошибка в коде получателя. Бесконечные повторы такого «ядовитого» сообщения забьют очередь. Обычно после нескольких попыток его откладывают в отдельную очередь недоставленных (dead letter queue) и зовут человека.
Долгие процессы: саги и менеджеры процессов
Заголовок раздела «Долгие процессы: саги и менеджеры процессов»Почему нет общей транзакции
Заголовок раздела «Почему нет общей транзакции»Путь студента от записи до сертификата проходит через пять контекстов и длится недели. Ни одна транзакция базы его не удержит. Гектор Гарсиа-Молина и Кеннет Салем ещё в 1987 году в статье «Sagas» предложили разбивать долгую транзакцию на последовательность коротких T1, T2, …, Tn, у каждой из которых есть компенсирующее действиекомпенсирующее действиеEN compensating transactionНовое действие, которое по смыслу отменяет результат уже завершённого шага саги: возврат денег вместо «отката» платежа, отмена записи вместо удаления. Прошлое не стирается — к нему добавляется новый факт. C1, C2, … Если шаг Tk невозможен, выполняются компенсации уже сделанных шагов в обратном порядке. Такую конструкцию они назвали сагойсагаEN sagaДолгий процесс, разбитый на шаги — локальные транзакции в разных контекстах; если шаг невозможен, уже сделанное отменяют компенсирующими действиями. Идея — из статьи Гектора Гарсиа-Молины и Кеннета Салема «Sagas» (1987).. В микросервисах сага — последовательность локальных транзакций в разных сервисах, связанных сообщениями.
Компенсация — не откат. Отменить завершённый шаг нельзя: деньги списаны, письмо ушло. Можно сделать новое действие, которое по смыслу его отменяет: вернуть платёж, отменить запись, отправить письмо с извинением. Прошлое не стирается — к нему добавляется новый факт, как в журнале событий.
Хореография
Заголовок раздела «Хореография»ХореографияхореографияEN choreographyСпособ вести процесс между контекстами без центра: каждый контекст подписан на события других и сам решает, что делать дальше. Мало связей, но процесс целиком не виден ни в одном месте кода. — процесс без центра: каждый контекст подписан на события других и сам решает, что делать. Это прямое продолжение политик из Event Storming (урок DDD.2.3): «когда платёж получен — подтвердить запись», «когда запись подтверждена — открыть доступ», «когда курс пройден — выдать сертификат».
sequenceDiagram participant P as Оплата participant N as Набор participant L as Обучение participant C as Сертификация participant U as Уведомления P-)N: ПлатёжПолучен Note over N: подтвердить запись N-)L: ЗаписьПодтверждена N-)U: ЗаписьПодтверждена Note over L: занятия, задания, недели учёбы L-)C: КурсПройден Note over C: выдать сертификат C-)U: СертификатВыдан
Плюсы — мало связей: Набор не знает о Сертификации, новый подписчик не требует правки чужого кода. Минус хорошо сформулировал Мартин Фаулер, и «Cosmic Python» приводит его слова: уведомления событиями дают слабую связанность, но если через них идёт логически единый процесс, его трудно увидеть — он не записан явно ни в одном месте кода, а значит, трудно отлаживать и менять.
Оркестрация и менеджер процесса
Заголовок раздела «Оркестрация и менеджер процесса»ОркестрацияоркестрацияEN orchestrationСпособ вести процесс между контекстами из одного места: оркестратор (часто менеджер процесса) отправляет команды участникам и реагирует на их ответы. Процесс виден целиком, но оркестратор знает обо всех участниках. — процесс ведёт один участник. Его часто строят как менеджер процессаменеджер процессаEN process managerОбъект со своим состоянием, который ведёт один экземпляр долгого процесса: получает события, решает, какую команду отправить дальше, следит за сроками. Шаблон из книги Грегора Хопа и Бобби Вулфа «Enterprise Integration Patterns». — шаблон из книги Грегора Хопа и Бобби Вулфа «Enterprise Integration Patterns»: объект со своим состоянием на каждый экземпляр процесса. Он получает события, решает, какую команду отправить следующей, и следит за сроками. Вот менеджер для отрезка «запись → оплата»:
from datetime import datetime, timedelta
PAYMENT_DEADLINE = timedelta(hours=48) # условие учебного примера
class EnrollmentProcess: """Менеджер процесса «запись → оплата» для одной записи: помнит шаг и срок, отдаёт команды контекстам."""
def __init__(self, enrollment_id, enrolled_at): self.enrollment_id = enrollment_id self.deadline = enrolled_at + PAYMENT_DEADLINE self.step = "ждём оплату"
def handle(self, event, now): """Событие → список команд (кому и что сделать). Повтор события на том же шаге команд не даёт.""" if event == "PaymentReceived" and self.step == "ждём оплату": self.step = "ждём подтверждения" return [("Набор", "подтвердить запись")] if event == "PaymentReceived" and self.step == "отменена": self.step = "возврат" # компенсация: запись не восстанавливаем return [("Оплата", "вернуть платёж полностью")] if event == "EnrollmentConfirmed" and self.step == "ждём подтверждения": self.step = "зачислен" return [("Обучение", "открыть доступ"), ("Уведомления", "письмо «вы зачислены»")] if event == "Tick" and self.step == "ждём оплату" and now >= self.deadline: self.step = "отменена" return [("Набор", "отменить запись: не оплачена за 48 часов")] return []
start = datetime(2026, 10, 1, 10, 0)for title, events in [("Анна платит вовремя", [("PaymentReceived", 5), ("PaymentReceived", 6), ("EnrollmentConfirmed", 6), ("Tick", 49)]), ("Борис опоздал с оплатой", [("Tick", 24), ("Tick", 48), ("PaymentReceived", 50), ("PaymentReceived", 50)])]: process = EnrollmentProcess("enr-1", start) print(title) for event, hours in events: commands = process.handle(event, start + timedelta(hours=hours)) print(f" +{hours:>2} ч {event:20} → {commands or 'ничего'} [{process.step}]")Анна платит вовремя + 5 ч PaymentReceived → [('Набор', 'подтвердить запись')] [ждём подтверждения] + 6 ч PaymentReceived → ничего [ждём подтверждения] + 6 ч EnrollmentConfirmed → [('Обучение', 'открыть доступ'), ('Уведомления', 'письмо «вы зачислены»')] [зачислен] +49 ч Tick → ничего [зачислен]Борис опоздал с оплатой +24 ч Tick → ничего [ждём оплату] +48 ч Tick → [('Набор', 'отменить запись: не оплачена за 48 часов')] [отменена] +50 ч PaymentReceived → [('Оплата', 'вернуть платёж полностью')] [возврат] +50 ч PaymentReceived → ничего [возврат]Весь процесс читается в одном месте: шаги, срок 48 часов (событие Tick — сигнал таймера), компенсация. Повторные
события ничего не ломают, потому что каждое действие привязано к шагу. Состояние менеджера процесса хранят так же
надёжно, как агрегат: в базе, с версией, а команды отправляют через тот же outbox.
| Хореография | Оркестрация | |
|---|---|---|
| Где процесс | размазан по подписчикам | в одном менеджере процесса |
| Связи | контексты знают только события | оркестратор знает всех участников и их команды |
| Сроки и компенсации | у каждого свои таймеры, легко забыть | в одном месте |
| Риск | никто не видит процесс целиком | оркестратор обрастает бизнес-логикой чужих контекстов |
| Когда брать | 2–3 шага, участники независимы | длинный процесс со сроками, компенсациями и вопросом «на каком он шаге?» |
В школе разумно сочетать: короткие реакции («запись подтверждена — открыть доступ», «сертификат выдан — отправить письмо») — хореографией, а отрезок «запись → оплата → подтверждение или отмена» с таймером и возвратами — менеджером процесса. Главное правило оркестратора: он решает, что делать дальше, но не проверяет правила чужих моделей — вместимость потока по-прежнему защищает агрегат Набора.
Модульный монолит или микросервисы
Заголовок раздела «Модульный монолит или микросервисы»Ограниченный контекст — граница модели, а не обязательно отдельный сервис (урок DDD.2.1).
Частый и разумный старт — модульный монолитмодульный монолитEN modular monolithОдно развёртываемое приложение, разделённое на модули по ограниченным контекстам: у модуля публичный интерфейс и свои данные, чужие внутренности не импортируют. Границы дёшево передвигать; модуль можно позже вынести в отдельный сервис.: одно приложение, разделённое на модули
по контекстам. У модуля открытый интерфейс (функции api.py и события) и свои таблицы; в чужие классы и таблицы не
ходят. Связь — через шину сообщений в памяти и outbox. Как наводить такие границы в живой системе и проверять их
автоматически, разбирает урок DDD.4.3.
МикросервисымикросервисыEN microservicesАрхитектура из небольших независимо развёртываемых сервисов, каждый со своими данными, общающихся по сети. Даёт независимые релизы и масштабирование, но приносит сетевые сбои, согласованность в конечном счёте и сложную эксплуатацию. делают границу физической: отдельный процесс, отдельная база, связь по сети. Это дорогое удовольствие, и платить за него стоит, когда есть причина:
| Причина вынести контекст в сервис | Пример в школе |
|---|---|
| независимые релизы разных команд | команда «Витрины» выкатывает каталог по пять раз в день, а Набор — раз в неделю |
| другой профиль нагрузки | витрину каталога смотрят в сотни раз чаще, чем записываются |
| отдельные требования к безопасности и надёжности | Оплата с платёжными данными — в отдельном защищённом контуре |
| другая технология | будущие рекомендации курсов на моделях машинного обучения и GPU |
| изоляция сбоев | падение рассылок не должно мешать записи |
Цена тоже понятна: сетевые сбои и задержки, согласованность в конечном счёте, outbox и саги вместо транзакций, версионирование контрактов, распределённая отладка и эксплуатация. Хорошее правило — резать по границам контекстов, а не по таблицам («сервис студентов», «сервис курсов» — путь к распределённому кому грязи), и начинать с модульного монолита: передвинуть границу между модулями дешевле, чем между сервисами. Тем, кто идёт этим путём, «Cosmic Python» советует книгу Сэма Ньюмена «Monolith to Microservices».
Практика
Заголовок раздела «Практика»Соберём цепочку «оплата прошла → запись подтверждена → сертификат выдан» как модульный монолит на sqlite3 из
стандартной библиотеки: у Набора, Обучения и Сертификации свои таблицы, общая служебная таблица outbox и журнал
обработанных сообщений processed. Оплата сообщает о платеже событием с id записи — счёт ведь выставлялся по ней.
Ретранслятор умеет «падать» между доставкой и отметкой — посмотрим, как система переживает повтор. Сохраните в
chain.py и запустите.
import jsonimport sqlite3
db = sqlite3.connect(":memory:")db.executescript(""" CREATE TABLE enrollments (enrollment_id TEXT PRIMARY KEY, student_id TEXT, cohort_id TEXT, status TEXT); -- Набор CREATE TABLE access (student_id TEXT, cohort_id TEXT, PRIMARY KEY (student_id, cohort_id)); -- Обучение CREATE TABLE certificates (number TEXT PRIMARY KEY, student_id TEXT, cohort_id TEXT, UNIQUE (student_id, cohort_id)); -- Сертификация -- общие служебные таблицы: исходящие сообщения и журнал обработанных (кто что уже обработал) CREATE TABLE outbox (seq INTEGER PRIMARY KEY, message_id TEXT UNIQUE, type TEXT, data TEXT, published INTEGER DEFAULT 0); CREATE TABLE processed (consumer TEXT, message_id TEXT, PRIMARY KEY (consumer, message_id)); INSERT INTO enrollments VALUES ('enr-1', 'anna', 'python-2026-11', 'PENDING_PAYMENT');""")
def publish_later(message_id, type_, **data): """Положить сообщение в outbox — в той же транзакции, что и изменения, которые его вызвали.""" db.execute("INSERT INTO outbox (message_id, type, data) VALUES (?, ?, ?)", (message_id, type_, json.dumps(data)))
def once(consumer, message_id, effect, data): """Идемпотентный получатель: проверка журнала, действие и отметка — в одной транзакции.""" with db: # транзакция: всё или ничего if db.execute("SELECT 1 FROM processed WHERE consumer = ? AND message_id = ?", (consumer, message_id)).fetchone(): print(f" [{consumer}] {message_id} уже обработано — пропускаю") return effect(data) # первичный ключ (consumer, message_id) — страховка от гонки двух одновременных доставок db.execute("INSERT INTO processed VALUES (?, ?)", (consumer, message_id))
# ---------- Реакции контекстов (политики «когда …, тогда …») ----------def nabor_on_payment(d): cur = db.execute("UPDATE enrollments SET status = 'CONFIRMED' " "WHERE enrollment_id = ? AND status = 'PENDING_PAYMENT'", (d["enrollment_id"],)) if cur.rowcount == 1: student, cohort = db.execute("SELECT student_id, cohort_id FROM enrollments WHERE enrollment_id = ?", (d["enrollment_id"],)).fetchone() publish_later(f"confirmed:{d['enrollment_id']}", "EnrollmentConfirmed", enrollment_id=d["enrollment_id"], student_id=student, cohort_id=cohort) print(f" [Набор] запись {d['enrollment_id']} подтверждена")
def learning_on_confirmed(d): db.execute("INSERT INTO access VALUES (?, ?)", (d["student_id"], d["cohort_id"])) print(f" [Обучение] {d['student_id']} получила доступ к занятиям {d['cohort_id']}")
def certification_on_completed(d): number = f"CERT-{d['cohort_id']}-{d['student_id']}" db.execute("INSERT INTO certificates VALUES (?, ?, ?)", (number, d["student_id"], d["cohort_id"])) publish_later(f"issued:{number}", "CertificateIssued", number=number, student_id=d["student_id"]) print(f" [Сертификация] выдан сертификат {number}")
def notifications_on_issued(d): print(f" [Уведомления] письмо {d['student_id']}: ваш сертификат {d['number']}")
SUBSCRIBERS = { "PaymentReceived": [("Набор", nabor_on_payment)], "EnrollmentConfirmed": [("Обучение", learning_on_confirmed)], "CourseCompleted": [("Сертификация", certification_on_completed)], "CertificateIssued": [("Уведомления", notifications_on_issued)],}
def relay(crash_after_delivery=False): """Ретранслятор: доставить первое неопубликованное, отметить, повторить. Может «упасть» между ними.""" while row := db.execute("SELECT seq, message_id, type, data FROM outbox " "WHERE published = 0 ORDER BY seq LIMIT 1").fetchone(): seq, message_id, type_, data = row print(f" relay → {type_} ({message_id})") for consumer, effect in SUBSCRIBERS.get(type_, []): once(consumer, message_id, effect, json.loads(data)) if crash_after_delivery: print(" !! процесс упал: доставил, но не успел отметить отправку") return with db: db.execute("UPDATE outbox SET published = 1 WHERE seq = ?", (seq,))
print("Оплата: платёж pay_001 по записи enr-1 получен")with db: publish_later("payment:pay_001", "PaymentReceived", enrollment_id="enr-1", amount=3_000_000)relay(crash_after_delivery=True)print("Перезапуск:")relay()print("Обучение: anna прошла курс")with db: publish_later("completed:anna:python-2026-11", "CourseCompleted", student_id="anna", cohort_id="python-2026-11")relay()print("Брокер доставил CourseCompleted повторно:")db.execute("UPDATE outbox SET published = 0 WHERE type = 'CourseCompleted'")relay()print("Итог:", db.execute("SELECT status FROM enrollments").fetchone()[0], "|", "доступов:", db.execute("SELECT COUNT(*) FROM access").fetchone()[0], "|", "сертификатов:", db.execute("SELECT COUNT(*) FROM certificates").fetchone()[0])Оплата: платёж pay_001 по записи enr-1 получен relay → PaymentReceived (payment:pay_001) [Набор] запись enr-1 подтверждена !! процесс упал: доставил, но не успел отметить отправкуПерезапуск: relay → PaymentReceived (payment:pay_001) [Набор] payment:pay_001 уже обработано — пропускаю relay → EnrollmentConfirmed (confirmed:enr-1) [Обучение] anna получила доступ к занятиям python-2026-11Обучение: anna прошла курс relay → CourseCompleted (completed:anna:python-2026-11) [Сертификация] выдан сертификат CERT-python-2026-11-anna relay → CertificateIssued (issued:CERT-python-2026-11-anna) [Уведомления] письмо anna: ваш сертификат CERT-python-2026-11-annaБрокер доставил CourseCompleted повторно: relay → CourseCompleted (completed:anna:python-2026-11) [Сертификация] completed:anna:python-2026-11 уже обработано — пропускаюИтог: CONFIRMED | доступов: 1 | сертификатов: 1На что посмотреть:
- Изменение и событие — одна транзакция. В
nabor_on_paymentстатус записи и строка outboxEnrollmentConfirmedфиксируются вместе внутриonce. Упади код между ними — откатится и то и другое, а платёж останется необработанным и придёт снова. - Падение ретранслятора — не катастрофа. Платёж обработан, но не помечен отправленным; после перезапуска он ушёл
снова, и журнал
processedотсеял повтор. Ничего не потеряно и не задвоено. - Две линии защиты от дублей. Журнал обработанных ловит повтор того же сообщения, а
UNIQUE (student_id, cohort_id)в таблице сертификатов — правило предметной области — не даст выдать второй сертификат, даже если когда-нибудь придёт другое сообщение о том же самом. - Контексты не знают друг о друге. Набор не вызывает Обучение, Сертификация не знает об Оплате — каждая реакция подписана на событие. Это хореография; при желании отрезок «запись → оплата» можно отдать менеджеру процесса из раздела выше.
Попробуйте сами: уберите из once проверку журнала и посмотрите на итоговую строку, а потом добавьте в
nabor_on_payment ветку для отменённой записи — запрос возврата в outbox.
Проверь себя
Заголовок раздела «Проверь себя»- Контексты обмениваются командами, событиями и запросами. Синхронные вызовы создают временную связанность: всё должно работать одновременно. События развязывают контексты ценой согласованности в конечном счёте и процесса, размазанного по подписчикам.
- Интеграционное событие — публичный контракт с типом, версией и простыми полями; доменные события живут внутри контекста, и наружу выходят не все. Принимающая сторона переводит чужие события на свой язык, как предохранительный слой.
- Сохранить и опубликовать — две записи без общей транзакции. Transactional outbox пишет событие в ту же транзакцию, ретранслятор доставляет его позже — «как минимум один раз». «Ровно один раз» по сети недостижим; его эффект даёт идемпотентный получатель с журналом обработанных в той же транзакции.
- Долгий процесс — сага из локальных шагов с компенсациями (не откатами). Хореография подходит коротким реакциям, оркестрация менеджером процесса — длинным процессам со сроками и возвратами.
- Модульный монолит — разумный старт; выносить контекст в сервис стоит ради независимых релизов, нагрузки, безопасности, технологии или изоляции сбоев, и резать — по границам контекстов, а не по таблицам.
Дальше: урок DDD.4.3 — DDD в существующей системе. Почти всегда DDD внедряют в живой проект: «пузырь» нового контекста, предохранительный слой вокруг унаследованного кода, шаблон «душитель» и поэтапное выделение модулей — с чего начать завтра и как не провалить внедрение.