23 окт. 2025 г.·6 мин чтения

Общие API‑контракты, которые синхронизируют веб и мобильные приложения

Узнайте, как общие API‑контракты фиксируют enum‑ы, формат пагинации и форму ошибок, чтобы веб и мобильная команды реже писали обходы на стороне клиента.

Общие API‑контракты, которые синхронизируют веб и мобильные приложения

Почему клиенты продолжают делать ветвления вокруг одного и того же API

Клиенты начинают делать ветвления, когда API говорит одно и то же чуть разными способами. Один эндпоинт возвращает paid, другой — PAID, а третий шлёт 2. На бэкенде это выглядит как мелочь. На клиенте это превращается в дополнительные проверки, запасные метки и ещё одно место, где экран может сломаться.

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

Паттерн повторяется везде: enum‑ы меняют имена или типы, пагинация переключается между номерами страниц и курсорами, а тела ошибок используют разные поля в каждом эндпоинте. Со временем каждый новый экран собирает немного дополнительной логики. Разработчики перестают доверять форме ответа и пишут код под неожиданные случаи вместо контракта.

Это меняет и поведение пользователей. Одна платформа может трактовать неизвестный enum как «другое». Другая — блокировать действие и показать ошибку. Один и тот же бэкенд‑ответ, разные последствия.

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

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

С чего начать стандартизацию

Начните с частей API, которые обе платформы трогают постоянно. Если веб и мобильное приложение читают id, status, title, total или updated_at на одних и тех же экранах, эти поля должны вести себя одинаково везде. Небольшие различия в общих полях создают большую часть ветвлений, и эти ветвления распространяются.

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

Вторая стадия важнее, чем команды обычно ожидают. Многие API возвращают 30 полей, но клиенты зависят лишь от 6. Стандартизируйте эти 6 в первую очередь. Если экран заказов всегда нуждается в order_id, status, currency, total_amount и created_at, зафиксируйте их до того, как тратить время на редко используемые поля.

Затем решите, что может варьироваться. Метки могут меняться. Необязательные заметки могут быть пустыми. Коды статусов, правила страниц и поведение с null обычно не должны дрейфовать между эндпоинтами. Если один эндпоинт шлёт status: "paid", а другой — status: "completed" для одного и того же состояния, клиенты добавят спец‑кейсы и будут держать их годами.

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

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

Чёткие правила для enum‑ов

Значения enum‑ов должны выглядеть как внутренние коды, а не как текст для экрана. Если приложение показывает «Приостановлено», «On hold» или переведённую метку, API всё равно должен шлёт одно стабильное значение, например on_hold. Это не позволит веб‑ и мобильным командам привязывать бизнес‑логику к формулировкам, которые продукт или маркетинг могут поменять на следующей неделе.

Выбирайте значения скучные и долговечные. Нижний регистр и snake_case легко сканировать и трудно неверно интерпретировать. Избегайте пробелов, знаков препинания со смыслом и меток типа "Plan A", которые понятны только на одном экране.

Старые клиенты рано или поздно увидят незнакомое значение. Планируйте это заранее. У каждого клиента должен быть безопасный fallback: показать нейтральную метку, скрыть действия, зависящие от известного состояния, и залогировать событие. Падение, вечный спиннер или неверная попытка угадать состояние стоят дороже, чем показать «Неизвестно».

Переименование значений enum — это то место, где команды обычно создают боль. Если awaiting_docs стало waiting_for_documents, относитесь к этому как к новому значению, а не как к быстрой переименовке. Сохраняйте старое значение до тех пор, пока все активные версии клиентов не перестанут его отправлять или ожидать. На практике это часто означает один‑два релиз‑цикла, а иногда и дольше для мобильных приложений.

Также полезно решить, кто может менять enum и как эти изменения проходят. Простая политика достаточна. Продукт определяет новое бизнес‑состояние и когда оно появляется. Владелец бэкенда добавляет его в контракт и помечает старые значения как deprecated вместо удаления. Владельцы веба и мобильных добавляют обработку нового значения и fallback для неизвестных значений. Удалять deprecated‑значение можно только после того, как его использование упадёт до нуля.

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

Выберите один формат пагинации

