
Введение
Привет, Хабр! Каждый раз, создавая новый эндпоинт, я ловил себя на мысли: «А как назвать маршрут?». Казалось, что где-то есть законы и правила, которые помогают создавать API последовательно. Со временем я наткнулся на диаграммы по проектированию, прочитал книгу JJ Geewax — API Design Patterns (Джей‑Джей Гивакс), изучил рекомендации от крупных компаний и понял важную мысль: проектирование API — это такая же область знаний со своими принципами и стандартами.
Следование этим правилам даёт практический результат:
API становятся удобными для разработчиков благодаря единообразным паттернам и стандартам веба.
Бизнес‑логику и сам API легче переиспользовать.
Интерфейс остаётся понятным и предсказуемым для внешних клиентов.
Однако возникает проблема: стандарты есть, но они разные. Многие из них красивы на бумаге, но непонятно, как их применить в обычном CRUD‑приложении без сложной бизнес‑логики.
Цель статьи — дать компактную шпаргалку по проектированию API для простых CRUD‑сервисов и показать ход мыслей, который позволяет проектировать последовательно и осмысленно.
Часть 1. Всё начинается с домена и кода
В хорошем API сначала проектируются ресурсы и взаимодействие с ними. Если доменная модель и её операции ясны, HTTP‑слой становится простым и предсказуемым.

Минимальный словарь действий
Чтобы достичь ожидаемого результата, опираемся на пять базовых операций в бизнес‑логике:
GetResource — получить конкретный ресурс.
ListResources — получить коллекцию ресурсов (с фильтрами/сортировкой/пагинацией).
CreateResource — создать ресурс.
UpdateResource — изменить ресурс (частично или полностью).
DeleteResource — удалить ресурс.
Чего не должно быть: GetByOrganization, GetByUser, Insert, Upsert, DeleteAll, FindOrCreate, ArchiveOldResources и т. д.
Вся вариативность — в параметрах, а не в новых именах методов.
Единообразный нейминг делает навигацию в коде очевидной.
Фильтр вместо
GetBy*убирает дубли и взрыв количества эндпоинтов.
Часть 2. Стандартные методы API
Это те методы, на которые стоит посмотреть в первую очередь.

Ресурс: Resource
Модель для примера:
{
"id": "guid",
"title": "string",
"status": "active|archived|draft"
}
1) Создание
POST /v1/resources
Тело запроса:
{ "title": "New resource", "status": "draft" }
Ответ 201 + созданный ресурс:
{ "id": "guid", "title": "New resource", "status": "draft" }
2) Получение конкретного ресурса
GET /v1/resources/{id}
Ответ 200 + ресурс; 404 — не найден.
3) Получение всех ресурсов
GET /v1/resources
Ответ 200:
{
"resources": [
{ "id": "guid", "title": "New resource", "status": "draft" }
]
}
Возвращаем объект, а не «голый массив» — так проще расширять контракт (метаданные, пагинация и т. д.).
При отсутствии ресурсов возвращаем 200 и пустую коллекцию.
4) Полная замена (replace)
PUT /v1/resources/{id}
Тело запроса — полное представление ресурса (всё, что должно остаться в состоянии после замены):
{ "title": "Updated", "status": "active" }
Коды ответа:
201 — созданный ресурс (если позволяем клиенту создавать со своим идентификатором; иначе — 404).
200 — актуальный ресурс (если он был и мы его заменили).
Если поле не прислано, оно должно принять значение по умолчанию.
5) Частичное обновление
PATCH /v1/resources/{id}
Тело запроса — только изменяемые поля:
{ "status": "archived" }
Ответ 200 — актуальный ресурс; 404 — не найден.
6) Удаление
DELETE /v1/resources/{id}
Ответ 204 (без тела).
Рекомендации по разделу
Всегда множественное число сущности в путях:
/resources.Для стандартных операций идентификатор — только в пути:
/resources/{id}.Возвращаем полный ресурс на POST/PUT/PATCH — так проще дебажить, тестировать и поддерживать.
Часть 3. Расширение стандартных методов
Теперь усилим базовые операции — без размножения эндпоинтов и версий.

