Ты не найдёшь эту ошибку. Потому что её нет в твоём коде. Как Self-describing API спасает от чужих рефакторингов

от автора

Когда в последний раз ты искал баг, который оказался не твоей ошибкой? А сколько раз ты делал это сегодня?

Я про те случаи, когда всё падает, консоль красная, ты перепроверяешь код в десятый раз и не понимаешь, где накосячил. А потом выясняется: бэк просто поменял контракт. Без предупреждения. Без обновлённого свагера. Просто «бесшумный рефакторинг».

Вот только рефачил не ты. А гадать, почему прилетает user_name, хотя ещё вчера было username, приходится тебе.

Это не история про плохой бэк. Это история про то, как устроены процессы в командах, где больше нескольких разработчиков, которые не сидят в одном чате 24/7. Руководители не держат в голове каждое минорное изменение. Бэк не всегда синхронизируется с фронтом. Документация либо отсутствует, либо устарела.

Помню конкретный случай. Зарелизили фичу на день позже, потому что бэк переименовал userId в userUid. Фронт честно слал userId, бэк честно его игнорировал и подставлял дефолтное значение. Никакой ошибки. Просто данные молча сохранялись не в ту учётку. Полдня тестировщики гадали, почему фича работает не так, как задумано. Ещё полдня я перекапывал свой код, прежде чем полез в контракт и увидел, что поле теперь называется по-другому.

Несколько месяцев я работал с одним сервисом в такой парадигме. Искал несоответствия в контрактах, сверял пути, переписывал типы. В пиковые недели релизов у команды уходило около 25 часов не на фичи, а на вынужденную синхронизацию. И я решил, что с этим пора заканчивать. Причём не просто починить конкретную боль, а сделать шаг к автоматизации и унификации всего слоя взаимодействия с API.

Решение: Self-describing API

Я поднял вопрос о внедрении Self-describing API. Мне больше нравится термин «динамическая документация».

Звучит сложно. На деле — топорно и просто.

По сути, это обычный свагер (спецификация OpenAPI, которая описывает, как выглядит REST-ручка), который приходит в ответ на GET-запрос /api/dynamic-doc/. В ответе массив доступных ручек с их описанием в унифицированном (что важно: все ручки отдают описание в одной структуре, иначе фронт не сможет распарсить это одним кодом) формате.

[  {    "name": "intern.list",    "path": "api/interns",    "method": "GET",    "params": {}  },  {    "name": "intern.create",    "path": "api/interns",    "method": "POST",    "params": {      "coffeeLevel": {        "type": "string",        "required": true      }    }  }]

Что мы имеем в такой ручке: URL, метод, допустимые параметры и их дефолтные значения.

В типизации это выглядит наглядно:

interface ApiParam {  type: 'string' | 'number' | 'boolean' | 'array' | 'object'  required: boolean  default?: string | number | boolean  description?: string}interface ApiEndpoint {  name: string  path: string  method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'  params: Record<string, ApiParam>}type DynamicDoc = ApiEndpoint[]

И вот здесь ключевой момент.

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

Кажется, что это путь в никуда? На самом деле нет.

Потому что «не надо знать» в данном случае значит «не зависит».

И это меняет всё.

Этим мы не просто чиним конкретную проблему. Мы выстраиваем автоматизированный слой коммуникации с бэком. Унифицированный. Предсказуемый. Который не требует ручной синхронизации.

Как это работает в коде

Перед стартом приложения мы вызываем ручку документации. Фронт загружает доку и кеширует её. Дальше обновляем раз в пять минут либо инвалидируем (принудительно сбрасываем кеш и грузим свежую версию) при 400-х ошибках.

Теперь вместо жёстко зашитого вызова:

fetch('/api/intern', {  method: 'POST',  body: {    patience: 0,    mood: 'debugging'   }})

Используем:

api.call('findTheIntern', {  coffeeLevel: 'empty',  hope: false})

Тут api.call — это тонкая обёртка, которая подставляет URL, метод и параметры из динамической документации. Выглядит это примерно так:

async function apiCall(endpointName: string, params: Record<string, unknown>) {  const doc = await getDynamicDoc()  const endpoint = doc.find(e => e.name === endpointName)    if (!endpoint) throw new Error(`Unknown endpoint: ${endpointName}`)    const url = buildUrl(endpoint.path, params)  const body = buildBody(endpoint.params, params)    return fetch(url, { method: endpoint.method, body })}

getDynamicDoc проверяет кеш, сравнивает версию и при необходимости дёргает /api/dynamic-doc/.

Почему это не просто генерация клиента

До этого я автоматизировал генерацию типов из OpenAPI и остался в восторге от подхода (описал это в статье «Оптимизация без AI: как я автоматизировал API-ручки и типы»). Генерация клиента хороша, когда бэк стабилен и контракты не меняются каждую неделю.

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

В отличие от статической генерации, у нас нет файла с типами, который лежит в проекте и требует обновления при каждом чихе на бэке. Бэк может поменять поле value на new_value. Мы просто ничего не делаем. Фронт подхватывает изменения из доки и передаёт актуальные поля. Переписывать код не нужно. Типы не нужно править. Шаблоны не нужно трогать.

Но здесь есть тонкость. Динамическая документация решает проблему с параметрами запроса. А что со структурой ответа?

Я пробовал передавать тип возвращаемых данных отдельным полем:

{  "responseStructure": "arr",  "responseType": "Intern",  "responseSchema": {    "id": "string",    "name": "string",    "coffeeLevel": "string",    "hope": "boolean",    "mood": "string"  }}

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

Да, можно полностью перевести фронт на рельсы управляемости бэком. Но тогда ты теряешь типизацию (твой редактор больше не подскажет, какие поля есть у объекта, и не подсветит ошибку до запуска). Теряешь статический анализ. Начинаешь ловить ошибки только в рантайме. Для прототипов это ок. Для продакшена — надо думать.

И как обычно, куда же без подводных камней

Динамическая документация — не волшебная пилюля. Вот что я быстро понял.

Производительность. Если дёргать /api/dynamic-doc/ перед каждым запросом, получишь дополнительный сетевой вызов. Я кеширую доку на пять минут и инвалидирую при 400-х ошибках. Добавил version в ответ: если версия изменилась, фронт перезагружает доки при следующем вызове. Это сняло проблему.

Потеря типизации. Самый больной вопрос. Пришлось отказаться от статических типов для путей и параметров. Но взамен получаем гибкость. Компромисс: на уровне обёртки валидируем параметры через Zod, а схемы ответов всё ещё лежат на фронте статически. Да, это возвращает проблему синхронизации структуры ответа. Но структуру меняют реже, чем пути и параметры. Пока такой гибрид меня устраивает.

Оверхед на бэк. Теперь бэк должен не только обрабатывать бизнес-логику, но и отдавать актуальную документацию.

Когда это стоит пробовать, а когда нет

Не буду делать вид, что этот подход универсальный. Вот мой субъективный чек-лист.

Попробовать стоит, если:

· Бэк часто меняет контракты и не всегда предупреждает.

· У вас больше пары десятков ручек и они постоянно эволюционируют.

· Команда распределённая, нет постоянной синхронизации.

Не стоит, если:

· Бэк стабилен, контракты заморожены.

· У вас 20 ручек и генерация клиента работает без сбоев.

· Синхронизация явно контролируется.

Что в итоге

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

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

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

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

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

А как вы решаете проблему синхронизации контрактов? Используете генерацию, самописное или просто договариваетесь в чате? А может, вообще отказались от REST и ушли в GraphQL?

ссылка на оригинал статьи https://habr.com/ru/articles/1056692/