Модуль 1. Когда монолиту пора: границы, а не мода

Перед началом: курс «Мессенджер: P2P, группы и продакшен» — монолит задеплоен, релизный конвейер работает (стадия 1). Инструменты: репозиторий трека (boilerplate), docker. Трудоёмкость: ~4 ч.

🔥 Инцидент: «релиз стикеров уронил чат всем»

Пятница, выкатываем стикеры: новая фича тянет за собой тяжёлый JSON-каталог и заметно прожорливее остального API. Через минуту после релиза дежурный видит две картины сразу: p99 REST-эндпоинтов вырос, а график WS-соединений пошёл пилой — тысячи клиентов переподключаются. Причина проста: WS-хаб живёт в том же процессе, что и API; горутины кодирования стикеров съели CPU, хаб не успевал читать сокеты, health-проверка начала флапать, оркестратор рестартовал поды — и каждый рестарт рвал все соединения всех пользователей. Откатили стикеры — чат ожил, хотя стикеры к чату отношения не имеют.

Это и есть технический шов: у realtime-доставки другой профиль нагрузки — десятки тысяч долгоживущих соединений, чувствительность к паузам GC, свой график релизов. А у бизнес-фич — свой. Пока они в одном процессе, любой релиз любой фичи — это релиз чата. Первый этап переезда снимет именно эту боль; но прежде чем что-то резать, нужно понять, где проходят границы.

📖 1.1 Что стадия 1 уже подготовила

Переезд начинается не с Kubernetes, а с устройства монолита. В boilerplate курса 1 уже лежат четыре решения, которые сделают вынос дешёвым:

  1. Интерфейс брокера. Все подписки идут через events.Broker — сегодня за ним in-memory pub/sub, завтра будет Redis Streams. Ни один юзкейс не знает, как устроена доставка.
  2. Outbox-конверт. То, что монолит кладёт в outbox-таблицу, — уже событие с типом и payload. Это будущий конверт шины, дословно.
  3. WS-протокол с seq. Клиент синхронизируется по номерам сообщений, а не по факту «соединение живо». Потеря транспорта — не потеря данных.
  4. JWT. Токены уже не привязаны к сессии в памяти — их можно будет валидировать без похода в чужую БД (останется только сменить подпись).

Проверьте это на своей копии: найдите в internal/ интерфейс брокера и структуру outbox-события. Если бы shов не было, первым модулем пришлось бы делать именно их — а это уже другой курс.

📖 1.2 Bounded contexts: карта домена

Карта контекстов мессенджера

Границы сервисов — это границы языков, а не папок. В домене мессенджера их четыре:

КонтекстТипЯзыкСервис
Identity & Accesssupportingаккаунт, токен, профиль, контакт, блокировкаuser-service
Messagingcoreдиалог, сообщение, seq, участие, роль, прочитаноmessenger-service
Realtime Deliverygenericсоединение, подписка, fan-out, presence, конвертevent-service ×N
Notificationssupportingpush-подписка, «кого будить», агрегацияnotification-worker

Типы поддоменов подсказывают, куда вкладываться: core — то, чем продукт отличается (Messaging), туда идут лучшие люди и самое внимательное проектирование; supporting — нужно, но не различает (учётки, пуши); generic — решённая задача (доставка realtime): берём готовые паттерны и не изобретаем свою шину.

Отношения между контекстами — тоже контракт: user-publishes user.updated, остальные ведут каталоги карточек; messenger и event связаны только версионированным конвертом (Published Language). И правило карты: события назад не текут — presence и typing живут в Realtime Delivery и не становятся доменными событиями Messaging, их потеря — норма.

📖 1.3 Тест границы: почему p2p и группы — один сервис

Популярное предложение — развести messenger-p2p-service и messenger-group-service. Проверим тестом контекста: «меняется ли значение слов?» «Сообщение» в личке и «сообщение» в группе — одно слово с одними инвариантами: лента с seq, курсорная пагинация, непрочитанные, дедупликация по client_msg_id, outbox-конвейер. Механизмы совпадают целиком:

МеханизмdirectgroupОбщий?
Лента, seq, курсорыдада✅ один код, одна таблица
Outbox → шина → доставкадада✅ один конвейер
Read-позиции, непрочитанныедада✅ одна формула
Участникировно 2, без ролейN, роли⚠️ capability, не язык
Названиеиз собеседникаtitle⚠️ отображение
Приглашения, модерациянетда⚠️ только у групп