Пагинация становится грязной, когда один эндпоинт использует page=2, другой — offset=20, а третий возвращает курсор. Тогда веб и мобильные команды начинают добавлять исключения повсюду.

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

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

Клиентам также нужно одно чёткое правило для первого запроса. Для номеров страниц это обычно page=1 и выбранный limit. Для курсоров определите, отправляет ли первый запрос вообще пустой курсор или не отправляет поле вовсе. Не оставляйте это на усмотрение каждого хендлера бэкенда.

Обычно в пагинированном ответе достаточно нескольких стабильных полей:

  • items для результатов
  • limit для размера страницы
  • next_cursor или page для следующего шага
  • total, если вы поддерживаете это последовательно

Пустые результаты не должны менять структуру. Возвращайте items: [], сохраняйте поля пагинации и делайте значение следующей страницы явно пустым, например null. Не переходите к items: null и не убирайте метаданные только потому, что нечего показывать.

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

Держите форму ошибок предсказуемой

Привлечь временного CTO
Получите старший технический совет по API‑контрактам, правилам rollout и архитектуре продукта.

Когда формы ответов об ошибках меняются от одного эндпоинта к другому, веб и мобильные команды начинают писать спец‑кейсы. Один экран смотрит в error.message, другой — в errors[0], третий падает на общую алерту. Такая уборка накапливается быстро.

Каждая ошибка должна использовать одни и те же верхнеуровневые поля, даже если HTTP‑статус меняется. 400, 401, 404 или 500 всё ещё могут следовать одной структуре. Клиенты парсят ошибки один раз и переиспользуют обработку везде.

Простой формат обычно достаточен:

  • code — стабильный машинно‑читаемый код ошибки
  • message — текст для человека
  • retryable — можно ли попытаться снова
  • user_fixable — может ли пользователь исправить ситуацию
  • fields — ошибки по полям формы, в одной и той же форме каждый раз

Держите code и message отдельными. Код должен быть стабильным для логики, аналитики и тестов. Сообщение может меняться ради тона, фразировки или перевода без ломки клиентов. Если мобильное приложение хочет показывать локальное сообщение для EMAIL_ALREADY_USED, оно может делать это безопасно.

Ошибки по полям требуют такой же дисциплины. Не возвращайте map в одном эндпоинте и массив в другом. Выберите один формат и держите его. Массив часто проще расширять позже.

{
  "code": "VALIDATION_ERROR",
  "message": "Some fields need attention.",
  "retryable": false,
  "user_fixable": true,
  "fields": [
    { "field": "email", "code": "INVALID_FORMAT", "message": "Enter a valid email address." },
    { "field": "password", "code": "TOO_SHORT", "message": "Password must be at least 8 characters." }
  ]
}

Это также помогает при фоновых ошибках. Если тайм‑аут возвращает retryable: true, приложение может показать «Попробуйте снова», а не винить пользователя. Если ошибка прав доступа возвращает user_fixable: false, клиент может прекратить просить ввод, который пользователь не может изменить.

Одно маленькое правило снимает много ветвлений: держите форму тела стабильной и позволяйте значениям объяснять, что произошло.

Внедряйте контракт по шагам

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

Начните там, где код клиентов уже грязный. Ищите эндпоинты, которые заставляют веб и мобильную команды писать спец‑кейсы, маппить странные enum‑ы или угадывать значение ошибки. Если три экрана несут один и тот же обход, этот эндпоинт должен быть вверху списка.

Прежде чем кто‑то поменяет код, опишите контракт простым языком. Держите его коротким. Хороший драфт говорит: «status использует эти точные значения», «списки всегда возвращают одни и те же поля пагинации» и «каждая ошибка включает code, message и details». Если продукт‑менеджер может прочитать и понять без подсказок — вы на верном пути.

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

Пилотный rollout обычно проходит так:

  1. Выберите одну фичу с реальным трафиком, но ограниченным объёмом, например историю заказов или уведомления.
  2. Примените контракт там сначала и держите старое поведение за флагом версии или адаптером.
  3. Наблюдайте, что ломается в клиентах, тестах, логах и заявках в поддержку.
  4. Подправьте формулировки, где люди интерпретировали правила по‑разному.
  5. Переносите те же правила на следующую фичу только после того, как первая успокоится.