Пагинация
Самая простая и распространённая — offset‑based. Параметры: skip, take (синонимы: offset, limit).
GET /v1/resources?skip=20&take=10 — пропускаем 20, берём 10.
Ответ:
{
"resources": [
{ "id": "guid", "title": "..." }
],
"total": 350
}
totalв ответе помогает клиенту понять, есть ли смысл тянуть следующие страницы.Сортируем результат хотя бы по дате добавления, если пользователь не указал иного, чтобы возвращать детерминированные данные.
Обязательно фиксируем максимальный
take.Если выборка нестабильна, огромна, с тяжёлой сортировкой — используем cursor‑based пагинацию.
См. также:
Microsoft REST API Guidelines — collections: https://github.com/microsoft/api-guidelines/blob/vNext/azure/Guidelines.md#collections
Google API Design Guide — Standard methods: List: https://google.aip.dev/132
Фильтрация
Чтобы не уходить в парсинг, берём прямой путь: явные query‑параметры на каждый фильтр.
Суффиксы операторов:
_eq(по умолчанию, можно опустить),_ne,_lt,_gt,_lte,_gte_in,_nin— для множеств
Для вложенных полей — «плоские» имена: meta_created_at_gte=...
Примеры:
GET /v1/resources?status_in=active,draft
GET /v1/resources?title_eq=Design
GET /v1/resources?meta_created_at_gte=2023-01-01&meta_created_at_lte=2023-06-01
Для фильтрации по вложенным массивам сложных объектов (например,
resource.users=[{...}]) лучше не использовать query‑параметры, а реализовывать отдельный метод поиска с POST‑телом.Неизвестные параметры — по умолчанию игнорируем.
Для полнотекстового поиска лучше использовать параметр
search.
С продвинутой реализацией через параметр filter можно ознакомиться по ссылкам:
Microsoft REST API Guidelines — filter: https://github.com/microsoft/api-guidelines/blob/vNext/azure/Guidelines.md#filter
Google API Design Guide — Filtering: https://google.aip.dev/160
Сортировка
Простой вариант — не парсить единый orderby, а держать сортировки в явных переменных.
created_at_sort=asc|desc, title_sort=asc|desc. Отсутствие — поле не участвует.
Примеры:
GET /v1/resources?created_at_sort=asc
GET /v1/resources?created_at_sort=asc&title_sort=desc
Сортировка через один query‑параметр представлена по ссылкам:
Microsoft REST API Guidelines — orderby: https://github.com/microsoft/api-guidelines/blob/vNext/azure/Guidelines.md#orderby
Google API Design Guide — List: https://google.aip.dev/132
ИМХО: использование
orderby/filter— это парсинг и связка с внутренней моделью, что делать в каждом микросервисе накладно; явные переменные делают контракт очевидным и упрощают реализацию.
Мягкое удаление (soft delete)
Оставляем единую схему: статус удалённости храним в колонке, а не в новых таблицах.
Модель: добавляем поле deleted_at: string|null.
Удалить (в архив) —
DELETE /v1/resources/{id}→ 204 (проставляемdeleted_at).Восстановить —
PATCH /v1/resources/{id}/restore→ 200 (ставимdeleted_at = nullи возвращаем ресурс).Список с удалёнными —
GET /v1/resources?include_deleted=true.
Ответ (пример):
{
"resources": [
{ "id": "guid", "title": "...", "deleted_at": null },
{ "id": "guid", "title": "...", "deleted_at": "2024-11-12T10:36:15Z" }
],
"total": 100
}
Полное удаление (подтверждаем явно) —
DELETE /v1/resources/{id}?force=true→ 204. Полезно для «удалить из архива/навсегда».
Частичное извлечение полей (fields)
Простого решения нет: нужна разборка строки и динамическая сборка ответа. Если это действительно нужно — вы делаете что-то сложнее CRUD и стоит опираться на гайдлайны:
Microsoft REST API Guidelines — select query: https://github.com/microsoft/api-guidelines/blob/vNext/azure/Guidelines.md#query-options
Google API Design Guide — Partial responses: https://google.aip.dev/157#field-masks-parameter
Примеры:
GET /v1/resources?fields=id,title
GET /v1/resources/{id}?fields=id,status
Ответ (для списка):
{
"resources": [
{ "id": "guid", "title": "..." }
],
"total": 123
}
Примеры «всё вместе»
Список с пагинацией, фильтрами, сортировкой и усечёнными полями
GET /v1/resources?skip=10&take=5&status_in=active,draft&created_at_sort=desc&fields=id,title&include_deleted=false
{
"resources": [
{ "id": "guid1", "title": "Design Guide" },
{ "id": "guid2", "title": "API Patterns" }
],
"total": 100
}
Мягкое удаление / восстановление / форс‑удаление
DELETE /v1/resources/{id} → 204
PATCH /v1/resources/{id}/restore → 200 (тело)
DELETE /v1/resources/{id}?force=true → 204
Часть 4. Пользовательские методы и пакетные операции
Стандартных GET/POST/PUT/PATCH/DELETE хватает на ~80% CRUD. Оставшиеся ~20% — доменные действия, которые:
меняют состояние ресурса не как «полная/частичная замена» (пример: «подтвердить», «отменить», «применить скидку»);
запускают вычисление (пример: «посчитать тариф»);
не создают новый ресурс напрямую, но производят эффект (пример: «просмотреть ресурс»).

