Действия, не вписывающиеся в CRUD

Урок о том, что делать с операциями, которые не сводятся к «создать/прочитать/изменить/удалить».

CRUD-разрыв — ситуация, когда у бизнеса есть действие («опубликовать», «отменить», «пересчитать»), для которого нет очевидного существительного-ресурса и стандартного метода.

REST красиво ложится на данные: сущности — это существительные, а GET/POST/PATCH/DELETE закрывают чтение и правку. Но реальные системы полны процессов: заказ отменяют, статью публикуют, корзину оформляют, счёт пересчитывают. Соблазн велик — добавить POST /orders/42/cancel и забыть про чистоту. Иногда это даже правильный ответ. Но прежде чем тянуться к глаголу, стоит проверить три более «ресурсных» приёма — часто действие удаётся выразить как изменение состояния, и API остаётся стройным.

Приём 1: действие — это изменение поля (флаг через PATCH)

Многие «действия» на самом деле меняют состояние ресурса. «Опубликовать статью» = перевести её status из draft в published. А смена поля — это PATCH элемента, штатная CRUD-операция. Глагол не нужен вовсе.

# Вместо POST /articles/15/publish
curl -X PATCH https://api.example.com/articles/15 \
  -H "Content-Type: application/json" \
  -d '{"status": "published"}'

Такой подход хорош, когда действие сводится к одному-двум полям и не запускает сложный побочный процесс. Состояние ресурса становится явным и читаемым: клиент видит status в ответе и понимает, что доступно дальше.

ДействиеКак флаг
опубликоватьPATCH /articles/15{"status":"published"}
архивироватьPATCH /projects/7{"archived":true}
включить уведомленияPATCH /settings{"notify":true}

Приём 2: действие — это под-ресурс (POST в коллекцию)

Если действие порождает сущность с собственной жизнью — у него есть время, причина, инициатор, статус — его лучше смоделировать как новый ресурс. «Отменить заказ» создаёт отмену: у неё есть дата, причина, кто отменил. Тогда это POST в под-коллекцию.

curl -X POST https://api.example.com/orders/42/cancellation \
  -H "Content-Type: application/json" \
  -d '{"reason": "changed mind", "by": "user"}'
{
  "id": 301,
  "order_id": 42,
  "reason": "changed mind",
  "by": "user",
  "created_at": "2026-06-22T10:14:00Z"
}

Теперь отмена адресуема (GET /orders/42/cancellation), её можно прочитать, она хранит контекст. Это куда богаче, чем глагол /cancel, который ничего не оставляет после себя. Тот же приём: «вернуть деньги» → POST /orders/42/refunds, «лайкнуть пост» → POST /posts/15/likes, «отправить письмо повторно» → POST /messages/9/deliveries.

Как выбрать между флагом и под-ресурсом

  • Флаг (PATCH) — если действие меняет состояние самого ресурса и не нужно хранить историю каждого срабатывания.
  • Под-ресурс (POST) — если у действия есть собственные атрибуты, история, и оно может повторяться (несколько возвратов по заказу).

Приём 3: controller-ресурс (прагматичный компромисс)

Остаются действия, которые не меняют одно поле и не создают красивую сущность: «пересчитать корзину», «проверить промокод», «сконвертировать валюту», «перестроить индекс». Это вычисления-процессы. Для них существует признанный паттерн — controller-ресурс: глагол-действие как последний сегмент, вызываемый через POST.

POST /carts/9/recalculation       # пересчитать корзину
POST /invoices/77/recalculate     # пересчитать счёт
POST /search                      # сложный поиск с телом-запросом
POST /currency/conversions        # сконвертировать (тело: from, to, amount)

Это сознательное отступление от «только существительные», и оно легитимно: даже сторонники строгого REST признают, что не всякий процесс — это ресурс. Главное — соблюдать дисциплину: такие ручки всегда POST (они не идемпотентны и не кэшируются), их немного, и они выделены как явное исключение, а не как стиль всего API. Существительное-отглагольное (recalculation) читается чуть лучше глагола (recalculate), но это уже вкусовщина.

