09 окт. 2025 г.·7 мин. чтения

Несоответствия контракта frontend-backend, из-за которых не сохраняются формы

Несоответствия контракта фронтенда и бэкенда заставляют формы выглядеть успешными, хотя данные не сохраняются. Приведите в соответствие DTO, ошибки валидации, коды статуса и формат пагинации.

Несоответствия контракта frontend-backend, из-за которых не сохраняются формы

Что значит, когда форма отправлена, но ничего не сохраняется

Контракт между фронтендом и бэкендом — это общее соглашение между вашей формой и API: какие поля отправляются, как они называются, каких они типов и что API возвращает при успехе или ошибке.

Когда форма «отправлена», но ничего не сохраняется, это обычно означает, что фронтенд выполнил свою часть (отправил запрос), но бэкенд не записал данные. Сложность в том, что пользователь часто не видит явной ошибки, потому что ответ выглядит как успешный, интерфейс игнорирует ответ или API возвращает неожиданный формат.

Типичный пример: форма посылает { email, password }, а API ожидает { userEmail, passwordHash }. Запрос доходит до сервера, валидация падает, а API возвращает общий 200 с { ok: false } (или пустое тело). Фронтенд воспринимает любой 200 как успех, показывает уведомление, и пользователь уходит, хотя база данных осталась без изменений.

Это часто случается в быстро собранных или сгенерированных ИИ прототипах. Инструменты могут быстро создать рабочий UI и API, но часто угадывают имена полей, забывают серверные правила валидации или придумывают форматы ответа. Когда позже вы меняете мок-эндпоинт на реальный, «контракт» смещается, и сохранения начинают тихо падать.

Исправление требует не одной правки в одном файле. Обычно нужно согласовать несколько частей стека:

  • Request DTOs (имена полей, типы, обязательные/опциональные)
  • Ошибки валидации (единая форма, которую UI может показать рядом с полями)
  • Коды статуса (чтобы успех и ошибка были однозначны)
  • Ответы при успехе (возвращайте то, что нужно UI, чтобы подтвердить сохранение)
  • Ответы списков (единый формат пагинации для всех эндпоинтов)

Если вы унаследовали приложение, созданное ИИ, и сохранения работают нестабильно, это именно те проблемы, с которыми часто сталкиваются команды вроде FixMyMess: код «работает», но контракт непоследователен. Остальная часть этого руководства посвящена тому, как сделать контракт явным, предсказуемым и трудным для случайного нарушения.

Общие симптомы и что они обычно означают

Форма может выглядеть так, будто всё прошло успешно, хотя ничего не изменилось. Самый частый признак — уведомление об успехе (или зелёная галочка), но после обновления страницы видны старые данные. Это обычно означает, что UI решил, что всё успешно, по неверному сигналу, а не по тому, что сервер действительно сделал.

На стороне бэкенда обращайте внимание на ответы, которые «кажутся» успешными, но таковыми не являются. Классическая проблема контракта — 200 OK с ошибкой внутри JSON (например, { "ok": false, "message": "invalid" }). Другой вариант — 204 No Content, хотя фронтенд ожидает возвращённую сохранённую запись с id или обновлёнными полями.

У разработчиков подсказки часто мелкие и их легко пропустить: в консоли поле отображается как undefined, которое вы уверены заполнили, или в вкладке Network виден ответ в формате, который вы не учитывали (например, data вместо result, или массив вместо объекта). Это несоответствия контракта фронтенд-бэкенд на виду.

Распространённые симптомы и вероятные причины:

  • Появляется сообщение об успехе, но после обновления ничего не изменилось: запрос либо не попал на endpoint сохранения, либо попал с отсутствующими/переименованными полями.
  • Сохранение работает только для некоторых пользователей: правила валидации на бэкенде отличаются от фронтенда, или обязательные поля зависят от роли пользователя.
  • Бэкенд возвращает 200, но UI ведёт себя странно: ошибка закодирована в JSON, а не через код статуса.
  • UI показывает «Сохранено», но в списке всё ещё старый элемент: кэш не инвалидирован, или ответ не содержит обновлённую запись.
  • Пагинация выглядит неработающей (отсутствующие элементы, повторы): фронтенд ожидает page/total, а бэкенд возвращает nextCursor/items (или наоборот).

Правило: доверяйте вкладке Network больше, чем UI. Если полезная нагрузка запроса и ответ не соответствуют тому, что подразумевает ваш код, форма может «отправляться» бесконечно, не сохраняя данные.