Правила
Глагол после
{id}:POST /v1/resources/{id}/apply-discount.Метод — чаще POST (есть побочный эффект).
Исключения: вычислительные методы и счётчики, которые не меняют состояние → GET.
Примеры пользовательских методов
Увеличение счетчика просмотра ресурса (есть побочный эффект записи в лог/метрику)
POST /v1/resources/{id}/view
Счётчики (без побочного эффекта)
Публичные:
GET /v1/resources/count
GET /v1/users/{user-id}/resources/count
Внутренние (на нескольких ресурсах):
GET /_internal/resources/count
GET /_internal/organizations/{organization-id}/resources/count
GET /_internal/organizations/{organization-id}/users/{user-id}/resources/count
Расчёт стоимости (вычисление, без изменения состояния)
POST /v1/resources/calculate
Тело:
{ "destination": "New York", "deliverySpeed": "express" }
Ответ 200:
{ "price": 42.50, "currency": "USD", "eta_days": 2 }
Применение скидки (мутация)
POST /v1/resources/{id}/apply-discount
Тело:
{ "discount_сode": "SUMMER21" }
Пакетные операции (batch)
Снижают количество запросов к API, позволяют согласованно менять несколько ресурсов и упрощают клиентскую логику.
Общие принципы
Однотипность внутри запроса: не смешиваем «создать» и «удалить».
Атомарность: либо всё, либо ничего.
Лимиты: ограничиваем количество элементов и возвращаем понятную ошибку 413 Payload Too Large с подсказкой.
Массовое чтение
Частый анти‑паттерн:
GET /v1/resources/batch?ids=1&ids=2&...
Риск — лимит длины URL при больших батчах. Лучше так:
POST /v1/resources/batch-get
Тело:
{ "ids": ["guid-1", "guid-2"] }
Ответ 200:
{
"resources": [
{ "id": "guid-1", "title": "A", "status": "draft" },
{ "id": "guid-2", "title": "B", "status": "active" }
]
}
Массив без обёртки ограничивает нас — формат с объектом позволяет добавить флаги (
idempotency,forceи др.).
Массовое создание
POST /v1/resources/batch
Тело:
{
"resources": [
{ "title": "A", "status": "draft" },
{ "title": "B", "status": "active" }
]
}
Ответ 201:
{
"resources": [
{ "id": "guid-1", "title": "A", "status": "draft" },
{ "id": "guid-2", "title": "B", "status": "active" }
]
}
Batch‑обновление
PUT /v1/resources/batch
Тело:
{
"resources": [
{ "id": "guid-1", "status": "active", "title": "API Patterns" },
{ "id": "guid-2", "status": "active", "title": "Design Guide" }
]
}
Batch‑удаление
POST /v1/resources/batch-delete
Тело:
{
"ids": ["guid-1", "guid-2"],
"force": false
}
Ответ 200.
Согласно RFC 9110 рекомендуется использовать POST с телом, вместо DELETE.
Часть 5. Критика

