Алексей Меленчук
вся записка
узел 01 · Ядро
дата
объём 6 мин чтения

Где на самом деле живёт бизнес-логика

Одно и то же правило оказывается реализовано в базе, в сервисе и на клиенте — с тремя разными поведениями. Разбор того, кто и что должен вынуждать.

Коротко. Вопрос не в том, где логике место, а в том, куда она расползается, если никто не решил. Инвариант живёт там, где его можно вынудить под конкуренцией, — и таких мест меньше, чем кажется.

Возьмите любое правило вашей системы. Скажем: у пользователя не может быть двух активных подписок.

Теперь найдите, где оно записано. С высокой вероятностью в трёх местах. На форме — чтобы кнопка гасла. В сервисе — проверкой перед вставкой. И, если повезло, уникальным индексом в базе.

Три реализации одного правила. Вопрос, который решает всё: они согласованы?

Три места вынуждают по-разному

Разница между ними не в слоях архитектуры, а в том, что каждое видит и в какой момент срабатывает.

Клиент видит одного пользователя и одну вкладку. Он не знает о втором запросе, который тот же человек отправил из другого браузера. Клиентская проверка — это интерфейс: она объясняет, почему кнопка не нажимается, и экономит сетевой обмен. Ничего вынудить она не может в принципе, потому что запрос можно отправить мимо неё.

Это не спорный тезис, но следствие из него игнорируют постоянно: клиентская валидация не имеет права быть единственной. Ни для одного правила. Никогда.

Сервис видит один запрос в одном процессе. Проверка «сначала посчитаем, потом вставим» корректна ровно до того момента, когда таких процессов становится два. Между SELECT и INSERT есть окно, и второй запрос попадает в него. Не «может попасть» — попадёт, вопрос частоты.

Классическое возражение: «мы обернём в транзакцию». Транзакция сама по себе не спасает. На уровне изоляции READ COMMITTED, который стоит по умолчанию в PostgreSQL, две транзакции спокойно прочитают отсутствие записи и обе вставят. Нужна либо блокировка, либо уникальное ограничение, либо SERIALIZABLE с готовностью ловить откаты и повторять.

База видит всех писателей сразу. Это её единственное, но решающее преимущество: она физически стоит на пути каждой записи. Уникальный индекс не проверяет правило — он делает нарушение невозможным.

Что база умеет выразить

Класс уже, чем хотелось бы, и в этом вся сложность.

Уникальность, ссылочная целостность, CHECK на значения строки, ограничения исключения для пересекающихся диапазонов, NOT NULL. Этого хватает на удивление много где: непересекающиеся брони, один активный договор, неотрицательный остаток.

Дальше начинается территория триггеров и хранимых процедур. И вот здесь у меня позиция, с которой многие не согласятся: логику в триггерах я считаю проигрышем почти всегда, и причина не в производительности.

Триггер — это код, выпавший из ваших инструментов. Он не проходит код-ревью вместе с остальным изменением. Он не покрыт тестами так, как покрыт сервис. Он не откатывается вместе с релизом. И он невидим: разработчик читает функцию сервиса, видит INSERT, и не знает, что после вставки произошло ещё три вещи.

Ограничение — другое дело. Ограничение декларативно, оно не выполняет код, и его нарушение приходит к вам понятной ошибкой, которую можно поймать.

Что происходит, когда никто не решил

Логика оседает там, куда её толкнул ближайший срок.

Правило про подписку появилось на форме, потому что задача пришла от дизайнера. Через полгода нашли обход через API — добавили проверку в сервис. Ещё через год обнаружили дубли в отчёте — повесили индекс, и он не создался, потому что дубли уже есть. Их почистили руками, скриптом, который никто не сохранил.

Теперь правило существует в трёх местах и ведёт себя по-разному: на форме — одно, в API — другое, в базе — третье. Никто не может сказать, какое из трёх правильное, потому что правильного нет: есть три исторических слоя.

Это не история про плохих разработчиков. Каждый из трёх шагов был разумным в свой момент. Плохо то, что решение «где живёт это правило» никогда не принималось явно.

Что делать, когда база не может

Самая частая ситуация — правило есть, ограничением не выражается. «Сумма позиций заказа не должна превышать лимит клиента»: тут два агрегата, и никакой CHECK этого не увидит.

Три рабочих подхода, по возрастанию цены.

Блокировка агрегата. Перед проверкой берём строку клиента на запись — SELECT ... FOR UPDATE. Все, кто хочет менять заказы этого клиента, выстраиваются в очередь. Просто, надёжно, и упирается в пропускную способность по одному клиенту.

Оптимистическая версия. У клиента есть номер версии, он входит в условие обновления. Разошлись — операция не прошла, повторяем. Дешевле блокировки при редких конфликтах и дороже при частых.

Один обработчик на ключ. Изменения по клиенту идут через очередь с партиционированием по идентификатору клиента. Конкуренции нет по построению. Самый дорогой вариант: он меняет модель взаимодействия с синхронной на асинхронную, и это видно пользователю.

Выбор между тремя определяется частотой конфликтов, а её надо посмотреть, а не угадать. Если два запроса по одному клиенту сталкиваются раз в месяц, оптимистическая версия бесплатна. Если постоянно — она превращается в бесконечный цикл повторов.

Отдельно про валидацию и инвариант

Их путают, и от этого половина споров.

Валидация — проверка входных данных: строка не пустая, число положительное, дата в будущем. Она смотрит на один запрос и не требует знания о состоянии системы. Её место — на входе, и дублировать её на клиенте нормально.

Инвариант — утверждение о состоянии системы, которое должно быть истинным всегда. «У клиента не больше одной активной подписки» — инвариант. Он требует знания обо всех остальных записях и о том, что кто-то может менять их прямо сейчас.

Валидацию можно проверять где угодно. Инвариант — только там, где видны все изменения. Из этого различия и растёт всё остальное: валидация в сервисе — норма, инвариант в сервисе — обещание, которое он не может сдержать под конкуренцией.

Как я это раскладываю

Одно решение, принятое один раз, и записанное:

Инварианты, которые база умеет выразить, живут в базе. Уникальность, ссылки, диапазоны. Сервис при этом не перестаёт проверять — он проверяет для того, чтобы вернуть человеку понятную ошибку, а не для того, чтобы гарантировать. Гарантирует ограничение. Сервис ловит его нарушение и переводит на человеческий язык.

Всё остальное живёт в сервисе, и там же живёт вопрос конкуренции. Если правило нельзя выразить ограничением, значит защищать его придётся блокировкой, версией строки или очередью с одним обработчиком на ключ. Это работа, и её надо сделать осознанно, а не надеяться, что два запроса не встретятся.

Клиент не владеет ничем. Он дублирует проверку ради скорости отклика и не является источником правды ни для одного правила.

Когда это можно игнорировать. Если у вас ровно один процесс-писатель и он таким останется — конкуренции нет, и половина рассуждения теряет смысл. Только проверьте это утверждение, прежде чем на него опереться: «один процесс» перестаёт быть правдой в день, когда кто-то поднимает второй экземпляр ради отказоустойчивости.