Новое в журнале. Дизайн API для долгой жизни проекта
Принципы и архитектура

Дизайн API для долгой жизни проекта

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

23 октября 20269 мин чтенияПрактикам
Дизайн API для долгой жизни проекта
4 клиентакоторых вы не контролируете
1 заголовокIdempotency-Key защищает от дублей

API живёт дольше, чем кодовая база, которая его обслуживает. Мобильное приложение на старой версии, интеграция партнёра, написанная три года назад и не тронутая с тех пор, внутренний скрипт, который никто не помнит кто писал — все они продолжают дёргать одни и те же эндпоинты, пока разработчики меняют всё вокруг. Спроектировать API «для долгой жизни» значит заранее признать: часть клиентов вы не контролируете и не можете заставить обновиться по расписанию.

Отсюда вытекает идея, которая реже звучит вслух, чем должна: API — это интерфейс, и у него, как у любого интерфейса, есть пользователь со своими ожиданиями. Разработчик, который читает документацию и пишет код против вашего эндпоинта, переживает тот же опыт, что обычный человек в мобильном приложении — ему либо понятно и предсказуемо, либо приходится гадать и читать исходники. Там, где за этим API стоит готовый продукт с интерфейсом для живых людей — админка, личный кабинет, мобильное приложение, — ту же дисциплину предсказуемости обычно поручают отдельной команде: например, профильный дизайн интерфейсов в YUSMP Group, которая отвечает за то, чтобы это ощущалось так же продуманно, как хороший API.

Почему API стареет быстрее, чем кажется

Простой пример: команда переименовывает поле user_name в username в ответе эндпоинта, потому что так логичнее и единообразнее с остальным API. Для фронтенда, который деплоится вместе с сервером, правка занимает пять минут. Для мобильного приложения, которое пользователи не обновляют годами, это тихо ломает экран профиля у части аудитории — без ошибки 500, без строки в логах, просто с пустым полем на экране.

Ошибка здесь не в самом переименовании, а в том, что оно ушло в прод без контракта, который бы заранее предупредил: это breaking change. Публичный или долгоживущий внутренний API обязан различать два вида изменений — те, что безопасны для существующих клиентов, и те, что требуют отдельного плана выката. Дальше в статье — конкретные правила для каждой из типичных зон, где это различие чаще всего теряется.

Что общего у API и дизайна интерфейса

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

Developer experience (DX) — это, по сути, UX для той самой аудитории, которая работает с вашим продуктом через код, а не через экран. Решения, которые ниже разбираются по пунктам — версионирование, нейминг, идемпотентность, пагинация, формат ошибок — это не бюрократические формальности, а конкретные ответы на вопрос «что увидит и почувствует разработчик, который впервые открывает документацию вашего API через год».

Версионирование — выбрать один подход и не менять

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

URI-путь (/v1/users) — когда это разумный дефолт

Версия прямо в адресе — самый очевидный способ для разработчика клиента: видно в логах, в документации, в адресной строке. Это разумный дефолт для публичного API, где клиентов много и вы не контролируете, когда и как они делают запросы. Минус — версия «размножается» по всему адресному пространству, и приходится поддерживать параллельные роуты для каждой активной версии.

Header / content-negotiation — для внутренних API, где вы контролируете клиентов

Версия передаётся заголовком, например Accept: application/vnd.company.v2+json, а URL остаётся неизменным. Это чище с точки зрения REST-пуризма, но требует от клиента осознанной работы с заголовками — удобно, когда клиентов немного и это ваши же сервисы, и неудобно для внешних интеграторов, которые часто забывают заголовок и попадают на дефолтную версию без предупреждения.

Query-параметр — почему это компромиссный, а не лучший вариант

Версия вида ?version=2 проще всего добавить в существующий API задним числом, но она же проще всего и забывается: параметр необязателен по природе URL, и кэширующие прокси часто не учитывают query-параметры при построении ключа кэша, из-за чего разные версии ответа могут неожиданно перекрываться в кэше. Разумно использовать её как временный мост при миграции на URI-версионирование, а не как постоянную схему.

Обратная совместимость: что можно менять без страха, а что — нет

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

Не breaking (безопасно)Breaking (требует версии или плана выката)
Добавить новое необязательное поле в ответПереименовать или удалить существующее поле
Добавить новый эндпоинтИзменить тип поля (строка → число)
Добавить новое значение enum, если клиент обязан игнорировать неизвестныеУдалить существующее значение enum
Сделать обязательный параметр запроса необязательнымСделать необязательный параметр обязательным
Расширить лимит (например, увеличить максимальный page size)Изменить код ответа для существующего сценария

Граница не всегда очевидна на старте: добавление нового значения enum безопасно только если клиенты написаны с явным default-веткой на неизвестные значения — а это стоит прописать в документации контракта отдельно, а не предполагать по умолчанию.

Нейминг ресурсов и методов — мелочи, которые накапливаются

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

  • Ресурсы — существительные во множественном числе: /orders, а не /order и не /getOrders
  • Разделитель слов в пути — kebab-case: /shipping-addresses, а не /shippingAddresses или /shipping_addresses
  • Действие выражает HTTP-метод, а не глагол в пути: DELETE /orders/42, а не POST /orders/42/delete
  • Вложенность ресурсов отражает реальную иерархию данных, а не произвольную группировку по экрану фронтенда

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

