DELETE: 204 и идемпотентность

Spring REST & MVC
24 уровень , 4 лекция
Открыта

1. DELETE как операция и контракт

Когда начинаешь писать API, особенно после первых успешных POST и PATCH, есть соблазн думать так: «Ну удаление — это просто обновление, только без JSON в ответе». Это удобная мысль, но она ведёт к путанице и у клиента, и у сервера. DELETE — это отдельная договорённость о жизненном цикле ресурса: мы не “меняем поля”, мы убираем ресурс из коллекции. Именно поэтому мы обсуждаем его отдельно и так же строго, как POST/PUT/PATCH.

Удаление ресурса — это про то, что клиент больше не ожидает найти его по URI. Если пользователь удалил задачу, то повторный GET /api/v1/tasks/{taskId} должен честно сказать «не найдено», а не «ну… где-то она была, но мы её не показываем». В нашем учебном проекте это особенно полезно, потому что у нас уже есть статус ARCHIVED, который означает «задача существует, но её жизненный цикл завершён». Архивация — это состояние ресурса, а удаление — это отсутствие ресурса. Смешивать эти два смысла — примерно как путать «выключить компьютер» и «выкинуть компьютер в окно». Оба варианта приводят к тому, что «больше не работает», но последствия и ожидания разные. Именно из этой развилки вырастают операции, где ресурс продолжает существовать, но меняет состояние: это уже другой контракт, не DELETE.

В реальном мире многие системы действительно используют soft delete (логическое удаление) и не удаляют записи физически, потому что аудит, отчётность, юридические требования и всё такое. Но наш курс сознательно остаётся в границах web/API контракта, без БД и без «эксплуатационных драм». Поэтому в Task Tracker API удаление будет простым и честным: удалили — значит, больше не существует в нашем in-memory репозитории.

Контракт DELETE /api/v1/tasks/{taskId}

Перед тем как писать код, полезно зафиксировать контракт в человеческом виде. Когда вы делаете DELETE, вы должны заранее решить четыре вещи: какой URI, какой статус на успех, что происходит при отсутствии ресурса и какой будет формат ошибок. Если эти решения не принять заранее, контроллер получится «на авось», а потом вы будете героически чинить клиентов, которые уже начали привыкать к странному поведению.

В нашем проекте контракт такой: удаление адресует конкретный ресурс, поэтому URI ровно один — /api/v1/tasks/{taskId}. Успех мы обозначаем 204 No Content, чтобы явно показать: операция выполнена, но возвращать в теле нечего. Можно было бы вернуть 200 OK и какое-нибудь «Task deleted successfully» (и даже сделать это очень вдохновляюще), но это быстро превращает API в чат-бота, который разговаривает строками вместо контрактов. У нас уже есть для “разговоров” ProblemDetail и нормальные статус-коды — давайте ими и пользоваться.

При попытке удалить отсутствующую задачу мы не будем делать вид, что всё отлично. В Task Tracker API правильная модель — честный 404 Not Found с нашим единым error contract, где code = TASK_NOT_FOUND. Это решение важно для предсказуемости клиента. Клиент обычно удаляет конкретную сущность из своего UI; если она уже исчезла на сервере, клиенту полезно это знать. И да, именно здесь появляется тот самый “философский” момент про идемпотентность: DELETE идемпотентен по эффекту на состояние, но это не означает «всегда один и тот же ответ».

Если собрать это в маленькую таблицу, получится понятная матрица, которой удобно пользоваться и при разработке, и при тестировании:

Сценарий Запрос Ответ Тело ответа
Задача существовала и удалена DELETE /api/v1/tasks/{taskId} 204 No Content отсутствует
Задачи нет DELETE /api/v1/tasks/{taskId} 404 Not Found application/problem+json (ProblemDetail, code=TASK_NOT_FOUND)

Заметьте, что здесь нет “особого response DTO”. Это нормально: DELETE чаще всего — операция без успешного тела ответа. Если вы ловите себя на мысли «хочу вернуть DTO после удаления», остановитесь и спросите: а что клиент должен с ним делать, если ресурс теперь удалён? Обычно это лишнее.

2. Реализация: контроллер, сервис, репозиторий

Контроллер: ResponseEntity<Void> и 204

В DELETE контроллеру нужна ровно одна вещь: честно отразить контракт 204 No Content. Он принимает taskId, делегирует удаление в сервис и не пытается разговаривать с клиентом лишними строками.