Это частый сценарий, который мы видим в FixMyMess, когда сгенерированный ИИ прототип подсоединяет кнопку и уведомление, но никогда не подтверждает, что сервер действительно что-то сохранил.

Контракт, о котором нужно договориться: формы, имена и типы

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

Начните с записи минимального контракта для одного сохранения. Если какая-либо часть расплывчата, разные части стека заполнят пробелы по-разному.

  • Endpoint + метод (например, POST /users vs PUT /users/:id)
  • Обязательные заголовки (особенно Content-Type и авторизация)
  • Форма тела запроса (имена полей, вложенность, опционально vs обязательно)
  • Форма ответа (что клиент должен читать, чтобы обновить UI)
  • Форма ошибок (как возвращаются проблемы валидации)

Именование — первое место, где контракты дрейфуют. Если фронтенд отправляет firstName, а бэкенд ожидает first_name, поле может быть проигнорировано или сохранён будет дефолт.

Типы — второе. Частый случай: UI отправляет age: "32" как строку, а бэкенд ожидает число. Некоторые фреймворки приводят типы, некоторые отклоняют, некоторые превращают в null. Если null допустим, вы получите пустое значение в базе без уведомления.

Лишние и отсутствующие поля также могут исчезнуть молча. Например, форма включает marketingOptIn, но DTO на сервере не содержит этого поля. В зависимости от стека это поле может быть отброшено при десериализации без ошибки. Обратно тоже больно: бэкенд требует companyId, а фронтенд его никогда не отправляет — сервер создаст запись, не привязанную ни к чему.

Практический способ поймать это рано — взять один реальный запрос из инструментов разработчика и сравнить его со схемой DTO и правилами валидации на сервере построчно, согласовав точные имена и типы перед правками. Это то, что FixMyMess быстро находит при аудите сгенерированных ИИ прототипов.

Пошагово: выравнивание DTO от полей формы до базы данных

Когда форма «отправлена», но ничего не сохраняется, обычная причина простая: фронтенд посылает один формат, а бэкенд ожидает другой. Исправление начинается с решения, кто определяет истину, и затем проверки каждого шага от полей формы до хранилища.

1) Выберите единый источник правды

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

2) Запишите DTO с реальными примерами

Не полагайтесь на «всё очевидно». Запишите один пример для создания и один для обновления. Обновления часто падают, потому что требуется id, допускаются частичные поля или используются другие имена.

// Create
{ "email": "[email protected]", "displayName": "Sam", "marketingOptIn": true }

// Update
{ "id": "usr_123", "displayName": "Sam Lee", "marketingOptIn": false }

Затем запишите response DTO, который UI реально ожидает. Если UI ожидает user.id, а API возвращает userId, сохранения могут «работать», но UI не сможет отобразить обновлённое состояние.

3) Проследите путь от формы до базы

Пройдитесь по всей цепочке хотя бы один раз, от начала до конца:

  • Имена и типы полей формы (строки, числа, булевы)
  • Полезная нагрузка, отправляемая по сети (включая заголовки, например content type)
  • Разбор и валидация DTO на бэкенде (обязательные поля, дефолты)
  • Маппинг на колонки базы данных (имена и приведение типов)
  • Тело ответа, которое UI читает для обновления экрана

4) Проверяйте точную полезную нагрузку, которую отправляет UI

Скопируйте реальный запрос из Network и воспроизведите его. Это ловит проблемы вроде "true" (строка) vs true (булевое), отсутствующих полей или неожиданной вложенности.

5) Изменяйте одну сторону, затем ретест

Почините либо маппинг на фронтенде, либо DTO на бэкенде, но не оба одновременно. Держите один «золотой» payload и ожидаемый ответ, чтобы убедиться, что вы не просто переместили рассогласование.

Если вы унаследовали код, сгенерированный ИИ, такие рассогласования часты, потому что сгенерированные UI и API часто развиваются отдельно. Платформы вроде FixMyMess обычно начинают с аудита контракта и точек маппинга перед правкой бизнес-логики, потому что именно там прячутся тихие ошибки сохранения.

Сделайте ошибки валидации последовательными и простыми для отображения

Fix Validation Users Can See
Standardize validation errors so users see what to fix instead of a fake success.

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

Практичный шаблон — всегда возвращать одну и ту же структуру для ошибок валидации:

