Материал подготовлен в рамках курса «Микросервисная архитектура».

Всем привет, меня зовут Сергей Прощаев. Я Tech Lead и руководитель направления Java | Kotlin разработки в FinTech. Также преподаю на курсах разработки и архитектуры в ОТУС. В этой статье я хочу поговорить о том, как проектировать REST API, чтобы они не превращались в головную боль для всех — от разработчиков до конечных пользователей.

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

Однажды команда запустила сервис заказов. Всё было красиво: документация в Swagger, код на FastAPI, CI/CD. Но через месяц после релиза начались проблемы. Фронтендеры жаловались, что не могут понять, почему заказ то создаётся, то нет. Бэкендеры из смежного сервиса кухни не могли получить статус заказа. Всё работало, но как‑то… криво. Оказалось, проблема была не в коде, а в том, как мы спроектировали API. Мы использовали HTTP просто как транспорт, а не как полноценный инструмент.

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

Что такое REST на самом деле

Когда я спрашиваю на собеседованиях «Что такое REST?», 90% кандидатов отвечают «это когда есть методы GET, POST, PUT, DELETE». Это не совсем так. REST — это архитектурный стиль, который Рой Филдинг описал в своей диссертации ещё в 2000 году. Он базируется на шести ограничениях, которые делают API слабосвязанными и масштабируемыми.

Давайте разберёмся, что это значит на практике.

Рис. 1. Шесть ограничений REST
Рис. 1. Шесть ограничений REST

Клиент‑серверная архитектура и отсутствие состояния

Первое и самое важное — разделение задач. Клиент не должен знать, как сервер хранит данные. Сервер не должен знать, как выглядит интерфейс пользователя. Это даёт возможность развивать их независимо.

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

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

Кэширование и многоуровневость

Когда это уместно, ответы сервера должны кэшироваться. GET‑запросы — идеальные кандидаты. В том же сервисе заказов мы кэшировали статус заказа на 30 секунд. Это сократило нагрузку на базу данных в 5 раз.

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

Код по запросу и единство интерфейса

Последние два ограничения часто вызывают вопросы. «Код по запросу» — опциональное ограничение, позволяющее серверу отправлять клиенту исполняемый код (например, JavaScript). Это редко встречается в бэкенд‑API.

А вот единство интерфейса — критически важно. Оно требует, чтобы API был согласованным. Если вы назвали эндпоинт /orders, то все операции с заказами должны быть на этом пути. Если вы используете JSON, то весь API должен использовать JSON.

Гипермедиа: модная штука, которая часто остаётся за бортом

В академическом REST есть понятие HATEOAS (Hypermedia as the Engine of Application State). Идея в том, что каждый ответ должен содержать ссылки на возможные действия с ресурсом. Например, ответ с заказом должен содержать ссылку на его оплату и отмену.

{
  "id": "924721eb-a1a1-4f13-b384-37e89cee0875",
  "status": "progress",
  "created": "2023-09-01",
  "order": [
    { "product": "cappuccino", "size": "small", "quantity": 1 }
  ],
  "links": [
    { "href": "/orders/8/cancel", "description": "Cancels the order", "type": "POST" },
    { "href": "/orders/8/pay", "description": "Pays for the order", "type": "POST" }
  ]
}

Звучит красиво, но на практике я видел HATEOAS в продакшене только пару раз. Почему? Причин несколько:

  1. Документация OpenAPI и так даёт всю информацию о возможных операциях.

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

  3. Некоторые ссылки зависят от состояния ресурса. Нельзя отменить уже выполненный заказ.

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

Мой совет: не гонитесь за HATEOAS, если у вас нет явной потребности в этом. Для 95% API достаточно хорошей документации.

Модель зрелости Ричардсона

Чтобы оценить, насколько ваш API соответствует REST‑принципам, Леонард Ричардсон предложил модель зрелости из четырёх уровней (см. рис. 2).

Рис. 2. Модель зрелости Ричардсона
Рис. 2. Модель зрелости Ричардсона

Уровень 0: RPC‑стиль

На этом уровне HTTP используется просто как транспорт. Все запросы идут на один эндпоинт (часто /api), а действие определяется в теле запроса:

POST /api
{
  "action": "placeOrder",
  "order": [...]
}

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

Уровень 1: Ресурсы

На этом уровне мы начинаем использовать разные URL для разных ресурсов. Например, /orders для заказов, /users для пользователей. Но методы HTTP всё ещё игнорируются — все запросы идут через POST.

Уровень 2: Методы HTTP и статус‑коды

Вот здесь начинается настоящий REST. Мы начинаем использовать методы HTTP по назначению:

  • GET /orders/{id} — получить заказ

  • POST /orders — создать заказ

  • PUT /orders/{id} — обновить заказ

  • DELETE /orders/{id} — удалить заказ

И возвращаем правильные статус‑коды: 200 при успехе, 201 при создании, 404 если не нашли, 400 если плохой запрос.

Уровень 3: HATEOAS

Третий уровень добавляет гипермедиа‑ссылки. Как я уже говорил, это редко используется в реальных проектах.

В своей практике я стараюсь проектировать API на уровне 2. Это золотая середина между правильностью и прагматичностью.

Проектирование эндпоинтов

Теперь давайте перейдём к практике. Как правильно спроектировать эндпоинты?

Ресурсы и коллекции

В REST есть два типа ресурсов:

  • Коллекции — списки сущностей (например, /orders)

  • Синглтоны — конкретная сущность (например, /orders/{order_id})

Это простое правило даёт нам понятную структуру URL.

Методы HTTP