Идемпотентность — почему POST без защиты опасен

GET, PUT и DELETE идемпотентны по спецификации HTTP: повторный одинаковый запрос должен приводить к тому же результату, что и первый. POST — не идемпотентен, и именно поэтому повторный POST на создание заказа или платежа — одна из самых частых причин двойных списаний в реальных системах: клиент не получил ответ из-за таймаута сети и честно повторил запрос, думая, что первый не дошёл.

POST /payments HTTP/1.1
Idempotency-Key: 7c2f9e41-3b4a-4a9d-9a21-6e5f0c1d8a90
Content-Type: application/json

{"amount": 4990, "currency": "RUB", "order_id": "ORD-10432"}

Клиент сам генерирует уникальный ключ идемпотентности для конкретной бизнес-операции и передаёт его в заголовке. Сервер запоминает результат первого запроса с этим ключом и при повторе — в том числе при сетевом ретрае того же клиента — возвращает тот же ответ, не выполняя операцию второй раз. Реализация требует отдельного хранилища для ключей с TTL, но стоимость этого хранилища несопоставима со стоимостью разбирательства с клиентом по поводу задвоенного платежа.

Пагинация: offset vs cursor — когда что

Offset-пагинация (?page=3&per_page=20) интуитивно понятна и позволяет прыгнуть на произвольную страницу, но пересчитывает смещение по всей таблице при каждом запросе и даёт дубли или пропуски строк, если данные добавляются или удаляются между запросами соседних страниц. Для таблицы, которая активно растёт в реальном времени, это не теоретический риск, а гарантированный баг при достаточном трафике.

КритерийOffsetCursor (keyset)
Прыжок на произвольную страницуДаНет, только вперёд/назад от курсора
Стабильность при параллельной записиЛомается: дубли/пропускиУстойчива к вставкам и удалениям
Производительность на больших offsetПадает с ростом смещенияСтабильна независимо от позиции
Где уместнаАдминка, небольшие, редко растущие наборыПубличный API, лента, растущие таблицы

Практический вывод простой: offset — нормальный выбор для внутренней админки с ограниченным и предсказуемым объёмом данных, cursor — обязателен для любого публичного или активно растущего API, где клиенты листают данные, пока вы продолжаете писать в ту же таблицу.

Ошибки как часть контракта, а не как исключение

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

{
  "type": "https://api.example.com/errors/insufficient-funds",
  "title": "Недостаточно средств",
  "status": 402,
  "detail": "На балансе 1200 ₽, требуется 4990 ₽",
  "instance": "/payments/8f21a0"
}

Это формат application/problem+json из RFC 9457 — современный стандарт для структуры ошибок REST API, который уже поддерживают многие фреймворки «из коробки». Использовать собственный формат вместо него не ошибка, но тогда стоит хотя бы держать его внутренне консистентным, как подробно разбирает руководство Postman по лучшим практикам REST API: один формат ошибки на весь API, без исключений для «особых» эндпоинтов.

Как не узнать о breaking change постфактум

Самый надёжный способ не узнавать о сломанном контракте от разъярённого клиента в поддержке — контрактные тесты против OpenAPI-спеки, встроенные в CI. Спека описывает контракт декларативно: какие поля обязательны, какие типы, какие коды ответов возможны. Тест в пайплайне сравнивает реальный ответ сервиса с этой спекой на каждый pull request и падает red, если изменение расходится с задокументированным контрактом.

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

Если API — часть продукта с интерфейсом для людей

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

Частые вопросы

Что значит «breaking change» в API?

Любое изменение контракта, после которого существующий клиент перестаёт работать без правок на своей стороне: переименованное или удалённое поле, смена типа данных, удалённое значение enum, новый обязательный параметр или изменённый код ответа.

Нужно ли версионировать внутренний API, который используете только вы сами?

Формальная схема версий нужна реже: если все клиенты — ваши собственные сервисы, их можно обновить одновременно с сервером. Но даже тогда полезно фиксировать контракт через OpenAPI-спеку и предупреждать команды о breaking change заранее, а не постфактум.

Чем cursor-пагинация лучше offset для растущих таблиц?

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

Как добавить Idempotency-Key, если клиент его не поддерживает?

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

Сколько версий API нужно поддерживать одновременно?

Универсального числа нет, но практичный ориентир — не больше двух-трёх активных версий с чётким сроком поддержки для каждой. Больше версий резко увеличивает стоимость тестирования и сопровождения на каждое изменение.

Что такое application/problem+json и зачем он нужен?

Это content-type из RFC 9457, стандартизирующий структуру объекта ошибки API: тип проблемы, заголовок, код статуса и детали в едином формате. Клиенту больше не нужно парсить произвольный текст ошибки или угадывать структуру по документации конкретного API.

Как тестировать, что новый релиз не сломал контракт API?

Контрактными тестами в CI, которые сверяют реальные ответы сервиса с OpenAPI-спекой на каждый pull request. Это не заменяет обычные unit- и интеграционные тесты, но ловит ровно тот класс проблем, который юнит-тесты внутри одного сервиса обычно не видят.

Код, который не стыдно передать

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

Случайный разбор