Пять сервисов, синхронно вызывающих друг друга, каждый с доступностью 99,9%, дают на выходе 99,5%. Разделение монолита само по себе надёжности не добавляет - оно её отнимает, и возвращать приходится отдельно.
Ради чего тогда разбивают: независимый деплой, раздельное масштабирование нагруженных частей, границы ответственности между командами. Паттерны ниже — про то, как это получить и чем за это платят. Strangler Fig и API Gateway, Service Mesh и Sidecar, Database per Service, CQRS, Event Sourcing.
Паттерн «удушающее дерево» (Strangler Fig)
Strangler Fig - способ переписать монолит, ни разу его не остановив: перед ним ставится фасад, и функциональность по частям уезжает из монолита в новые сервисы, пока от него ничего не остаётся. Название придумал Мартин Фаулер - фикус-душитель прорастает в развилке чужого дерева и постепенно занимает его место.

С чего начинать: Первым выносят не самый важный кусок, а самый удобный: мало входящих зависимостей, своя граница данных, заметный, но не критичный трафик. Обычно это край системы - уведомления, отчёты, поиск, экспорт. Ядро домена, где сходятся все транзакции, брать нельзя: вынести его, не разобравшись сразу со всеми данными, не выйдет, и миграция чаще всего останавливается именно там. Смысл первого шага не в ценном сервисе, а в том, чтобы команда один раз прошла весь путь -выделение, деплой, мониторинг, откат — на чём-то, что не страшно уронить.
Где проводить границу: По доменным контекстам, и признак верной границы один: бизнес-операция целиком помещается внутри одного сервиса и не требует распределённой транзакции. Если для оформления заказа приходится синхронно ходить в три новых сервиса, граница проведена неправильно - вы получили те же связи, что были в монолите, плюс сеть между ними.
Что делать с данными: Переадресация запросов - простая часть. Сложность в том, что выделенному сервису нужны данные из базы монолита, и на время миграции, а это месяцы, кто-то должен ими владеть. Вариантов три, и выбрать нужно до первого переключения трафика.
Новый сервис ходит в базу монолита. Быстро стартовать, но вы получаете распределённый монолит: два приложения связаны схемой одной базы, и «усыхание» на этом обычно заканчивается. Временная мера на один релизный цикл, не дольше.
Данные копируются в базу нового сервиса, источник правды остаётся в монолите. Синхронизация в одну сторону — событиями или чтением журнала транзакций (CDC). Новый сервис только читает, изменения по-прежнему идут через монолит. В этом состоянии можно жить долго.
Владение передано новому сервису, монолит ходит к нему через API. Финал. Требует короткого окна двойной записи и последующей сверки — самая ответственная операция во всей миграции, делать по одному сервису за раз.
Про откат стоит понимать его границу: Пока новый сервис только читает, вернуть трафик на монолит - это переключение маршрута в фасаде, вопрос секунд. После передачи владения отката больше нет: у сервиса накопились изменения, которых в монолите не существует, и «вернуть нагрузку обратно» означает обратную миграцию данных. Поэтому передачу владения планируют отдельно - с окном, сверкой и заранее написанным сценарием возврата.
Когда миграция закончена - и почему она часто не заканчивается. За первый год выносят 60% функциональности, оставшиеся 40% оказываются самыми переплетёнными, бюджет заканчивается - и компания навсегда остаётся с монолитом, микросервисами и фасадом между ними, то есть с двумя системами и стоимостью поддержки обеих. Это не гипотетический риск, а самый вероятный исход, если за ним не следить.
Противоядие организационное: считать долю оставшегося в монолите - по эндпойнтам, трафику или таблицам, лишь бы одним способом — и требовать её движения каждый квартал. Цифра не изменилась - миграция остановилась, и это повод для решения. «Останавливаемся здесь осознанно, остаток монолита остаётся навсегда» - тоже допустимое решение, если принято явно.
Но фасад - не бесплатная деталь: Через него с первого дня идёт весь трафик, то есть это единая точка отказа со всеми требованиями: отказоустойчивость, кластеризация, мониторинг, отдельный владелец. Те же требования разбираются ниже в разделе про API Gateway, и это не совпадение: фасад Strangler Fig обычно и есть будущий API-шлюз, просто на раннем этапе. Выбирать его стоит сразу с этой мыслью, а не заводить временный прокси.
Паттерн API Gateway
API Gateway - единственный адрес, который знают клиенты. Шлюз принимает внешний запрос, решает, какому сервису его отдать, и возвращает ответ, внутренняя топология наружу не видна.

