{ "index": 48, "slug": "editorial-2026-09-practice-frontend-backend-boundary", "title": "Ответ 200 не описывает экран: как проверить модель на границе UI и API", "excerpt": "Клиент может получить успешный HTTP-ответ и всё равно показать неверное состояние. Разбираем порядок проверок, runtime-контракт и отрицательные пути для screen model.", "contentHtml": "
Кнопка показывает «Готово». Запрос завершился с кодом 200. Пользователь нажимает «Изменить», а компонент падает: поле переименовали, список действий пришёл строкой или новый статус не попал в клиентский код. Иногда экран не падает. Он выбирает состояние по умолчанию и показывает устаревшую информацию.
\nЦена ошибки выше исключения. Пользователь повторяет команду. Оператор ищет проблему в сети. Разработчик смотрит на типы, которые были верны во время сборки. На границе процесса уже лежит другой JSON. Если UI записал его в state без проверки, ошибка проявится только в редком сценарии.
\nТезис статьи прост: статус HTTP сообщает результат обмена, но не доказывает, что тело подходит конкретному экрану. Клиент должен проверить HTTP, определить наличие representation, разобрать JSON, проверить минимальную screen model и только потом передать данные компоненту. Для каждого шага нужен отрицательный путь.
\nУ ответа есть несколько уровней смысла. Код 200 сообщает, что сервер обработал запрос успешно на уровне операции. Заголовок Content-Type заявляет формат тела. JSON parser проверяет синтаксис. Runtime-валидатор проверяет поля, которые нужны экрану. Ни один предыдущий шаг не заменяет следующий.
Тело {\"status\":\"done\",\"allowedActions\":\"edit\"} может быть корректным JSON. Но экран, который знает только ready, pending и blocked, не может безопасно выбрать состояние для done. Строка вместо массива также не становится моделью от того, что в ней записано знакомое слово.
Граница принадлежит адаптеру данных, а не JSX-компоненту. Компонент получает проверенную модель или явный результат ошибки. Он не должен угадывать неизвестный статус, подставлять пустой массив и считать это подтверждённым состоянием.
\nНе копируйте в UI всю доменную сущность. Выпишите поля, по которым компонент принимает решение. Для экрана заказа это состояние, набор известных действий и код сообщения. У каждого поля есть тип, допустимые значения и действие при нарушении.
\n| Поле | Допустимое значение | Решение UI | Отрицательный путь |
|---|---|---|---|
status | ready | pending | blocked | выбрать состояние экрана | остановить render |
allowedActions | массив известных команд | показать разрешённые кнопки | не создавать кнопку |
messageCode | непустая строка | выбрать локализованный текст | показать безопасную ошибку |
| HTTP status | статус операции | отделить успех от отказа | не строить модель только по числу |
Словарь статусов должен быть закрытым, пока команда не описала новый переход. Это не запрет на развитие API. Новый статус должен менять контракт, обработчик и тест. Молчаливый fallback скрывает изменение API и превращает его в случайный UI-дефект.
\nНиже учебный пример на JavaScript. Списки статусов и действий выбраны для демонстрации. Они не описывают production API и не обещают production-результат. В реальном проекте их заменяет контракт конкретного endpoint.
\nconst statuses = new Set(['ready', 'pending', 'blocked']);\nconst actions = new Set(['retry', 'edit', 'cancel']);\nfunction validateScreenModel(value) {\n const errors = [];\n if (!value || typeof value !== 'object' || Array.isArray(value)) return { valid: false, errors: ['body-must-be-object'] };\n if (!statuses.has(value.status)) errors.push('status-is-unknown');\n if (!Array.isArray(value.allowedActions)) errors.push('allowedActions-must-be-array');\n else if (value.allowedActions.some((item) => !actions.has(item))) errors.push('allowedActions-contains-unknown-action');\n if (typeof value.messageCode !== 'string' || value.messageCode.length === 0) errors.push('messageCode-must-be-non-empty');\n return { valid: errors.length === 0, errors };\n}\nasync function readScreen(response) {\n if (response.status === 204) return { kind: 'empty-success', next: 'refetch-screen' };\n if (!response.ok) return { kind: 'http-error', status: response.status };\n if (!response.headers.get('content-type')?.includes('application/json')) return { kind: 'wrong-media-type' };\n let body;\n try { body = await response.json(); } catch { return { kind: 'invalid-json' }; }\n const result = validateScreenModel(body);\n return result.valid ? { kind: 'screen-model-ready', model: body } : { kind: 'invalid-screen-model', errors: result.errors };\n}\nФункция возвращает классификацию, а не случайную строку из parser. Такой результат связывается с error boundary, логом и безопасным состоянием компонента. В технический канал передавайте код нарушения и request id. Не отправляйте весь payload: он может содержать персональные или доменные данные.
\nПорядок проверок важен. Для 204 нельзя вызывать response.json(): успешное выполнение не означает наличие representation. Для неверного Content-Type повтор запроса обычно не исправит формат. Для невалидной модели retry тоже не является решением: сервер может стабильно возвращать тот же payload.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Экран показал «Готово», следующий клик падает | 200 приняли за модель | сравнить тело с runtime-схемой | запретить запись в state до валидации |
| DELETE вызывает ошибку parser | helper ждёт JSON после 204 | проверить статус до json() | вернуть empty-success и перечитать ресурс |
| Появилась кнопка неизвестной команды | UI доверяет строке action | проверить каждый элемент словарём | отклонить модель и не создавать кнопку |
| 422 превратился в общий toast | прикладной отказ назвали сетью | прочитать machine code и поле | подсветить поле, если контракт это разрешает |
| После timeout команда выполнилась дважды | клиент повторил запрос вслепую | проверить idempotency key и статус операции | не повторять; перечитать результат |
| Типы проходят, внешний сервис прислал другой JSON | тип сборки не проверяет сеть | воспроизвести фактический ответ | добавить contract или runtime test |
Чтение и изменение состояния требуют разных правил. Query получает representation и обновляет экран после проверки. Command просит сервер изменить состояние. Ответ 202 означает принятие асинхронной работы, а не завершённый переход. Ответ 204 означает успешную операцию без тела. В обоих случаях клиенту нужен путь к актуальной модели.
\nНе превращайте ответ команды в локальный флаг isSuccess. Если запрос оборвался после отправки, клиент не знает, успел ли сервер изменить ресурс. Повтор POST может создать вторую операцию. Безопасный вариант зависит от API: idempotency key, endpoint статуса или повторное чтение. Если контракт ничего не даёт, UI не может честно обещать результат после timeout.
Ошибки 409, 422, 429 и 5xx нельзя свести к одному toast. 409 может требовать перечитать конфликтующий ресурс. 422 может вернуть ошибки полей. 429 может содержать ограничение частоты и время следующей попытки. 5xx допускает повтор только при известной идемпотентности и ограниченном бюджете. Код статуса сужает выбор, но не заменяет error detail и правило следующего действия.
\nRuntime-валидатор проверяет форму данных, но не доказывает бизнес-истину. status: ready может быть формально корректным и устареть через секунду. Для этого нужны версия ресурса, контроль конкуренции, кэш-политика или повторное чтение. Валидация не заменяет authorization: наличие действия в JSON не выдаёт право на серверную операцию.
OpenAPI и сгенерированные TypeScript-типы описывают договорённость, но тип на этапе сборки не проверяет байты из сети. При независимом выпуске сервисов оставьте тест фактического ответа или runtime-схему. Ручная функция подходит для маленькой модели. Для сложных вложенных структур используйте schema validator и измерьте стоимость на реальном размере payload.
\nУчебный пример не описывает конкретную бизнес-модель. Он показывает место проверки и решения, которые нужно подтвердить контрактом проекта. Если один endpoint обслуживает несколько экранов, не расширяйте универсальный объект бесконечно. Назовите отдельные read model или версионируйте ответ.
\nГраница готова, если для каждого поддержанного ответа команда может назвать четыре вещи: какую модель можно передать в UI, какое действие доступно, какое запрещено и каким тестом это доказано. Тест должен показать, что валидный 200 проходит в render, 204 не запускает JSON parser, неизвестный статус останавливает render, а timeout команды не вызывает слепой повтор.
\nПроверка незавершена, если компонент выбирает состояние по умолчанию после ошибки схемы или строит кнопку из свободной строки. Искомый результат — наблюдаемая граница, где транспортный ответ превращается в screen model только после проверки.
\n