Стоит ли на POST/PUT/PATCH всегда возвращать весь ресурс, если сервер уже принял изменения и на клиенте есть актуальная версия?
Да. В обычных CRUD‑сервисах это упрощает жизнь: меньше дополнительных запросов, легче дебажить и тестировать.
Не упрёмся ли мы в лимиты по количеству/длине query‑параметров? Это не станет проблемой для фильтрации и сортировки?
Для типичного CRUD с десятком явных фильтров проблем нет. Когда критериев много — используем метод POST и передаём параметры в теле.
Заключение
Я намеренно не касался в статье тем идемпотентности, ETag, версионирования и других аспектов — о них написано достаточно в официальных гайдлайнах. Цель была показать ход мыслей и что 80% задач можно закрыть небольшим и понятным набором правил. Придерживаясь их, вы снижаете риски дублирования, облегчаете поддержку и оставляете простор для расширения. Остальные 20% случаев потребуют осознанных решений — и здесь полезно сверяться с индустриальными стандартами.
Если у вас есть рекомендации, как сделать эту статью ещё практичнее и доступнее — напишите в комментариях или мне в профиль. По мере накопления фидбэка дополню разделы.
Полезные ссылки
RFC 9110 — HTTP Semantics (методы, коды, заголовки)
https://datatracker.ietf.org/doc/html/rfc9110RFC 5789 — метод PATCH
https://datatracker.ietf.org/doc/html/rfc5789Google API Design Guide
https://cloud.google.com/apis/design/Microsoft REST API Guidelines
https://github.com/microsoft/api-guidelines/blob/vNext/azure/Guidelines.md
Комментарии (0)

NightBlade74
17.09.2025 13:14Опять притащили коды статусов HTTP как коды ответов на запросы к уровню приложения.
HTTP всегда должен отвечать 2хx, если запрос с точки зрения HTTP выполнен, 4хх - если ошибка со стороны клиента, 5хх - что-то не так на сервере.
А вот в теле ответа быть уже статус ответа на сам REST запрос, причем желательно не просто одним числом, а поподробнее.
А у вас программист, вызывающий сервис, потом голову будет ломать, что же он перепутал: эндпоинт или фильтр запроса и с многоэтажными матами пытаться парсить строки ответов, следующие за HTTP-статусами в заголовке. Не надо смешивать уровни OSI.

Masnin
17.09.2025 13:14Ну да, точно)
А потом после таких "проектировщиков" в ELK приложение 100% отвечает 200 ОК, при том что там 500-х половина. Successfully Failed антипаттерн какой-то.
Ещё и модель OSI прилетаете. Http, на секундочку, уровень "прикладной", то есть как бы как раз того самого приложения.
Работайте кодами ответов HTTP и не слушайте вредных советов и команда SRE скажет вам спасибо за это.

id_bones Автор
17.09.2025 13:14Не успел вовремя ответить, пример с ELK очень в тему, сам с данной проблемой много возился, спасибо!

savostin
17.09.2025 13:14А можно подробнее что за ELK проблема? Я вот тоже склонен считать, что HTTP коды должны относиться к HTTP только. Если endpoint не найден - 404, если ресурс не найден - 200 + status: error
Имхо в статье много противоречий, исключений и заплаток.

id_bones Автор
17.09.2025 13:14Добрый день!
Имхо в статье много противоречий, исключений и заплаток.
Относительно какой теоретической базы противоречия?
В конце статьи указал основные материалы на которые опирался при написании. Там где были расхождения, приложил ссылки для углубления.
А можно подробнее что за ELK проблема?
Допустим у меня есть базовый HttpClient и у меня стоит задача: отказоустойчивость, логгирование ошибок, метрики. Я как разработчик хочу просто вызвать метод типа PostAsync(url, query, body ...) и получить в ответе модель, которую создал. Чтобы это было просто и удобно обычно создают или используют готовые библиотеки.
Как библиотека поймет что нужно залоггировать/сделать ретрай запрос? В согласованных системах достаточно будет проверки статус кода.В вашем же случае нужно вычитывать всё тело запроса и искать там заветное слово: "ok": true|false? "success": true|false ? Вот например API slack, где я с этим сталкивался:
https://docs.slack.dev/apis/web-api/#responses
(elk не прикладываю, так как по итогу использовал библиотеку, а не api на прямую)Когда в теле все же уместно слать 200 и status/status_code?
Когда есть ясная архитектурная особенность, например работа с большим количеством данных => batch методы.
Я в статье рекомендую:Атомарность: либо всё, либо ничего.
Но если бизнес-сценарий особенный и например в массовых методах нужно выполнить хоть что-то (например принять хотя бы часть валидных данных на индексацию), то уместно отступать от этой рекомендации, но сложность задачи при этом возрастает в разы.