Пример метода контроллера (внутри вашего TaskController, который уже живёт под /api/v1/tasks):

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;

@DeleteMapping("/{taskId}")
public ResponseEntity<Void> delete(@PathVariable String taskId) {
    // Важно: контроллер не содержит бизнес-логики — только делегирует в сервис
    taskWriteService.delete(taskId);

    // Важно: 204 означает, что тела ответа нет (и не будет)
    return ResponseEntity.noContent().build(); // HTTP 204, body отсутствует
}

Здесь важно не пытаться «украсить» ответ. Если вы выбрали 204, не нужно потом добавлять .body(...). Формально IDE может вам даже подсказать, что так нельзя (и будет права). На уровне контракта 204 No Content буквально говорит: «тела нет». Это делает API проще для клиентов: они не думают, что нужно что-то читать, и просто обновляют своё состояние.

Если задача не найдена, сервис бросит TaskNotFoundException, а ваш @ControllerAdvice уже превратит это в ProblemDetail; локальный try/catch здесь только раздвоит контракт.

Сервис: доменная логика 404

В сервисе мы решаем единственную доменную вещь: что делать, если задачи нет. В нашем контракте отсутствие ресурса при DELETE — это 404, значит сервис должен выбросить TaskNotFoundException, а не молча сделать вид, что всё прошло. Самый удобный способ — попросить репозиторий сразу сказать, было ли реальное удаление: так мы избегаем лишнего existsById(...) + deleteById(...).

Пример сервисного метода:

import org.springframework.stereotype.Service;

@Service
public class DefaultTaskWriteService implements TaskWriteService {

    @Override
    public void delete(String taskId) {
        // Репозиторий возвращает факт удаления: true — удалили, false — не нашли
        boolean deleted = taskRepository.deleteById(taskId);

        // Если нечего удалять — это доменная ошибка "не найдено"
        if (!deleted) {
            throw new TaskNotFoundException(taskId);
        }
    }
}

Сервис не возвращает ResponseEntity и не знает про статус-коды: он живёт в терминах домена — “удалить задачу по id” и “если её нет — это not found”. Перевод доменной ошибки в HTTP (404) у нас уже централизован в @ControllerAdvice. Так проект остаётся слоистым и понятным.

Если вам психологически проще сначала «проверить, есть ли задача», а потом удалить — это тоже допустимо (особенно для новичка). Но тогда вы делаете два обращения к хранилищу вместо одного, и есть риск, что вы забудете синхронизировать поведение. Для in-memory репозитория это не страшно, но привычку лучше формировать хорошую.

И да, можно спросить: «А почему мы вообще не делаем “тихий delete”, который всегда возвращает 204 даже если задачи нет?» Потому что мы сознательно выбрали честный контракт. Тихий delete бывает полезен в некоторых системах, но он должен быть явным решением, а не «ну так получилось». В нашем проекте решение другое: отсутствие задачи — это 404.

Репозиторий in-memory: boolean deleteById(...)

В in-memory хранилище у нас обычно лежит Map<String, Task>. Удаление тогда превращается в одну строку: storage.remove(taskId). Но именно эта одна строка помогает красиво реализовать контракт 404 без дополнительных проверок. Мы можем понять, была ли запись, потому что remove(...) возвращает удалённое значение (или null, если нечего удалять).

Чтобы это было удобно на уровне сервиса, репозиторию полезно дать метод, который возвращает факт удаления. Это избавляет нас от отдельного existsById и делает поведение однозначным: либо удалили и вернули true, либо не нашли и вернули false.

Пример части интерфейса репозитория:

public interface TaskRepository {

    /**
     * @return true, если задача с таким id была и её удалось удалить; false — если задачи не было
     */
    boolean deleteById(String taskId);
}

Пример in-memory реализации (фрагмент класса в пакете infrastructure.repository.inmemory):

import java.util.Map;

public class InMemoryTaskRepository implements TaskRepository {

    private final Map<String, Task> storage;

    @Override
    public boolean deleteById(String taskId) {
        // remove(...) вернёт удалённый объект или null, если ключа не было
        return storage.remove(taskId) != null;
    }
}

Здесь важно понимать простую мысль: «правда» о том, было ли удаление, живёт в слое хранения, потому что именно он реально удаляет. Сервис решает, что делать с false (в нашем случае — бросить TaskNotFoundException). Контроллер решает, что вернуть при успехе (204). Все три слоя участвуют, но каждый делает свою работу и не лезет в чужую.