Семантика методов HTTP — это то, что часто путают. Давайте раз и навсегда разберёмся:

  • GET — получение ресурса. Не должен изменять состояние на сервере. Должен быть идемпотентным.

  • POST — создание ресурса. Не идемпотентен. Если отправить два одинаковых POST‑запроса, создадутся два ресурса.

  • PUT — полная замена ресурса. Идемпотентен. Если отправить два одинаковых PUT‑запроса, результат будет одинаковым.

  • PATCH — частичное обновление ресурса. Может быть идемпотентным, но не обязан.

  • DELETE — удаление ресурса. Идемпотентен. Повторный DELETE не должен приводить к ошибке (обычно возвращают 204 или 404 — важно придерживаться единого правила).

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

Статус‑коды

Статус‑коды — это язык, на котором API говорит клиенту, что произошло. Нельзя использовать только 200 и 500.

Группы статус‑кодов:

  • 2xx — успех

  • 3xx — перенаправление

  • 4xx — ошибка клиента

  • 5xx — ошибка сервера

Для нашего сервиса заказов мы используем:

  • 201 Created — заказ создан

  • 200 OK — запрос выполнен успешно

  • 204 No Content — ресурс удалён

  • 400 Bad Request — синтаксическая ошибка в запросе

  • 404 Not Found — заказ не найден

  • 422 Unprocessable Entity — данные валидны, но не могут быть обработаны (например, товара нет в наличии)

  • 500 Internal Server Error — непредвиденная ошибка на сервере

Параметры запроса

Когда эндпоинт возвращает список ресурсов, нужно дать клиенту возможность фильтровать и пагинировать результаты.

GET /orders?status=completed&page=2&limit=10

Пагинация — важная тема. В одном проекте мы не предусмотрели пагинацию для списка заказов. Через год в базе было 2 миллиона заказов, и эндпоинт /orders просто падал. Пришлось срочно переписывать клиентское приложение.

Я предпочитаю параметры page и per_page — они интуитивно понятны. Альтернатива — limit и offset, но с большими значениями offset возникают проблемы с производительностью.

Проектирование полезной нагрузки

Полезная нагрузка (payload) — это данные, которые передаются в теле запроса или ответа. Здесь тоже есть свои правила.

Для POST‑запросов

При создании ресурса ответ должен содержать полное представление созданного ресурса с серверными полями (id, дата создания, статус). Это подтверждает, что ресурс создан корректно, и избавляет клиента от лишнего запроса.

{
  "id": "924721eb-a1a1-4f13-b384-37e89cee0875",
  "status": "pending",
  "created": "2023-09-01",
  "order": [...]
}

Для PUT и PATCH

При обновлении ресурса тоже стоит возвращать полное представление. Клиент может сверить, правильно ли применились изменения.

Для GET

Для списков есть два подхода:

  1. Возвращать полные представления всех элементов. Просто для клиента, но тяжело для сети.

  2. Возвращать только идентификаторы, а детали запрашивать отдельно. Экономит трафик, но увеличивает количество запросов.

В публичных API чаще используют первый подход. Во внутренних — можно выбирать исходя из потребностей.

Для ошибок

Тело ответа с ошибкой должно быть структурированным. Я использую поле detail, как это делает FastAPI:

{
  "detail": "User with this email already exists"
}

Или более развёрнутый вариант:

{
  "error": {
    "code": "USER_ALREADY_EXISTS",
    "message": "User with email user@company.com already exists",
    "timestamp": "2024-03-15T10:30:00Z"
  }
}

Реальный кейс: как неправильный статус‑код убил производительность

Хочу поделиться историей, найденной на просторах сети. Один из спроектированных сервисов возвращал 200 OK для всех запросов. Даже если заказ не находился, он возвращал 200 с пустым телом. Казалось бы, какая разница?

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

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

После того как команда разработки добавила правильные статус‑коды и настроила HTTP‑кэширование, нагрузка на базу упала на 80%. Всё из‑за того, что теперь начали кэшировать ответы 404 (чтобы не искать несуществующее повторно) и корректно настроили заголовки Cache‑Control и ETag для успешных ответов 200.

Асинхронные сценарии и диаграммы последовательности

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

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

Рис. 3. Диаграмма последовательности: асинхронная отправка email при регистрации
Рис. 3. Диаграмма последовательности: асинхронная отправка email при регистрации

Обратите внимание на ключевую деталь: клиент получает ответ 201 Created сразу, не дожидаясь реальной отправки письма. Это улучшает UX и разгружает API.

Нефункциональные требования

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

  • Производительность: 100 RPS на регистрацию в час пик

  • Доступность: 99.9% uptime

  • Безопасность: пароли хранятся в bcrypt, все эндпоинты кроме регистрации и входа защищены авторизацией

  • Аудитинг: все действия администратора логируются с указанием кто, когда и что сделал

Эти требования напрямую влияют на архитектуру. Например, чтобы обеспечить 100 RPS, нам может понадобиться кэш Redis и горизонтальное масштабирование.

Заключение

Проектирование REST API — это не про красоту ради красоты. Это про предсказуемость, надёжность и удобство. Хороший API экономит команде месяцы разработки и миллионы рублей на поддержке.

Если этот разбор показался вам полезным и вы хотите превратить этот навык из интуитивного в системный, приглашаю вас на открытый урок 23 апреля в 20:00 «Паттерны RESTful API. Как проектировать удобные, масштабируемые и гибкие API?». На уроке мы разберём реальные кейсы, посмотрим на код и пройдём путь от идеи до готового API‑проекта.

Планируете пойти дальше? — Есть бонус: при прохождении бесплатного вступительного тестирования даём скидку 15% на курс. Это способ проверить свой уровень и заодно получить более выгодные условия.