Эта пилотная фаза экономит время. Часто выясняется, что контракт выглядел ясно на бумаге, но всё ещё допускал двоякое толкование. Гораздо дешевле исправлять это после одной фичи, чем после двадцати.

Перед каждым релизом запускайте автоматические проверки против контракта. Тесты схемы, валидация enum‑ов и снимки (snapshot) для полезных нагрузок ошибок ловят дрейф рано. Если у вас уже есть CI, добавьте эти проверки туда, чтобы никто не выпустил тихое изменение формата в пятницу вечером.

Простой пример со статусом заказа

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

Представьте систему заказов с тремя клиентами: админ‑панелью, приложением покупателя и приложением курьера. Если бэкенд шлёт paid на один экран, payment_received на другой и ready_for_pickup только для курьеров, каждый клиент начнёт добавлять свои починки. Так маленькие странности API превращаются в грязный код.

Общий контракт держит все три экрана на одних правилах. Enum статуса заказа одинаков везде, даже если на каждом экране показывают разные метки.

{
  "order": {
    "id": "ord_4821",
    "status": "packed"
  },
  "status_enum": [
    "pending_payment",
    "paid",
    "packed",
    "out_for_delivery",
    "delivered",
    "cancelled"
  ]
}

Админ может показать «Packed and waiting», покупатель — «Preparing your order», курьер может игнорировать статусы, которые ему не нужны. Все три клиента читают одно и то же значение enum: packed. Им не нужны ветвления типа «если admin — это значение, если mobile — другое».

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

{
  "items": [{ "id": "ord_4821", "status": "delivered" }],
  "next_cursor": "eyJpZCI6IjQ4MjEifQ==",
  "has_more": true
}

Это работает и для бесконечной прокрутки на мобильном, и для кнопки «Загрузить ещё» на десктопе. Ни одному клиенту не нужен спец‑лог для одной страницы и курсора в другом месте.

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

{
  "error": {
    "code": "payment_failed",
    "message": "Card was declined",
    "details": {
      "field": "card_number"
    },
    "retryable": false
  }
}

С такой формой каждый клиент может следовать одному шаблону:

  • показывать message пользователю
  • использовать code для логики экрана
  • читать details для ошибок по полям
  • решать, пробовать ли повторно по retryable

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

Частые ошибки команд

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

Обычная ошибка — переименовывать enum‑ы, чтобы они совпадали с новым UI. Бэкенд годами возвращал in_progress, потом кто‑то поменял на processing, потому что продукт обновил копирайт. Текст UI менять можно. Менять значение enum — нельзя. Текст интерфейса может обновляться каждую неделю. Значения контракта должны оставаться скучными и стабильными.

Пагинация дрейфует похожим образом. Старый эндпоинт возвращает page и total_pages, новый — cursor и has_more. Оба формата работают, но смешивать их в похожих эндпоинтах заставляет клиентов писать адаптеры вместо продуктовой логики.

Обработка ошибок часто ухудшается под давлением. Многие команды упаковывают все сбои в одну строку вида «Что‑то пошло не так». На сервере это кажется просто, но всю тяжесть работы перекладывает на клиентов. Приложениям нужна предсказуемая форма ошибок, чтобы отличать валидацию, ошибки аутентификации и временные проблемы сервера.

Обращение с null вызывает тихие поломки. Если phone раньше был null, а теперь API шлёт пустую строку, клиентский код может трактовать это по‑разному. Никто не замечает, пока не начнут ломаться поиск, фильтры или формы.

Неизвестные значения — ещё одна слабая точка. Если сервер добавит новый enum позже, приложения не должны угадывать, что он означает. Решите поведение fallback заранее.

Большая часть бардака приходит из нескольких привычек: связывать значения контракта с текстом UI, поддерживать старые и новые форматы пагинации без плана, прятать все ошибки в одно сообщение, тихо менять правила null и полагать, что клиенты «разберутся» с новыми enum‑ами.

Стабильные контракты кажутся строгими, но они экономят время. Главное — они не дают мелочным рассинхронизациям расползаться по каждому клиенту.

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

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

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

Запустите короткий обзор перед релизом и используйте реальные примеры полезных нагрузок, а не только документацию. Контракт может выглядеть верно на бумаге и всё равно сломаться, когда один клиент получит пустой список, неизвестный enum или 401 с другим телом.