И ещё один момент, который полезно для мышления. deleteById с boolean делает поведение очень похожим на многие реальные технологии. Например, SQL DELETE возвращает количество удалённых строк. То есть идея «удали и скажи, сколько реально удалилось» — не учебная игрушка, а нормальная инженерная практика.

3. Идемпотентность DELETE

Идемпотентность: эффект на состояние, не на ответ

Слово “идемпотентность” звучит так, будто его придумали, чтобы отпугнуть новичков и защитить сервер от лишних вопросов. На самом деле идея простая и практичная: если клиент повторит запрос (например, из-за сетевого сбоя или retry), то сервер не должен “накапливать эффект” бесконечно. Повторный DELETE не должен удалять «ещё сильнее» — потому что сильнее уже некуда. Либо ресурс исчез, либо его нет.

Важно не перепутать две вещи: идемпотентность — это про состояние ресурса на сервере, а не про то, что сервер обязан каждый раз отвечать одинаково. Очень частая ошибка — думать: «Если DELETE идempotentный, то второй DELETE должен вернуть тот же 204». Нет, не должен. Он должен не менять состояние дальше. А как именно он сообщит об этом клиенту — это часть контракта, который вы выбираете.

Давайте посмотрим на это как на мини-математику, но без страданий. Представим состояние системы как “множество задач”.

До первого запроса задача существует: tasks = { ..., X, ... }
После DELETE задача исчезает: tasks = { ..., ... } (без X)
После второго DELETE множество остаётся тем же самым, потому что X уже нет.

Это и есть идемпотентность: после первого успешного удаления дальнейшие повторения не меняют систему.

flowchart LR
    %% Идемпотентность: повторный DELETE не меняет состояние дальше
    A["Состояние S0: задача существует"] -->|DELETE| B["Состояние S1: задачи нет"]
    B -->|DELETE повторно| B

В нашем контракте мы решили, что второй DELETE вернёт 404 Not Found. Это не нарушает идемпотентность, потому что эффект на состояние всё равно «задачи нет». Мы просто говорим клиенту: «удалять больше нечего». Клиенту это даже полезно: если он думал, что задача ещё была, теперь он знает, что нет.

На этом CRUD-рамка write-операций фактически замыкается: create, replace, patch и delete уже ведут себя предсказуемо на уровне HTTP. Дальше начинаются операции, где ресурс остаётся жить, но меняет состояние или обзаводится подресурсами, и одной CRUD-семантики там уже мало.

Чтобы окончательно снять путаницу, полезно сравнить safe и idempotent. Мы говорили об этом в HTTP-блоке курса, но сейчас это прямо “в точку”: DELETE — идемпотентный, но не safe. Safe-методы не меняют состояние (например, GET). DELETE меняет состояние (удаляет ресурс), поэтому он не safe. Но при повторе он не должен превращаться в “удаляющую многоножку”, которая каждый раз делает что-то новое.

Небольшая таблица для закрепления:

Метод Safe Idempotent Интуитивный смысл
GET да да читаем, не меняем
DELETE нет да меняем один раз, повтор не усиливает эффект

И вот где это становится практичным. Клиенты реально делают повторы. Браузеры, мобильные приложения, прокси, балансировщики, библиотека HTTP-клиента — все они могут повторить запрос, если не уверены, что ответ дошёл. И вам как разработчику API важно, чтобы повторный DELETE не делал что-то неожиданное. В нашем случае “неожиданность” отсутствует: задача уже не существует — значит, состояние не меняется.

4. Проверка контракта вручную

.http запросы и ответы

Когда студент видит 204 No Content, первая реакция часто такая: «А где ответ?». И это нормальная реакция: мы привыкли, что сервер всегда что-то “говорит”. Но 204 — как раз тот случай, когда молчание сервера и есть понятный ответ. Поэтому полезно один раз увидеть это глазами клиента.

Пример запроса в .http файле:

### delete task (success)
# Успешное удаление: ожидаем 204 и пустое тело
DELETE http://localhost:8080/api/v1/tasks/6b34c1c1-5f9e-4d7d-9d6a-4c4e2e3a1111
Accept: application/json

Если задача существовала, вы увидите примерно такой ответ (без тела):