{
  "error": {
    "type": "validation_error",
    "fields": [
      { "field": "email", "code": "invalid_format", "message": "Enter a valid email." },
      { "field": "password", "code": "too_short", "message": "Password must be at least 12 characters." }
    ],
    "non_field": [
      { "code": "state_conflict", "message": "This invite has already been used." }
    ]
  }
}

Держите для каждой проблемы три вещи: имя поля (совпадающее с DTO), стабильный код (чтобы UI мог реагировать) и человекочитаемое сообщение (для показа). Если возвращаются только сообщения, UI начинает угадывать и ломается при изменении текста.

Ошибки, не завязанные на поле, тоже важны. Права доступа, конфликты состояния и лимиты запросов не привязаны к одному полю, поэтому должны идти в отдельном месте, например non_field (или global). UI может показывать их рядом с кнопкой отправки или как баннер.

На фронтенде маппинг должен быть простым и предсказуемым:

  • Очищать предыдущие ошибки перед отправкой.
  • Для каждого элемента fields[] прикреплять message к соответствующему полю ввода по имени.
  • Если поле неизвестно — трактовать как глобальную ошибку (это часто сигнал дрейфа DTO).
  • Показывать non_field[] в одном видимом месте.

И, наконец, не прячьте ошибки валидации внутри «успешных» ответов. Если сохранение не получилось, возвращайте ошибку с кодом не-2xx и телом ошибки. Смешивание предупреждений в ответе 200 — это путь к тихим провалам сохранения, особенно в сгенерированных ИИ приложениях, которые мы видим в FixMyMess.

Коды статуса и ответы об успехе, которым можно верить

Многие несоответствия контракта начинаются с одной простой лжи: сервер возвращает статус успеха, но UI не может понять, действительно ли сохранение прошло. Если фронтенд считает любой 200 «сохранённым», вы получаете классический случай: «тост говорит успех, но после обновления ничего нет».

Используйте коды статуса как ясный сигнал и держите форму ответа честной.

Простой предсказуемый шаблон

Выберите правила, которым будете следовать всегда:

  • 201 Created при создании новой записи, и включайте новый ресурс в тело ответа.
  • 200 OK для чтения и обновления, с JSON-телом, представляющим сохранённое состояние.
  • 204 No Content только если вы действительно не возвращаете тело (и клиенту не нужны новые данные).
  • 422 Unprocessable Entity для проблем валидации (ошибки полей, которые пользователь может исправить).
  • 409 Conflict для дубликатов или конфликтов версий (запрос валиден, но не применим как есть).

Возвращать 200 OK с объектом вроде { error: ... } — ловушка. Многие фронтенды проверяют только response.ok или HTTP-код. UI покажет успех, а бэкенд тихо отказал.

Идемпотентность, дубликаты и поведение «попробуйте снова»

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

Используйте 409 когда уже существует уникальное значение (например, email) или когда оптимистическая блокировка провалилась (устаревший updatedAt или version). Используйте 422 когда полезная нагрузка сама по себе неверна (отсутствуют обязательные поля, неверный формат).

Что должен возвращать успешный save

Даже при обновлениях возвращайте канонические данные, которые сервер сохранил, а не просто эхо того, что прислал клиент. Хороший ответ при сохранении обычно включает:

  • id
  • updatedAt (или version)
  • нормализованные поля (обрезанные строки, вычисленные значения по умолчанию)
  • любые значения, сгенерированные сервером (slugs, status)

Пример: если фронтенд отправил " Acme ", ответ должен вернуть "Acme". Так UI сразу совпадает с реальным состоянием, и вы быстро ловите проблемы контракта. Команды часто приносят в FixMyMess сломанные ИИ API, где «успешный» ответ на самом деле скрывал отказ сохранения за 200.

Форматы пагинации, которые остаются стабильными по всему стеку

Пагинация — это контракт, а не деталь реализации. Если фронтенд и бэкенд не согласованы по форме, вы получаете пустые таблицы, повторяющиеся строки или «Загрузить ещё», которое никогда не заканчивается. Эти несоответствия часто встречаются в сгенерированных ИИ API, где UI и сервер были сгенерированы отдельно.

Выберите один стиль пагинации и явно его назовите

Самый быстрый путь избежать путаницы — выбрать один стиль и записать точные параметры запроса.

  • page + pageSize: просто для нумерованных страниц, но может быть медленным, если БД считает и пропускает много записей.
  • offset + limit: легко реализуется, но вставки/удаления могут вызвать дубли или пропуски.
  • cursor: лучше для «бесконечной прокрутки», стабилен при изменениях, но требует токен-курсор и строгий порядок сортировки.

