Границы сервисов и предметная область

Где на самом деле проходят швы системы: почему нельзя резать по техническим слоям, что такое 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.
Проверьте себя
1. По какому принципу стоит проводить границы между сервисами?
AПо техническим слоям: отдельно UI, отдельно бизнес-логика, отдельно доступ к данным
BПо таблицам базы: один сервис на каждую сущность
CПо бизнес-возможностям и ограниченным контекстам предметной области
DПо языкам и фреймворкам, которые знает команда
2. Какая проверка лучше всего показывает, что граница сервиса проведена верно?
AВ сервисе не больше десяти тысяч строк кода
BСервис можно выкатить в прод и откатить, не координируясь с другими командами
CУ сервиса есть свой Dockerfile и свой репозиторий
DСервис общается с остальными только по REST