# Успех: статус 204, тело отсутствует
HTTP/1.1 204 No Content

Теперь повторим тот же запрос ещё раз, чтобы проверить негативный сценарий и одновременно почувствовать идемпотентность:

### delete task (not found)
# Повторный DELETE: задача уже удалена, ожидаем 404 и ProblemDetail
DELETE http://localhost:8080/api/v1/tasks/6b34c1c1-5f9e-4d7d-9d6a-4c4e2e3a1111
Accept: application/json

И теперь ответ будет 404, а тело — ваш ProblemDetail в application/problem+json. Конкретные поля зависят от того, как вы оформляли error contract в модуле про ошибки, но смысл примерно такой:

{
  "title": "Not Found",
  "status": 404,
  "detail": "Task not found: 6b34c1c1-5f9e-4d7d-9d6a-4c4e2e3a1111",
  "code": "TASK_NOT_FOUND",
  "instance": "/api/v1/tasks/6b34c1c1-5f9e-4d7d-9d6a-4c4e2e3a1111"
}

И вот в этот момент обычно “щёлкает”: 204 — это не «сервер забыл ответить», а “контракт говорит, что body не нужен”. А 404 — это не «сервер обиделся на повтор», а честная информация: ресурса уже нет.

5. Типичные ошибки при DELETE и идемпотентности

Ошибка №1: возвращать 204 No Content, но всё равно отправлять тело ответа.
Иногда рука по привычке пишет return ResponseEntity.noContent().body("Deleted");. Так делать нельзя не потому, что “Spring ругается”, а потому что вы ломаете смысл 204: вы одновременно обещаете отсутствие тела и пытаетесь его отправить. Если вы хотите вернуть тело — выбирайте другой контракт. В нашем проекте тело не нужно, поэтому noContent().build() — идеальный вариант.

Ошибка №2: путать “архивировать” и “удалить” и реализовать DELETE как смену status = ARCHIVED.
Это очень распространённая путаница: кажется, что “удалённая задача” и “архивная задача” — одно и то же. Но для клиента это разные сценарии. Архивная задача всё ещё существует по URI, её можно получить, у неё есть история и поля. Удалённой задачи нет. Если вы подменяете одно другим, клиент внезапно начинает получать “удалённые” задачи в списке, и начинается весёлый карнавал условий в UI.

Ошибка №3: делать “тихий delete” без согласованного решения и всегда возвращать успех.
Некоторые API сознательно возвращают 204 даже если ресурса нет, чтобы клиенту было проще. Это допустимо, но это должно быть заранее принятое правило, отражённое в документации и тестах. Если вы делаете так “потому что удобно”, вы рискуете получить ситуацию, когда один контроллер возвращает 404, а другой — 204, и клиент вынужден угадывать смысл. В Task Tracker API мы фиксируем честный 404, поэтому сервис должен бросать TaskNotFoundException, а handler — стабильно превращать это в ProblemDetail.

Ошибка №4: реализовать удаление в контроллере напрямую, обходя сервисный слой.
Кажется, что “ну тут же всего одна строчка”, и сервис “не нужен”. Но именно так и появляются проекты, где половина логики живёт в контроллере, половина — в сервисе, а ещё четверть — в душе разработчика. Удаление — такая же операция контракта, как PUT или PATCH. Если вы держите контроллер тонким, вы потом благодарите себя, когда добавляете правила, логирование и тесты.

Ошибка №5: понимать идемпотентность как “сервер обязан отвечать одинаково на повтор”.
Идемпотентность — про эффект на состояние, а не про идентичный статус-код. В нашем контракте второй DELETE возвращает 404, и это нормально: состояние не меняется, задача уже отсутствует. Если вы будете пытаться “подогнать” ответы под одинаковость, вы можете спрятать важную информацию от клиента или начать ломать согласованность ошибок.

1
Задача
Spring REST & MVC, 24 уровень, 4 лекция
Недоступна
`DELETE` с `204 No Content` и реальным удалением
`DELETE` с `204 No Content` и реальным удалением
1
Задача
Spring REST & MVC, 24 уровень, 4 лекция
Недоступна
Повторный `DELETE` и состояние ресурса после удаления
Повторный `DELETE` и состояние ресурса после удаления
1
Опрос
CRUD операции, 24 уровень, 4 лекция
Недоступен
CRUD операции
Создание, обновление, удаление
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