Когда выбрали — соблюдайте во всех эндпоинтах. UI, рассчитанный на page=3&pageSize=20, некорректно поведёт себя, если один эндпоинт вдруг ждёт offset=40&limit=20.

Зафиксируйте форму ответа, которую читает UI

Решите, на какие поля фронтенд может опираться. Безопасный дефолт: items плюс способ понять, есть ли ещё данные. Totals — опционально и могут быть дорогими.

Очень частое рассогласование — бэкенд возвращает { data: [...] }, а UI ждёт [...] или items. Запрос проходит, UI ничего не рендерит, и никто не видит ошибки.

Чтобы страницы не перемешивались, зафиксируйте правила в контракте:

  • Всегда требуйте детерминированной сортировки (например, sort=createdAt:desc).
  • Применяйте фильтры до пагинации и, по возможности, возвращайте применённые фильтры.
  • Для курсорной пагинации основание курсора должно совпадать с полями сортировки, которые вы возвращаете.
  • Будьте последовательны в пустых состояниях: возвращайте items: [] с hasMore: false.

Когда FixMyMess проводит аудит прототипов, нестабильная пагинация часто скрывает реальные сохранения: запись есть в базе, но она никогда не появляется в списке, который пользователь проверяет сразу после сохранения.

Частые ловушки, вызывающие молчаливый провал сохранения

Audit Auth and Secrets
Catch exposed secrets and broken authentication before they block real users.

Форма может выглядеть исправно и при этом не сохранять данные, потому что UI и API расходятся в мелочах. Эти несоответствия часто не дают явной ошибки, и люди начинают думать, что с базой что-то не так, хотя проблема в форме запроса.

Одна распространённая ловушка — тихая приведение типов в JSON. Фронтенд отправляет строку вместо числа, или отправляет пустую строку для nullable-поля. Некоторые серверы молча выбрасывают поля, которые не могут распарсить, и затем сохранение падает позже, потому что отсутствующее поле требуется.

Другой классический случай: поля, которые в UI выглядят опциональными, но обязательны для сохранения. Мультиарендные приложения часто требуют tenantId, orgId или userId. Если эти поля обычно заполняются из контекста авторизации, мелкая ошибка в авторизации может сделать их пустыми, не меняя форму.

Даты тоже причиняют тонкие ошибки. Пикер даты может отправлять "01/02/2026", а API ожидает ISO "2026-02-01". Часовые пояса тоже сдвигают значения. Вы сохраняете "14 янв", а сервер кладёт "13 янв" в UTC, и кажется, что сохранение не сработало.

Несоответствия контекста авторизации коварны. UI показывает вас залогиненным, потому что есть токен, но API считает запрос анонимным — заголовок пропал, куки заблокированы или токен просрочен.

Оптимистичные UI-обновления могут всё это скрыть. Экран обновился, как будто сохранение прошло, а бэкенд отклонил запрос.

Обратите внимание на такие признаки:

  • В Network виден 200, но в теле ответа есть "error" или возвращённая запись не изменилась.
  • API возвращает 204, и UI не может подтвердить, что именно сохранилось.
  • В полезной нагрузке отсутствуют обязательные ID, но не показывается ошибка поля.
  • Дата выглядит верной в UI, но в базе другая.
  • UI обновляется до завершения API-вызова.

Когда мы аудируем сгенерированные ИИ приложения в FixMyMess, часто находим две разные формы DTO в разных экранах — поэтому одна страница сохраняет, а другая тихо падает с теми же полями формы.

Быстрый чеклист перед релизом

Форма, которая «отправлена», — не то же самое, что данные, которые сохранены. Перед релизом сделайте быстрый контракт-чек, покрывающий весь путь: поля UI, API DTO, валидацию и то, что сервер возвращает.

Начните с реальных примеров, а не только типов. Положите пример запроса и пример ответа рядом с кодом и подтвердите, что они соответствуют тому, что сервер реально получает и возвращает. Обратите внимание на именование (camelCase vs snake_case), опциональные и обязательные поля и типичные несоответствия вроде чисел, приходящих как строки.

