Ограничения базы данных vs код: где должны жить правила домена
Ограничения в базе и проверки в приложении формируют качество данных, баги и поддержку. Узнайте, что стоит реализовывать в SQL, что — в приложении и как остановить дрейф правил.

Почему всё быстро становится запутанным
Большинство команд не начинают с чёткого разделения между ограничениями в базе и логикой в приложении. Правило появляется в проверке формы. Потом кто-то дублирует ту же проверку в обработчике API. Потом миграция добавляет SQL-ограничение «на всякий случай».
Это какое-то время кажется нормальным. Потом одна версия правила меняется, а другие — нет. Продуктовая команда решает, что отменённые аккаунты могут снова использовать старые имена пользователя. Приложение принимает это, но старый уникальный индекс всё ещё отвергает вставку в проде.
И обратная ситуация тоже встречается. Разработчик ужесточает правило в базе после появления неправильных записей, но приложение продолжает показывать старое сообщение об ошибке и повторять запросы, которые никогда не пройдут. Пользователи видят случайные сбои. На самом деле они совсем не случайны.
Плохие данные также просачиваются через боковые двери. Импорты, админ-скрипты, фоновые задания, воркеры повторов и разовые правки не всегда проходят через ту же логику валидации, что основное приложение. Если правило живёт только в коде, одна короткая «лазейка» может перечеркнуть много проделанной работы.
Команды также тратят время на споры о владении, вместо того чтобы ясно задокументировать правило. Кто-то говорит: «бизнес-логика должна быть в приложении». Другой отвечает: «база — последняя линия защиты». Оба могут быть правы. Истинная проблема проще: никто не назвал инвариант так, чтобы код, SQL и тесты могли им делиться.
Дрейф начинается тихо. Миграция попала в пятницу, изменение сервиса ушло в понедельник, а документация застыла в тикете. Через месяц у команды три версии одного и того же правила и никто им не доверяет.
С ростом продукта это только хуже. Появляются новые скрипты. Больше сервисов трогают одни и те же таблицы. Больше повторов попадает в крайние случаи. Если правило расплывчато, данные тоже станут расплывчатыми.
Что считать инвариантом
Инвариант — это правило, которое должно оставаться истинным каждый раз, когда данные сохраняются, обновляются или читаются. Если правило может быть нарушено на какое-то время в нормальном процессе, скорее всего это не инвариант, а шаг рабочего процесса или побочный эффект.
Запишите каждое правило простой фразой. “У электронной почты пользователя должен быть уникальный адрес.” “Сумма счёта не может быть отрицательной.” “Подписка не может принадлежать удалённому аккаунту.” Простые предложения быстро выявляют слабые правила.
Шаги рабочего процесса звучат иначе. “Отправить приветственное письмо после регистрации” — это не инвариант. Система всё ещё сможет работать, если письмо задержится или отправится повторно. “Платная подписка должна иметь plan_id” — другое дело. Если это правило ломается, сами данные перестают иметь смысл.
Отделяйте постоянные правила от шагов процесса
Простой тест помогает: если это правило нарушится, у вас плохие данные или просто незавершённый процесс? Плохие данные указывают на инвариант. Незавершённый процесс — на логику приложения, задания или обработку событий.
Полезно также спросить, кто может нарушить правило и как. Баг в одном API-рутине может создать пропущенный статус. Массовый импорт может обойти валидацию. Ручное SQL-правку может пропустить проверку безопасности. Когда вы называете путь нарушения, легче решить, где нужна защита.
Некоторые правила локальны и их просто применять рядом с данными. Другие зависят от времени, внешних систем или действий пользователя, и их должен управлять код.
Размер области имеет значение
Сгруппируйте правила по объёму перед принятием решения о месте их реализации. Некоторые правила касаются одной строки, например price >= 0. Некоторые — всей таблицы, например уникальные email или одна активная подписка на аккаунт. Другие пересекают границы систем и зависят от Stripe, почтового сервиса или стороннего API.
Правила на уровне строки и таблицы обычно лучше держать в SQL, потому что каждое записывающее действие проходит через одни и те же проверки. Правила, которые зависят от внешних систем, обычно принадлежат коду, потому что база не видит полной картины.
Короткий список правил лучше громоздкой спецификации. Если команда может прочитать десять простых предложений и согласиться, что нельзя нарушать, большинство споров исчезнут.
Правила, которыми должна управлять база данных
Самые безопасные правила живут там, где каждую запись должен пройти проверку. Если мобильное приложение, админ-панель, фоновое задание и скрипт импорта — все касаются одних и тех же таблиц, только база видит всё.
Начните с фактов, которые должны быть верны для каждой строки. Если запись пользователя всегда нуждается в email, используйте NOT NULL. Если публичный slug должен быть уникальным — UNIQUE индекс. Если скидка должна быть между 0 и 100 — CHECK-ограничение. Эти правила маленькие, строгие и скучные. Именно поэтому SQL должен ими владеть.
Внешние ключи тоже должны быть здесь. Если счёт должен принадлежать клиенту, или подписка указывать на существующий аккаунт, база должна отвергать сиротские строки. Проверки в коде помогают, но они пропускают крайние случаи при повторах, конкурентных запросах или старых скриптах, которые обходят новую логику.
Простое правило: держите идентичность, владение и финансовую безопасность близко к данным. Если нарушение испортит данные, посчитает неправильно сумму или потеряет след того, кому что принадлежит — держите это в базе.
Обычно это значит обязательные поля, уникальность, числовые границы, простые проверки статусов и отношения родитель‑дочерних сущностей. Деньги требуют особого внимания. Если система хранит цены, балансы, возвраты или кредиты, база должна блокировать невозможные значения. Отрицательное количество может быть допустимо в одной таблице и багом в другой. Решите один раз, а затем зафиксируйте.
Правила владения важны по той же причине. Если для рабочей области может существовать только одна активная подписка на определённый план, ограничение в базе сильнее обещания на уровне приложения. Два запроса могут прийти одновременно. Приложение может увидеть «нет активной подписки» дважды. Уникальный индекс остановит вторую вставку.
Правила, которые должна владеть приложением
Некоторые правила слишком часто меняются, зависят от большого контекста или затрагивают много подвижных частей, чтобы безопасно жить в SQL. Правила рабочей логики обычно принадлежат коду. Если продуктовая команда может изменить правило на следующей неделе, не прятайте его в триггер.
Черновики, утверждения, льготные периоды и плановые действия подходят под этот паттерн. Запись может быть черновиком, перейти в ожидание утверждения, истечь через 72 часа, а потом снова открыться при загрузке недостающего документа. База может хранить статус и метки времени. Приложение решает, когда каждый шаг начинается и заканчивается.
Правило также должно быть в коде, когда перед сохранением нужно спросить что-то вне базы. Подумайте о проверке способа оплаты, запросе в антифрод-сервис или верификации налогового номера. SQL должен сохранить ответ сервиса, а не вести диалог.
Сообщения пользователю тоже принадлежат приложению. База может отвергнуть плохие данные, но не должна решать, показывать ли «Пожалуйста, укажите рабочий email» или «Ваш менеджер должен одобрить запрос». Эти сообщения меняются в зависимости от продукта, экрана и аудитории.
Правило обычно лежит в приложении, когда оно зависит от времени, ролей, изменений состояний, внешних API или нескольких записей в определённом порядке. То же самое относится и к тому, что касается очередей или сторонних инструментов.
Держите многошаговые потоки в одном слое сервиса. Если регистрация создаёт аккаунт, запускает триал, отправляет письмо и ставит задачу в очередь — одна часть приложения должна координировать эту работу. Повторы, логирование и тесты становятся гораздо проще.
То же самое применимо к правилам, которые пересекают системы. Если изменение в вашем приложении должно согласовываться с консумером очереди, платежным провайдером и CRM, база сама по себе не удержит всё в согласованном виде. Поместите правило туда, где видна вся цепочка, протестируйте end‑to‑end и изменяйте без рискованных миграций.
Простой тест для каждого правила
Команды часто спорят о совершенно разных вещах как будто они одно и то же. Налоговый расчёт, формат email и жёсткое «никогда не должно происходить» правило данных не принадлежать в одном месте.
Начните с грубого вопроса: нужно ли этому правилу быть верным для каждой записи, кто бы её ни отправил? Если да — база обычно безопаснее. Уникальные email, обязательные внешние ключи, положительные количества и допустимые значения статуса подходят сюда, потому что плохие данные не должны попадать в таблицу.
Затем спросите, кто может обойти приложение. Многие команды думают, что их API всё защищает, но данные также приходят через админ-скрипты, разовые исправления, CSV-импорты, фоновые задания и миграции. Если любой из этих путей пишет строки, проверки только в приложении оставляют дыру.
Третий вопрос делает решение практичным: может ли SQL выразить правило ясно, без странных ухищрений? Простой CHECK, UNIQUE, NOT NULL, FOREIGN KEY или исключающее правило обычно подходит. Если правило требует внешних сервисов, длинных рабочих потоков или контекста из нескольких шагов — держите его в приложении.
Поместите правило в самый нижний слой, который может его чисто обеспечить. Это уменьшает повторения и снижает вероятность, что один путь записи забудет проверку.
Последний шаг экономит много будущего времени. Для каждого правила запишите однострочную заметку в документации схемы, комментарии миграции или инженерных заметках с самим правилом, владельцем и причиной. Например: “Пользователь может иметь только одну активную подписку — владельцем является SQL с частичным уникальным индексом.” Такие маленькие записи упрощают поиск дрейфа при ревью.
Пример: регистрация подписки
Поток регистрации ясно показывает разделение. Пользователь выбирает план, вводит купон и запускает триал. Некоторые правила должны жить в SQL, потому что они защищают запись вне зависимости от того, через какое приложение или скрипт она создана.
База должна отвергнуть строку подписки, если в ней нет user_id, нет plan_id или статус вне допустимого набора, например pending, trialing, active или canceled. Внешние ключи гарантируют, что пользователь и план существуют. CHECK-ограничение держит статус в рамках. Если продукт допускает только одну активную подписку на пользователя, частичный уникальный индекс заблокирует дубликаты даже когда два запроса приходят одновременно.
Это важнее, чем кажется многим командам. Один медленный callback платежа и одно нетерпеливое двойное нажатие могут создать две активные строки. Код приложения часто пропускает такую гонку. SQL — нет.
Другие правила чаще меняются или зависят от внешних систем. Код должен решать, длится ли триал 7 или 14 дней, применим ли купон к этому плану и одобрил ли платёжный провайдер платёж. Эти правила используют политику цен, даты и ответы третьих сторон. Их лучше держать рядом с логикой оформления заказа, где команда может тестировать и менять их без миграции.
Чистый поток прост: код валидирует запрос, купон и окно триала. Код общается с платёжным сервисом, чтобы создать или подтвердить платёж. SQL вставляет или обновляет подписку под строгими ограничениями. Затем код обрабатывает последующую работу: письма, квитанции и попытки биллинга.
Держите эту последнюю часть вне основной транзакции. Если письмо не отправилось, повторите задачу. Если биллинг требует ещё одной попытки, поставьте задачу в очередь снова. Не ослабляйте правила данных ради того, чтобы регистрация продолжилась. Запись подписки должна оставаться корректной даже когда письмо, вебхуки или фоновые воркеры ведут себя плохо.
Как остановить дрейф между кодом и SQL
Дрейф начинается, когда одно и то же правило описано тремя разными способами. В приложении оно называется active_subscription_required, в миграции — chk_sub_status, а в документации — “только платные пользователи”. Через месяц никто не знает, это одно правило или три разных.
Дайте каждому правилу одно понятное имя и используйте его везде: в коде, миграциях, тестах и документации. Если у пользователя должен быть уникальный email — называйте правило users_email_unique везде, где это важно. Эта привычка экономит время на ревью и исправления инцидентов.
Относитесь к изменениям схемы как к продуктовым изменениям
Ручные правки в базе кажутся быстрыми, но они создают тихий ущерб. Кто-то добавляет проверку в проде вручную, другой позже обновляет приложение, и теперь система имеет правило лишь в одном месте.
Проходите каждое изменение ограничения через миграцию, даже если оно кажется маленьким. Ревьюйте файлы миграций так же внимательно, как и код приложения. Плохое изменение схемы может заблокировать регистрации, отвергнуть валидные заказы или впустить плохие данные на недели, прежде чем кто-то заметит.
Не нужно муторных процессов. Дайте каждому правилу одно имя, меняйте ограничения только через миграции, проверяйте диффы миграций в pull request и обновляйте документацию в том же изменении.
Тестируйте приложение и базу отдельно
Многие команды тестируют только счастливый путь через приложение. Это пропускает распространённую проблему: прямые SQL-импорты, админ‑скрипты и фоновые задания могут обходить проверки приложения.
Тестируйте случаи отказа с обеих сторон. Сначала отправьте некорректный ввод через приложение и убедитесь, что пользователь получает понятную ошибку. Потом попробуйте те же плохие данные через прямой SQL и подтвердите, что база их блокирует. Если обе стороны падают по одной причине, правило гораздо менее склонно к дрейфу.
Когда вы заменяете слабую проверку более жёсткой, удаляйте старую. Старые проверки быстро накапливаются. Они сбивают с толку новых разработчиков и иногда отвергают данные по неправильной причине. Если внешний ключ теперь защищает отношение, удалите старую проверку в приложении, которая ничего больше не добавляет.
Команды, которые строго придерживаются этого, обычно видят меньше странных багов с данными. Они не полагаются на память. Они делают правило видимым, проверяемым и тестируемым.
Ошибки, которые приводят к плохим данным
Плохие данные редко начинаются с одного большого провала. Они обычно просачиваются через маленькие обходные пути, которые тогда кажутся безобидными. Форма проверяет одно правило, админ-инструмент его пропускает, фоновая задача забывает — и теперь одна запись может существовать в трёх состояниях.
Валидация в UI — самая распространённая ловушка. Она помогает пользователям, но не защищает базу. Люди импортируют CSV, вызывают API напрямую, запускают скрипты и редактируют записи из внутренних инструментов. Если правило важно для каждой строки, база должна это обеспечить.
Команды также создают дрейф, когда копируют одно правило в каждый сервис. Один сервис обрезает пробелы, другой — нет. Один отвергает дубликаты email, другой разрешает их при массовом импорте. Через несколько месяцев никто не знает, какая версия правильная. Поместите общие инварианты в одно место, а приложение используйте для сообщений и рабочих потоков вокруг них.
Триггеры создают другой вид беспорядка. Они могут решать реальные проблемы, но плохо подходят для простых проверок вроде “значение должно быть положительным” или “статус должен быть одним из этих четырёх”. CHECK, FOREIGN KEY или UNIQUE индекс легче увидеть и протестировать. Когда правило простое, записывайте его там, где любой разработчик быстро его заметит.
Старые nullable‑колонки — ещё один тихий источник плохих строк. Команда решила, что теперь у каждого аккаунта должен быть billing_country, но колонка остаётся nullable из‑за страха перед миграцией. Через полгода половина нового кода предполагает, что поле всегда есть, а старые пути всё ещё пишут NULL. Этот раскол создаёт баги, которые выглядят случайно.
Временные исключения требуют особого подозрения. Обход миграции, который так и не был удалён, скрипт поддержки, который вставляет неполные записи, одноразовое значение статуса «пока что» или флаг API, который пропускает валидацию в проде — всё это может превратиться в постоянную ошибку проектирования.
Стартапы часто попадают в это, когда двигаются быстро или полагаются на инструменты ИИ для генерации кода. Скорость помогает, но свободные правила быстро накапливаются. Если временная дыра остаётся после завершения чрезвычайной ситуации, это уже не временно.
Быстрая проверка перед релизом
Правило не готово только потому, что один экран работает в разработке. Релиз — это момент, когда другие пути начнут затрагивать ваши данные: скрипты, импорты, повторы, админ‑инструменты и конкурентные запросы. Именно там слабые правила ломаются.
Начните с теста обхода. Если кто-то может сделать прямой INSERT в базу и создать запись, которую приложение отвергло бы — правило слишком легко обойти. Поместите инвариант в SQL или поддержите проверку приложения ограничением.
Фоновые задания требуют такого же внимания. Команды часто валидируют ввод в веб-приложении, а потом забывают, что воркер, миграция или скрипт импорта записывают в те же таблицы. Эти пути не заботятся о логике контроллера. Их интересует только то, что принимает база.
Перед релизом сделайте короткий прогон. Попробуйте запись вне обычного потока запросов, например через скрипт или ручной SQL. Отправьте тот же запрос дважды почти одновременно и посмотрите, появятся ли дубликаты. Прочитайте сообщение об ошибке и спросите, поймёт ли другой разработчик, что сломалось. Протестируйте один валидный кейс, который должен сохраниться корректно, и один невалидный кейс, который должен упасть по ожидаемой причине.
Гонки заслуживают особого внимания. Если два запроса могут создать один и тот же аккаунт, купон или подписку одновременно, логики на уровне приложения обычно недостаточно. Добавьте уникальное ограничение или используйте транзакционный паттерн, который блокирует дубликат ещё до записи.
Сообщения об ошибках важнее, чем команды думают. «Валидация не пройдена» почти бесполезно в два часа ночи. “Email должен быть уникальным” или “end_date должен быть позже start_date” экономят время и прекращают догадки.
Если код, тесты и SQL отвергают одни и те же плохие данные — вы в хорошей форме. Если один слой принимает, а другой отвергает — исправьте это несоответствие до релиза. Так начинается дрейф.
Следующие шаги для вашей команды
Начните с правил, которые действительно вредят при нарушении. Запишите десять из них. Выберите те, которые ломают деньги, создают дубликаты, нарушают владение или порождают состояния, которые нельзя исправить вручную за пять минут.
Затем отсортируйте каждое правило по тому, как долго оно должно оставаться верным. Если правило должно соблюдаться при каждой записи — независимо от приложения, скрипта, админ‑инструмента или импорта — поместите его ближе к данным. Обычно это значит ограничение, внешний ключ, уникальный индекс, CHECK или правило транзакции в SQL.
Оставляйте правила в коде, когда они зависят от времени, рабочих процессов или внешних систем. Проверки платёжного шлюза, верификация email, лимиты по скорости, утверждения и feature‑флаги принадлежат приложению, потому что база не видит полной картины.
Простой рабочий план достаточен: перечислите десять самых болезненных правил, пометьте каждое как постоянное, основанное на процессе или зависящее от третьей стороны, сначала перенесите постоянные правила в SQL, оставьте процессные и внешние — в коде, и зафиксируйте окончательного владельца каждого правила в одном месте.
Последний пункт важнее, чем кажется. Короткий реестр правил — достаточно. Одна страница подойдёт. Для каждого правила укажите простую формулировку, где оно живёт, как вы его тестируете и кто в последний раз его менял. Просматривайте эту страницу при обсуждении изменений схемы или новых фич.
Не пытайтесь перенести всё сразу. Выберите две–три болезненные вещи, исправьте разделение и посмотрите на результат в проде. Команды обычно видят меньше краевых багов, меньше заявок в поддержку и меньше споров при ревью.
Если разделение всё ещё кажется расплывчатым, внешнее ревью может помочь, прежде чем путаница затвердеет в виде технического долга. Oleg Sotnikov at oleg.is работает со стартапами и малыми компаниями по архитектуре, инфраструктуре и в роли Fractional CTO, включая практические решения о том, что должно быть в SQL, а что — в коде приложения.
Часто задаваемые вопросы
Стоит ли выносить всю валидацию в базу данных?
Нет. Поместите постоянные правила данных в SQL, а проверки, зависящие от процессов или внешних сервисов — в код. Если правило должно соблюдаться при каждой записи, база должна его вынуждать.
Какие правила SQL должен обеспечивать в первую очередь?
Начните с правил, которые защищают форму данных и владение. NOT NULL, UNIQUE, CHECK и FOREIGN KEY покрывают большую часть первичного этапа, потому что их соблюдают все скрипты, задания и HTTP-вызовы.
Что следует держать в приложении, а не в базе?
Оставляйте в коде то, что часто меняется или требует контекста, которого нет в базе. Длительность триала, политика купонов, потоки утверждений, проверки на мошенничество и ответы платёжных провайдеров лучше реализовывать в приложении.
Как отличить инвариант от шага процесса?
Задайте простой вопрос: если это правило не выполнено, появятся ли плохие данные или просто незавершённый процесс? Плохие данные указывают на инвариант. Незавершённый шаг — на логику приложения, задания или обработку событий.
Почему валидации в UI недостаточно?
Потому что интерфейс можно обойти. Импорты, админ-панели, фоновые задания и ручные скрипты могут напрямую записать строки в базу. Если правило важно для каждой строки, оно должно быть в базе.
Можно ли дублировать проверку и в приложении, и в базе?
Да, но с разделением обязанностей. Пусть SQL блокирует некорректные данные, а приложение превращает это правило в понятное сообщение для пользователя и в читаемый поток.
Как избежать расхождения между кодом и SQL?
Давайте правилу одно имя и используйте его в коде, миграциях, тестах и документации. Меняйте ограничения только через миграции и проверяйте невалидные записи как через приложение, так и прямым SQL-запросом.
Подходят ли триггеры для доменных правил?
Обычно нет. Для простых проверок CHECK, UNIQUE или FOREIGN KEY легче читать, ревьюить и тестировать. Триггеры применяйте только когда правило нельзя выразить простым ограничением.
Как справляться с гонками типа дублирующих подписок?
Используйте ограничение базы данных или транзакционный паттерн, который блокирует дубликаты при записи. Проверки в приложении часто ломаются при двойных кликах, повторных попытках или двух одновременных запросах.
Что нужно протестировать перед релизом?
Попробуйте одну корректную запись и одну некорректную через приложение, затем повторите то же самое прямым SQL или скриптом. Проверьте дублирующие запросы, фоновые задания и читаемость сообщений об ошибках — так вы найдёте пробелы до того, как их увидят пользователи.