За последние годы разработчики в распределённых системах почти решили инфраструктурные проблемы: масштабирование, деплой, отказоустойчивость. Ценой этого прогресса стал экспоненциальный рост сложности бизнес-логики, которая всё чаще выражается не в коде, а в порядке сервисных вызовов.
Современные команды научились разбивать монолиты на микросервисы, оркестрировать их в Kubernetes и наблюдать через распределённые трассировки. Инфраструктура стала управляемой, но сама бизнес-логика — нет. И чем больше сервисов создаётся, тем сложнее сохранять целостность данных, порядок действий и предсказуемость поведения.
Типичный сценарий: бизнес приходит с задачей "Если в корзине три товара категории "Электроника", положи в подарок чехол, но только если регион доставки не "Дальний Восток". Звучит как if-else на пять строк. Но в распределённой системе это превращается в такой ��ебе квест: BasketService синхронно обращается к Catalog, затем к Warehouse, затем к GeoService. Где-то посередине случается таймаут, где-то - сетевой сбой, и в коде начинают появляться саги, компенсации и ретраи.
В результате бизнес-логика перестаёт быть просто кодом. Она превращается в топологию вызовов: тонкий слой условий, размазанный по HTTP-клиентам и контроллерам. И чем сложнее система, тем страшнее становится трогать эти цепочки.
Я приглашаю сегодня взглянуть на проблему под другим углом. Что если пересмотреть не инструменты, а саму парадигму управления состоянием?
Симптомы проблем
Как понять, что ваша архитектура страдает от "распределенного спагетти"? Вот четыре симптома, которые я наблюдаю регулярно:
Код описывает как сделать, а не что должно быть
Вместо ясного бизнес-правила в коде зашита инструкция по его выполнению:
//вместо красиво-декларативного: Заказ можно оплатить, если все товары в наличии //мы видим императивную последовательность: try { inventoryService.checkAvailability(items); paymentService.processPayment(order); orderService.updateStatus("paid"); inventoryService.reserveItems(items); notificationService.sendReceipt(order); } catch (InventoryException e) { paymentService.refund(order); //компенсация throw new BusinessException("Товара нет"); }
В данном случае бизнес-правило "оплата возможна при наличии товаров" оказалось размазано по цепочке вызовов, обработке исключений и компенсирующим действиям. Логика теперь живёт в транспорте между сервисами, а не в доменной модели.
Эффект домино при изменениях
Вы меняете правило расчёта скидки в одном сервисе, а ломается валидация в трёх других, потому что они неявно полагались на порядок вызовов или промежуточное состояние.
Полу-состояния
Пользователь видит, что деньги списались, но статус заказа "Обработка". Или хуже: "ошибка, попробуйте позже", потому что ответ от склада не дошёл до оркестратора, и система застряла между двумя валидными, но несовместимыми состояниями. Консистентность становится "eventual", а головная боль поддержки - "immediate".
Страх рефакторинга
"Работает - не трогай". Добавить новое поле в сущность Order требует согласования пяти команд и обновления трёх контрактов (gRPC/OpenAPI). Любое изменение бизнес-логики превращается в инфраструктурный проект, потому что логика зашита в контракты и порядок взаимодействия сервисов.
Excel для бэкенда
А что если проблема не в том, как вызываются сервисы, а в том, что мы считаем центром системы?
В большинстве современных архитектур таким центром является процесс — цепочка шагов, которые должны выполниться в определённом порядке.
Я же предлагаю рассмотреть альтернативу: сделать центром системы состояние данных и описывать бизнес-логику через требования к нему (State-first).
Лучшая метафора, которую я смог подобрать, это Excel. Когда вы работаете в таблице, вы не пишете императивный скрипт: "Если изменилась ячейка A1, пойди в ячейку B1, обнови ее, потом подожди, потом обнови C1". Нет. Вы пишете декларативные формулы: B1 = A1 * 2. C1 = B1 + 10. Excel сам строит граф зависимостей. Excel сам решает, в каком порядке пересчитывать ячейки. Он гарантирует, что данные всегда согласованы.
Что если применить этот принцип к бэкенду?
Было: Event → Service A → Service B → Service C
Стало: Событие → Изменение состояния → Выполнение правил → Согласованное состояние
Мы перестаем оркестрировать сервисы. Мы начинаем оркестрировать последствия.
Но разумеется, бэкенд не Excel, и речь тут идет не о копировании реализации, а о заимствовании модели: декларативные зависимости и автоматический пересчёт следствий.
Consistency Engine
Для себя я назвал предложенный подход Consistency Engine. Это специализированный runtime (в рамках одного Bounded Context) инструмент (приложение), задача которого — обеспечить детерминированный переход состояния на основе набора правил.
И это не очередной Workflow Engine (типа Camunda), который управляет шагами во времени. Тем более это не Saga Orchestrator, который вызывает внешние сервисы.
Ключевая особенность Engine — монополия на запись в доменную модель. Ни один сервис , охваченный логикой Engine не имеет права напрямую изменять состояние агрегатов. Это не оптимизация и не удобство, а архитектурное требование, без которого невозможны детерминизм и строгая консистентность.
В моем представлении работает это так:
Сервисы отправляют в Engine события-намерения ("пользователь хочет оплатить заказ")
Engine загружает текущее состояние
Примеряет, и если все проходит - применяет каскад декларативных правил
Атомарно коммитит итоговое состояние
Капитан-очевидность: Вы изобрели хранимые процедуры?
Самый простой вопрос: "Автор, ты предлагаешь вернуться к триггерам в Oracle/Postgres?"
Концептуально — да. Технически — категорически нет.
Проблемы триггеров в БД всем известны: они скрыты, их сложно тестировать и разрабатывать, они привязаны к вендору БД.
Правила Consistency Engine — это код (пока не решил какой).
Они лежат в Git.
Они проходят Unit-тесты.
Они версионируются через CI/CD.
Они исполняются в приложении, а не в базе данных.
Это "Cloud-Native триггеры", если хотите. Прозрачные и управляемые.
В рамках этой статьи я сознательно не фиксирую язык правил. Склоняюсь к обычному коду с жёсткой типизацией и CI-пайплайном. Rule-DSL - это отдельный слой сложности, который имеет смысл только при зрелой доменной экспертизе.
Пример с циклической зависимостью
Допустим, у нас есть Basket.
Правила:
ItemAdded→ пересчитатьTotal.Если
Total > 1000→ применитьDiscount 10%.Если есть
Discount→ обновитьTotal.
Пользователь набрал товаров на 1050р.
Правило 2 видит > 1000, дает скидку.
Правило 3 обновляет
Total. Теперь сумма 945р.Правило 2 видит < 1000, снимает скидку.
Правило 3 обновляет
Total. Снова 1050р. Infinite Loop.
В императивном коде микросервисов такой баг отлаживать -**па. В State-first архитектуре это решается на уровне компиляции графа или runtime convergence.
Движок на стадии компиляции видит цикл. Чтобы система работала, правила должны быть конвергентными (сходиться к фиксированной точке). Если за X итераций состояние не стабилизировалось, Engine откатывает транзакцию и выбрасывает ошибку.
Мы узнаем о баге сразу, а не когда база данных ляжет от нагрузки.
Технические идеи
Монополия на запись и Single-Writer
Чтобы гарантировать консистентность без распределенных блокировок, Engine должен быть единственным писателем для конкретного агрегата (например, OrderId). Предпочтительнее использовать паттерн Single-Writer per Key.
В целом это гарантия корректности, а не оптимизация производительности. Все команды для заказа #123 выстраиваются в очередь к одному потоку (или актору), который держит состояние в памяти. Это позволяет обрабатывать тысячи изменений в секунду без SELECT FOR UPDATE в базе данных.
Все события для одного агрегата сериализуются Engine. Порядок их применения становится частью детерминированной истории агрегата.
State-first модель предполагает, что агрегат является логически цельным объектом, имеет явные ограничения по размеру и сложности. Engine может проверять эти ограничения (объём состояния, количество элементов, число применённых правил, время обработки) и рассматривать их нарушение как ошибку домена. Если агрегат перестаёт укладываться в заданные ресурсные и семантические ограничения writer’а или требует постраничной загрузки, это сигнал к его декомпозиции, а не к усложнению Engine.
НО! Если агрегат велик и бизнес-инварианты требуют атомарности, State-first модель принимает высокую latency как цену детерминизма. А если write-latency критична — State-first может быть не лучшим выбором.
Запрет на IO внутри правил
Это один из критических моментов. Внутри правил (if X then Y) запрещены:
Сетевые вызовы (HTTP, gRPC).
Запросы в БД.
Генерация случайных чисел.
Чтение системного времени.
Внешние данные не запрашиваются внутри правил. Они приходят явно в виде событий или атрибутов состояния (например, ExchangeRateUpdated, LimitApproved). Engine должен работать только с уже зафиксированными фактами, а не с источниками этих фактов.
Кроме того, правило должно быть чистой функцией: (State, Event) -> State. Только так мы получим детерминизм. Если повторим те же события завтра, мы получим ровно то же состояние. Это делает отладку тривиальной: вы просто берете журнал событий с прода и воспроизводите его локально.
Важно: запрет на IO внутри правил не означает изоляцию от внешнего мира.
Все внешние данные (anti-fraud, ML-проверки, KYC, курсы валют) поступают в Engine
явно, в виде зафиксированных фактов — событий или атрибутов состояния
(FraudCheckPassed, FraudCheckFailed, ExchangeRateUpdated).
Consistency Engine не принимает решений в условиях неопределённости. Он работает только с уже подтверждёнными фактами и проверяет, допустимо ли текущее состояние домена с их учётом.
Эволюция правил
Изменение правил не означает автоматический пересчёт всего исторического состояния.
В базовой модели новые правила применяются к новым изменениям состояния, а существующие агрегаты пересчитываются только при следующем событии изменения.
По умолчанию предполагается работа Engine в forward-only режиме. Массовый пересчёт истории — это отдельная, осознанная операция, выходящая за рамки обычного runtime-потока.
Анализ графа зависимостей на этапе компиляции
При деплое сервиса правила не просто "загружаются". Engine статически анализирует их семантику:
Правило А читает поле
itemsи пишетsubtotal.Правило Б читает
subtotalи пишетtax.
Строится направленный граф зависимостей (DAG). Мы знаем порядок выполнения до того, как пришел первый запрос. Граф строится на этапе компиляции/деплоя, а не в runtime.
Границы применимости
Consistency Engine сознательно ограничен одним bounded context и одним агрегатом. Он не решает задачу глобальной консистентности между разными доменами. Single-Writer работает на уровне агрегата в смысле DDD, а не на уровне таблицы или отдельной сущности.
Взаимодействие между контекстами происходит исключительно через события и side-effects, а не через совместное состояние.
Если бизнес-правило требует синхронной консистентности между несколькими агрегатами или контекстами, а так же если агрегат становится слишком большим - это сигнал либо к пересмотру границ домена, либо к тому, что данный сценарий не подходит для state-first модели.
Ошибки домена и ошибки исполнения
В Engine я вижу принципиально два класса ошибок:
Ошибки домена - логические противоречия модели (нарушение инвариантов, отсутствие конвергенции). Они означают, что данное изменение состояния недопустимо, и возвращаются вызывающему сервису как бизнес-ошибка.
Ошибки исполнения - сбои инфраструктуры (падение ноды, проблемы с хранилищем). Они не меняют семантику домена и обрабатываются стандартными механизмами ретраев и failover.
Это разделение позволяет чётко понимать, что именно пошло не так — бизнес или инфраструктура.
Что будет с сервисами
С внедрением Consistency Engine у сервисов изменится контракты.
Теперь они не могут:
Менять состояние напрямую. Никаких
UPDATE orders SET status = ...в обход Engine.Оркестрировать бизнес-процесс. Сервис не решает "теперь вызови доставку". Он лишь сообщает Engine факт: "Пользователь нажал кнопку".
Читать из Engine (в синхронном потоке). Engine оптимизирован на запись (Write Model). Долбить его SELECT-ами нельзя.
Но отвечают за:
Прием HTTP, валидация JSON, авторизация.
Авторство правил: Код правил пишет команда домена.
Side-Effects: Об этом ниже.
Side-Effects и Read Models
Вы спросите: "Ну отлично, состояние мы посчитали красиво. А как списать деньги? Как отправить письмо? Как показать пользователю список заказов?"
Здесь вступает в силу строгое разделение (CQRS).
Side-Effects
Поскольку внутри правил IO запрещен, все внешние действия выносятся наружу.
Engine коммитит состояние.
Engine пишет в Change Log (или Outbox): "Заказ #123 перешел в статус PAID".
Асинхронные воркеры (Side-effect processors) читают этот лог и выполняют грязную работу: отправляют уведомления, шлют email, пушат в Kafka.
Если воркер упал — не страшно. Он перечитает лог и попробует снова (At-least-once delivery). Идемпотентность воркеров критически важна. Состояние внутри Engine от этого не пострадает.
Side-effects запускаются только после успешного коммита состояния. Если транзакция не закоммичена — никаких side-effects быть не может.
Read Models
Так как Engine — это черный ящик для записи, нам нужны витрины для чтения. Те же воркеры читают Change Log и обновляют проекции:
Таблицу в Postgres для поиска по фильтрам.
Индекс в Elasticsearch для полнотекстового поиска.
Кэш в Redis для UI.
Да, здесь появляется Eventual Consistency (задержка между записью и чтением). Но в 99% случаев (список заказов, история операций) это допустимо. Там, где нужно "Read-your-own-writes" (пользователь обновил страницу сразу после клика), мы можем вернуть актуальное состояние сразу в ответе на команду изменения.
Итог
Какие плюсы я вижу в данном решении:
Железобетонная консистентность. Никаких полу-состояний.
Объяснимость. Trace показывает: "Поле X изменилось правилом №5, потому что сработало правило №2".
Простота изменений. Добавить новое правило проще, чем врезаться в цепочку из 5 микросервисов.
Чем мы заплатим:
Смена парадигмы. Разработчикам сложно перестать мыслить шагами ("сделай то, потом это") и начать мыслить инвариантами.
Задержку на запись. Последовательная очередь на агрегат может быть медленнее параллельной записи (хотя отсутствие локов БД часто компенсирует это).
Задержку на чтение. Нужно проектировать UI с учетом того, что данные в поиске могут отстать на 100мс.
Consistency Engine не пытается восстановить состояние из истории событий и не делает события первичным источником истины. Источником истины является текущее согласованное состояние, а события и change-log - лишь побочный продукт его изменения.
И Engine не подразумевает миграции всей системы (по крайней мере, сразу). Я вижу его внедрение точечно - для отдельных агрегатов с высокой плотностью бизнес-правил, оставаясь изолированным от остальной архитектуры.
Заключение
Предложенная мною идея о Consistency Engine это не серебряная пуля и не призыв сжечь ваши микросервисы. Это проект архитектурного паттерна для тех зон, где сложность бизнес-правил превышает сложность инфраструктуры. Для финтеха, биллинга, сложного e-commerce.
К сожалению у меня нет готового решения с 10k звезд, показывающее, что все предложенное на 100% идеально сработает. Эта статья - плод размышлений над тем, почему наши системы раз за разом становятся все более хрупкими и что с этим можно сделать.
Предлагаю обсудить:
Где эта модель, по-вашему, развалится в первую очередь?
Как вы сейчас решаете проблему "размазанной" бизнес-логики?
Готовы ли вы пожертвовать синхронным чтением ради гарантий целостности?
Материал подготовлен автором telegram-канала о изучении Java.