Один внешний запрос шлюз может разложить на несколько внутренних и собрать ответ: страница заказа собирается из сервисов заказов, доставки и отзывов за один вызов клиента вместо трёх.
Но так собирается только чтение. Операцию, меняющую состояние нескольких сервисов - оформление заказа со списанием оплаты, разбивать тем же способом нельзя: если оплата прошла, а сервис заказов не ответил, откатывать шлюзу нечем, ни общей транзакции, ни компенсаций у него нет. Останется «деньги списаны, заказа нет». Распределённые изменения - задача Saga, а не шлюза.
Кроме маршрутизации шлюз обычно берёт сквозные задачи: терминацию TLS, логирование, кэширование, ограничение частоты запросов, трансформацию протоколов - внешний REST во внутренний gRPC. Две из них стоит оговорить отдельно.
Аутентификация на шлюзе не означает, что дальше можно не проверять. Шлюз аутентифицирует внешнего вызывающего и превращает его токен во внутренний контекст, но сами сервисы обязаны этот контекст проверять. Иначе любой, кто оказался внутри периметра - соседний сервис, скомпрометированный под, подрядчик в той же сети, получает права любого пользователя.
Сколько из этого писать самому, зависит от того, что уже есть. В Kubernetes маршрутизация извне и балансировка между репликами закрыты платформой, и отдельный шлюз нужен только ради агрегации, версионирования публичного API и управления ключами внешних потребителей. Оттуда же стоит взять разделение владения: точкой входа владеет команда платформы, маршрутами - команды сервисов. Иначе общая конфигурация быстро становится ничьей.
Механизм версионирования выбирают заранее, вариантов три. Версия в пути (/v2/orders) проще всего отлаживается и кэшируется, но протекает в адрес ресурса. Версия в заголовке оставляет адрес чистым, зато её не видно в логах и запрос сложнее воспроизвести руками. Согласование через Accept формально корректнее всего и встречается реже всего. Большинство публичных API берут первый.
Цена шлюза - не только единая точка отказа. То, что лёг шлюз и недоступна вся система, очевидно, отсюда кластеризация, отдельный владелец и мониторинг, не привязанный к сервисам за ним. Менее очевидна пропускная способность. На синтетическом стенде, где nginx-шлюз и сервис стоят в контейнерах на одной машине, то есть сети между ними нет вообще, проксирование добавляло около 0,4 мс на запрос, а пропускная способность падала втрое: с ~220 до ~75 тысяч запросов в секунду. Задержка редко оказывается критичной, а вот трёхкратная разница означает, что масштабировать шлюз придётся раньше, чем сервисы за ним.
Backend for Frontend — вариация, где под каждый тип клиента стоит свой шлюз. Заводят её, когда наборы данных у веба и мобильного разошлись всерьёз. Цена в том, что сквозная логика - авторизация, ограничение частоты, логирование - размножается по шлюзам и со временем начинает расходиться, поэтому BFF делают по факту расхождения, а не заранее под каждую платформу.
Паттерн Service Mesh
Service Mesh и API Gateway - не конкуренты, а разные оси трафика, и это стоит развести сразу. Шлюз обслуживает north-south: вход снаружи, внешние клиенты, их аутентификация, публичный контракт. Mesh обслуживает east-west: вызовы сервисов между собой внутри периметра, где внешних клиентов нет. В большой системе стоят оба, и путь запроса выглядит так: клиент → шлюз → прокси сервиса A → прокси сервиса B → сервис B. Istio при этом умеет закрывать и north-south своим ingress-gateway, поэтому в чистом Kubernetes два слоя иногда сводят к одному продукту.

