Files
progcode/editorial/agent-rewrites/046.json
T

8 lines
28 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 46,
"slug": "editorial-2026-09-field-frontend-backend-boundary",
"title": "Граница frontend и backend: как доказать, где расходится правило скидки",
"excerpt": "Браузер показывает скидку, сервер считает другую сумму, а повторный запрос только запутывает расследование. Разбираем воспроизводимый кейс, контракт расчёта, статусы HTTP и безопасный порядок проверки.",
"contentHtml": "<p>Самый опасный дефект на границе frontend и backend выглядит убедительно с обеих сторон. JavaScript показывает покупателю скидку 10%, кнопка становится активной, а сервер при оформлении заказа возвращает полную стоимость. В другом варианте браузер получает ответ 200 и рисует старую скидку из локального состояния. Если сразу переписать условие в одном месте, можно скрыть симптом и оставить два разных правила.</p>\n<p>Ниже — учебный кейс с фиксированными тестовыми данными, а не отчёт о конкретной production-системе. У корзины есть промокод <code>SAVE10</code>. Клиент предварительно показывает скидку для суммы от 5 000 рублей. Backend дополнительно проверяет категорию товара и актуальность корзины. Редкий сценарий: сумма подходит, но один товар исключён из акции. Локальная функция показывает 600 рублей скидки, сервер возвращает решение <code>rejected</code> и ноль.</p>\n<p>Главный вывод практический: денежный результат и право применить скидку должны иметь одного авторитетного владельца. Frontend может дать быстрый preview (предварительный расчёт), но не должен выдавать его за подтверждённое состояние. Чтобы найти нарушенную границу, нужно связать намерение пользователя, исходный запрос, ответ сервера, адаптированную модель и вход компонента в рендер. Если один переход не зафиксирован, причина остаётся гипотезой.</p>\n<h2>Сначала разделите preview и подтверждённый расчёт</h2>\n<p>У интерфейса есть две разные задачи. Preview отвечает на вопрос «что примерно произойдёт, если текущие условия сохранятся». Подтверждённый расчёт отвечает на вопрос «какую сумму система разрешает использовать в следующей операции». Эти значения могут совпадать, но это не одно и то же поле.</p>\n<p>В учебном контракте браузер отправляет состав корзины и код акции. Сервер проверяет цены, доступность, права на акцию, исключения по категориям и текущую ревизию корзины. Ответ содержит сумму в копейках, решение и код причины. Клиент не пересчитывает ответ, а отображает его. Так правило не дублируется в двух исполнителях.</p>\n<figure><img src='/assets/editorial/2026/frontend-backend-boundary-2026-review-evidence-loop.svg' alt='Схема расследования расхождения скидки между frontend и backend: intent, request, response и render input связаны идентификатором запроса' loading='lazy' /><figcaption>Проверяйте четыре соседних перехода. Снимок интерфейса показывает только финальный render input и не доказывает, какой ответ его породил.</figcaption></figure>\n<p>Отдельное поле <code>previewDiscountCents</code> полезно только при явно описанном статусе «предварительно». Поле <code>discountCents</code> в подтверждённой модели должно приходить из ответа сервера. Если оба значения отображаются рядом, подпишите источник и момент расчёта, иначе пользователь увидит число без его условий применимости.</p>\n<h2>Воспроизводимый сценарий с одной редкой веткой</h2>\n<p>Для проверки возьмём корзину из одного товара. Все числа ниже — тестовый fixture (фиксированный набор входов), их можно перенести в unit- или contract-тест. Цена хранится в копейках, чтобы пример не зависел от округления чисел с плавающей точкой.</p>\n<pre><code>POST /api/cart/quote\nContent-Type: application/json\nAccept: application/json, application/problem+json\nX-Request-Id: test-quote-046\n\n{\n \"cartRevision\": 12,\n \"promoCode\": \"SAVE10\",\n 'items': [\n { \"sku\": \"A-1\", \"priceCents\": 600000, \"category\": \"gift-card\" }\n ]\n}\n\nHTTP/1.1 200 OK\nContent-Type: application/json\nETag: \"quote-12-7\"\n\n{\n \"cartRevision\": 12,\n \"decision\": \"rejected\",\n \"discountCents\": 0,\n \"totalCents\": 600000,\n \"reasonCode\": \"category-excluded\"\n}</code></pre>\n<p>Тестовый сервер должен возвращать один и тот же ответ для этого входа. В браузере неправильная реализация может сначала показать 60 000 копеек, потому что локальное условие видит только сумму. После ответа она обязана заменить preview на <code>discountCents: 0</code> и показать причину, если такой код предусмотрен интерфейсным контрактом.</p>\n<p>Второй прогон меняет только категорию на <code>electronics</code>. Если политика акции разрешает её, ожидаемое решение — <code>accepted</code>, скидка 60 000 и итог 540 000 копеек. Третий прогон меняет <code>cartRevision</code> на устаревшее значение. Он нужен, чтобы отделить расхождение бизнес-правила от конфликта состояния. Нельзя считать эти три случая одной ошибкой «скидка не работает».</p>\n<div class='table-scroll'><table><caption>Минимальная матрица воспроизведения границы</caption><thead><tr><th scope='col'>Вход</th><th scope='col'>Ожидаемое решение backend</th><th scope='col'>Что может ошибочно показать UI</th><th scope='col'>Проверка</th></tr></thead><tbody><tr><td>600 000 копеек, <code>gift-card</code>, SAVE10</td><td><code>rejected</code>, скидка 0</td><td>Скидка 60 000 по локальному порогу суммы</td><td>Сопоставить response с render input</td></tr><tr><td>600 000 копеек, <code>electronics</code>, SAVE10</td><td><code>accepted</code>, скидка 60 000</td><td>Старая модель после смены товара</td><td>Проверить cache key и отмену прошлого запроса</td></tr><tr><td>Устаревшая cartRevision</td><td>Конфликт по текущему состоянию</td><td>Успех из optimistic UI</td><td>Записать порядок запросов и статус ответа</td></tr><tr><td>Невалидный ответ без decision</td><td>Остановка адаптера</td><td>Тихая подстановка прежней скидки</td><td>Проверить schema validation и отрицательный тест</td></tr></tbody></table></div>\n<p>Матрица полезна тем, что меняет одну причину за раз. Если одновременно менять промокод, состав корзины и ревизию, положительный или отрицательный результат не подскажет, какая граница нарушена.</p>\n<h2>Проверяйте не только статус, но и representation</h2>\n<p>В Fetch свойство <code>Response.ok</code> означает только статус из диапазона 200–299. Поэтому <code>response.ok === true</code> не доказывает, что тело содержит именно модель расчёта. Для endpoint, который должен вернуть JSON-котировку, проверяйте ожидаемый статус, <code>Content-Type</code> и обязательные поля отдельно.</p>\n<p>Статус 204 означает успешное выполнение без содержимого ответа. Он может быть правильным для команды, после которой клиент сам перечитывает ресурс, но не заменяет JSON-ответ котировки. Статус 202 означает, что запрос принят в обработку, а обработка ещё не завершена; его нельзя трактовать как подтверждённую сумму. Статус 409 описывает конфликт с текущим состоянием целевого ресурса и подходит для отдельного сценария устаревшей корзины, если это согласовано контрактом.</p>\n<p>Клиентская ветка должна различать транспортную ошибку, отказ доменного правила и невалидную representation (представление ресурса). Сетевой сбой не дал ответа. Отказ промокода дал ответ с понятным решением. Невалидная схема говорит, что текущая версия клиента не может безопасно применить контракт. У всех трёх случаев разный следующий шаг.</p>\n<pre><code>type Quote = {\n cartRevision: number;\n decision: 'accepted' | 'rejected';\n discountCents: number;\n totalCents: number;\n reasonCode?: string;\n};\n\nasync function readQuote(response: Response): Promise&lt;Quote&gt; {\n if (!response.ok) {\n throw new Error('quote-http-' + response.status);\n }\n\n if (response.status !== 200) {\n throw new Error('quote-representation-missing');\n }\n\n const contentType = response.headers.get('content-type') || '';\n if (!contentType.includes('application/json')) {\n throw new Error('quote-content-type-invalid');\n }\n\n const value = await response.json() as Partial&lt;Quote&gt;;\n const validDecision = value.decision === 'accepted' || value.decision === 'rejected';\n const validMoney = Number.isInteger(value.discountCents) &amp;&amp;\n Number.isInteger(value.totalCents) &amp;&amp; value.discountCents &gt;= 0;\n\n if (!Number.isInteger(value.cartRevision) || !validDecision || !validMoney) {\n throw new Error('quote-schema-invalid');\n }\n\n return value as Quote;\n}</code></pre>\n<p>Фрагмент намеренно не делает вывод о конкретном API: он проверяет только локальный адаптер. В production-системе схему нужно синхронизировать с владельцем backend, а коды ошибок — с договорённостью интерфейса. Если обязательное поле отсутствует, безопаснее остановить переход и показать нейтральное состояние, чем сохранить старую скидку, будто она подтверждена.</p>\n<h2>Найдите точку расхождения по четырём снимкам</h2>\n<p>Начните с intent — намерения пользователя: товар выбран, промокод введён, пользователь нажал «Рассчитать». Затем сохраните нормализованный request: метод, шаблон маршрута, безопасный идентификатор запроса, ревизию корзины и хэш набора SKU. Секреты, полные персональные данные и платёжные реквизиты в диагностический контекст не входят.</p>\n<p>Третий снимок — response. Зафиксируйте статус, <code>Content-Type</code>, коды решения, ревизию и ETag, если сервер его отдаёт. Не нужно копировать тело целиком: для расследования достаточно разрешённого набора полей. Четвёртый снимок — render input, то есть объект, который действительно получил selector или компонент. Network-панель сама по себе не показывает этот объект.</p>\n<p>Если response содержит <code>discountCents: 0</code>, а render input содержит 60 000, ищите ошибку в адаптере, store, cache key или optimistic patch. Если render input уже равен нулю, а экран показывает 60 000, переходите к selector, memoization, локальному state или hydration. Если request не содержит категорию, backend не обязан восстановить её из догадки клиента: это дефект request contract.</p>\n<p>Для каждого снимка используйте один корреляционный ключ, например <code>test-quote-046</code>. Это не стандарт HTTP и не замена распределённой трассировке, а договорённость диагностического сценария. Ключ связывает события, но не доказывает причинность: порядок и содержимое переходов всё равно нужно проверить.</p>\n<h2>Отделите cache от race и устаревшей ревизии</h2>\n<p>Кеш даёт обычно повторяемый результат для одного ключа: тот же запрос получает ту же старую representation. Для проверки сравните URL, параметры, заголовки кеша, ревизию и ETag. Новый URL с добавленным случайным параметром — плохой диагностический инструмент, если он меняет контракт и не отражает настоящий путь приложения.</p>\n<p>Гонка зависит от порядка. Запрос A отправлен для <code>electronics</code>, запрос B — после изменения товара для <code>gift-card</code>. Если B вернулся первым, а A пришёл позже, устаревший A может перезаписать store. Искусственно задержите только один ответ в тестовом сервере и запишите последовательность. Если результат меняется вместе с задержкой, гипотеза о race получила воспроизводимую проверку.</p>\n<p>ETag и <code>If-Match</code> решают другой класс задачи. HTTP определяет ETag как валидатор representation, а <code>If-Match</code> позволяет условно выполнять изменение и предотвращать потерянную запись. Это полезно для обновления корзины или применения команды, но не превращает любой локальный <code>cartRevision</code> в HTTP-валидатор. Сопоставьте оба поля только после явного описания их владельца и жизненного цикла.</p>\n<p>При несовпадении ревизии сервер может вернуть 412 для проваленной предварительной проверки или 409 для конфликта состояния — точный выбор задаёт контракт. Клиент должен показать действие: перечитать корзину, пересчитать котировку или попросить повторить после подтверждения. Автоматический повтор команды с неизвестной идемпотентностью может создать второй побочный эффект.</p>\n<h2>Порядок расследования и исправления</h2>\n<ol><li>Опишите один симптом с числами и условиями: «при категории gift-card preview показывает скидку 60 000 копеек, а quote возвращает 0».</li><li>Зафиксируйте fixture: вход корзины, промокод, ревизию, ожидаемый статус, тело ответа и ожидаемый render input.</li><li>Проверьте request до backend. Убедитесь, что в нём есть все поля, влияющие на бизнес-решение, а сериализация не теряет категорию или ревизию.</li><li>Проверьте status, Content-Type и representation. Не называйте ответ успешным только из-за завершившегося Promise или свойства <code>ok</code>.</li><li>Сравните response с моделью после adapter и с объектом, который получил компонент. Так локализуется первая граница расхождения.</li><li>Повторите сценарий с одной изменённой переменной: категория, ревизия, порядок ответов или cache key. Не смешивайте эксперименты.</li><li>Исправьте владельца правила. Backend остаётся источником подтверждённой суммы; frontend удаляет дублирующее условие или помечает его только как preview.</li><li>Добавьте отрицательные тесты: исключённая категория не получает скидку, устаревший ответ не перезаписывает новый, невалидное тело не сохраняет старую модель.</li><li>Проверьте интерфейс после обновления страницы. Успешный первый рендер не доказывает, что состояние переживает повторное чтение.</li></ol>\n<p>Если правило меняется часто, храните условия акции в одном контракте или сервисе, а не копируйте их в два языка. Если быстрый preview нужен для отзывчивости, верните ему ограниченный статус и замените его подтверждённой котировкой после ответа. Выигрыш в скорости интерфейса не оправдывает два независимых источника суммы.</p>\n<h2>Как оформлять отказ, чтобы не маскировать причину</h2>\n<p>Для прикладного отказа API может использовать формат Problem Details с медиа-типом <code>application/problem+json</code>. RFC 9457 описывает поля вроде <code>type</code>, <code>title</code>, <code>detail</code> и расширения для конкретного типа проблемы. Такой формат помогает адаптеру отличить ожидаемый отказ промокода от транспортной ошибки, но не диктует тексты для UI и не разрешает раскрывать внутренние детали.</p>\n<pre><code>HTTP/1.1 409 Conflict\nContent-Type: application/problem+json\n\n{\n \"type\": \"https://api.example.test/problems/cart-revision\",\n \"title\": \"Cart changed\",\n \"detail\": \"Refresh the cart before calculating the quote\",\n \"instance\": \"/requests/test-quote-046\",\n \"cartRevision\": 11,\n \"expectedRevision\": 12\n}</code></pre>\n<p>Адрес <code>api.example.test</code> в примере фиктивный. Настоящий type URI должен принадлежать владельцу API и иметь документированное значение. Поля <code>detail</code> и <code>instance</code> нельзя без фильтра показывать пользователю или отправлять в общий лог: они могут содержать идентификаторы, внутренние маршруты или данные, которые не нужны для принятия решения.</p>\n<p>На frontend полезно иметь явное отображение: «Корзина изменилась — обновите расчёт», «Промокод не действует для выбранного товара» и «Не удалось получить расчёт». Это разные действия. Общий toast «ошибка сети» заставит пользователя повторять запрос, хотя сервер уже вернул корректный отказ по бизнес-условию.</p>\n<h2>Ограничения применимости</h2>\n<p>Этот способ подходит для синхронного HTTP-расчёта, где можно получить request, response и render input. Он не решает сам по себе асинхронную обработку очередью: для неё нужен отдельный статус операции, политика повторов и источник истины о завершении. Он также не заменяет авторизацию, аудит денежных операций, contract testing или проверку округления на сервере.</p>\n<p>Нельзя переносить правило «backend всегда прав» на отображение, которое сознательно является предварительным прогнозом. Preview может быть полезен, если пользователь видит его статус, а окончательная операция повторно проверяет условия. Нельзя считать ETag защитой от повторной покупки, если endpoint не описывает идемпотентность команды. Нельзя использовать тестовые идентификаторы и фиктивные type URI как production-конфигурацию.</p>\n<p>Если нет доступа к телу ответа или к безопасному воспроизведению, остановите сильный вывод. Скриншот показывает симптом, но не доказывает источник числа. Один лог backend показывает обработку запроса, но не доказывает, какой объект получил компонент. В таком случае запросите обезличенный response, request id и минимальный тестовый fixture, а не исправляйте случайный слой.</p>\n<h2>Критерий готовности</h2>\n<p>Исправление границы готово, когда fixture с исключённой категорией и fixture с разрешённой категорией дают разные, ожидаемые решения; подтверждённая сумма приходит из одного владельца; response и render input можно связать безопасным идентификатором; устаревший ответ не меняет новую модель; невалидный контракт не сохраняет прежнюю скидку; после обновления страницы отображается то же подтверждённое состояние.</p>\n<p>Это проверяемый критерий, а не обещание отсутствия всех ошибок. Перед выпуском отдельно проверьте денежное округление, права на акцию, кеширование, повтор команды и наблюдаемость. Если хотя бы одно из этих условий не входит в тестовый стенд, зафиксируйте границу результата и не называйте локальный прогон доказательством всей системы.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href='https://www.rfc-editor.org/rfc/rfc9110.html' target='_blank' rel='noopener noreferrer'>RFC 9110: HTTP Semantics</a> — нормативное описание статусов 202, 204, 409, 412, ETag, If-Match и общих правил HTTP. Документ не определяет бизнес-правило скидки или состояние конкретного frontend.</li><li><a href='https://fetch.spec.whatwg.org/' target='_blank' rel='noopener noreferrer'>WHATWG Fetch Standard</a> — официальный стандарт Fetch: Response, status, ok и диапазон ok status 200–299. Он не задаёт схему вашей котировки и не выбирает текст сообщения пользователю.</li><li><a href='https://www.rfc-editor.org/rfc/rfc9457.html' target='_blank' rel='noopener noreferrer'>RFC 9457: Problem Details for HTTP APIs</a> — нормативный формат структурированных деталей HTTP-проблем и media type application/problem+json. Он не заменяет redaction policy, авторизацию и доменный контракт.</li></ul>"
}