Как спроектировать REST API без ошибок: 3 задачи с разбором

Всем привет, меня зовут Сергей Прощаев. Я Tech Lead в FinTech и преподаватель в OTUS. Сегодня — не лекция, а диагностика. За последние два года на собеседованиях 90% аналитиков уверенно рассуждают про REST, но путают PUT и PATCH, возвращают 200 при создании ресурса или забывают про версионирование. Поэтому три коротких кейса из практики — решите сами, а потом сверимся.

Важно понимать: в API‑дизайне редко существует единственно правильный ответ. Обычно это компромисс между HTTP‑семантикой, удобством клиентов, зрелостью платформы и стоимостью поддержки. Мои разборы — это аргументированная позиция, а не истина в последней инстанции.

Подписывайся на наши соц сети:

https://vk.com/javatutorial

https://max.ru/javalib

https://t.me/javalib

Рис. 1. Диагностика: сможете ли вы спроектировать API, которое не сломается через полгода?
Рис. 1. Диагностика: сможете ли вы спроектировать API, которое не сломается через полгода?

Кейс № 1. Метод и идемпотентность: простая операция, которая ломает production

Условие.
Вы проектируете API для сервиса управления подписками. Нужно реализовать операцию «отключить автопродление» для активной подписки пользователя. Бэкенд‑разработчик говорит: «Сделаем POST /subscription/{id}/cancel-auto-renewal — это действие, оно не идемпотентно, каждый запрос отключает опцию». Продакт добавляет: «А если клиент по ошибке нажмёт кнопку дважды, ничего страшного — пусть просто вернёт успех».

Остановитесь и выберите вариант (можно мысленно):

  1. Оставить POST /subscription/{id}/cancel-auto-renewal — клиент сам отвечает за дубли.
  2. Использовать PUT /subscription/{id}/auto-renewal с телом {enabled: false} — при повторном вызове эффект тот же.
  3. Использовать DELETE /subscription/{id}/auto-renewal — удаляем свойство «автопродление».
  4. Сделать PATCH /subscription/{id} и передавать {"autoRenew": false}.

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

Разбор.

  • Вариант 1 (POST с глаголом в URL) — это action‑oriented API‑подход, ближе к RPC‑стилю. Он широко используется в индустрии (Stripe, Kubernetes, AWS) и сам по себе не является ошибкой. Но у него есть недостатки: сложнее обеспечивать единообразие семантики, идемпотентность и предсказуемость поведения API по мере роста системы.
  • Вариант 2 (PUT на подресурс auto-renewal) — технически идемпотентен, PUT заменяет состояние ресурса целиком. Но здесь есть нюанс: подресурс auto-renewal — это логическое свойство основного ресурса subscription. Создавать отдельный «ресурс» для одного булева поля — избыточно, хотя и допустимо. Я такое видел в API некоторых CRM‑систем, и это работает, но увеличивает количество эндпоинтов.
  • Вариант 3 (DELETE /auto-renewal) — тоже идемпотентен. DELETE здесь выглядит спорно не потому, что подресурс невозможен (с точки зрения REST ресурсом может быть любая адресуемая концепция), а потому что семантика операции становится неоднозначной. Клиенту неочевидно: мы удаляем настройку автопродления, отключаем флаг или удаляем связанную сущность. Для простого переключения состояния PATCH читается понятнее.

Правильный вариант (4) — PATCH /subscription/{id} с телом {"autoRenew": false}.

Почему?

  • PATCH предназначен для частичного изменения ресурса. Важно понимать: RFC не гарантирует идемпотентность PATCH. Однако конкретная PATCH‑операция может быть идемпотентной, если повторный запрос приводит к одному и тому же состоянию ресурса. В нашем случае {"autoRenew": false} — именно такой сценарий.
  • Для простого изменения одного поля PATCH выглядит проще и компактнее. Отдельный подресурс имеет смысл, когда у настройки появляется собственный lifecycle, права доступа или отдельные операции (например, /users/1/mfa).
  • Клиенту легко включить автопродление обратно: PATCH с {"autoRenew": true}.

Техническое уточнение: RFC PATCH не навязывает конкретный формат тела. Но на практике чаще используют стандартизированные варианты: JSON Patch (RFC 6902) или JSON Merge Patch (RFC 7386). В нашем примере подойдёт JSON Merge Patch, если зафиксировать это в контракте.

Какой навык проверяли: умение выбрать HTTP‑метод с учётом идемпотентности и семантики, а не «как удобнее написать контроллер».