Устроен mesh как распределённая система прокси. Data plane - прокси рядом с каждым сервисом, перехватывающие весь входящий и исходящий трафик, сервисы «думают», что общаются напрямую. Control plane раздаёт им конфигурацию, выпускает и ротирует сертификаты, собирает телеметрию. Правила вида «10% трафика на v2» или «включить mTLS везде» задаются через него, но не руками: они описываются ресурсами Kubernetes, лежат в репозитории и приезжают через CI, иначе теряется главное, воспроизводимость.
С 2024 года у Istio есть вторая модель, ambient: на каждом узле работает один общий прокси ztunnel, закрывающий L4 - взаимный TLS, авторизация, метрики, - а прокси уровня L7 поднимается только там, где нужны ретраи, разбиение трафика и разбор HTTP.
Раз весь трафик идёт через прокси, mesh умеет всё, что умеет прокси: балансировать, дробить трафик по версиям для канареек, повторять неудачные вызовы, размыкать цепь, считать время ответа и статус каждого вызова. Ничего из этого не требует правки сервисов.
Но внедряют mesh обычно не ради этого. Причина чаще всего одна: требование шифровать трафик внутри периметра приходит от безопасности или регулятора, а mesh закрывает его без правки приложений - прокси поднимают mTLS между собой, сервисы об этом не знают.
Одну вещь mesh не закрывает, хотя от него этого ждут. Заголовки трассировки он пробрасывает между сервисами, но не может протащить их через ваш код: если сервис принял запрос и пошёл дальше, не скопировав заголовки в исходящий вызов, цепочка оборвётся. Бесплатной трассировки не бывает, минимальная правка приложений нужна всё равно.
Оверхед стоит знать до внедрения. По официальным замерам Istio на 1000 запросах в секунду с полезной нагрузкой 1 КБ и включённым mTLS один sidecar стоит 0,20 vCPU и 60 МБ памяти - на каждый под. На двухстах подах это 40 vCPU и 12 ГБ только на инфраструктуру. Ambient в той же конфигурации - 0,06 vCPU и 12 МБ на узел.
Ошибочная конфигурация control plane ломает коммуникацию сразу во всей системе, а не в одном сервисе, и разбираться с этим некому, кроме тех, кто mesh поднимал. Практический ориентир: меньше двух десятков сервисов и никто не требует шифрования внутри периметра - хватит API Gateway и библиотеки ретраев с таймаутами.
Паттерн Sidecar
Sidecar — вспомогательный процесс, живущий рядом с приложением и берущий на себя то, что к бизнес-логике не относится: TLS наружу, отправку логов, получение конфигурации. В Kubernetes это второй контейнер в том же поде, общаются они по localhost.

