{ "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

Что именно считается успехом

\n

У ответа есть несколько уровней смысла. Код 200 сообщает, что сервер обработал запрос успешно на уровне операции. Заголовок Content-Type заявляет формат тела. JSON parser проверяет синтаксис. Runtime-валидатор проверяет поля, которые нужны экрану. Ни один предыдущий шаг не заменяет следующий.

\n

Тело {\"status\":\"done\",\"allowedActions\":\"edit\"} может быть корректным JSON. Но экран, который знает только ready, pending и blocked, не может безопасно выбрать состояние для done. Строка вместо массива также не становится моделью от того, что в ней записано знакомое слово.

\n

Граница принадлежит адаптеру данных, а не JSX-компоненту. Компонент получает проверенную модель или явный результат ошибки. Он не должен угадывать неизвестный статус, подставлять пустой массив и считать это подтверждённым состоянием.

\n
\"Поток
Проверки идут от транспорта к экрану. Ошибка на любом шаге останавливает передачу данных в render state.
\n

Минимальная модель экрана

\n

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

\n
Минимальная screen model для состояния заказа
ПолеДопустимое значениеРешение UIОтрицательный путь
statusready | pending | blockedвыбрать состояние экранаостановить render
allowedActionsмассив известных командпоказать разрешённые кнопкине создавать кнопку
messageCodeнепустая строкавыбрать локализованный текстпоказать безопасную ошибку
HTTP statusстатус операцииотделить успех от отказане строить модель только по числу
\n

Словарь статусов должен быть закрытым, пока команда не описала новый переход. Это не запрет на развитие API. Новый статус должен менять контракт, обработчик и тест. Молчаливый fallback скрывает изменение API и превращает его в случайный UI-дефект.

\n

Рабочий пример: проверяем ответ до render

\n

Ниже учебный пример на JavaScript. Списки статусов и действий выбраны для демонстрации. Они не описывают production API и не обещают production-результат. В реальном проекте их заменяет контракт конкретного endpoint.

\n
const 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.

\n

Симптомы и действия на границе

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Экран показал «Готово», следующий клик падает200 приняли за модельсравнить тело с runtime-схемойзапретить запись в state до валидации
DELETE вызывает ошибку parserhelper ждёт JSON после 204проверить статус до json()вернуть empty-success и перечитать ресурс
Появилась кнопка неизвестной командыUI доверяет строке actionпроверить каждый элемент словарёмотклонить модель и не создавать кнопку
422 превратился в общий toastприкладной отказ назвали сетьюпрочитать machine code и полеподсветить поле, если контракт это разрешает
После timeout команда выполнилась дваждыклиент повторил запрос вслепуюпроверить idempotency key и статус операциине повторять; перечитать результат
Типы проходят, внешний сервис прислал другой JSONтип сборки не проверяет сетьвоспроизвести фактический ответдобавить contract или runtime test
\n

Query, command и пустой результат

\n

Чтение и изменение состояния требуют разных правил. Query получает representation и обновляет экран после проверки. Command просит сервер изменить состояние. Ответ 202 означает принятие асинхронной работы, а не завершённый переход. Ответ 204 означает успешную операцию без тела. В обоих случаях клиенту нужен путь к актуальной модели.

\n

Не превращайте ответ команды в локальный флаг isSuccess. Если запрос оборвался после отправки, клиент не знает, успел ли сервер изменить ресурс. Повтор POST может создать вторую операцию. Безопасный вариант зависит от API: idempotency key, endpoint статуса или повторное чтение. Если контракт ничего не даёт, UI не может честно обещать результат после timeout.

\n

Ошибки 409, 422, 429 и 5xx нельзя свести к одному toast. 409 может требовать перечитать конфликтующий ресурс. 422 может вернуть ошибки полей. 429 может содержать ограничение частоты и время следующей попытки. 5xx допускает повтор только при известной идемпотентности и ограниченном бюджете. Код статуса сужает выбор, но не заменяет error detail и правило следующего действия.

\n

Как внедрить границу

\n
  1. Выберите endpoint, где компонент читает поля напрямую или использует fallback после ошибки parser.
  2. Зафиксируйте intent: query, command или получение результата фоновой операции.
  3. Выпишите минимальную screen model и закройте словари статусов, действий и кодов сообщений.
  4. Разделите обработку HTTP, media type, JSON parse и runtime validation. Для 204 задайте результат без тела.
  5. Определите отрицательный путь для неизвестного поля, неверного типа, 4xx, 5xx и timeout.
  6. Добавьте тесты на валидный ответ, неизвестный статус, неправильный массив, пустое тело и неверный media type.
  7. Передавайте в компонент только проверенную модель. Ошибку показывайте безопасным состоянием, диагностику связывайте с request id.
  8. Проверьте повтор команды отдельно и зафиксируйте, как клиент узнаёт результат после обрыва сети.
\n

Ограничения

\n

Runtime-валидатор проверяет форму данных, но не доказывает бизнес-истину. status: ready может быть формально корректным и устареть через секунду. Для этого нужны версия ресурса, контроль конкуренции, кэш-политика или повторное чтение. Валидация не заменяет authorization: наличие действия в JSON не выдаёт право на серверную операцию.

\n

OpenAPI и сгенерированные TypeScript-типы описывают договорённость, но тип на этапе сборки не проверяет байты из сети. При независимом выпуске сервисов оставьте тест фактического ответа или runtime-схему. Ручная функция подходит для маленькой модели. Для сложных вложенных структур используйте schema validator и измерьте стоимость на реальном размере payload.

\n

Учебный пример не описывает конкретную бизнес-модель. Он показывает место проверки и решения, которые нужно подтвердить контрактом проекта. Если один endpoint обслуживает несколько экранов, не расширяйте универсальный объект бесконечно. Назовите отдельные read model или версионируйте ответ.

\n

Проверяемый критерий готовности

\n

Граница готова, если для каждого поддержанного ответа команда может назвать четыре вещи: какую модель можно передать в UI, какое действие доступно, какое запрещено и каким тестом это доказано. Тест должен показать, что валидный 200 проходит в render, 204 не запускает JSON parser, неизвестный статус останавливает render, а timeout команды не вызывает слепой повтор.

\n

Проверка незавершена, если компонент выбирает состояние по умолчанию после ошибки схемы или строит кнопку из свободной строки. Искомый результат — наблюдаемая граница, где транспортный ответ превращается в screen model только после проверки.

\n

Проверяемые источники

" }