savostin
17.09.2025 13:14Противоречия не "с теоретической базой", а логические:
GET /resources/:id - не найден = 404, GET /resources/ - не найдено ни одного = 200
PUT /v1/resources/{id} - 404, если не позволено создавать ресурс "со своим идентификатором"? внезапно.
Правила
Глагол после
{id}:POST /v1/resources/{id}/apply-discount.Метод — чаще POST (есть побочный эффект).
Исключения: вычислительные методы и счётчики, которые не меняют состояние → GET.
Расчёт стоимости (вычисление, без изменения состояния)
POST /v1/resources/calculateРиск — лимит длины URL при больших батчах. Лучше так:
POST /v1/resources/batch-getНо как же "без изменения состояния"? Я понимаю, что мера вынужденная, но "как же принципы"?
Согласно RFC 9110 рекомендуется использовать POST с телом, вместо
DELETE.Даже в RFC противоречия...

id_bones Автор
17.09.2025 13:14Уместный комментарий! Чтобы из статьи получилась шпаргалка, я постарался сократить теорию до фактов. Для углубления прикрепил "полезные ссылки".
GET /resources/:id - не найден = 404, GET /resources/ - не найдено ни одного = 200
PUT /v1/resources/{id} - 404, если не позволено создавать ресурс "со своим идентификатором"? внезапно.
GET /resources/{id} -> 404, когда конкретного ресурса не существует
GET /resources/ -> 200 с пустой коллекцией ([] или { items: [], total: 0 }), когда коллекция существует, но в ней пока ничего нет.
{id} - конкретный ресурс, он либо есть, либо его нет
resources - ресурс-коллекция, она можно сказать существует всегда (если конечно существует родительский ресурс /parent/{parentId}/resources).Коллекцию мы часто перечисляем (через пагинацию) и будет странно на этапе перехода по страницам внезапно получить 404. Эту тему можно развивать дальше, например в сторону фильтров, поиска.
POST /v1/resources/batch-getНо как же "без изменения состояния"? Я понимаю, что мера вынужденная, но "как же принципы"?
Это осознанный компромисс и допустимая практика. Да GET - обязан быть безопасным. Но POST тоже может быть таким, если вы его так определили

NightBlade74
17.09.2025 13:14А потом после таких "проектировщиков" в ELK приложение 100% отвечает 200 ОК, при том что там 500-х половина. Successfully Failed антипаттерн какой-то.
Ну, вас же не пугает, что еще и ошибки сокетов могут быть. А то так же получается, пакетик пришел в ответ - значит, все работает.
В вашем же случае нужно вычитывать всё тело запроса и искать там заветное слово
Это из другого вашего комментария, но я здесь цитирую. Так в чем проблема получить тело и десериализовать его из JSON в объект типа Result, который содержит Success или Fault? Это все равно придется сделать. Ради чего ориентироваться на статус HTTP здесь, чтобы на пару строк выше логирование вставить?
Ещё и модель OSI прилетаете. Http, на секундочку, уровень "прикладной", то есть как бы как раз того самого приложения.
На секундочку, HTTP является прикладным уровнем только для браузеров, да и то в современных реалиях вряд ли уже. Для REST это по сути транспортный протокол, только более высокого уровня.
Работайте кодами ответов HTTP и не слушайте вредных советов и команда SRE скажет вам спасибо за это.
Я вполне, кстати, реальный конкретный пример привел, когда писал, что мне пришлось ломать голову, как обрабатывать 404 ресурса и 404 уровня приложения, когда все было в кучу свалено.
На самом деле вот то, что мы здесь обсуждаем и есть одна из самых больших проблем REST (не как концепции, а как реализации REST over HTTP), которая приводит к жутковатому переплетению транспорта и уровня приложения: когда часть запросов посылается в JSON, а часть (в GET) компонентом URL; вышеуказанная проблема "как возвращать ошибку" и т.д.