Короткий чеклист, ловящий большинство тихих провалов сохранения:

  • Подтвердите, что DTO для создания и обновления точно соответствуют полям UI (имена, типы и какие поля допускают null или могут отсутствовать).
  • Сделайте так, чтобы каждое неудачное сохранение возвращало статус не-2xx и единый формат ошибки (с общим сообщением и ошибками по полям).
  • Убедитесь, что UI может мапить ошибки сервера на те же имена полей, что видит пользователь (не использовать emailAddress на сервере, если форма показывает email).
  • Проверьте, что все эндпоинты списков используют один и тот же формат ответа пагинации (ключ items, общее количество, page/limit и где лежит метадата).
  • Протестируйте реально одно создание и одно обновление с реальной записью в базе, затем обновите страницу и подтвердите, что значения сохранились.

Практический тест: преднамеренно отправьте одно неверное поле (например, слишком короткий пароль). Если UI показывает тост об успехе, или в сети виден 200 при отсутствии изменений, где-то контракт врёт.

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

Реалистичный пример: сохранение выглядит успешным, но данные неверны

Stop Silent Save Failures
Get a free code audit to find why your form submits but nothing persists.

Обычная история несоответствия контракта: форма регистрации показывает тост об успехе, но пользователь потом не может войти. Все думают, что «аутентификация сломана», хотя реальная ошибка в форме запроса и ответе.

Фронтенд посылает такой payload:

{
  "email": "[email protected]",
  "password": "P@ssw0rd!",
  "passwordConfirm": "P@ssw0rd!"
}

API ожидает password_confirmation (snake_case) и игнорирует passwordConfirm. Если API также возвращает 200 OK с общим { "success": true }, UI будет радоваться, хотя сервер никогда не проверил подтверждение пароля и мог сохранить неправильное значение или отклонить внутри.

Решение простое: договоритесь об одном DTO и одном формате ошибок. Либо переименуйте поле в UI, либо на сервере принимайте оба ключа и мапьте их на один DTO.

При успешном сохранении возвращайте то, что доказывает, что запись создана:

{
  "id": "usr_123",
  "email": "[email protected]"
}

Для нового пользователя используйте 201 Created. При ошибке валидации — 422 Unprocessable Entity и ошибки по полям, которые UI может показать рядом с инпутами:

{
  "errors": {
    "password_confirmation": ["Does not match password"]
  }
}

Второй мини-кейс — на страницах списков: фронтенд строит контролы пагинации на основе total, а API возвращает только курсор и items. UI рендерит «Страница 1 из 0» или отключает Далее, хотя данные есть.

Выберите один стиль пагинации и придерживайтесь его. Если нужны totals — возвращайте items и total. Если нужна курсорная пагинация — возвращайте items, nextCursor и hasNext, и не пытайтесь одновременно читать total.

Следующие шаги: зафиксировать контракт и предотвратить повторные ошибки

Несоответствия контракта повторяются по одной причине: контракт живёт в головах людей, а не в проверяемом виде. Решение скучное, но эффективное: запишите его, тестируйте и относитесь к изменениям как к серьёзным.

Начните с одностраничной заметки-контракта для ключевых эндпоинтов (обычно: create, update, list). Держите её простым языком и добавьте конкретные примеры.

  • Request DTO: имена полей, обязательные vs опциональные, типы и как отправляются пустые значения
  • Response DTO: что возвращает «успех» (сохранённая запись vs просто id)
  • Формат ошибок: единая форма для валидаций и ошибок сервера, плюс несколько примеров
  • Коды статуса: что используете для create/update/not found/validation failures
  • Пагинация: параметры и форма ответа (items, total, page, pageSize)

Затем добавьте небольшие проверки контракта для ключевых эндпоинтов, чтобы ломки ловились в тот же день, когда они появляются. Это могут быть простые snapshot-тесты на бэкенде или скрипт в CI, который отправляет известные payload'ы и проверяет форму ответа и код статуса.

Выберите короткий список правил «никогда не меняются молча» и принудительно их соблюдайте:

  • Ошибки валидации всегда мапятся на поля (и содержат читаемое сообщение)
  • Успех никогда не возвращает 200 с ошибкой в теле
  • Пагинация всегда возвращает одни и те же ключи, даже когда items пусты
  • DTO не переименовывают поля без bump версии или скоординированного релиза

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

Если кодовая база сгенерирована ИИ и паттерны непоследовательны, сфокусированный аудит и исправление контрактов — самый быстрый путь к стабильности. FixMyMess делает бесплатные аудиты кода, затем правит контракты end-to-end (DTO, валидация, коды статуса, пагинация), чтобы приложение в продакшене вело себя так же, как на демо.