Проверяйте несколько вещей каждый раз. Дайте веб‑ и мобильной команде одинаковые примеры успеха и ошибок и убедитесь, что оба клиента их парсят без специальных фикс‑парсеров. Просмотрите каждый enum и договоритесь, что делать, если сервер пришлёт новое неизвестное значение. Протестируйте пагинацию на краях: первая страница, последняя страница и пустой результат. Форма должна оставаться одинаковой. Сравните ответы об ошибках для 400, 401, 404 и 500 и убедитесь, что поля совпадают, даже если сообщения разные. Наконец, зафиксируйте каждое ломающее изменение и правило версионирования, чтобы никто не гадал, работает ли старая версия приложения.

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

Команды часто пропускают проверку неизвестных enum‑ов. Это ошибка. Мобильные приложения могут оставаться у пользователей месяцами, поэтому они должны уметь обрабатывать новые значения без принудительного обновления.

Если кто‑то говорит «зависит от клиента», остановитесь и ужесточите контракт до релиза.

Что делать дальше

Не начинайте с полной чистки API. Обычно это тормозит, и команды возвращаются к старым привычкам. Выберите один шаблон контракта и примените его к следующей фиче, которую запускаете.

Сделайте шаблон скучным и строгим. Он должен определять значения enum, один формат пагинации и одну форму ответа об ошибке. Если новый эндпоинт не помещается в шаблон — рассматривайте это как проблему дизайна, а не как задачу для каждого клиента.

Короткая проверка перед релизом экономит много переделок. Соберите бэкенд, веб и мобиль в одном звонке на 15–20 минут. Пройдите примеры запросов и ответов вместе, проверьте краевые случаи и договоритесь, что происходит, когда данные отсутствуют, значение неизвестно или запрос падает.

Простая рутина работает так:

  1. Опишите контракт до завершения работы бэкенда.
  2. Проверьте его с вебом и мобильными перед релизом.
  3. Выпускайте в одной новой фиче, а не в десяти.
  4. Отслеживайте каждое место, где клиенты всё ещё ветвят из‑за багов бэкенда.
  5. Убирайте эти случаи по одному в следующих релизах.

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

Это не требует гигантского процесса. Один шаблон, одна проверка и короткий список доработок принесут больше пользы, чем длинный стандарт, который никто не читает.

Если команда хочет внешнего обзора, Oleg Sotnikov на oleg.is помогает стартапам и небольшим командам с ужесточением API‑контрактов, правилами rollout и архитектурой приложений как временный CTO.

Часто задаваемые вопросы

Почему веб‑ и мобильные команды продолжают добавлять ветвления вокруг одного и того же API?

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

Что нужно стандартизировать в первую очередь?

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

Как проектировать значения enum?

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

Что клиент должен делать с незнакомым значением enum?

Обращайтесь с неизвестными значениями как с обычными и безопасными для обработки. Покажите нейтральную метку, скрывайте действия, зависящие от известного состояния, и логируйте событие вместо того, чтобы догадываться.

Использовать номера страниц или курсоры?

Выберите один формат для похожих коллекций и придерживайтесь его. Номера страниц подходят для простых и стабильных списков; курсоры — для лент и данных, которые смещаются во время прокрутки. Главное — не смешивать оба без явной причины.

Что делает пагинированный ответ удобным для клиентов?

Возвращайте постоянную форму: items всегда массив, пустые результаты выглядят как items: [], и метаданные пагинации не исчезают только потому, что данных нет.

Что должен содержать хороший ответ об ошибке API?

Одна и та же форма ошибки для 400, 401, 404 и 500. Стабильный code, читаемый message и понятные поля вроде retryable или деталей по полям формы позволяют клиентам парсить ошибки один раз и переиспользовать логику везде.

Как внедрить общий API‑контракт без большого рефактора?

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

Какие ошибки чаще всего разрушают общие API‑контракты?

Часто разрушают контракт переименования enum‑ов в угоду новому UI, смешение стилей пагинации, тихие смены правил null или упаковка всех ошибок в одну строку. Такие изменения кажутся безобидными на сервере, но создают работу для каждого клиента.

Что нужно проверять перед релизом?

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