id_bones Автор
17.09.2025 13:14Так в чем проблема получить тело и десериализовать его из JSON в объект типа Result, который содержит Success или Fault?
В целом этим и приходится заниматься когда интегрируешься с подобными API которые возвращают 200 и ошибку в теле.
Но чаще всего большинство современных интеграций оперирует статус-кодом, что позволяет работать с ними "из коробки"Я вполне, кстати, реальный конкретный пример привел, когда писал, что мне пришлось ломать голову, как обрабатывать 404 ресурса и 404 уровня приложения, когда все было в кучу свалено.
Отличный пример в тему статьи! Тоже бывало ломал голову тот ли dns адрес получилось собрать.
Есть практика: "когда сервер намеренно не хочет раскрывать, что ресурс существует", я опираюсь чаще всего на неё, но не пропагандирую)На самом деле вот то, что мы здесь обсуждаем и есть одна из самых больших проблем REST
Согласен с вами! Тема очень холиварная

NightBlade74
17.09.2025 13:14И еще вопрос: как в тех же самых логах отличать 404 некорректного пользовательского ввода, которого всегда дофига, и реальную (архиважную!) проблему потери ресурса вследствие слетевшей/изменившейся конфигурации на одной из сторон? Лезть анализировать тело ответа уже на стороне ELK?

id_bones Автор
17.09.2025 13:14HTTP это не просто транспорт, а протокол прикладного уровня, на который опираются разработчики браузеров, CDN, прокси. Да и при обычной интеграции многие инфраструктуры на это завязаны: ретраи, SLO, алерты и тд. Всегда 2хх, остальное в body, как правило, ломает поведение экосистемы веба. Понимаю, что требование к приложениям у нас могут быть разные, но вся эта практика, книги и гайдлайны не на пустом месте появились - на это я и обращаю внимание читателя

NightBlade74
17.09.2025 13:14А при чем здесь браузеры, CDN, прокси? Первые вообще из схемы исключаем, ну, если только кто-то не захочет просто браузер как инструмент визуализации использовать для запроса к сервису. CDN и прокси все равно по большей части, они на другом уровне работают. Мож, конечно, у них логирование по-другому сработает, если на некорректный (с точки зрения фильтра запроса) ответить 200, а не 404, но не думаю, что это имеет большое значение.

sylotana
17.09.2025 13:14Как вообще книга? Советуешь к прочтению? Я сейчас другую читаю (Арно Лоре. Проектирование веб-API), если читал её, то что думаешь о ней? Я пока на первых главах

id_bones Автор
17.09.2025 13:14Книга интересная, но думаю, что достаточно прочесть хотя бы одну по проектированию API и дальше углубляться в существующие практики. Потому что, когда я начал сверять полученные идеи с собственными подходами и гайдлайнами крупных компаний, то местами нашёл расхождения, но везде прослеживается общий смысл. На этой почве и пришла идея этой статьи, показать что API может быть последовательным и предсказуемым, а конкретная реализация уже может зависеть от местных правил в компании

ivvi
17.09.2025 13:14Просмотр ресурса (есть побочный эффект записи в лог/метрику)
POST /v1/resources/{id}/viewПочему здесь POST?

id_bones Автор
17.09.2025 13:14Понял почему возник вопрос, в статье поправил немного формулировку:
Просмотр ресурса -> Увеличение счетчика просмотра ресурса
POST - потому что подразумевается изменение состояния (увеличение счетчика)
ArchDemon
Какой-то странный REST у нас получается. Запрос - явный GET, но из-за длинных параметров (и невозможности передать параметры в теле) мы должны использовать POST.
Тогда проще использовать POST для всего. И получим RPC (JSON-RPC)
id_bones Автор
Спасибо за комментарий! Цель статьи обозначить вектор проектирования, конкретная реализация в разных компаниях и командах может отличаться, всё зависит от контекста.
Использование POST для всего подряд усложняет настройки кэширования, ссылки/закладки и другие оптимизации опирающиеся на rest стандарты