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

Всем привет, меня зовут Сергей Прощаев. Я 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% на курс. Это способ проверить свой уровень и заодно получить более выгодные условия.

Комментарии (14)


  1. AlexMcArrow
    09.04.2026 20:33

    ИМХО:

    • Уровень 0: REST

    • Уровень 1: MOREREST

    • Уровень 2: HATEOAS

    • Уровень 0: RPC

    А указываемая проблематика RPC - что все в одной точке, методов становится все больше и дальше ужас, боли и страдания. Может просто надо хорошо организовывать именование методов - можно использовать dotnotatiion-формат ( [model].[action].[subaction].[wtfyoudoing] = user.create user.update user.profile user.profile.edit auth.login auth.logout и т.д.).

    Все так любят REST - но мне кажется он не очень корректным когда APP начинает указывать Веб-серверу “врать” о фактическом состоянии запрашиваемых объектов - GET /users/1 и получаем 404 - не потому что такого пользователя нет, а потому что у тебя нет прав (ты авторизован, но не Админ - например) и ты не можешь его видеть. Но получается что адрес на который я сделал запрос не существует, то есть я должен не верить ответу Веб-сервера (его кодам), а оперировать бизнес-логикой приложения, знать (понять прочитав содержимое тела ответа) как работает тот или иной Сервис. И может показаться логичным - что нужно знать и понимать ответы Сервиса, но это не ответы Сервиса - это ответы веб-сервера. В противовес у RPC все более однозначно: HTTP - является чистым транспортом задача которого доставлять данные. Ты сделал запрос (POST /api и получил ответ: 200 OK {“code”:20401,…} - что обозначает код? читайте документацию, ровно так же как нужно читать документацию по REST-сервису, что обозначает его HTTP-код на том или ином пути в той или иной ситуации.


    1. minamoto
      09.04.2026 20:33

      GET /users/1 и получаем 404

      Разве не для этого статус 403?


      1. bighorik
        09.04.2026 20:33

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

        Думаю, об этом речь


        1. AlexMcArrow
          09.04.2026 20:33

          Верно - очень частая практика. Логическое скрытие сущностей - “то что фактически существует - представляется как отсутствующее” - и вот тут начинается сущий АД, особенно когда ты не просто часть команды разработки SPA, а внешний для Сервиса потребитель.

          Для потребителя API такое поведение превращается в многослойный пирог из композиции: http-route * auth-state * global-bussines-action * action = множественная вариация поведения и ответа API.


      1. AlexMcArrow
        09.04.2026 20:33

        403 - верно, решает именно эту задачу. Но хочется сказать словами современника - “Можно, а зачем?”. Зачем HTTP-коды, они все равно не могут покрыть даже 95% кейсов и формируется двух этапность обработки результата (HTTP-код + код-приложения). И возникает вопрос зачем лишнее? Пусть транспорт - занимается транспортом, а приложение в рамках контента определяет свою бизнес-логику.


        1. minamoto
          09.04.2026 20:33

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


          1. AlexMcArrow
            09.04.2026 20:33

            А я как раз за такой вариант и хочу от API

            Попробуем посмотреть на этот процесс со стороны логики, а не “так делают все”.

            У нас есть транспортный слой (HTTP). Я делаю запрос (и даже не важно какой) - и я хочу от транспорта (HTTP) что бы он выполнил свою роль - передачу данных. Ведь сегодня HTTP, завтра HTTP2, затем HTTP3 итак далее. А вот когда я получу сообщение, я как получатель уже буду его читать и принимать бизнес решение - что делать. А мы видим картину что транспорт берет на себя роль участника бизнес процесса, он содержит бизнес указатели (HTTP-коды показывают есть у меня доступ или нет) - хотя от него ожидается просто доставка.

            По простому:

            • 200 - объект данных есть

            • 404 - объект данных отсутствует

            Я делаю запрос в ендпоинт, а мне говорят 404. Нет кого? Ендпоинта, объекта данных или сработала хитрая (и не верная) бизнес логика и объект есть, но для тебя нет (сейчас, временно, постоянно и т.п.)

            или

            • 200 - ендпоинт есть - дальше смотрим что ответил ендпоинт


            1. NoSkill24
              09.04.2026 20:33

              В вашем локальном API вы можете делать как угодно, если для вас это достаточно. Васянить можно как угодно. Но! Строго говоря, в общем смысле, API не может просто взять и поменять протокол. HTTP-коды не должны использоваться для бизнес-логики (хоть они ее частично и содержат). Эти коды используются для балансировщиков и маршрутизации запросов в сложных системах, чтобы им не приходилось каждый раз декодировать ваш payload и знать вашу бизнес-логику. А вот ваша чистая бизнес-логика должна быть полностью реализована на кодах ошибок и не должна анализировать для этого HTTP-коды.


  1. Michael888
    09.04.2026 20:33

    А как может быть пагинация менее затратной чем offset/limit?


    1. minamoto
      09.04.2026 20:33

      Если я правильно понял мысль - при пагинации клиент не может управлять limit-ом, может только выбрать номер страницы. А в случае offset/limit можно передать limit 1000000 и наблюдать, как сервер пытается отдать нужное количество.


      1. Michael888
        09.04.2026 20:33

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


    1. VanKrock
      09.04.2026 20:33

      На самом деле тут зависит от того какую пагинацию вы реализуете, если у вас в сервисе именно фиксированные страницы, то да, но тут есть проблема, если данные в процессе добавляются в начало, то все данные съезжают, то есть на новой странице вы можете увидеть данные, которые уже видели на предыдущей. Другой подход last_id/limit, то есть вы получаете данные не с конкретного offset, а с конкретного id, это позволяет делать пагинацию как единую ленту


  1. VanKrock
    09.04.2026 20:33

    Я в pet проектах делаю гибридные решения, мне не нравится использовать методы http, раз сервис SPA использую только POST хотя url обычные, не endpoint в теле, так лучше видно запросы в браузере при отладке на вкладке network, GET остаётся для фронта, в GET есть кеширование, но как его инвалидировать? Проще сделать кеширование на уровне сервиса, так есть полный контроль для кеша.


  1. Ex-Kaiser
    09.04.2026 20:33

    Иногда встречал такую ситуацию, что POST-запрос одновременно содержит и body, и query-параметры с фильтрами. Ну, вернее, API спроектировано так, что определенные роуты требуют указания и body, и query. На мой взгляд это было странно и выглядело как явный анти-паттерн, но бэкендеры уверяли меня, что это совершенно нормально. Так насколько это все-таки допустимо?