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

Почему сбои ограничений так раздражают пользователей
Когда кто‑то нажимает Сохранить, он ожидает один из двух результатов: получилось или можно быстро исправить то, что не так. Чаще пользователи видят общий баннер вроде «Запрос не удался» или «Что‑то пошло не так». Форма остаётся прежней, ничего не подсвечено, и приходится гадать.
Именно этот разрыв делает важным UX ошибок ограничений базы данных. База данных применяет правила, о которых пользователь не знал: «это значение должно быть уникальным», «запись должна ссылаться на существующий объект», «это поле не может быть пустым». Если приложение скрывает эти правила за расплывчатой ошибкой, людям кажется, что их винят за то, чего они не понимают.
Общие ошибки также подрывают доверие. Пользователи думают, что приложение ненадёжно, поэтому повторяют попытки, обновляют страницу или бросают задачу. В рабочем контексте они пишут в поддержку со скриншотами, которые ничего не проясняют, потому что на снимке нет полезной информации.
Типичный пример: кто‑то создаёт запись клиента и получает «Сохранение не удалось». Он снова пытается с тем же email — и снова провал. Теперь остаются сомнения: дублирует ли система данные, теряет ли или и то, и другое.
База часто является последним источником правды, даже если вы валидируете на UI. Она видит актуальное состояние, включая изменения от других пользователей, фоновые задачи и интеграции. Поэтому сбои ограничений будут происходить — и это нормально.
Хороший результат прост: превратите правило базы в сообщение, которое указывает на конкретное поле и следующий шаг. Например:
- «Этот email уже используется. Попробуйте другой email или войдите в систему.»
- «Выберите существующий счёт. Выбранный аккаунт больше не существует.»
- «Требуется номер телефона.»
Дальше в статье — как выполнять этот перевод, чтобы сбои превращались в быстрое восстановление, независимо от того, кодите вы стек вручную или используете инструмент вроде AppMaster.
Типы ограничений, с которыми вы столкнётесь (и что они означают)
Большинство моментов с «запрос не удался» происходят из небольшого набора правил базы данных. Если вы можете назвать правило, вы обычно можете превратить его в понятное сообщение рядом с нужным полем.
Вот типичные ограничения простыми словами:
- Уникальное ограничение (Unique constraint): значение должно быть уникальным. Примеры: email, username, номер счета или внешний ID. При сбое пользователь не «сделал что‑то не так», он столкнулся с уже существующими данными.
- Внешний ключ (Foreign key constraint): одна запись ссылается на другую, которая должна существовать (например,
order.customer_id). Сбой случается, когда ссылка удалена, никогда не существовала или UI передал неправильный ID. - NOT NULL: обязательное значение отсутствует на уровне базы. Это может случиться, даже если форма выглядит заполненной (например, UI не отправил поле, или API перезаписал его).
- Check: значение выходит за допустимые рамки, например «quantity must be > 0», «status должен быть одним из этих значений» или «скидка между 0 и 100».
Сложность в том, что одна и та же реальная проблема может выглядеть по‑разному в зависимости от базы и инструментов. Postgres может давать понятное имя ограничению, а ORM — обёртку с общей исключительной ситуацией. Одна и та же уникальность может проявляться как «duplicate key», «unique violation» или через код ошибки вендора.
Практический пример: кто‑то редактирует клиента в админке, нажимает Сохранить и получает отказ. Если API может сказать UI, что это было уникальное ограничение на email, вы можете показать «Этот email уже используется» под полем Email вместо расплывчатого тоста.
Рассматривайте тип ограничения как подсказку о том, что пользователь может сделать дальше: выбрать другое значение, выбрать существующую связанную запись или заполнить обязательное поле.
Что должно делать хорошее сообщение на уровне поля
Сбой ограничения базы — это техническое событие, но опыт должен быть как обычная подсказка. Хороший UX превращает «что‑то сломалось» в «вот что исправить», без лишних догадок.
Используйте простой язык. Меняйте базовые термины вроде «unique index» или «foreign key» на понятные людям формулировки. «Этот email уже используется» гораздо полезнее, чем «duplicate key value violates unique constraint».
Показывайте сообщение там, где происходит действие. Если ошибка явно относится к одному вводу, прикрепите её к этому полю, чтобы пользователь мог исправить тут же. Если ошибка относится ко всему действию (например, «нельзя удалить, потому что это используется в другом месте»), покажите её на уровне формы с понятным следующим шагом.
Конкретность лучше вежливости. Полезное сообщение отвечает на два вопроса: что нужно изменить и почему это отклонили. «Выберите другой username» лучше, чем «Неверный username». «Выберите клиента перед сохранением» лучше, чем «Данные отсутствуют».
Будьте осторожны с конфиденциальными деталями. Иногда самое «полезное» сообщение раскрывает лишнюю информацию. На экране входа или сброса пароля фраза «Учётная запись для этого email не найдена» может помочь злоумышленникам. В таких случаях используйте более безопасные формулировки: «Если учётная запись совпадает с этим email, вы получите сообщение».
Также планируйте одновременные ошибки. Одно сохранение может провалиться по нескольким ограничениям. UI должен уметь показать несколько сообщений для полей одновременно, не перегружая экран.
Сильное сообщение на уровне поля использует простые слова, указывает правильное поле (или ясно показывает, что это ошибка формы), говорит, что изменить, не раскрывает приватные факты и поддерживает множественные ошибки в одном ответе.
Спроектируйте контракт ошибок между API и UI
Хороший UX начинается с соглашения: когда что‑то падает, API говорит UI точно, что произошло, а UI показывает это одинаково каждый раз. Без контракта вы вернётесь к общему тосту, который никому не помогает.
Практичный формат ошибки небольшой, но специфичный. Он должен содержать стабильный код ошибки, поле (если оно привязано к одному вводу), читабельное сообщение и опциональные детали для логов.
{
"error": {
"code": "UNIQUE_VIOLATION",
"field": "email",
"message": "That email is already in use.",
"details": {
"constraint": "users_email_key",
"table": "users"
}
}
}
Ключ в стабильности. Не показывайте пользователям сырые тексты базы и не заставляйте UI парсить строки ошибок Postgres. Коды должны быть одинаковыми на всех платформах (web, iOS, Android) и во всех эндпоинтах.
Решите заранее, как вы представляете ошибки полей и ошибки формы. Ошибка поля значит, что один ввод заблокирован (установите field, показывайте сообщение под полем). Ошибка формы значит, что действие не может быть завершено, хотя поля выглядят валидными (оставьте field пустым, покажите сообщение рядом с кнопкой Сохранить). Если несколько полей могут провалиться одновременно, возвращайте массив ошибок, каждая с собственным field и code.
Чтобы рендер был предсказуемым, сделайте правила UI скучными и предсказуемыми: показывайте первую ошибку вверху как короткое резюме и инлайн у поля, держите сообщения короткими и действующими, переиспользуйте одинаковую формулировку в разных потоках (регистрация, редактирование профиля, админка) и логируйте details, показывая пользователю только message.
Если вы используете AppMaster, относитесь к этому контракту как к любому другому выходу API. Ваш бэкенд может возвращать структурированную форму, а сгенерированные веб (Vue3) и мобильные приложения могут отрисовывать её одним шаблоном, чтобы каждое ограничение выглядело как подсказка, а не как падение системы.
Шаг за шагом: перевод ошибок БД в сообщения полей
Хороший UX ошибок ограничений начинается с принятия базы как последнего судьи, а не первой линии проверки. Пользователь никогда не должен видеть сырые SQL‑тексты, трассировки стека или расплывчатое «запрос не удался». Он должен видеть, какое поле требует внимания и что делать дальше.
Практичный поток, который работает в большинстве стеков:
- Решите, где перехватывать ошибку. Выберите одно место, где ошибки базы превращаются в ответы API (обычно репозиторий/DAO или глобальный обработчик ошибок). Это предотвращает хаос «иногда инлайн, иногда тост».
- Классифицируйте сбой. При отказе записи определяйте класс: уникальное ограничение, внешний ключ, NOT NULL или check. Используйте коды драйвера, когда возможно. Избегайте парсинга человекочитаемого текста, если есть альтернатива.
- Сопоставляйте имена ограничений с ключами полей. Ограничения — отличные идентификаторы, но UI нужны ключи полей. Держите простой lookup, например
users_email_key -> emailилиorders_customer_id_fkey -> customerId. Поместите его рядом с кодом, который владеет схемой. - Генерируйте безопасное сообщение. Стройте короткий пользовательский текст по классу ошибки, а не по сырым сообщениям БД. Unique -> «Это значение уже используется.» FK -> «Выберите существующего клиента.» NOT NULL -> «Это поле обязательно.» Check -> «Значение выходит за допустимый диапазон.»
- Возвращайте структурированные ошибки и отрисовывайте их инлайн. Отправляйте предсказуемый payload (например:
[{ field, code, message }]). В UI прикрепляйте сообщения к полям, пролистывайте и фокусируйте первое ошибочное поле, а глобальный баннер оставляйте только как краткое резюме.
Если вы строите с AppMaster, примените ту же идею: перехватывайте ошибку БД в одном месте бэкенда, переводите её в предсказуемый формат ошибок полей, затем показывайте рядом с вводом в web или mobile UI. Это сохраняет опыт постоянным, даже если модель данных меняется.
Реалистичный пример: три проваленных сохранения — три полезных исхода
Эти отказы часто сводят к одному общему тосту. Но каждому нужен свой ответ, даже если все они исходят от базы.
1) Регистрация: email уже используется (уникальное ограничение)
Сырой отказ (что вы видите в логах): duplicate key value violates unique constraint "users_email_key"
Что должен увидеть пользователь: «Этот email уже зарегистрирован. Попробуйте войти или используйте другой email.»
Покажите сообщение рядом с полем Email и сохраните заполненную форму. Если можно, предложите второе действие вроде «Войти», чтобы не оставлять пользователя в догадках.
2) Создать заказ: отсутствует клиент (внешний ключ)
Сырой отказ: insert or update on table "orders" violates foreign key constraint "orders_customer_id_fkey"
Что должен увидеть пользователь: «Выберите клиента для создания заказа.»
Это не выглядит как «ошибка» — это нехватка контекста. Подсветьте селектор Клиента, сохраните уже добавленные позиции, и если клиент был удалён в другой вкладке, скажите прямо: «Этот клиент больше не существует. Выберите другого.»
3) Обновление профиля: отсутствует обязательное поле (NOT NULL)
Сырой отказ: null value in column "last_name" violates not-null constraint
Что должен увидеть пользователь: «Фамилия обязательна.»
Так выглядит хорошая обработка ограничений: обычный фидбэк формы, а не системный сбой.
Чтобы помочь поддержке, не раскрывая технических деталей пользователю, храните полный текст ошибки в логах (или во внутренней панели ошибок): включайте request ID и user/session ID, имя ограничения (если есть) и таблицу/поле, полезную нагрузку API (маскируйте чувствительные поля), метку времени, endpoint/action и сообщение, показанное пользователю.
Внешние ключи: помогите пользователю восстановиться
Ошибки внешнего ключа чаще всего означают, что человек выбрал что‑то, чего больше нет, что больше не разрешено или не соответствует текущим правилам. Цель — не просто объяснить сбой, а дать понятный следующий шаг.
Большую часть времени внешнему ключу соответствует одно поле: селектор, ссылающийся на другую запись (Customer, Project, Assignee). Сообщение должно называть объект, который узнает пользователь, а не концепт базы. Избегайте внутренних ID или имён таблиц. «Клиент больше не существует» полезно. «FK_orders_customer_id violated (customer_id=42)» — нет.
Надёжный сценарий восстановления трактует ошибку как устаревший выбор. Предложите пользователю заново выбрать из актуального списка (обновите dropdown или откройте поисковый селектор). Если запись была удалена или заархивирована — скажите прямо и направьте к альтернативе. Если у пользователя нет доступа, объясните: «У вас больше нет прав на этот объект» и предложите выбрать другой или обратиться к администратору. Если создание связанной записи — нормальный следующий шаг, предложите «Создать нового клиента» вместо принудительного повтора попытки.
Удалённые и заархивированные записи — частая ловушка. Если UI может показывать неактивные элементы для контекста, помечайте их (Archived) и не позволяйте выбирать. Это предотвращает ошибку заранее, но всё равно нужно обрабатывать случаи, когда другой пользователь изменил данные.
Иногда ошибку внешнего ключа следует показывать как ошибку формы, а не поля. Делайте так, когда нельзя надёжно сказать, какая ссылка сломалась, когда несколько ссылок недействительны или когда реальная проблема — это права на выполнение действия в целом.
NOT NULL и валидация: предотвращайте ошибку, но всё равно обрабатывайте её
NOT NULL — самое простое для предотвращения и самое раздражающее, когда проскакивает. Если пользователь видит «запрос не удался» после оставления обязательного поля пустым, база выполняет работу UI. Хороший UX означает, что UI блокирует очевидные случаи, а API всё равно возвращает понятные полевые ошибки, когда что‑то проскользнуло.
Начните с ранних проверок в форме. Помечайте обязательные поля рядом с вводом, а не только в общем списке. Короткая подсказка вроде «Нужно для чеков» полезнее одной красной звёздочки. Если поле условно обязательно (например, «Название компании» только когда «Тип счёта = Бизнес»), делайте правило видимым в момент, когда оно становится актуальным.
Валидация на UI недостаточна. Пользователи могут обойти её устаревшими версиями приложений, нестабильной сетью, массовыми импортами или автоматизацией. Дублируйте правила в API, чтобы не тратить круг запрос‑ответ только ради отказа на уровне базы.
Согласуйте формулировки по всему приложению, чтобы люди понимали, чего ждать. Для отсутствующих значений пишите «Обязательно». Для лимита длины — «Слишком длинно (макс 50 символов)». Для формата — «Неверный формат (используйте [email protected])». Для типа — «Должно быть числом.»
Частичные обновления усложняют NOT NULL. PATCH, который опускает обязательное поле, не должен падать, если в базе уже есть значение, но должен падать, если клиент явно установил null или пустое значение. Решите это правило один раз, задокументируйте и применяйте последовательно.
Практичный подход — валидировать на трёх уровнях: правила формы на клиенте, валидация запроса в API и финальная страховка, которая ловит ошибку NOT NULL в базе и сопоставляет её с нужным полем.
Частые ошибки, которые возвращают к «запрос не удался»
Самый быстрый путь испортить обработку ограничений — сделать всю жёсткую работу в базе, а затем скрыть результат за общим тостом. Пользователю всё равно, что сработало ограничение. Ему важно понять, что исправить, где и в безопасности ли его данные.
Один распространённый промах — показывать сырые тексты базы. Сообщения вроде duplicate key value violates unique constraint выглядят как падение, даже когда приложение может восстановиться. Они также создают тикеты в поддержку, потому что пользователи копируют страшный текст вместо исправления поля.
Ещё одна ловушка — полагаться на сопоставление строк. Это работает до тех пор, пока вы не смените драйвер, не обновите Postgres или не переименуете ограничение. Тогда ваше «email уже используется» сопоставление тихо слетает, и вы снова возвращаетесь к «запрос не удался». Предпочитайте стабильные коды ошибок и включайте имя поля, понятное UI.
Изменения схемы ломают сопоставление полей чаще, чем ожидают. Переименование email в primary_email может превратить ясное сообщение в данные без места для отображения. Делайте сопоставление частью того же набора изменений, что и миграция, и тестируйте, чтобы падение по неизвестному ключу не прошло незамеченным.
Большой убийца UX — превращение каждого сбоя ограничения в HTTP 500 без тела. Это говорит UI «это ошибка сервера», и он не может показать подсказки по полям. Большинство ошибок ограничений можно исправить пользователем, поэтому возвращайте валидационный ответ с деталями.
Несколько паттернов, за которыми стоит следить:
- Сообщения об уникальном email, которые подтверждают существование аккаунта (в регистрационных потоках используйте нейтральную формулировку)
- Обработка «по одной ошибке за раз» и скрытие второй сломанной поля
- Многошаговые формы, которые теряют ошибки после переходов назад/вперёд
- Повторы, которые отправляют устаревшие значения и затирают правильные сообщения полей
- Логи, которые теряют имя ограничения или код ошибки, усложняя расследование
Например, если форма регистрации говорит «Email уже существует», вы можете непреднамеренно подтвердить наличие аккаунта. Безопаснее — «Проверьте почту или попробуйте войти», при этом всё равно прикрепив ошибку к полю email.
Бычная чек-лист перед релизом
Перед выпуском проверьте мелочи, которые решают, будет ли сбой ограничений полезной подсказкой или тупиком.
Ответ API: может ли UI действительно на него среагировать?
Убедитесь, что каждая валидационная ошибка возвращает структуру, достаточную для указания конкретного ввода. Для каждой ошибки возвращайте field, стабильный code и читабельное message. Покройте обычные случаи базы (unique, foreign key, NOT NULL, check). Технические детали оставьте для логов, а не для пользователя.
Поведение UI: помогает ли оно человеку восстановиться?
Даже идеальное сообщение ощущается плохо, если форма мешает пользователю. Фокусируйте первое ошибочное поле и прокручивайте к нему при необходимости. Сохраняйте то, что пользователь уже ввёл (особенно при множественных ошибках). Показывайте ошибки сначала на уровне поля, краткое резюме — только если это действительно помогает.
Логи и тесты: ловите ли вы регрессии?
Обработка ограничений часто ломается тихо при изменениях схемы, так что относитесь к ней как к поддерживаемой фиче. Логируйте внутренне ошибку БД (имя ограничения, таблицу, операцию, request ID), но не показывайте это напрямую пользователю. Добавьте тесты хотя бы по одному примеру на каждый тип ограничения и проверяйте, что сопоставление полей остаётся стабильным, даже если точная формулировка ошибки в базе меняется.
Следующие шаги: сделайте это консистентным по всему приложению
Большинство команд решают проблему экрана за экраном. Это помогает, но пользователи замечают разрывы: в одной форме сообщение ясное, в другой — всё ещё «запрос не удался». Консистентность превращает патч в стабильный паттерн.
Начните с того, что раздражает больше всего. Просмотрите неделю логов или тикетов в поддержку и выберите несколько ограничений, которые повторяются чаще всего. Эти «топовые нарушители» должны быть первыми, кому вы сделаете дружелюбные полевые сообщения.
Относитесь к переводу ошибок как к маленькой продуктовой задаче. Держите одну общую таблицу сопоставлений для всего приложения: имя ограничения (или код) -> ключ поля -> сообщение -> подсказка по восстановлению. Пишите сообщения простыми словами и делайте подсказку действенной.
Лёгкий план развёртывания, который влезет в плотный продакт‑цикл:
- Определите 5 ограничений, которые чаще всего видят пользователи, и запишите точные сообщения, которые хотите показывать.
- Добавьте таблицу сопоставлений и используйте её в каждом эндпоинте, сохраняющем данные.
- Стандартизируйте, как формы рендерят ошибки (одно и то же место, тон и поведение фокуса).
- Проверьте сообщения с нетехническим коллегой, спросите: «Что бы вы сделали дальше?»
- Добавьте по одному тесту на форму, который проверяет подсветку правильного поля и читаемость сообщения.
Если вы хотите получить такое единообразие без ручной правки каждого экрана, AppMaster (appmaster.io) поддерживает бэкенд‑API и генерацию web и нативных приложений. Это упрощает повторное использование одного структурированного формата ошибок на клиентах, чтобы полевые подсказки оставались одинаковыми при изменениях модели данных.
Напишите короткую «стилистику сообщений об ошибках» для команды: что избегать (термины базы) и что должно быть в каждом сообщении (что произошло и что делать дальше).
Вопросы и ответы
Относитесь к этому как к обычной подсказке формы, а не к краху системы. Покажите короткое сообщение рядом с тем полем, которое нужно исправить, сохраните введённые данные пользователя и объясните, что делать дальше простыми словами.
Ошибка на уровне поля указывает на конкретное поле и говорит, что нужно исправить прямо там — например, «Email уже используется». Общая ошибка заставляет угадывать, повторять попытки и писать в поддержку, потому что не объясняет, что менять.
По возможности используйте стабильные коды ошибок от драйвера базы данных, затем сопоставляйте их с типами понятными пользователю: уникальность, внешний ключ, обязательное поле, диапазон и т. п. Избегайте парсинга сырых текстов ошибок — они меняются в разных драйверах и версиях.
Храните простое сопоставление имени ограничения с ключом поля UI в бэкенде, рядом с кодом, который владеет схемой. Например: уникальный индекс на email -> email, orders_customer_id_fkey -> customerId, чтобы UI мог подсветить правильный ввод.
По умолчанию: «Это значение уже используется» плюс понятный следующий шаг — «Попробуйте другое» или «Войти», в зависимости от потока. На экранах регистрации/сброса пароля используйте нейтральную формулировку, чтобы не подтверждать существование учётной записи.
Объясните это как устаревший или недействительный выбор, понятный пользователю: «Этот клиент больше не существует. Выберите другого.» Если восстановление требует создания связанной записи, предложите путь создания вместо бессмысленных повторных попыток.
Отмечайте обязательные поля в UI и валидируйте до отправки, но всё равно обрабатывайте отказ базы как страховку. При ошибке покажите простое сообщение «Обязательно» на поле и сохраните остальную форму.
Возвращайте массив ошибок, каждая с ключом поля, стабильным кодом и коротким сообщением, чтобы UI мог показать их все одновременно. На клиенте фокусируйте первое ошибочное поле, но сохраняйте видимыми остальные сообщения, чтобы пользователь не застрял на одном.
Используйте последовательный формат, который отделяет то, что видит пользователь, от внутренних деталей для логов — например, сообщение для пользователя плюс внутренние поля с именем ограничения и request ID. Никогда не показывайте пользователю сырые SQL-ошибки и не заставляйте UI парсить текст БД.
Централизуйте перевод в одном месте на бэкенде, возвращайте предсказуемую форму ошибки и рендерьте её одинаково в каждой форме. С AppMaster можно применять единый структурированный контракт ошибок для сгенерированных API и клиентов, чтобы сообщения оставались консистентными при изменениях модели данных.