Природа действияПриёмПример
смена состоянияфлаг через PATCHPATCH /articles/15
событие с историейпод-ресурс POSTPOST /orders/42/cancellation
вычисление/процессcontroller-ресурс POSTPOST /carts/9/recalculation

Как работает под капотом

Почему именно POST для действий-исключений? Дело в гарантиях методов. HTTP делит методы по двум свойствам: безопасность (не меняет состояние) и идемпотентность (повтор даёт тот же результат).

Метод   Безопасный  Идемпотентный   Подходит для действия?
GET     да          да              нет — не должен менять состояние
PUT     нет         да              да, если повтор безопасен
PATCH   нет         нет*            да, для смены полей
POST    нет         НЕТ             да — для не идемпотентных действий

«Пересчитать» или «отменить» — действия не идемпотентные или порождающие новую сущность каждый раз, поэтому им подходит POST, который ничего и не обещает про повтор. Ставить такое на GET опасно: браузеры, прокси и краулеры свободно повторяют и кэшируют GET, и однажды поисковый робот, обойдя ссылки, может «отменить» сотню заказов. Именно поэтому действие никогда не вешают на GET — даже если технически это сработает.

Для не идемпотентных действий, которые нельзя повторять (списание денег), на сервере добавляют ключ идемпотентности — клиент шлёт заголовок Idempotency-Key, сервер запоминает его и на повторный запрос с тем же ключом возвращает прежний результат, не выполняя действие дважды. Это компенсирует то, что POST сам по себе не идемпотентен.

Частые ошибки

  • Глагол по привычке. POST /publishArticle, когда хватило бы PATCH /articles/15 со сменой status. Сначала проверьте, не смена ли это состояния.
  • Действие на GET. GET /orders/42/cancel — катастрофа: краулеры и прокси повторят и закэшируют. Действия — только небезопасные методы (POST/PATCH).
  • Зоопарк controller-ручек. Если глаголов-исключений становится больше, чем ресурсов, вы строите RPC, а не REST. Держите их редкими и явными.
  • Потеря контекста. Глагол /cancel не хранит причину и время. Если это важно — моделируйте как под-ресурс cancellation.
  • Нет защиты от повтора. Платёжные действия без Idempotency-Key при ретрае спишут деньги дважды.

Итоги

  • Прежде чем добавлять глагол, проверьте: не смена ли это состояния (тогда PATCH поля) и не событие ли с историей (тогда под-ресурс POST).
  • Под-ресурс делает действие адресуемым и хранит контекст: POST /orders/42/cancellation.
  • Controller-ресурс — легитимный компромисс для вычислений-процессов, но всегда POST и в малых дозах.
  • Действие никогда не вешают на GET — его повторяют и кэшируют без спроса.
  • Для неповторяемых действий (платежи) добавляйте Idempotency-Key.
Проверьте себя
1. Действие «опубликовать статью» сводится к переводу поля status из draft в published. Как смоделировать его наиболее «ресурсно»?
APOST /articles/15/publish
BGET /publishArticle?id=15
CPATCH /articles/15 с телом {"status":"published"}
DPUT /publish/articles/15
2. Почему «отмену заказа» с причиной, датой и инициатором лучше смоделировать как POST /orders/42/cancellation, а не как POST /orders/42/cancel?
AТак короче URL
BПод-ресурс cancellation адресуем, хранит контекст (причину, время) и его можно прочитать; глагол ничего не оставляет
Ccancel запрещён стандартом
DРазницы нет, это синонимы
3. Почему действие вроде «отменить заказ» нельзя вешать на метод GET?
AGET не поддерживает параметры
BGET безопасен и идемпотентен: браузеры, прокси и краулеры свободно повторяют и кэшируют его, что выполнит действие неожиданно
CGET работает медленнее POST
DGET нельзя использовать с авторизацией
4. Для какого типа действия лучше всего подходит controller-ресурс вроде POST /carts/9/recalculation?
AДля смены одного поля ресурса
BДля удаления элемента коллекции
CДля вычисления-процесса, который не меняет одно поле и не создаёт красивую сущность
DДля получения списка с фильтром