Чаще всего сайдкар выступает в одной из двух ролей, и у них есть имена. Ambassador смотрит наружу: представляет приложение во внешней сети, берёт на себя TLS, ретраи, преобразование протоколов - приложение шлёт обычный HTTP на localhost, сайдкар устанавливает HTTPS. Adapter смотрит внутрь: приводит то, что отдаёт приложение, к формату, который ждёт платформа. Классический пример - экспортер метрик: приложение публикует их по-своему, сайдкар превращает в то, что читает Prometheus.
Переиспользуется образ, а не экземпляр. Один и тот же образ подключают к разным приложениям, но экземпляр всегда свой у каждого пода.
Независимость сайдкара легко переоценить. Поведение действительно меняется конфигурацией без пересборки приложения - новая политика ретраев или новый адрес лог-сервера не трогают код. Но обновить образ сайдкара, не затронув приложение, нельзя: в Kubernetes любое изменение спецификации пода, включая смену образа одного контейнера, пересоздаёт под целиком. Масштабировать отдельно тоже не выйдет - сайдкар по определению живёт один к одному с экземпляром приложения и адресуется по localhost.
Сайдкар должен стартовать раньше приложения и останавливаться позже него. Иначе приложение потеряет первые запросы при старте и последние при выключении. Обычные контейнеры в поде такой гарантии не дают: порядок их запуска и остановки не определён, и гонка «приложение поднялось раньше прокси» - классическая проблема первых внедрений service mesh. Правильный механизм - объявить сайдкар в initContainers с restartPolicy: Always, тогда kubelet гарантирует, что он запущен до основного контейнера и остановлен после.
И лимиты. Заниженный лимит памяти убивает не сайдкар, а весь сетевой путь пода: приложение остаётся живым, но перестаёт куда-либо ходить.
Дальше пример, на котором такой сайдкар чаще всего и ломается. Приложение на Python делает обычный запрос:
response = requests.get("http://localhost:8080/api/data")
А рядом стоит nginx, который принимает это нешифрованно и ходит наружу по HTTPS:
location / { proxy_pass https://external-service:443; proxy_ssl_server_name on; # по умолчанию off — SNI не отправляется proxy_ssl_verify on; # по умолчанию off — сертификат не проверяется proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt; proxy_ssl_verify_depth 2; # по умолчанию 1 proxy_ssl_protocols TLSv1.2 TLSv1.3; }
Две первые директивы обязательны, и обе по умолчанию выключены. Без proxy_ssl_server_name on сайдкар не отправляет SNI, и любой хост за CDN или за балансировщиком с несколькими сертификатами оборвёт handshake: приложение получит 502, а в логе будет SSL_do_handshake() failed … tlsv1 unrecognized name. Без proxy_ssl_verify on сайдкар не проверяет сертификат внешнего сервиса вообще - примет самоподписанный, выписанный на чужое имя, и вернёт приложению совершенно честный 200.
Отсюда правило, которое стоит держать в голове при любом применении sidecar: перенося функцию из приложения в сайдкар, вы наследуете дефолты сайдкара, а не той библиотеки, которой пользовались раньше. requests в Python проверяет сертификаты по умолчанию, nginx в роли обратного прокси - нет. Наивный перенос TLS в сайдкар не повышает, а понижает безопасность, и заметить это без специальной проверки невозможно: снаружи всё выглядит работающим.
Прокси service mesh - это сайдкары, и всё сказанное про порядок запуска и лимиты относится к ним в первую очередь.
Паттерн «База данных на сервис» (Database per Service)
У каждого сервиса своя база, и в чужую он не ходит — только через API её владельца. Это не про физически отдельный сервер: несколько сервисов могут жить в одном PostgreSQL, но в разных схемах, куда у соседей нет доступа. Важна не изоляция железа, а то, что схему можно поменять, ни с кем не согласовывая.

Одно ожидание стоит поправить сразу. Разделение баз само по себе не изолирует отказы: если сервис A вызывает B синхронно и без таймаута, упавшая база B подвесит и A - сначала кончатся рабочие потоки, потом соединения, и отказ пойдёт вверх по цепочке. Изоляция появляется, когда на вызывающей стороне есть таймауты и circuit breaker, разделение баз лишь делает её возможной.
Где провести границу. Польза от локальных транзакций появляется, только если бизнес-операция целиком помещается внутри сервиса. Критерий: внутри одного сервиса должно оказаться всё, что обязано меняться одновременно и проверяться на непротиворечивость в момент изменения. Если для правила «нельзя оформить заказ на товар, которого нет на складе» нужно синхронно заглянуть в чужую базу, граница проведена неверно — дальше вы будете либо нарушать изоляцию, либо строить сагу там, где её могло не быть.
JOIN между сервисами не сделать. Нужные данные либо запрашивают по API, либо держат у себя проекцией, которую сервис поддерживает, слушая события владельца. Здесь ловушка, в которую попадают почти все: если владелец сначала пишет изменение в свою базу, а потом отдельным вызовом публикует событие, это запись в две системы без общей транзакции. Упал брокер между вызовами - изменение есть, события нет, подписчики не узнают о нём никогда, и никакой ошибки при этом не возникнет. Лечится Transactional Outbox: событие пишется в ту же базу и в той же транзакции, что и само изменение, в отдельную таблицу, а отправкой в брокер занимается отдельный процесс, который эту таблицу читает.
Транзакции стали локальными. Согласованность между сервисами достигается теперь в конечном счёте — событиями или сагами. Сага разбивает бизнес-операцию на цепочку локальных транзакций, каждая публикует событие, запускающее следующий шаг. Отката в привычном смысле нет: вместо него для каждого шага пишется компенсирующее действие. Не «отменить списание», а «сделать возврат» - компенсация такая же обычная бизнес-операция, видимая в истории, а не откат, стирающий следы.
Соединения кончаются раньше ресурсов базы. Это тот предел, в который упираются на практике, и он неочевиден. Каждая реплика держит свой пул, и общее число перемножается: двадцать реплик с пулом по десять - двести соединений к одной базе. У PostgreSQL по умолчанию max_connections = 100, причём три зарезервированы для суперпользователя, - предел наступит задолго до того, как кончатся процессор или память. Лечится не наращиванием max_connections (каждое соединение стоит памяти и нагружает планировщик), а внешним пулером вроде PgBouncer в режиме transaction pooling.
Отчёты через границы сервисов собирают не API-композицией и не ночными скриптами, а CDC: отдельный процесс читает журнал транзакций каждой базы и передаёт поток изменений в аналитическое хранилище, ничего не требуя от самих сервисов. Известная реализация - Debezium. Это отдельный уровень архитектуры, закладывать его лучше заранее.
Одна деталь, которую стоит назвать явно: цену товара на момент заказа сервис заказов хранит у себя, а не запрашивает у каталога. Это не дублирование по недосмотру, а осознанная копия - для заказа она часть его собственной истории и не должна меняться задним числом при обновлении прайс-листа.
Одна база на несколько сервисов - не ошибка, а компромисс с понятной ценой. Вы получаете привычные ACID-транзакции через границы сервисов и одну базу в эксплуатации, платя связанностью по схеме (изменение таблицы требует согласования между командами) и связанностью во время выполнения (долгая транзакция одного сервиса блокирует другой). В каталоге микросервисных паттернов Shared Database значится именно как паттерн со списком компромиссов, а не как антипаттерн. Проблема не в том, что так нельзя, а в том, что выйти из этого состояния позже дороже, чем не входить: чем дольше живёт общая схема, тем больше кода в неё врастает. Поэтому если общая база выбрана сознательно, зафиксируйте сразу, при каком условии будете из неё выходить.
CQRS (разделение команд и запросов)
CQRS разводит запись и чтение: команда меняет состояние и почти ничего не возвращает, запрос читает и ничего не меняет, и модели у них разные.

Мартин Фаулер посвятил паттерну отдельную заметку, и его позицию стоит привести точно, потому что её часто передают наоборот: он относится к CQRS сдержанно и прямо пишет, что применять его надо с большой осторожностью, что подходящие домены - явное меньшинство и что областью применения должен быть отдельный ограниченный контекст, а не система целиком.
Форм у паттерна две, и это различие важнее всего остального. Простая - одна база, но разные модели в коде: командная с инвариантами и проверками, запросная с денормализованными представлениями под конкретные экраны. Продвинутая - отдельные хранилища: одно принимает запись, второе обновляется по событиям и обслуживает чтение.
Дальше - и в этой статье, и в большинстве материалов - разбирается почти исключительно продвинутая форма, отчего складывается впечатление, что CQRS обязательно означает две базы и шину событий. Это не так. Подавляющему большинству систем хватает простой: она даёт основную часть выигрыша и не тащит за собой ни eventual consistency, ни проекций, ни отдельной инфраструктуры. Отдельные хранилища вводят, когда упёрлись в измеренное ограничение - реплики для чтения перестали справляться либо запись и чтение требуют принципиально разных гарантий. «Мы сразу сделали правильно» - плохое основание для второй базы.
Как только хранилища разъехались, появляется задержка: после команды изменение не сразу видно на стороне чтения. Способов жить с этим немного. Идентификатор, сгенерированный клиентом, позволяет показать созданную сущность сразу, не дожидаясь проекции. Команда может вернуть номер версии, по которому последующее чтение подождёт, пока проекция догонит, это read-your-writes. Наконец, чтения самого автора изменения можно на несколько секунд направлять на write-сторону, а всех остальных на read. Чего делать не стоит - оставлять интерфейс, где пользователь нажал «Сохранить» и на следующем экране своих данных не увидел.
Проекцию рано или поздно придётся перестроить - ошибка в обработчике, потерянное событие, изменившееся требование. Это штатная операция, и предусмотреть её надо заранее. В связке с Event Sourcing ответ простой: очистить проекцию и переиграть журнал с начала. Без Event Sourcing журнала нет, и перестраивать приходится из write-модели — нужен процесс, умеющий прочитать текущее состояние write-базы и собрать read-таблицы заново, плюс способ понять, что проекция отстала или разошлась: счётчик обработанных событий и регулярная сверка. Без такого процесса первая же ошибка в проекции будет чиниться руками и по ночам.
// Командная модель - обработчик команды создания заказа public class OrderCommandHandler { private final OrderRepository repo; private final OutboxRepository outbox; private final TransactionTemplate tx; public OrderCommandHandler(OrderRepository repo, OutboxRepository outbox, TransactionTemplate tx) { this.repo = repo; this.outbox = outbox; this.tx = tx; } public void handle(CreateOrderCommand cmd) { // 1. Валидация бизнес-правил // 2. Создание объекта заказа, добавление товаров, расчет суммы Order order = new Order(cmd.getOrderId()); // ... (добавить товары и прочее) // 3. Заказ и событие сохраняются ОДНОЙ транзакцией в ОДНУ базу tx.execute(() -> { repo.save(order); outbox.save(new OrderCreatedEvent(cmd.getOrderId())); }); // 4. Отдельный процесс читает таблицу outbox и публикует события в брокер } } // Запросная модель - обработчик запроса на получение заказа public class OrderQueryService { private final OrderViewRepository readDb; public OrderQueryService(OrderViewRepository readDb) { this.readDb = readDb; } public OrderDto handle(GetOrderQuery query) { // Точечный запрос по ключу к денормализованной таблице read-модели return readDb.findById(query.getOrderId()) .map(OrderDto::from) .orElse(null); } }
Две детали здесь отвечают на вопросы, которые возникают сразу.
Идентификатор заказа приходит в команде, а не выдаётся базой. Это ответ на «откуда клиент узнает id, если команда ничего не возвращает»: он его и генерирует, обычно UUID. Побочная выгода - команда становится идемпотентной, повторная отправка с тем же идентификатором не создаст второй заказ.
Заказ и событие сохраняются одной транзакцией в одну базу. Наивный вариант - repo.save(order), следом eventBus.publish(…) - это та самая двойная запись из раздела про Database per Service: упал брокер между вызовами, и read-модель о заказе не узнает никогда. Отсюда таблица outbox и отдельный процесс, который её читает.
Разносить write и read по разным сервисам можно, но не обязательно - CQRS живёт и внутри одного. Разделение на сервисы имеет смысл, когда у сторон разошлись требования к масштабированию, а не само по себе.
Паттерн Event Sourcing
Event Sourcing - состояние не хранится, оно вычисляется: в журнал пишутся события, а текущее состояние агрегата получается их последовательным применением. состояние = f(все прошлые события). Отсюда же возможность получить состояние на любой момент в прошлом - достаточно проиграть журнал до нужной точки.

Главная выгода очевидна - полная история и аудит, но у переигрывания есть оговорка, которую замечают поздно. Оно отвечает на новые вопросы только теми данными, которые в событиях уже записаны. Если MoneyDeposited в своё время сохранили без временной метки или без указания канала операции, никакая новая логика их не восстановит: журнал воспроизведёт историю, но не изобретёт её. Отсюда правило проектирования событий - записывать всё, что известно в момент возникновения, даже если сегодня это никому не нужно. Место на диске дешевле, чем невозможность ответить на вопрос через два года.
События хранятся в event store, сгруппированные по агрегатам: все события заказа 1234 - одна последовательность. Чтобы получить состояние, их загружают и применяют к пустому объекту:
Order order = new Order(); for (Event event : eventsForOrder1234) { order.apply(event); }
Весь смысл в методе apply. Соблазнительно объявить по перегрузке на каждый тип события - apply(OrderCreated), apply(ItemAddedToOrder), но так код не соберётся: перегрузки в Java выбираются по статическому типу аргумента, а в цикле он всегда Event. Поэтому apply один, с диспетчеризацией внутри:
public void apply(Event event) { switch (event) { case OrderCreated e -> { this.id = e.orderId(); this.status = "создан"; } case ItemAddedToOrder e -> { items.add(e.item()); total = total.add(e.price()); } case OrderShipped e -> { this.status = "отгружен"; this.shippedAt = e.date(); } default -> throw new IllegalStateException( "Неизвестный тип события: " + event.getClass().getName()); } }
Ветка default обязательна, и это не перестраховка. Через полгода появится новый тип события - скажем, MoneyTransferredOut. Без неё восстановление молча его пропустит: исключения не будет, в логах ничего не появится, а состояние окажется неверным ровно на сумму всех таких событий, и обнаружится это при сверке через неизвестное время. Восстановление из журнала должно падать на неизвестном событии, а не досчитывать до правдоподобного, но неверного числа.
Снапшоты - периодически сохранённое состояние, чтобы не проигрывать длинную историю с нуля. Две оговорки, обе практические. Нужны они меньшинству агрегатов: если у типичного агрегата десятки событий, проигрывание стоит доли миллисекунды, а снапшот добавляет кода и ещё одно место, где данные могут разойтись, вводить их стоит по замеру. И снапшот является закешированным результатом apply, поэтому при изменении логики apply все существующие снапшоты становятся недействительными: их версионируют и умеют выбрасывать целиком.
Запись события - не просто append, и без этого вся конструкция небезопасна. Если два процесса одновременно меняют один агрегат, оба прочитают одну историю, оба примут решение на её основе и оба допишут свои события - получатся два списания при остатке, которого хватало на одно. Защита - оптимистическая блокировка по версии: при чтении запоминается номер последнего события, при записи он передаётся как ожидаемый, а вставка идёт с уникальным индексом по паре (aggregate_id, version). Кто-то успел записать раньше - вставка нарушит уникальность, команда упадёт, её нужно повторить, перечитав историю.
Исправлять записанное нельзя. Ошиблись в событии - добавляется компенсирующее, а неверное остаётся в журнале. Видно и что было записано, и когда поправили, и на сколько.
Персональные данные в неизменяемом журнале - ограничение, о котором вспоминают поздно, и оно неприятно сочетается с областями, где Event Sourcing рекомендуют в первую очередь. Требование удалить персональные данные по запросу субъекта в append-only журнале невыполнимо: событие нельзя ни изменить, ни удалить, не сломав модель. Обходной путь - crypto-shredding: персональные поля пишутся зашифрованными, ключ хранится отдельно и привязан к субъекту, удаление субъекта означает удаление ключа. События остаются, история цела, прочитать персональные поля уже нельзя. Закладывать это нужно в схему с самого начала - добавить шифрование задним числом значительно дороже.
Бесконечно растущий поток одного агрегата обычно означает, что границу агрегата провели неверно. Агрегат «Товар» с миллионом событий за пять лет - не повод для снапшотов, а повод пересмотреть модель. У здоровых агрегатов есть естественное завершение: заказ закрыт, счёт закрыт, отчётный период закрыт, и завершённые потоки можно переносить в архив, не трогая активные.
Схема событий эволюционирует тяжело: нельзя просто изменить поле, старые события уже лежат в журнале. Приходится версионировать события или мигрировать журнал, и второе нетривиально.
События бывают двух сортов, и смешивать их не стоит. В event store лежат внутренние, доменные - они спроектированы под восстановление состояния агрегата и являются частной деталью сервиса. Наружу публикуются отдельные интеграционные: более крупные, стабильные, с версионированной схемой. Публикуя внутренние напрямую, вы привязываете всех подписчиков к внутренней модели своего агрегата, и версионирование событий из внутренней задачи превращается в согласование релизов между командами.
Заключение
Общее у всех семи не в том, что они решают проблемы, а в том, где именно они обманывают ожидание. Разделение баз не изолирует отказы - изолируют таймауты и брейкер на вызывающей стороне. Аутентификация на шлюзе не означает, что сервисам можно не проверять. Сайдкар нельзя обновить или отмасштабировать отдельно от приложения. CQRS не требует двух баз. Mesh не даёт бесплатную трассировку. Откат в Strangler Fig существует ровно до передачи владения данными. Каждое из этих уточнений обходится дороже самого паттерна, потому что обнаруживается уже в проде.
Второе общее - почти все они добавляют компонент, у которого должен быть владелец. Фасад, шлюз, control plane меша, сайдкар в каждом поде, процесс, вычитывающий outbox, процесс, пересобирающий проекции. Это не строчка в архитектурной схеме, а дежурство, обновления и отдельная страница в рантбуке. Прежде чем брать паттерн, стоит спросить, кто будет чинить его в три часа ночи.
Комбинировать их всё равно приходится: миграция по Strangler Fig почти всегда идёт вместе с разделением баз, а CQRS без Outbox не работает. Но набирать их «чтобы было правильно» — худшее из оснований. У каждого есть измеримое условие, при котором он окупается, и в большинстве случаев оно ещё не наступило.