Когда разработчик впервые видит ваш API, он читает URL. Хорошо выстроенный путь говорит сам за себя: GET /orders/{id}/items — понятно без документации. Плохой путь (/getOrderItemList?orderId=5) заставляет лезть в Swagger каждый раз.
В этой статье разберём, как правильно строить URL: как называть ресурсы, как выбирать HTTP-методы и насколько глубокой может быть вложенность.
Разница между аккуратным и небрежным путём заметна не на одном эндпоинте, а когда их набирается десяток.
Слева путь — существительное, а действие несёт метод: двенадцать операций укладываются в три имени (orders, items, payments), остальное выводится. Справа у каждой операции своё имя — двенадцать штук, и каждое надо найти в документации.
Четыре цели хорошего URL
Прежде чем переходить к правилам, стоит понять, что именно мы хотим получить.
Предсказуемость — разработчик, знающий один эндпоинт, угадывает остальные. Если есть GET /orders, то логично предположить GET /orders/{id} и POST /orders.
Единообразие — одинаковые правила везде. Не /user-items в одном месте и /orderItems в другом.
Читаемость — URL читается как предложение: GET /orders/{id}/items — «получить позиции заказа».
Стабильность — URL — это публичный контракт. Его изменение ломает чужой код. Менять URL — то же самое, что менять сигнатуру публичного метода библиотеки.
Как записывать пути
Есть несколько простых правил, которых нужно придерживаться всегда.
Партнёр вызвал /api/v1/OrderItems, у нас путь /api/v1/orderItems, и на одном сервере это одно и то же, а на другом 404: регистр в пути зависит от сервера и файловой системы, а подчёркивание в адресной строке сливается с подчёркиванием ссылки. Поэтому пути пишут только строчными буквами и с дефисом между словами, это называется kebab-case:
| Путь | Годится | Почему |
|---|---|---|
/order-items | да | строчные буквы, слова через дефис |
/delivery-addresses | да | то же правило |
/OrderItems | нет | регистр в пути значим: это другой адрес, чем /orderitems |
/order_items | нет | подчёркивание исчезает под подчёркиванием ссылки |
/deliveryAddresses | нет | тот же регистр: промахнулся одной буквой — попал не туда |
Про регистр стоит сказать отдельно, потому что это не вкусовщина. Имя сайта регистр не различает: SHOP.RU и shop.ru — одно и то же место. А вот путь после имени сайта различает (так устроен сам формат адреса, RFC 3986): /OrderItems и /orderitems — два разных адреса, и сервер вправе отдать на них разное. Строчные буквы просто убирают повод ошибиться.
Подчёркивание мешает по другой причине: браузер и мессенджер рисуют ссылку подчёркнутой, и order_items читается как order items — символ в середине не виден.
Без слеша в конце, без расширений в имени файла:
| Путь | Годится | Почему |
|---|---|---|
/orders | да | имя ресурса и ничего лишнего |
/orders/ | нет | это уже другой адрес — в Spring Boot 3 такой запрос получит 404 |
/orders.json | нет | формат указывают через заголовок Accept |
Раньше слеш в конце был именно вкусовщиной: Spring сам сопоставлял /orders/ с /orders. Начиная со Spring Framework 6 (то есть Spring Boot 3) это поведение выключено — метод setUseTrailingSlashMatch объявлен устаревшим, а значение по умолчанию стало false. Клиент, привыкший дописывать слеш, теперь получит 404 на ровном месте. Поэтому правило и стоит держать: один ресурс — одна запись адреса.
Без глаголов в пути — для действий уже есть HTTP-методы:
| Запрос | Годится | Почему |
|---|---|---|
GET /orders | да | метод GET уже означает «получить» |
POST /orders | да | метод POST уже означает «создать» |
GET /getOrders | нет | глагол в URL повторяет метод |
POST /createOrder | нет | глагол в URL повторяет метод |
Коллекции и одиночные ресурсы
Одна из самых частых ошибок — путаница с числом существительного в пути.
Коллекция (список объектов) — множественное число:
/orders список заказов
/orders/{id} конкретный заказ
/users список пользователей
/users/{id} конкретный пользователь
Singleton (ресурс, который существует в единственном числе для данного контекста) — единственное число:
/users/{id}/profile профиль пользователя (у каждого один)
/settings глобальные настройки (одни для всей системы)
Типичная ошибка — смешивать числа:
/order ✗ используйте /orders
/orders/{id}/item ✗ используйте /orders/{id}/items
Имена берут из доменного языка
Ресурс должен называться так, как называется понятие в вашем проекте. Если в коде объект называется Order, то путь — /orders, а не /purchases или /transactions. Это упрощает навигацию по коду и документации — везде одно и то же слово.
Примеры соответствия:
Order→/ordersOrderItem→/orders/{id}/itemsDeliveryAddress→/delivery-addressesPayment→/payments
HTTP-методы: какой когда использовать
HTTP предоставляет несколько методов, каждый из которых несёт смысловую нагрузку. Нарушение этого смысла приводит к неожиданному поведению.
| Метод | Назначение | Повторный вызов безопасен? | Типичный статус |
|---|---|---|---|
GET | Чтение данных | Да | 200 |
POST | Создание / команда | Нет | 201 + заголовок Location |
PUT | Полная замена ресурса | Да | 200 с телом, 204 без тела, 201 если ресурс создан |
PATCH | Частичное обновление | Зависит от тела запроса | 200 с телом, 204 без тела |
DELETE | Удаление | Да | 204 |
У PUT в колонке статусов три значения, и это не расхлябанность. Обновили существующий заказ и вернули его же в ответе — 200. Обновили, но отвечать нечем (клиент и так знает, что послал) — 204. А если клиент сам выбрал адрес и по этому адресу ничего не было, PUT ресурс создаёт — тогда 201, как у POST. Главное — выбрать что-то одно и держаться этого по всему API.
Колонка «повторный вызов безопасен» про идемпотентность: два одинаковых запроса подряд оставляют систему в том же состоянии, что и один. Ответ потерялся в сети, клиент повторил: PUT со статусом PAID поставит тот же статус ещё раз, и ничего не сломается.
У PATCH это зависит от того, что внутри: «поставь статус PAID» повторять можно сколько угодно, «добавь ещё одну позицию в заказ» нет, каждый повтор добавит новую. Отдельно стоит GET: он не только идемпотентен, но и вовсе не меняет данные, а это разные свойства.
Важное правило: GET не должен менять данные. Это самая опасная ошибка — если операция имеет побочный эффект (списание денег, отмена заказа), она идёт через POST.
// Отмена заказа — это команда с побочным эффектом
@PostMapping("/orders/{id}/cancel")
public OrderResponse cancel(@PathVariable Long id) { ... }
// Так нельзя: GET подразумевает безопасное чтение
@GetMapping("/orders/{id}/cancel")
public OrderResponse cancel(@PathVariable Long id) { ... }
POST также используется для команд, которые не создают новый ресурс, но делают что-то необратимое: POST /orders/{id}/confirm, POST /payments/{id}/refund.
Вложенность: не глубже двух уровней
REST допускает вложенные пути, но злоупотреблять этим не стоит.
До двух уровней — нормально:
/orders/{orderId} 1 уровень
/orders/{orderId}/items 2 уровня
/orders/{orderId}/items/{itemId} 2 уровня + идентификатор
Три уровня и глубже — уже сложно:
/users/{userId}/orders/{orderId}/items/{itemId} ✗ слишком глубоко
Когда хочется сделать три уровня, обычно лучше перейти к плоскому пути с фильтром:
/orders?userId={userId} ✓ вместо /users/{userId}/orders
Вложенность оправдана, когда дочерний ресурс не существует без родителя. Например, позиция заказа (OrderItem) без заказа (Order) не имеет смысла — значит, /orders/{orderId}/items логично.
А вот платёж живёт своей жизнью: его ищут по номеру самого платежа, показывают в отчёте за месяц, сверяют с выпиской банка — и заказ тут лишь одно из свойств. Такой ресурс лучше положить в корень и фильтровать:
/payments?orderId={orderId} ✓ платёж находится и без заказа
/orders/{orderId}/payments ✗ единственный способ добраться до платежа
Проверка простая: если у ресурса есть жизнь помимо родителя — плоский путь с фильтром; если без родителя он бессмыслен — вложенный.
Одна и та же позиция заказа двумя путями: сверху три уровня вложенности, снизу плоский ресурс, где заказ становится фильтром /items?orderId=5.
Идентификатор — всегда в пути, не в теле
Если эндпоинт работает с конкретным объектом, его идентификатор должен быть в URL, а не в теле запроса:
PUT /orders/{id} ✓
PUT /orders { "id": 5, ... } ✗ id в теле запроса
Когда идентификатор в пути один, его часто так и пишут — {id}: имя ресурса стоит рядом, и путаться не с чем. А вот когда их два, каждому нужно своё имя:
/orders/{id}/items/{id} ✗ два разных значения под одним именем
/orders/{orderId}/items/{itemId} ✓ видно, где чей идентификатор
Это не про аккуратность, а про то, что первый вариант просто не работает. Spring Boot 3 такой путь даже не примет: при старте разбор шаблона падает с сообщением «Not allowed to capture 'id' twice in the same pattern». И описать такую операцию в OpenAPI тоже нечем — там параметр опознаётся парой «имя плюс место», а имён два одинаковых.
В коде — те же имена, что в пути:
@GetMapping("/orders/{orderId}/items/{itemId}")
public OrderItemResponse item(@PathVariable Long orderId,
@PathVariable Long itemId) { ... }
Нет ресурса и нет метода: 404 против 405
Два отказа, которые путают, хотя различие простое: 404 говорит «такого ресурса нет», 405 — «ресурс есть, но так с ним нельзя».
GET /orders/{id} на несуществующий или удалённый заказ — это 404, а не пустой 200. Пустой ответ с кодом успеха означает «вот заказ, и он пустой», и клиент не сможет отличить «нет заказа» от «заказ без данных»; вся обработка ошибок на стороне клиента на этом ломается. То же для удалённого: если заказ был и удалён, 404 — нормальный ответ (а 410 уместен, когда важно сказать «был, но больше нет навсегда»).
POST /orders/{id} там, где поддерживаются только GET и DELETE, — это 405, и вместе с ним сервер обязан прислать заголовок со списком разрешённых методов (Allow: GET, DELETE). Spring делает это сам, если путь совпал, а метод нет, — и именно поэтому важно, чтобы пути были описаны честно: при опечатке в пути вы получите 404 вместо 405 и будете искать ошибку не там.
Отдельный случай — коллекция. GET /orders?status=PAID, не нашедший ничего, — это 200 с пустым массивом, а не 404: коллекция существует, она просто пуста. 404 для коллекции означало бы, что такого ресурса нет вовсе.
Сколько ресурсов заводить
Вопрос «это поле карточки товара или отдельный ресурс» решается не вкусом, а тремя признаками.
Частота изменения. Остаток товара меняется каждую минуту, описание — раз в месяц. Если они в одном ресурсе, любой клиент, которому нужен остаток, перечитывает описание, а кэширование становится бессмысленным (весь ресурс устаревает по самому быстрому полю). Отсюда /products/{id} и /products/{id}/stock — разные ресурсы с разным временем жизни в кэше.
Права. Цена закупки видна менеджеру, но не покупателю; внутренние заметки — только поддержке. Поля с разными правами в одном ресурсе означают, что ответ надо собирать по-разному для разных ролей, и однажды кто-то забудет фильтр. Отдельный ресурс делает права проверяемыми: доступ к /products/{id}/cost либо есть, либо нет.
Объём. Полное описание с картинками и характеристиками — сотни килобайт, а списку нужны название и цена. Тяжёлую часть выносят отдельно (или отдают по запросу полей), иначе каждый список превращается в выгрузку.
Признак, по которому объединяют: данные всегда читают и меняют вместе, у них одни права и один ритм изменений. Всё остальное — повод для отдельного ресурса.
Идентификатор в пути: число или UUID
Выбор влияет не на красоту, а на безопасность и на эксплуатацию.
Последовательное число (/orders/1, /orders/2) удобно читать и легко угадать. Отсюда две беды. Первая — перебор: зная свой заказ 1043, легко попробовать 1042 и 1044, и если проверка прав где-то пропущена, это находка для злоумышленника (а пропущена она будет обязательно — на редком служебном методе). Вторая — утечка информации: по номеру видно, сколько всего заказов в системе и с какой скоростью они появляются; заказать два товара с интервалом в час и сравнить номера — готовая оценка оборота.
UUID (или другой непоследовательный идентификатор) обе проблемы снимает: угадать нельзя, объём не виден. Платят длиной пути и читаемостью (36 символов в адресе, невозможность продиктовать по телефону) и тем, что в базе такой ключ дороже — разбор в статье про UUID в PostgreSQL.
Практическая раскладка, которая встречается чаще всего: внутренний числовой ключ в базе для связей и внешний непоследовательный идентификатор в API. Ещё один вариант — короткий непоследовательный код (ord_7fK2mQ), который и не угадывается, и читается человеком.
И общее правило независимо от выбора: проверка прав никогда не должна зависеть от неугадываемости идентификатора. UUID — это защита от перебора, а не замена проверке «этот заказ принадлежит этому клиенту».
Где REST-именование ломается
Схема «существительное плюс метод» покрывает работу с ресурсами и перестаёт работать в четырёх случаях. Знать их полезно, чтобы не ломать схему там, где она работает, и не мучиться там, где она не подходит.
Поиск со сложными условиями. Фильтр на двадцать полей, вложенные условия, список из тысячи идентификаторов — всё это не влезает в строку запроса (у серверов и прокси предел на длину адреса, обычно 8 килобайт) и не читается. Ответ — POST /orders/search с телом; цена разобрана в статье про параметры запроса.
Отчёты и агрегаты. «Выручка по продавцам за месяц с накопительным итогом» — это не ресурс, а вычисление. Такое оформляют либо как ресурс-отчёт (/reports/revenue?from=…&to=…, и тогда его можно кэшировать), либо отдельной операцией.
Действия, меняющие состояние. Отменить заказ, повторить платёж, отправить письмо — это не создание и не изменение ресурса. Оформляют как подресурс-действие (POST /orders/{id}/cancel), и это осознанное отступление от чистой схемы — разбор в статье про действия и псевдонимы.
Операции над множеством. Пометить сто заказов, удалить пачку, импортировать файл. Для них заводят отдельный ресурс операции (POST /batch-jobs), который возвращает идентификатор и позволяет следить за выполнением, — иначе один запрос висит минутами и его нельзя ни отменить, ни повторить.
Если URL всё-таки пришлось менять
Правило «адреса не меняют» иногда приходится нарушить: ресурс переименовали, структуру исправили, схему привели в порядок. Тогда действуют как с любым ломающим изменением — через совместимость.
Постоянное перенаправление. Старый путь отвечает 301 (или 308, который гарантирует сохранение метода и тела) с заголовком на новый адрес. Клиенты, которые умеют следовать перенаправлениям, продолжат работать; те, что не умеют, получат понятный сигнал, а не ошибку.
Параллельная поддержка. Обе ручки работают и делают одно и то же, старая помечена как устаревшая заголовками (Deprecation, Sunset) — как именно, в статье про ограничения, файлы и вывод из эксплуатации. Это дороже перенаправления, зато не ломает клиентов, которые перенаправления не обрабатывают.
Срок и наблюдение. У старого пути должна быть метрика обращений и дата отключения. Убирают его не «когда-нибудь», а когда счётчик обращений держится на нуле оговорённый срок, — иначе старый путь живёт годами и никто не решается его тронуть.
Служебные эндпоинты — вне основного API
Большинство API строится на базовом пути /api/v1/.... Но есть служебные эндпоинты, которые нужны инфраструктуре, — они живут отдельно. В Spring Boot их отдаёт Actuator, и пути у него свои, под общим префиксом /actuator:
/actuator/health— работает ли приложение./actuator/health/readiness— готово ли оно принимать трафик (это спрашивает Kubernetes)./actuator/info— версия и метаинформация./actuator/prometheus— метрики в формате Prometheus; эндпоинт появляется только если добавить зависимостьmicrometer-registry-prometheus.
Префикс задаётся настройкой management.endpoints.web.base-path — при желании его меняют, например на /internal. Названия вроде /health и /metrics без префикса тоже встречаются, но это чужие соглашения, а не то, что Spring Boot отдаёт сам.
Служебные пути не версионируют и не требуют авторизации пользователя (или защищают через отдельный порт управления — management.server.port).
Коротко
- URL это публичный контракт, и меняют его как сигнатуру публичного метода: через
301, параллельную поддержку и объявленную дату отключения, а не одним коммитом. - Правила записи пути (строчные буквы, дефис, без слеша в конце и без
.json) нужны не для красоты, а чтобы клиент не промахнулся: со Spring Boot 3/orders/уже отдаёт 404, а не тот же ресурс. - Глагол в пути дублирует метод и удваивает словарь: на двенадцать операций хватает трёх имён ресурсов вместо двенадцати имён ручек.
- Число существительного это обещание клиенту, сколько объектов он получит: множественное у коллекции, единственное у ресурса, который существует в одном экземпляре.
- Вложенность оправдана, только когда без родителя дочерний ресурс бессмыслен; у ресурса со своей жизнью плоский путь и фильтр, иначе клиент таскает лишние идентификаторы, а сервер сверяет лишние связи.
- Идентификаторы живут в пути, и у каждого своё имя:
{orderId}и{itemId}, а два{id}в одном шаблоне Spring Boot 3 не примет прямо на старте. - Выбор между числом и UUID это выбор между читаемостью и защитой от перебора и от оценки оборота по номерам; проверка прав от неугадываемости идентификатора зависеть не должна.
- Служебные пути Actuator стоят вне
/api/v1/, их не версионируют и закрывают отдельным портом или правами.
Что почитать дальше
- Версионирование REST API — как вводить
/v2и не ломать клиентов. - Query-параметры и пагинация — фильтры, сортировка, курсорная пагинация.
- Ошибки и RFC 9457 — стандартный формат ошибок.