Из практики. Однажды мы сделали «быстрый» API для биллинга на POST /cancel-subscription. Всё работало полгода, пока не появился автоматический retry на клиенте. В день релиза нам прилетело: дважды вызванный POST отправлял второе уведомление в поддержку, создавал дубль в логах и запускал цепочку лишних событий. Со временем API начал дрейфовать в сторону RPC‑style интерфейса: появились десятки action‑endpoint«ов с разной семантикой и непредсказуемым поведением при повторных запросах и идемпотентности. Проблему решили за неделю, переведя критичные операции на идемпотентные методы. С тех пор я всегда спрашиваю на собеседованиях: „А что будет, если клиент отправит запрос дважды?“.»

Кейс № 2. Коды ответа и ошибки: 200 или 400?

Условие.
У вас есть API для регистрации пользователя: POST /users. Клиент отправляет email и пароль. Бизнес‑правило: email должен быть уникальным. Если пользователь с таким email уже существует, API должен вернуть ответ.

Остановитесь и выберите вариант (можно мысленно):

  1. 200 OK и в теле {"status": "error", "message": "Email exists"}.
  2. 400 Bad Request.
  3. 409 Conflict.
  4. 422 Unprocessable Entity.

Мои наблюдения. Примерно 40% аналитиков на интервью называют 400 или 409, но не могут объяснить разницу между 400 и 409. А 20% вообще честно говорят: «На прошлом проекте мы всегда возвращали 200 с полем success: false».

Разбор.

  • Вариант 1 (200 с ошибкой) — классическое «API как RPC». Для REST API это обычно считается плохой практикой, потому что ломает семантику HTTP и усложняет обработку ошибок на стороне клиента. Однако в некоторых интеграционных или legacy‑сценариях (например, GraphQL, асинхронная оркестрация) такой подход всё ещё встречается.
  • Вариант 2 (400 Bad Request) означает, что запрос синтаксически неверен или не соответствует схеме (например, поле email не строка). Но здесь запрос корректен с точки зрения структуры — проблема в бизнес‑логике (уникальность). 400 слишком общий, разработчики клиентов начнут путать ошибки валидации полей и бизнес‑конфликты.
  • Вариант 3 (409 Conflict) — рекомендованный. Статус говорит: «Запрос конфликтует с текущим состоянием ресурса». Конфликт уникальности email — классика. RFC 7231 прямо указывает, что 409 подходит для случаев, когда запрос не может быть выполнен из‑за конфликта с состоянием сервера. Добавляем в тело понятное сообщение {"error": "email_already_exists", "message": "User with this email already registered"}— и клиент знает, как обработать.
  • Вариант 4 (422 Unprocessable Entity) активно используется многими современными API и фреймворками (Rails, FastAPI, GitHub API). Проблема в другом: в индустрии нет единого консенсуса, где проходит граница между 400, 409 и 422. Поэтому важно не столько выбрать «идеальный» статус, сколько обеспечить единообразие внутри API‑платформы.

Как я обычно делаю:

  • 400 — для ошибок валидации на уровне полей (email не соответствует regex, пароль короткий).
  • 409 — для бизнес‑конфликтов, которые клиент мог бы предотвратить (email уже занят, заказ уже оплачен, документ заблокирован).
  • 200 с ошибкой — только в legacy или специфических интеграционных сценариях, но не в новом REST API.

Какой навык проверяли: понимание семантики HTTP‑статусов и различие между синтаксической ошибкой, бизнес‑конфликтом и успешным запросом.

Из практики. Помню историю: в одном финтех‑проекте мы использовали 409 для блокировки счетов, когда два запроса пытались списать деньги одновременно. Без этого статуса клиент не понимал, что нужно повторить запрос с обновлёнными данными. А с 409 — спокойно отрабатывал через retry.

Кейс № 3. Версионирование: URL, заголовок или что‑то ещё?

Условие.
Через полгода после запуска API вам нужно добавить новую функциональность в эндпоинт GET /users/{id}/orders. Старый формат ответа: {orderId, date, amount}. В новой версии требуется добавить поле status. (Изменения типа amount нет — только additive change, что потенциально обратно совместимо для клиентов, реализованных по tolerant reader pattern.)

Остановитесь и выберите вариант (можно мысленно):

  1. Добавить новую версию в URL: GET /v2/users/{id}/orders.
  2. Использовать custom заголовок: API-Version: 2.
  3. Использовать Accept‑заголовок с медиатипом: Accept: application/vnd.mycompany.v2+json.
  4. Не версионировать, а просто добавить поле status в старый ответ (обратная совместимость).

Мой личный опыт. Я пробовал все варианты.

