За последние годы разработчики в распределённых системах почти решили инфраструктурные проблемы: масштабирование, деплой, отказоустойчивость. Ценой этого прогресса стал экспоненциальный рост сложности бизнес-логики, которая всё чаще выражается не в коде, а в порядке сервисных вызовов.

Современные команды научились разбивать монолиты на микросервисы, оркестрировать их в 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 * 2C1 = 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 не имеет права напрямую изменять состояние агрегатов. Это не оптимизация и не удобство, а архитектурное требование, без которого невозможны детерминизм и строгая консистентность.

В моем представлении работает это так:

  1. Сервисы отправляют в Engine события-намерения ("пользователь хочет оплатить заказ")

  2. Engine загружает текущее состояние

  3. Примеряет, и если все проходит - применяет каскад декларативных правил

  4. Атомарно коммитит итоговое состояние

Капитан-очевидность: Вы изобрели хранимые процедуры?

Самый простой вопрос: "Автор, ты предлагаешь вернуться к триггерам в Oracle/Postgres?"

Концептуально — да. Технически — категорически нет.

Проблемы триггеров в БД всем известны: они скрыты, их сложно тестировать и разрабатывать, они привязаны к вендору БД.

Правила Consistency Engine — это код (пока не решил какой).

  • Они лежат в Git.

  • Они проходят Unit-тесты.

  • Они версионируются через CI/CD.

  • Они исполняются в приложении, а не в базе данных.

Это "Cloud-Native триггеры", если хотите. Прозрачные и управляемые.

В рамках этой статьи я сознательно не фиксирую язык правил. Склоняюсь к обычному коду с жёсткой типизацией и CI-пайплайном. Rule-DSL - это отдельный слой сложности, который имеет смысл только при зрелой доменной экспертизе.

Пример с циклической зависимостью

Допустим, у нас есть Basket.

Правила:

  1. ItemAdded → пересчитать Total.

  2. Если Total > 1000 → применить Discount 10%.

  3. Если есть Discount → обновить Total.

Пользователь набрал товаров на 1050р.

  1. Правило 2 видит > 1000, дает скидку.

  2. Правило 3 обновляет Total. Теперь сумма 945р.

  3. Правило 2 видит < 1000, снимает скидку.

  4. Правило 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 запрещен, все внешние действия выносятся наружу.

  1. Engine коммитит состояние.

  2. Engine пишет в Change Log (или Outbox): "Заказ #123 перешел в статус PAID".

  3. Асинхронные воркеры (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% идеально сработает. Эта статья - плод размышлений над тем, почему наши системы раз за разом становятся все более хрупкими и что с этим можно сделать.

Предлагаю обсудить:

  1. Где эта модель, по-вашему, развалится в первую очередь?

  2. Как вы сейчас решаете проблему "размазанной" бизнес-логики?

  3. Готовы ли вы пожертвовать синхронным чтением ради гарантий целостности?


Материал подготовлен автором telegram-канала о изучении Java.