Границы сервисов и предметная область
Где на самом деле проходят швы системы: почему нельзя резать по техническим слоям, что такое bounded context и как проверить границу одним вопросом.
Bounded context (ограниченный контекст) — участок предметной области, внутри которого каждый термин имеет ровно одно значение, а модель непротиворечива. Хорошая граница сервиса почти всегда совпадает с границей контекста.
Из предыдущих уроков ясно: микросервисы имеют смысл только тогда, когда сервис можно выкатить в одиночку. Всё остальное — декорации. Значит, весь вопрос сводится к одному: где резать. Разрежете правильно — фича живёт внутри одного сервиса и едет в прод без созвонов. Разрежете неправильно — получите то, что описано в прошлом уроке.
Как резать НЕ надо
Две ловушки, в которые попадают почти все.
| Неудачный принцип | Что получается | Симптом |
| По техническим слоям: сервис UI, сервис бизнес-логики, сервис доступа к данным | распределённый слоёный пирог: любая фича проходит через все три сервиса | задача «добавить поле в форму» требует трёх пул-реквестов и трёх релизов |
| По таблицам БД: сервис User, сервис Product, сервис Order | анемичные CRUD-обёртки над таблицами; вся логика уезжает в оркестратор, который снова монолит | сервисы умеют только create/read/update, но не умеют ни одного бизнес-действия |
Обе ловушки имеют общий корень: границы проведены по технической структуре, а меняется система по бизнес-причинам. Раз причины изменений идут поперёк границ, каждое изменение будет пересекать границы. Отсюда рабочий принцип: граница должна совпадать с причиной изменения.
Резать по бизнес-возможностям
Бизнес-возможность — это то, что компания умеет делать: «показывать каталог», «принимать оплату», «доставлять посылки». Спросите не «какие у нас таблицы», а «какие у нас умения» — и вы почти сразу получите первый черновик карты сервисов.
| Возможность | За что отвечает | Какими данными владеет | Из-за чего меняется |
| Каталог | карточки товаров, категории, поиск | товары, атрибуты, остатки для витрины | маркетинг меняет ассортимент и выкладку |
| Корзина | черновик покупки, промокоды | корзины, применённые скидки | меняются правила скидок |
| Заказы | оформление, статусы, отмена | заказы, позиции, история статусов | меняется процесс оформления |
| Оплата | провайдеры, чеки, возвраты | транзакции, платёжные методы | подключаем нового эквайера |
| Доставка | расчёт срока, передача курьеру | отправления, трек-номера | новая служба доставки |
| Уведомления | письма, пуши, СМС | шаблоны, история отправок | меняется канал коммуникации |
Обратите внимание на последнюю колонку — она и есть проверка. Причины изменений разные, значит, и меняться эти части будут в разное время и разными людьми. Это правильные швы.
Bounded context: один «Клиент» — четыре разных клиента
Классический спор в компании: «сколько у нас полей у сущности Клиент?» Правильный ответ — вопрос поставлен неверно. В разных контекстах «клиент» означает разное, и попытка склеить всё в одну «золотую модель» создаёт самую связную и самую хрупкую точку системы: любое изменение задевает всех.
| Контекст | Кто такой «клиент» | Что про него важно |
| Заказы | получатель | имя, адрес доставки, телефон |
| Оплата | плательщик | платёжные методы, лимиты, история возвратов |
| Поддержка | обратившийся | тикеты, приоритет, SLA |
| Маркетинг | сегмент | интересы, согласия на рассылку |
Правильное решение — у каждого контекста своя модель клиента, а связь между ними по идентификатору. Внутри контекста действует ubiquitous language (единый язык): бизнес, аналитик и код называют вещи одинаково. Если в коде класс Client, в аналитике «покупатель», а в поддержке «обращающийся» — это верный признак, что контексты перепутаны.
Карта контекстов
Контексты не живут в вакууме — они как-то относятся друг к другу. Эти отношения принято рисовать явно, чтобы понимать, кто кому диктует контракт.
Заказы ──(customer/supplier)──> Оплата
поставщик диктует контракт, потребитель подстраивается
Заказы ──(ACL)──> Легаси-склад 1С
anti-corruption layer: слой-переводчик, который не пускает
чужую модель внутрь нашего контекста
Заказы ──(события)──> Уведомления
published language: публикуем событие OrderPaid, кому надо — слушает
Anti-corruption layer (ACL) — самый полезный из этих приёмов на практике: тонкий слой перевода на границе с чужой или устаревшей системой. Без него терминология легаси-склада за полгода расползётся по всему вашему коду.
Признак хорошей границы — независимый деплой
Все красивые слова про домены проверяются одним прагматичным вопросом:
Могу ли я выкатить этот сервис в прод и при необходимости откатить его, не координируясь ни с одной другой командой?
Если ответ «нет» — граница проведена не там, и никакие диаграммы этого не исправят. Ответ хорошо измеряется цифрами:
- Доля задач, трогающих больше одного сервиса. Здоровое значение — меньше 20–30%. Если каждая вторая задача идёт в три репозитория, вы разрезали систему поперёк швов.
- Доля релизов, требующих синхронного выката с соседом. Норма — около нуля.
- Частота совместных изменений модулей. Её можно посчитать прямо по истории git: если два модуля почти всегда меняются в одном коммите — им место в одном сервисе.
Последний пункт — самый практичный. Вот простой анализ совместных изменений: на вход список коммитов (какие модули затронуты), на выход — пары, которые ходят парой.
from collections import Counter
from itertools import combinations
# каждый коммит — список модулей, которых он коснулся
commits = [
['orders', 'payments'],
['orders'],
['catalog'],
['orders', 'payments'],
['catalog', 'search'],
['orders', 'payments', 'delivery'],
['catalog', 'search'],
['notifications'],
['orders', 'delivery'],
['catalog', 'search'],
]
pairs = Counter()
for modules in commits:
for a, b in combinations(sorted(set(modules)), 2):
pairs[(a, b)] += 1
print('Меняются вместе:')
for (a, b), n in sorted(pairs.items(), key=lambda kv: (-kv[1], kv[0])):
print(f' {a} + {b}: {n} из {len(commits)} коммитов')
Результат:
Меняются вместе:
catalog + search: 3 из 10 коммитов
orders + payments: 3 из 10 коммитов
delivery + orders: 2 из 10 коммитов
delivery + payments: 1 из 10 коммитов
Что говорит эта табличка: catalog и search меняются вместе в каждом коммите, где вообще фигурирует каталог, — их не стоит разносить по разным сервисам. С orders и payments сложнее: связь сильная, но не абсолютная — стоит посмотреть на характер изменений. А notifications не связан ни с кем: отличный кандидат на самостоятельный сервис. Это не приговор, а гипотеза — но гипотеза, основанная на фактах, а не на вкусе архитектора.
Как это работает: данные принадлежат сервису
Из независимого деплоя вытекает жёсткое следствие: нет общих таблиц и нет JOIN через границу. Сервис Заказов не читает таблицу товаров — он хранит у себя то, что ему нужно. Дублирование данных здесь не грех, а осознанная плата за автономность (и часто ещё и требование бизнеса).
{
"orderId": "A-1042",
"customerId": "C-77",
"items": [
{
"productId": "P-9",
"titleSnapshot": "Кофемолка Vario",
"priceSnapshot": 8990
}
]
}
Заказ хранит productId — ссылку в чужой контекст — и снимок названия и цены на момент покупки. Это не «денормализация от лени»: цена в каталоге завтра изменится, а в чеке она обязана остаться прежней. Именно поэтому JOIN здесь был бы не только технически невозможен, но и логически неверен.
Актуальность данных, которые действительно нужно синхронизировать, поддерживают событиями: каталог публикует ProductRenamed, подписчики обновляют свои копии. Система становится согласованной в конечном счёте (eventual consistency) — данные сходятся не мгновенно, а через секунды. С этим надо считаться при проектировании интерфейсов, зато сервисы перестают падать друг из-за друга.
Частые ошибки
- Резать по слоям или по таблицам. Самый надёжный способ получить систему, где каждая фича трогает всё.
- Единая «золотая» модель Клиента на всю компанию. Она становится точкой, через которую связаны все сервисы разом.
- Сервис-оркестратор, который знает всё. Логика утекает в него, остальные сервисы превращаются в базы данных с HTTP-фасадом.
- Панический страх дублирования данных. Копия названия товара в заказе — это правильно. Общая таблица товаров на два сервиса — неправильно.
- Резать по существующим таблицам, потому что «так уже устроена база». Схема БД — след прошлых решений, а не карта предметной области.
- Начинать с диаграммы, а не с разговора с бизнесом. Границы контекстов находят на event storming с людьми, которые знают процесс, а не в редакторе схем.
Итоги
- Граница сервиса должна совпадать с причиной изменения: режем по бизнес-возможностям, а не по слоям и не по таблицам.
- Bounded context: один термин — одно значение внутри контекста; «клиент» в оплате и в поддержке это разные клиенты.
- Единственная надёжная проверка границы: могу ли я выкатить и откатить сервис, ни с кем не договариваясь.
- Модули, которые в истории git всегда меняются вместе, — кандидаты в один сервис.
- Данные принадлежат сервису: никаких общих таблиц; снимки и события вместо JOIN, ценой eventual consistency.