Одно правило, пять систем: как бизнес-логика начинает противоречить сама себе
Краткая аннотация
Одно и то же правило часто незаметно копируют в сайт, CRM, мобильное приложение, интеграции и отчёты. Пока условия не меняются, проблема невидима. Но после первой правки системы начинают принимать разные решения. Разбираю, откуда появляется такой рассинхрон и как его убрать без переписывания всей архитектуры.
Я долго относилась к дублированию бизнес-правил как к обычному техническому долгу. Ну лежит одна проверка в бэкенде, похожая — в CRM, ещё одна — в приложении. Работает же.
Потом поняла, что проблема начинается не тогда, когда код повторяется. Она начинается в момент, когда две системы получают право самостоятельно ответить на один и тот же бизнес-вопрос.
Например: положена клиенту бесплатная доставка или нет?
На первый взгляд это простое условие. Но стоит этому условию прожить год внутри реального продукта, и внезапно оказывается, что ответ на него знают сайт, приложение, CRM, сервис расчёта заказа, служба доставки и аналитика. Причём знают немного по-разному.
Как простое правило превращается в несколько разных правил
Возьму условный пример, который легко перенести почти на любой интернет-магазин или сервис.
Изначально правило звучит так:
бесплатная доставка доступна при сумме заказа от 3000 рублей.
Разработчик сайта пишет простую проверку: free_delivery = cart_total >= 3000
Через какое-то время мобильное приложение тоже должно показывать стоимость доставки. Там появляется такая же логика.
Потом CRM нужно подсвечивать оператору, должен ли клиент платить за доставку. Кто-то настраивает условие уже внутри CRM.
Ещё через месяц сервис оформления заказа получает собственную проверку, потому что именно он должен записать стоимость доставки в заказ.
В итоге одно бизнес-решение существует как минимум в четырёх местах.
И пока значение 3000 не меняется, архитектурной проблемы никто особенно не замечает.
Потом бизнес решает: с понедельника бесплатная доставка начинается от 3500 рублей.
И вот здесь всё становится интереснее.
Сайт обновили. Бэкенд обновили. Про CRM забыли.
Клиент собирает корзину на 3200 рублей и видит на сайте платную доставку. Потом звонит оператору, а CRM показывает, что доставка должна быть бесплатной.
Обе системы исправны.
Обе выполняют заложенную в них логику.
Просто бизнес-правило у компании теперь существует в двух версиях.
Цена — ещё не самое неприятное расхождение
Если бы проблема ограничивалась числом 3000 или 3500, её можно было бы довольно быстро найти.
В реальности правила обычно начинают обрастать исключениями.
Например, спустя несколько месяцев условие уже выглядит примерно так: free_delivery = ( cart_total >= 3500 and region not in remote_regions and delivery_type != "express" and not marketplace_order )
При этом приложение может знать про сумму и регион, но не учитывать express-доставку.
CRM учитывает express, но ничего не знает о заказах маркетплейса.
Сервис доставки получает уже готовый признак free_delivery, но на всякий случай имеет собственную проверку региона.
А отчёт вообще вычисляет бесплатную доставку постфактум по сумме заказа.
Теперь речь идёт уже не о разных константах.
Системы реализуют разные определения одного понятия.
Это гораздо опаснее.
При обычном баге можно найти неправильную функцию и исправить её. Здесь же каждая функция по отдельности может выглядеть совершенно разумно.
Я бы искала такие проблемы по четырём признакам:
- одинаковое бизнес-понятие вычисляется более чем в одном сервисе;
- значение правила захардкожено в нескольких местах;
- разные команды могут менять условие независимо;
- в событиях передаётся не решение, а набор данных, из которых каждый получатель принимает решение заново.
Последний пункт часто оказывается ключевым.
Если сервис заказов отправляет: { "cart_total": 3200, "region": "A", "delivery_type": "standard" }
а затем CRM, доставка и аналитика сами решают, бесплатная доставка или нет, я уже получила несколько независимых вычислителей одного бизнес-правила.
И рано или поздно они разойдутся.
Я разделяю данные и решение
Здесь есть важная архитектурная граница.
Передать сумму заказа — нормально. Это факт.
Передать регион — тоже нормально.
А вот ответ бесплатная доставка или нет — уже результат применения бизнес-правила.
Если этот ответ влияет на деньги, состояние заказа или обязательства перед клиентом, мне обычно хочется, чтобы существовало одно место, которое имеет право его вычислять.
Допустим, сервис оформления заказа принял решение: { "order_id": "A18452", "delivery_price": 0, "delivery_policy": "free_delivery_v7" }
Теперь CRM не должна повторно решать, бесплатна ли доставка.
Она просто показывает уже принятое решение.
Служба доставки тоже не пересчитывает его по собственным условиям.
А аналитика может увидеть не только результат, но и версию правила, по которой он был получен.
Это маленькая деталь, но она очень помогает через несколько месяцев.
Потому что вопрос почему у этого заказа доставка бесплатная превращается из археологии по нескольким репозиториям в нормальный технический вопрос: какая версия policy сработала?
Централизовать нужно не всё подряд
Здесь легко уйти в другую крайность и построить огромный центральный сервис, без которого половина компании перестанет работать.
Я этого тоже стараюсь избегать.
Не каждое повторяющееся условие является настоящим бизнес-правилом.
Например, проверка, что email содержит допустимый формат, вполне может существовать и на клиенте, и на сервере. У этих проверок разные задачи.
На клиенте она нужна для удобства пользователя.
На сервере — для защиты данных.
Даже если код похож, это не обязательно проблема.
Я задаю себе другой вопрос:
Если две системы получат разные ответы, сможет ли это изменить деньги, права, обязательства или состояние бизнес-процесса?
Если нет — дублирование может быть приемлемым.
Если да — правило уже стоит рассматривать как отдельную сущность.
Особенно внимательно я отношусь к правилам, которые определяют:
- стоимость;
- скидку;
- комиссию;
- доступность операции;
- кредитный или расходный лимит;
- право пользователя на действие;
- переход объекта в следующий статус.
У таких правил обычно должен появиться владелец.
Не владелец файла с кодом, а владелец самого смысла.
Кто может решить, что порог теперь 3500, а не 3000?
Кто определяет исключения?
С какой даты начинает действовать новая версия?
Вот это уже вопросы архитектуры не меньше, чем вопросы бизнеса.
Один источник истины не обязательно означает один сервис
Фраза единый источник истины звучит хорошо, но иногда её понимают слишком буквально.
Совсем не обязательно каждый раз делать отдельный микросервис, чтобы хранить одно условие.
Есть несколько вариантов.
В небольшом приложении правило может жить в одном доменном модуле, а остальные части системы будут обращаться к нему.
В распределённой архитектуре это может быть отдельный policy service.
В некоторых случаях достаточно централизованной конфигурации.
Если правило меняется редко и вычисляется локально, можно распространять версионированную конфигурацию между сервисами.
Главное не технология.
Главное, чтобы существовал один канонический источник определения правила.
Мне важно знать три вещи:
- где находится действующая версия;
- кто имеет право её изменить;
- как остальные системы узнают об изменении.
Если на эти вопросы нельзя быстро ответить, правило, скорее всего, уже размножилось.
Самая коварная копия бизнес-логики часто находится в отчёте
Про это я раньше думала меньше всего.
Допустим, система заказа правильно сохранила: delivery_price = 0
Но аналитик строит отчёт и вместо сохранённого результата вычисляет бесплатную доставку заново: CASE WHEN order_total >= 3500 THEN 1 ELSE 0 END
Кажется безобидным.
Но в этот момент аналитика создала ещё одну реализацию бизнес-правила.
Через месяц порог меняется.
Исторические заказы остаются теми же, а отчёт внезапно начинает классифицировать их уже по новому условию.
Ещё хуже, если в старом правиле существовали исключения по регионам, промокодам или способу доставки.
Тогда отчёт отвечает не на вопрос какую доставку получил клиент, а на совершенно другой:
какую доставку этот заказ получил бы по моему текущему упрощённому правилу.
Это две разные метрики.
Поэтому для состоявшихся бизнес-решений я предпочитаю хранить сам результат решения, а не пытаться вечно воспроизводить его из исходных данных.
Если клиент получил скидку 700 рублей, полезно хранить эти 700 рублей.
Если заказу была назначена бесплатная доставка, полезно хранить этот факт.
Если система приняла решение по версии правила v7, иногда стоит сохранить и версию.
Это делает историю воспроизводимой.
Версия правила решает ещё одну неприятную проблему
Представим, что новое правило бесплатной доставки начинает действовать 1 октября.
Клиент создал заказ 30 сентября, а оплатил 1 октября.
По какой версии считать?
Ответ зависит от бизнеса.
По времени создания корзины?
По созданию заказа?
По оплате?
По моменту передачи в доставку?
С технической точки зрения очень соблазнительно просто брать текущее правило в момент вычисления.
Но тогда повторная обработка старого заказа может дать новый результат.
Например, 30 сентября доставка была бесплатной от 3000 рублей.
Заказ на 3200 рублей получил бесплатную доставку.
Через неделю часть процесса пришлось переиграть.
Если сервис просто возьмёт актуальную конфигурацию, где порог уже 3500, тот же самый заказ неожиданно получит другой результат.
Для меня это хороший сигнал, что правило требует версионирования.
Не просто: free_delivery_threshold = 3500
а логически: policy_v6 → действует до 30 сентября policy_v7 → действует с 1 октября
И объект должен либо помнить применённую версию, либо иметь однозначное бизнес-время, по которому её можно восстановить.
Как я ищу размножившуюся бизнес-логику
Если подозреваю такую проблему, я не начинаю с поиска одинаковых кусков кода.
Копии могут выглядеть совершенно по-разному.
В одном сервисе будет: amount >= 3500
В другом — настройка в CRM.
В третьем — SQL CASE.
В четвёртом — формула внутри low-code сценария.
Строковый поиск здесь мало поможет.
Я начинаю с бизнес-вопроса.
Например:
Кто в нашей системе способен самостоятельно решить, что доставка должна стоить ноль рублей?
После этого прохожу весь путь объекта.
Сайт показывает цену.
API создаёт заказ.
CRM показывает его оператору.
Сервис логистики строит доставку.
Биллинг формирует сумму.
Аналитика считает расходы.
На каждом шаге спрашиваю не где лежат данные, а кто принимает решение.
Если решение принимается в трёх местах, мне уже есть что разбирать.
Проверять нужно не одинаковость кода, а одинаковость ответа
После этого я добавляю ещё один уровень защиты — тесты бизнес-правила.
Обычные unit-тесты конкретного сервиса здесь недостаточны.
Они прекрасно подтвердят, что каждая реализация соответствует собственному коду.
Мне же нужно проверить, что все потребители понимают правило одинаково.
Для правила бесплатной доставки я бы завела набор пограничных сценариев.
Например:
- заказ ровно на пороге;
- на один рубль ниже;
- удалённый регион;
- экспресс-доставка;
- обычная доставка;
- заказ маркетплейса.
И для каждого сценария должен существовать канонический ожидаемый результат.
Если система всё-таки вынуждена реализовывать правило локально, такой набор можно прогонять как contract test.
Тогда изменение policy ломает тест раньше, чем начинает ломать реальные заказы.
Но в идеальном варианте большинство систем вообще не вычисляют решение повторно.
Они получают его от того компонента, которому это решение принадлежит.
Что я бы не делала
Первое — не стала бы сразу переносить всю бизнес-логику в одну гигантскую систему правил.
Так легко получить новый монолит, просто с другим названием.
Второе — не пыталась бы устранить любое дублирование.
Frontend всё равно может иметь предварительную проверку, чтобы мгновенно показать клиенту ориентировочную стоимость. Важно только, чтобы сервер оставался окончательным источником решения.
Третье — не удаляла бы старые реализации за один релиз.
Если правило влияет на деньги, я сначала запустила бы новую реализацию параллельно старой и сравнивала результаты.
Например: old_result = true new_result = true
Ничего интересного.
Но если появляется: old_result = true new_result = false
заказ пока можно обработать по старой схеме, а расхождение отправить в журнал.
Такой shadow-режим помогает поймать исключения, о существовании которых документация давно забыла.
И они почти всегда находятся.
Вывод
Проблема дублирования бизнес-правила не в том, что программисты написали похожий код несколько раз.
Проблема появляется тогда, когда несколько частей системы получают право независимо отвечать на один бизнес-вопрос.
Сегодня их ответы совпадают.
Завтра меняется порог, появляется исключение или новая категория клиентов — и ответы расходятся.
После этого начинается довольно неприятный класс ошибок: сайт показывает одно, CRM другое, расчёт заказа третье, а отчёт спустя месяц пытается объяснить всё четвёртым правилом.
Поэтому я теперь смотрю на бизнес-логику немного иначе.
Если решение влияет на деньги, права или состояние процесса, я хочу понимать, где именно это решение принимается, кто владеет правилом, какая его версия действует и можно ли восстановить причину уже принятого решения.
Самая опасная бизнес-логика — не обязательно сложная.
Иногда это обычное условие из одной строки.
Просто однажды кто-то скопировал эту строку во вторую систему, потом в третью — и через год компания уже не может точно ответить, какое из четырёх одинаковых правил является настоящим.