Различия — capability и отображение, а не язык. Это два агрегата одного контекста. Резать их — получить distributed monolith: единый список диалогов придётся собирать на клиенте из двух сервисов, конвейер сообщений и read-состояние — дублировать двумя одинаковыми миграциями, а релизы «p2p» и «group» всё равно ходят парами (контракт, доставка, UI списка — общие).

Честные причины таких предложений решаются дешевле:

  • «группы сложнее, нужна отдельная команда» → модуль membership внутри messenger-service и владение пакетом;
  • «большие группы дают нагрузку на fan-out» → это нагрузка доставки — решается в event-service (стратегии fan-out, модуль 5), а не разрезом хранилища сообщений;
  • «хочется масштабировать хранение» → шардирование по conversation_id тем же кодом: ось — данные, а не тип диалога.

Membership — единственный реальный кандидат на выход в будущем, и для него есть точный критерий: свой язык (ban, appeal, trust level), свои потребители (модерация, антиабьюз) и свой темп релизов — одновременно. До тех пор — модуль; вынос позже дешев, потому что membership уже публикует member.changed и не участвует в транзакции записи сообщения.

📖 1.4 Что НЕ выделяем

  • ❌ presence-service — присутствие неразрывно от соединений: это event-service.
  • ❌ read-service / write-service — CQRS-читалка уже живёт на клиенте (sync по seq); отдельный read-сервис появится вместе с поиском.
  • ❌ api-gateway-сервис — роутинг по путям делает nginx; gateway понадобится, когда придут внешние клиенты с другими экранами (BFF).
  • ❌ сервис реакций — реакция не существует без сообщения: тот же язык, та же граница изменений; это таблица и модуль в messenger-service.

Общий приём: когда просят «выделить сервис X», проведите тест границы. Если язык общий и релизы ходят вместе — это модуль, и это хорошая новость: модуль дешевле сервиса на порядок.

📖 1.5 Компромиссы

  • Монолит vs сервисы. Монолит даёт транзакционность и нулевую сетевую цену; сервисы — независимые деплои и профили масштабирования. Переезд оправдан, когда боль конкретна (как WS-хаб в инциденте), а не «на вырост».
  • Доменная граница vs технический шов. user и messenger режем по языку домена; event-service — по техническому шву (stateful-соединения). Оба разреза законны, но путать их нельзя: шов не создаёт языка.
  • Готовность vs поспешность. Каждый вынос — цена эксплуатации ×N (мониторинг, деплой, дежурство). Правило курса: один этап — одна боль, подтверждённая метрикой.

💻 Практика

  1. Найдите швы (30 мин). В своей копии boilerplate найдите events.Broker и структуру outbox-события. Выпишите, какие типы событий уже существуют и у кого подписки. Это карта готовности к переезду.
  2. Поймайте боль (30 мин). Рестартните монолит при 50 открытых WS-соединениях (свой скрипт из курса 1). Запишите: сколько соединений потеряно, за сколько клиенты переподключились, что увидел пользователь. Это ваш «до» для модуля 5.
  3. Нарисуйте карту (45 мин). Возьмите свой рабочий проект (не мессенджер) и разложите его по контекстам: язык, тип поддомена (core/supporting/ generic), владелец. Отметьте, где сейчас граница проходит через транзакцию — это будущая боль.
  4. Тест границы (30 мин). Проведите тест для трёх предложений: «вынести реакции в сервис», «вынести группы в сервис», «вынести push в воркер». Два ответа — «модуль», один — «воркер». Объясните каждый ответ языком границ, а не вкусами.

🧩 Quiz

1. Почему p2p и группы — один сервис?
2. Какой признак вернее всего говорит, что перед вами отдельный bounded context?
3. Почему event-service выделяется даже раньше доменных сервисов?
4. Что делать с нагрузкой от больших групп на fan-out?
5. Куда пойдёт membership-поддомен (роли, инвайты, модерация)?

✅ Итоги модуля

  • Назвать четыре контекста домена и тип каждого (core/supporting/generic).
  • Провести тест границы по языку и линиям изменений на любом предложении «вынести сервис».
  • Объяснить, почему p2p и группы — один сервис, и чем это грозит при разрезе.
  • Показать в boilerplate швы, которые подготовил курс 1: брокер, outbox, seq, JWT.
  • Записать измеримую боль монолита («до») для первого этапа переезда.

🔗 Мостик к Модулю 2

Границы нарисованы, боли зафиксированы. Теперь соберём из контекстов целевую топологию: кто чем владеет, кто кого вызывает, где стоит nginx и почему на пути клиента не появится ни одной распределённой транзакции — и увидим, что из четырёх сервисов три появились почти бесплатно.