Разбор.

  • Вариант 1 (URL) — самый простой для понимания, его любят разработчики и DevOps. Главный минус URL‑версионирования — версия становится частью идентификатора ресурса, из‑за чего один и тот же бизнес‑ресурс начинает существовать по разным URI. С практической точки зрения это обычно приемлемо, но теоретически расходится с «чистым» REST‑подходом.
  • Вариант 2 (custom заголовок) — гибкий, но нестандартный. Прокси и кеширующие серверы могут игнорировать его, и вы рискуете, что старые клиенты случайно получат новую версию.
  • Вариант 3 (Accept‑медиатип) — ближе к классическому content negotiation подходу из REST. Он позволяет сохранить URI стабильным и даёт возможность версионировать отдельные представления ресурса. Недостаток: сложнее в тестировании и мониторинге (плохо видно в логах nginx).
  • Вариант 4 (additive change) — технически обратно совместим, если клиенты используют tolerant reader pattern и игнорируют неизвестные поля. Многие API поступают именно так (например, добавление status в ответ заказа). Но если клиенты применяют строгую валидацию схемы, они могут упасть. Поэтому «просто добавить поле» — это риск, зависящий от экосистемы.

Мой выбор. Для большинства внутренних B2B‑проектов я предпочитаю URL‑версионирование (/v1/..., /v2/...). Оно прозрачное, быстрое и снижает риск ошибок. А если API публичное и требует долгосрочной поддержки множества версий — использую Accept с медиатипом.

Что не рекомендуется: класть версию в query параметр (?version=2). Это часто создаёт проблемы с кешированием, маршрутизацией и наблюдаемостью API, поэтому query‑versioning обычно считается нежелательным.

Уточнение про индустрию: в индустрии встречаются разные подходы. GitHub исторически использовал vendor media types через Accept‑заголовок, а Stripe — версионирование через отдельный заголовок Stripe-Version. Универсального стандарта де‑факто не существует: выбор зависит от требований к обратной совместимости, инфраструктуре и сроку жизни API.

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

Живой пример. На одном e‑commerce проекте мы поленились версионировать и добавили новое поле grossAmount в ответ заказа. Через неделю клиенты с жёсткой схемной валидацией начали падать после появления нового поля. В итоге пришлось разворачивать параллельный эндпоинт под старый формат и переводить клиентов по заголовку. С тех пор версионирование закладываю в API с первого дня — даже если сейчас кажется, что «ничего не изменится».

Две схемы, которые структурируют мышление

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

Вот первая — про версионирование. В кейсе № 3 вы сравнивали варианты, а теперь увидите, как технически работает маршрутизация запросов в зависимости от выбора: URL‑версионирование или Accept‑медиатип. Схема наглядно показывает, где именно «встраивается» версия и почему это влияет на мониторинг и чистоту API (рис.2):

Рис. 2. Сравнение подходов к версионированию: URL vs Accept‑заголовок
Рис. 2. Сравнение подходов к версионированию: URL vs Accept‑заголовок

Что выносим из этой схемы: URL‑версионирование (/v1/...) просто для инфраструктуры, но версия становится частью идентификатора ресурса. Accept‑медиатип чище с точки зрения REST, но сложнее в мониторинге. Выбор зависит от того, публичное это API или внутреннее, и как долго вы планируете поддерживать старые версии.

А вторая схема — это алгоритм выбора HTTP‑статуса при ошибке. Во втором кейсе мы спорили, стоит ли возвращать 200 с ошибкой, 400, 409 или 422. Эта схема — мой рабочий чек‑лист. Она проведёт вас по шагам: сначала проверяем синтаксис, потом бизнес‑логику, потом права доступа. Держите её под рукой, когда будете проектировать новый эндпоинт (рис. 3):

Рис. 3 — алгоритм выбора HTTP‑статуса при ошибке
Рис. 3 — алгоритм выбора HTTP‑статуса при ошибке

Что выносим из этой схемы: сначала проверяем синтаксис и схему (статус 400). Затем бизнес‑конфликт (409). Потом аутентификацию и авторизацию (401/403). И не используем 200 с ошибкой в теле для новых REST API.

Финальный чек‑лист перед тем, как отдать API в разработку

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

  • URL идентифицирует ресурс, а не действие (никаких /createUser, /getData).
  • HTTP‑метод соответствует операции (GET для чтения, POST для создания, PUT/PATCH для обновления, DELETE для удаления).
  • Идемпотентность продумана: повторный PUT, PATCH, DELETE не вызывает побочных эффектов.
  • Коды ответов используются по назначению (201 при создании, 202 для асинхронных, 204при успешном удалении без тела).
  • Ошибки имеют тело с понятным кодом (error_code) и сообщением, а статус отражает суть (400, 409, 422, 404).
  • Версионирование заложено с первого дня — хотя бы на уровне маршрутов (/v1/...).
  • Есть спецификация OpenAPI, и она не врёт.

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

Источник

361 views·32 shares