Делайте AI‑генерируемые приложения безопаснее: опирайтесь на ограничения PostgreSQL (NOT NULL, CHECK, UNIQUE, FOREIGN KEY) до кода и тестов.

Код, сгенерированный ИИ, часто выглядит правильным, потому что он покрывает «счастливый» путь. Реальные приложения ломаются в серой середине: форма отправляет пустую строку вместо NULL, фоновые задания повторяются и создают одну и ту же запись дважды, или удаление родителя оставляет детей без ссылки. Это не экзотические ошибки. Они проявляются как пустые обязательные поля, дубли «уникальных» значений и сиротские строки, указывающие в никуда.
Они также пролетают через код-ревью и базовые тесты по простой причине: ревьюверы читают намерение, а не каждый крайний случай. Тесты обычно покрывают несколько типичных примеров, а не недели реального поведения пользователей, импорт из CSV, нестабильные сетевые повторы или конкурентные запросы. Если ассистент сгенерировал код, он может пропустить мелкие, но критичные проверки — например, обрезку пробелов, проверку диапазонов или защиту от условий гонки.
«Ограничения первыми, код вторым» означает, что вы помещаете непреложные правила в базу данных, чтобы плохие данные не могли сохраниться, независимо от того, какой путь записи их пытается записать. Приложение всё равно должно валидировать ввод для понятных сообщений об ошибках, но база данных хранит истину. Именно здесь ограничения PostgreSQL оказываются особенно полезны: они защищают от целых классов ошибок.
Короткий пример: представьте небольшой CRM. Скрипт импорта, сгенерированный ИИ, создаёт контакты. В одной строке email — "" (пустой), две строки повторяют один и тот же email с разным регистром, и один контакт ссылается на account_id, которого уже нет, потому что аккаунт был удалён в другом процессе. Без ограничений всё это может попасть в продакшн и позже поломать отчёты.
С правильными правилами базы эти записи не пройдут: записи провалятся сразу, близко к источнику. Обязательные поля не могут быть пропущены, дубли не проскользнут при повторах, связи не будут указывать на удалённые или несуществующие записи, а значения не выйдут за допустимые диапазоны.
Ограничения не решают все баги. Они не исправят запутанный интерфейс, неверный расчёт скидки или медленный запрос. Но они не дадут плохим данным тихо накапливаться — а именно там «краевые» баги, сгенерированные ИИ, часто становятся дорогими.
Ваше приложение редко — это один код и один пользователь. Типичный продукт имеет web UI, мобильное приложение, административные экраны, фоновые задания, импорты из CSV и иногда внешние интеграции. Каждый путь может создавать или изменять данные. Если каждый путь должен помнить одни и те же правила, кто‑то обязательно забудет.
База данных — это место, которое видят все. Когда вы относитесь к ней как к последнему шлагбауму, правила применяются автоматически ко всем путям. Ограничения PostgreSQL переводят «мы предполагаем, что это правда» в «это должно быть истинно, иначе запись отклоняется».
Для кода, сгенерированного ИИ, это особенно важно. Модель может добавить валидацию в React‑форму, но пропустить пограничный случай в фоновом задании. Или она справится с «счастливым» вводом, но сломается, когда реальный пользователь введёт что‑то неожиданное. Ограничения ловят проблемы в момент попытки записать плохие данные, а не недели спустя, когда вы отлаживаете странные отчёты.
Если вы пропускаете ограничения, плохие данные часто проходят молча. Сохранение проходит успешно, приложение продолжает работу, а проблема проявляется позже как тикет в поддержку, несоответствие в биллинге или панель, которой никто не доверяет. Исправление истории дорого, потому что вы правите прошлые записи, а не один запрос.
Плохие данные обычно проскальзывают в повседневных ситуациях: новая версия клиента отправляет поле пустым вместо отсутствующего, повтор приводит к дубликату, админская правка обходит проверки UI, файл импорта имеет неконсистентный формат или два пользователя одновременно меняют связанные записи.
Полезная мысленная модель: принимайте данные только если они валидны на границе. На практике эта граница должна включать базу данных, потому что она видит все записи.
NOT NULL — самое простое ограничение в PostgreSQL, и оно предотвращает удивительно большой класс ошибок. Если значение обязательно для корректного смысла строки, пусть база данных это гарантирует.
NOT NULL обычно верен для идентификаторов, обязательных имён и времённых меток. Если без значения нельзя создать валидную запись, не разрешайте её пустой. В небольшом CRM лид без владельца или времени создания — не «частичный лид», а повреждённые данные, которые позже приведут к странному поведению.
NULL появляется чаще с кодом, сгенерированным ИИ, потому что легко создать «опциональные» ветви, не заметив этого. Поле формы может быть опциональным на UI, API может принимать отсутствующий ключ, и одна ветка функции создания может пропустить присвоение значения. Всё компилируется и тесты для счастья проходят. Потом реальные пользователи импортируют CSV с пустыми ячейками, или мобильный клиент шлёт другой полезный набор данных, и NULL оказывается в базе.
Хорошая практика — комбинировать NOT NULL с осмысленным значением по умолчанию для полей, которыми управляет система:
created_at TIMESTAMP NOT NULL DEFAULT now()status TEXT NOT NULL DEFAULT 'new'is_active BOOLEAN NOT NULL DEFAULT trueУ дефолтов есть ограничения. Не назначайте значение по умолчанию для полей, вводимых пользователем, таких как email или company_name, только чтобы угодить NOT NULL. Пустая строка не «более валидна», чем NULL — она просто скрывает проблему.
Если вы не уверены, решите: действительно ли значение неизвестно, или оно представляет другое состояние. Если «пока не предоставлено» что‑то значит, подумайте о отдельном столбце состояния вместо повсеместного разрешения NULL. Например, оставьте phone nullable, но добавьте phone_status со значениями missing, requested или verified. Это сохраняет смысловую целостность по всему коду.
CHECK‑ограничение — это обещание вашей таблицы: каждая строка всегда должна удовлетворять правилу. Это один из простых способов предотвратить краевые случаи, которые тихо создают записи, выглядящие корректно в коде, но не имеют смысла в реальности.
CHECK лучше всего подходит для правил, которые зависят только от значений в той же строке: числовые диапазоны, допустимые значения и простые связи между столбцами.
-- 1) Totals should never be negative
ALTER TABLE invoices
ADD CONSTRAINT invoices_total_nonnegative
CHECK (total_cents >= 0);
-- 2) Enum-like allowed values without adding a custom type
ALTER TABLE tickets
ADD CONSTRAINT tickets_status_allowed
CHECK (status IN ('new', 'open', 'waiting', 'closed'));
-- 3) Date order rules
ALTER TABLE subscriptions
ADD CONSTRAINT subscriptions_date_order
CHECK (end_date IS NULL OR end_date >= start_date);
Хороший CHECK читается с первого взгляда. Рассматривайте его как документацию для данных. Предпочитайте короткие выражения, понятные имена ограничений и предсказуемые паттерны.
CHECK не для всего. Если правило требует смотреть другие строки, агрегировать данные или сравнивать между таблицами (например, «аккаунт не может превысить лимит по плану»), держите такую логику в коде приложения, триггерах или контролируемой фоновой задаче.
UNIQUE прост: база откажется сохранить две строки с одинаковым значением в ограниченном столбце (или с одинаковой комбинацией значений по нескольким столбцам). Это устраняет целый класс багов, когда путь «создать» срабатывает дважды, происходит повтор или два пользователя отправляют одно и то же одновременно.
UNIQUE гарантирует отсутствие дубликатов для точно определённых вами значений. Оно не гарантирует присутствие значения (NOT NULL), соответствие формату (CHECK) или то, что оно соответствует вашей идее равенства (регистр, пробелы, пунктуация), если вы это явно не определили.
Обычные места для уникальности: email в таблице пользователей, external_id из внешней системы или имя, которое должно быть уникально в рамках аккаунта, например (account_id, name).
Особенность: NULL и UNIQUE. В PostgreSQL NULL трактуется как «неизвестно», поэтому несколько NULL допустимы под UNIQUE. Если вы имеете в виду «значение должно существовать и быть уникальным», комбинируйте UNIQUE с NOT NULL.
Практичный паттерн для идентификаторов, видимых пользователю — уникальность без учёта регистра. Люди напишут «[email protected]», а позже «[email protected]» и будут ожидать, что это один и тот же адрес.
-- Case-insensitive unique email
CREATE UNIQUE INDEX users_email_unique_ci
ON users (lower(email));
-- Unique contact name per account
ALTER TABLE contacts
ADD CONSTRAINT contacts_account_name_unique UNIQUE (account_id, name);
Определите, что для ваших пользователей значит «дубликат» (регистр, пробелы, локальность), а затем зафиксируйте это один раз, чтобы все пути записи следовали одному правилу.
FOREIGN KEY говорит: «эта строка должна ссылаться на реальную строку в другой таблице». Без него код может тихо создавать сиротские записи, которые в изоляции выглядят корректно, но потом ломают приложение. Например: заметка ссылается на удалённого клиента или счёт ссылается на несуществующий user_id.
Внешние ключи особенно важны, когда два действия происходят рядом: удаление и создание, повтор после тайм‑аута или фоновая задача со «старой» информацией. База лучше следит за целостностью, чем каждая ветвь приложения по отдельности.
Опция ON DELETE должна соответствовать реальному значению связи. Спросите себя: «Если родитель исчезает — должен ли ребёнок продолжать существовать?»
RESTRICT (или NO ACTION): запрещает удалять родителя, если у него есть дети.CASCADE: удаление родителя удаляет и детей.SET NULL: ребёнок остаётся, но связь удаляется.Осторожнее с CASCADE. Иногда это верно, но иногда приводит к удалению большего объёма данных, чем ожидалось, если ошибка или действие администратора удаляет родителя.
В мульти‑тенантных приложениях внешние ключи — это не только корректность. Они предотвращают утечки между аккаунтами. Распространённый паттерн — иметь account_id в каждой таблице, которой владеет арендатора, и связывать отношения через него.
CREATE TABLE contacts (
account_id bigint NOT NULL,
id bigint GENERATED ALWAYS AS IDENTITY,
PRIMARY KEY (account_id, id)
);
CREATE TABLE notes (
account_id bigint NOT NULL,
id bigint GENERATED ALWAYS AS IDENTITY,
contact_id bigint NOT NULL,
body text NOT NULL,
PRIMARY KEY (account_id, id),
FOREIGN KEY (account_id, contact_id)
REFERENCES contacts (account_id, id)
ON DELETE RESTRICT
);
Это закрепляет в схеме «кто чем владеет»: заметка не сможет ссылаться на контакт из другого аккаунта, даже если код приложения (или сгенерированный LLM‑запрос) попытается так сделать.
Начните с кратного списка инвариантов: фактов, которые всегда должны быть верны. Формулируйте их просто. «Каждому контакту нужен email». «Статус должен быть одним из разрешённых». «Счёт должен принадлежать реальному клиенту». Это те правила, которые вы хотите, чтобы база гарантировала всегда.
Выпускайте изменения маленькими миграциями, чтобы не удивлять продакшн:
NOT NULL, UNIQUE, CHECK, FOREIGN KEY).Самая сложная часть — существующие плохие данные. Планируйте это заранее. Для дубликатов выберите «победителя», объедините остальные и оставьте небольшую запись аудита. Для пропущенных обязательных полей назначайте дефолт только если это действительно безопасно; иначе помещайте в карантин. Для сломанных связей либо переназначайте дочерние строки на правильного родителя, либо удаляйте неверные строки.
После каждой миграции протестируйте с несколькими записями, которые должны провалиться: попытайтесь вставить строку с пропущенным обязательным значением, вставить дубликат, вставить значение вне диапазона и сослаться на несуществующего родителя. Неуспешные записи — полезный сигнал. Они показывают, где приложение молча полагалось на «наилучшее усилие».
Представьте небольшой CRM: аккаунты (каждый покупатель вашего SaaS), компании, с которыми они работают, контакты в этих компаниях и сделки, привязанные к компании.
Это именно тот тип приложения, который люди быстро генерируют с помощью чат‑инструмента. В демо всё выглядит нормально, но реальные данные быстро становятся «грязными». Две ошибки появляются рано: дубли контактов (тот же email введён дважды с небольшими отличиями) и сделки, созданные без компании, потому что в одном пути забыли установить company_id. Ещё классическая ошибка — отрицательная сумма сделки после рефактора или ошибки парсинга.
Решение — не больше if‑ов. Это несколько правильно выбранных ограничений, которые делают невозможным сохранение плохих данных.
-- Contacts: prevent duplicates per account
ALTER TABLE contacts
ADD CONSTRAINT contacts_account_email_uniq UNIQUE (account_id, email);
-- Deals: require a company and keep the relationship valid
ALTER TABLE deals
ALTER COLUMN company_id SET NOT NULL,
ADD CONSTRAINT deals_company_fk
FOREIGN KEY (company_id) REFERENCES companies(id);
-- Deals: deal value cannot be negative
ALTER TABLE deals
ADD CONSTRAINT deals_value_nonneg CHECK (deal_value >= 0);
-- A few obvious required fields
ALTER TABLE companies
ALTER COLUMN name SET NOT NULL;
ALTER TABLE contacts
ALTER COLUMN email SET NOT NULL;
Речь не о строгости ради строгости. Вы переводите расплывчатые ожидания в правила, которые база данных будет применять всегда, независимо от того, какая часть приложения записывает данные.
Когда ограничения установлены, приложение становится проще. Можно убрать много защитных проверок, которые пытались обнаружить дубликаты постфактум. Ошибки становятся явными и понятными (например, «email уже существует для этого аккаунта» вместо странного поведения дальше по цепочке). И когда сгенерированный API‑маршрут забывает поле или неправильно обрабатывает значение, запись проваливается сразу, вместо тихого порчи данных.
Ограничения работают лучше всего, когда они соответствуют реальной работе бизнеса. Большая часть проблем возникает от того, что добавляют правила, которые в момент кажутся «безопасными», но позже превращаются в сюрпризы.
Частая опасность — повсюду ставить ON DELETE CASCADE. Это выглядит аккуратно, пока кто‑то не удалит родителя и база не удалит половину системы. Каскады подходят для действительно принадлежащих данных (например, черновики строк заказа, которые не должны существовать отдельно), но рискованы для важных сущностей (клиенты, счета, тикеты). Если вы не уверены — предпочитайте RESTRICT и обрабатывайте удаление преднамеренно.
Ещё одна проблема — слишком узкие CHECK‑правила. «Status должен быть ‘new’, ‘won’ или ‘lost’» звучит нормально, пока не понадобится ‘paused’ или ‘archived’. Хороший CHECK описывает стабильную истину, а не временный выбор интерфейса. Правило типа amount >= 0 живёт долго. Списки стран часто устаревают.
Повторяющиеся ошибки при добавлении ограничений уже после запуска:
CASCADE как инструмента очистки, затем непреднамеренное удаление слишком большого объёма данных;Про производительность: PostgreSQL автоматически создаёт индекс для UNIQUE, но внешние ключи не индексируют ссылочный столбец автоматически. Без такого индекса обновления и удаления родителя могут замедлиться, потому что Postgres вынужден сканировать дочернюю таблицу, чтобы проверить ссылки.
Перед ужесточением правила найдите существующие строки, которые ему не соответствуют, решите — исправить или поместить в карантин, и выкатывайте изменение по шагам.
Перед релизом уделите пять минут на каждую таблицу и запишите, что всегда должно быть правдой. Если вы можете сформулировать это простым английским (или русским), обычно это можно выразить ограничением.
Задайте для каждой таблицы вопросы:
Если вы используете инструмент сборки через чат, относитесь к этим инвариантам как к критериям приёмки для данных, а не к опциональным заметкам. Например: «Сумма сделки должна быть неотрицательной», «Email контакта уникален в рабочей области», «Задача должна ссылаться на реальный контакт». Чем явнее правила, тем меньше места для случайных краевых случаев.
Koder.ai (koder.ai) включает функции вроде режима планирования, снимков и отката, а также экспорт исходного кода, что упрощает итерации над изменениями схемы и безопасное ужесточение ограничений со временем.
Простой шаблон выката, который работает в реальных командах: выберите одну таблицу с высокой ценностью (users, orders, invoices, contacts), добавьте 1–2 ограничения, которые предотвращают худшие падения (обычно NOT NULL и UNIQUE), исправьте записи, которые проваливаются, затем повторяйте. Постепенное ужесточение правил лучше одной большой рискованной миграции.