Перейти к содержимому
Программа DDDDDD.4 DDD на практике

Интеграция контекстов: события, outbox, процессы

Как ограниченные контексты обмениваются данными: синхронные вызовы против событий, интеграционные события против доменных, надёжная публикация через transactional outbox, доставка «как минимум один раз» и идемпотентный получатель, саги и менеджеры процессов (хореография против оркестрации), модульный монолит против микросервисов. Сквозной пример — «оплата прошла → запись подтверждена → сертификат выдан».

УРОКddd/ddd-4-practice/02-integration.mdx2 ч 10 минУрок 2 из 4
После урока вы сможете
  • Выбрать между синхронным вызовом и событием для связи двух контекстов и отличить интеграционное событие от доменного
  • Объяснить проблему двойной записи и реализовать 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 json
from dataclasses import dataclass
from 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, только на входе из соседнего контекста.

В уроке DDD.3.3 мы договорились публиковать события после сохранения агрегата. Но «сохранить в базу» и «отправить в брокер сообщений» — две разные системы, и общей транзакции у них нет. Это проблема двойной записипроблема двойной записиEN dual write problemСохранить изменение в базе и отправить сообщение в брокер — две независимые операции; сбой между ними оставляет систему в противоречивом состоянии (запись подтверждена, а событие не ушло, или наоборот). Решается шаблоном 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/>журнал обработанных"]
Transactional outbox: изменение и событие фиксируются одной транзакцией, ретранслятор доставляет событие позже; получатели отсеивают повторы

Ретранслятор бывает двух видов, и оба есть в каталоге Ричардсона и в его фреймворке 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 json
import 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 статус записи и строка outbox EnrollmentConfirmed фиксируются вместе внутри once. Упади код между ними — откатится и то и другое, а платёж останется необработанным и придёт снова.
  • Падение ретранслятора — не катастрофа. Платёж обработан, но не помечен отправленным; после перезапуска он ушёл снова, и журнал processed отсеял повтор. Ничего не потеряно и не задвоено.
  • Две линии защиты от дублей. Журнал обработанных ловит повтор того же сообщения, а UNIQUE (student_id, cohort_id) в таблице сертификатов — правило предметной области — не даст выдать второй сертификат, даже если когда-нибудь придёт другое сообщение о том же самом.
  • Контексты не знают друг о друге. Набор не вызывает Обучение, Сертификация не знает об Оплате — каждая реакция подписана на событие. Это хореография; при желании отрезок «запись → оплата» можно отдать менеджеру процесса из раздела выше.

Попробуйте сами: уберите из once проверку журнала и посмотрите на итоговую строку, а потом добавьте в nabor_on_payment ветку для отменённой записи — запрос возврата в outbox.

Проверь себя6 вопросов
1 Чем интеграционное событие отличается от доменного? Выберите все верные варианты.Можно выбрать несколько ответов
2 Сервис приложения сохранил подтверждённую запись в базу, затем вызвал broker.publish(...), и процесс упал между этими строками. Что случилось и что помогает?
3 В outbox пять неотправленных сообщений. Ретранслятор опубликовал первое, второе и третье, но упал до того, как отметил третье отправленным. Сколько сообщений он опубликует после перезапуска? (целое число)
4 Какие обработчики идемпотентны, то есть повторная доставка того же сообщения ничего не меняет? Выберите все верные варианты.Можно выбрать несколько ответов
5 Платёж пришёл через час после того, как неоплаченную запись автоматически отменили (48 часов). Что должен сделать обработчик Набора по решению экспертов из урока DDD.2.3?
6 Когда для процесса между контекстами лучше выбрать оркестрацию (менеджер процесса), а не хореографию?
  • Контексты обмениваются командами, событиями и запросами. Синхронные вызовы создают временную связанность: всё должно работать одновременно. События развязывают контексты ценой согласованности в конечном счёте и процесса, размазанного по подписчикам.
  • Интеграционное событие — публичный контракт с типом, версией и простыми полями; доменные события живут внутри контекста, и наружу выходят не все. Принимающая сторона переводит чужие события на свой язык, как предохранительный слой.
  • Сохранить и опубликовать — две записи без общей транзакции. Transactional outbox пишет событие в ту же транзакцию, ретранслятор доставляет его позже — «как минимум один раз». «Ровно один раз» по сети недостижим; его эффект даёт идемпотентный получатель с журналом обработанных в той же транзакции.
  • Долгий процесс — сага из локальных шагов с компенсациями (не откатами). Хореография подходит коротким реакциям, оркестрация менеджером процесса — длинным процессам со сроками и возвратами.
  • Модульный монолит — разумный старт; выносить контекст в сервис стоит ради независимых релизов, нагрузки, безопасности, технологии или изоляции сбоев, и резать — по границам контекстов, а не по таблицам.

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

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

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

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

  • EN
    Harry Percival, Bob Gregory — Architecture Patterns with Python («Cosmic Python»), глава 11 «Event-Driven Architecture: Using Events to Integrate Microservices» и эпилог
    разделы «Distributed Ball of Mud, and Thinking in Nouns», «Error Handling in Distributed Systems» (временная связанность), «Internal Versus External Events», таблица плюсов и минусов; в эпилоге (epilogue_1_how_to_get_there_from_here.asciidoc) — раздел «Footguns»: надёжная доставка, outbox, идемпотентность, версии событийпересказ · CC-BY-ND
  • EN
    Chris Richardson — Pattern: Transactional outbox (microservices.io)
    каталог шаблонов автора книги «Microservices Patterns» (Manning, 2018): Transactional outbox, Polling publisher, Transaction log tailing, Idempotent consumer, Saga. Из среды подготовки урока сайт не открывался — описано общеизвестное; сверьтесь с оригиналомссылка
  • EN
    Eventuate Tram (Chris Richardson) — README
    раздел «How it works»: сообщения пишутся в таблицу OUTBOX в той же ACID-транзакции, что и данные, отдельный ретранслятор публикует их через чтение журнала транзакций (MySQL, PostgreSQL) или опросом таблицы; примеры саг с хореографией и оркестрациейпересказ · Apache-2.0
  • EN
    Hector Garcia-Molina, Kenneth Salem — Sagas (SIGMOD, 1987)
    исходная статья: долгая транзакция как последовательность коротких T1…Tn, у каждой есть компенсирующая транзакция; при сбое выполняются компенсации в обратном порядкессылка
  • EN
    DDD Crew — Domain Message Flow Modelling
    README: диаграмма потока сообщений между контекстами для одного сценария — команды, события, запросы, их порядок и содержимое; совет держать на диаграмме 5–9 сообщений; сообщения, зависящие от времени («в течение» и «после» — разные условия)пересказ · CC-BY-4.0

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