JavaRush /Курсы /Spring REST & MVC /409 Conflict для пре...

409 Conflict для предметных ограничений

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

1. Business conflict: запрос ок, но нельзя

Если вы когда-либо пытались оплатить покупку, а терминал вам говорил «операция невозможна» — вы уже сталкивались с business conflict. Карта настоящая, PIN введён, вы не перепутали поля местами, но текущие условия не позволяют выполнить действие: лимит, блокировка, недостаточно средств, магазин не принимает этот тип карты. В API это тот же сюжет: запрос синтаксически нормальный, валидация проходит, а вот доменные правила говорят «нет».

И тут у разработчика часто случается соблазн: «Ну раз клиент виноват, значит 400 Bad Request». Но 400 — это про неправильную форму запроса (invalid input), а 409 — про конфликт с состоянием ресурса. То есть клиент делает понятную попытку изменения, но ресурс сейчас в таком состоянии, что операция запрещена. Именно эту границу мы и фиксируем в контракте, чтобы клиент мог различать «я отправил мусор» и «я отправил нормальные данные, но выбрал неправильный момент/состояние».

2. 400 vs 409: простая проверка

На этом месте полезно поставить себе в голове очень простую «проверку на здравый смысл». 400 Bad Request — это когда сервер не принимает запрос как корректный вход (не тот тип, не те поля, не те значения, нарушены constraints). 409 Conflict — когда сервер принимает запрос как корректный по форме, но отказывается выполнять по смыслу, потому что текущий state ресурса не совместим с операцией.

Чтобы было совсем приземлённо, давайте сравним это в виде таблицы — без философии и RFC‑романтики:

Ситуация в Task Tracker API Что не так Корректный статус Нужны ли fieldErrors
В TaskCreateRequest.title пустая строка Вход невалиден (input validation) 400 Bad Request Да, полезно
В PATCH прислали несуществующий enum status = "WORKING" Вход невалиден (тип/enum) 400 Bad Request Обычно да/по ситуации
taskId не найден Ресурс отсутствует 404 Not Found Нет
Попытка перевести задачу из ARCHIVED в IN_PROGRESS Конфликт с текущим состоянием 409 Conflict Обычно нет
Попытка загрузить файл к архивной задаче Операция запрещена состоянием 409 Conflict Нет

Обратите внимание на одну важную деталь: 409 почти всегда «живет» после того, как вы нашли ресурс (иначе был бы 404) и после того, как вы приняли вход (иначе был бы 400). В этом смысле 409 — ошибка более «поздняя» по цепочке обработки.

3. Где рождается 409

Очень хочется проверять такие вещи прямо в контроллере: «если статус ARCHIVED — сразу вернуть 409». Но тогда контроллер начнёт пухнуть, превращаясь в «центр вселенной», и мы снова получим fat controllers (а мы их уже однажды хоронили, пусть земля будет им стекловатой).

Правильный слой для business conflicts — доменный/сервисный. Почему? Потому что запрет «архивную задачу нельзя менять» — это не HTTP‑деталь и не validation‑constraint. Это правило предметной области: как устроена задача и её жизненный цикл. Контроллеру важно только одно: «если сервис сказал, что конфликт — преврати это в ProblemDetail с 409».

В терминах цепочки обработки выглядит так (упрощённо, без деталей Spring internals):

flowchart TD
    A["HTTP запрос"] --> B["Body parsing + @Valid"]
    B -->|ok| C["Controller -> Service"]
    B -->|ошибка| V["400 INVALID_INPUT + fieldErrors"]
    C --> D["Repository: найти Task"]
    D -->|нет| N["404 TASK_NOT_FOUND"]
    D -->|есть| E["Проверка бизнес-правил"]
    E -->|конфликт| K["409 Business conflict"]
    E -->|ok| S["200/201/204 успешный ответ"]

Такой порядок помогает не мешать «неправильно заполненную форму» и «правильно заполненную форму, но действие запрещено».

4. Пример: запрет перехода статуса

Чтобы показать 409 на практике, нам нужно две вещи: правило, которое может нарушаться, и исключение, которое можно поймать в @ControllerAdvice. Самый наглядный пример в Task Tracker APIзапрещённый переход статуса. Суть простая: у задачи есть конечный набор состояний, и не все переходы между ними разрешены (например, из DONE можно идти в ARCHIVED, а из ARCHIVED — никуда).

Покажу максимально учебную реализацию проверки переходов. В реальном production‑коде вы бы, возможно, сделали state machine аккуратнее, но нам сейчас важна прозрачность.

package com.example.tasktracker.domain.service;

import com.example.tasktracker.domain.model.TaskStatus;

public class TaskStatusRules {

    /**
     * Проверяем, можно ли перейти из одного статуса в другой.
     * Это доменное правило, не связанное с HTTP и валидацией входных данных.
     */
    public boolean canMove(TaskStatus from, TaskStatus to) {
        // Явно фиксируем допустимые переходы между состояниями
        return switch (from) {
            case TODO -> to == TaskStatus.IN_PROGRESS || to == TaskStatus.BLOCKED;
            case IN_PROGRESS -> to == TaskStatus.BLOCKED || to == TaskStatus.DONE;
            case BLOCKED -> to == TaskStatus.IN_PROGRESS || to == TaskStatus.DONE;
            case DONE -> to == TaskStatus.ARCHIVED;
            case ARCHIVED -> false; // Архив — конечное состояние: дальше переходов нет
        };
    }
}

Теперь сервис, который меняет статус, может сделать понятную проверку. Обратите внимание: сначала мы ищем задачу (иначе будет 404), потом проверяем конфликт (иначе будет 409).

public void changeStatus(String taskId, TaskStatus newStatus) {
    // 1) Сначала убеждаемся, что ресурс существует: иначе это 404, а не 409
    Task task = taskRepository.findById(taskId)
            .orElseThrow(() -> new TaskNotFoundException(taskId));

    // 2) Затем проверяем доменные ограничения: корректный запрос, но состояние не позволяет операцию
    if (!statusRules.canMove(task.getStatus(), newStatus)) {
        throw new InvalidStatusTransitionException(task.getStatus(), newStatus);
    }

    // 3) Только после всех проверок меняем состояние
    task.setStatus(newStatus);
}

Важный смысл этого кода даже не в switch и не в исключении. Смысл в том, что конфликт — это результат доменной проверки, а не «ошибка HTTP». HTTP появится позже, в обработчике ошибок.

5. Исключение для бизнес‑конфликта

Теперь нам нужно исключение, которое несёт достаточно данных, чтобы собрать хороший ProblemDetail. Минимум, который нас интересует, — это код ошибки и контекст, который попадёт в detail. Часто ещё полезно иметь конкретные значения, например from и to, чтобы при желании добавить их как extension fields.

package com.example.tasktracker.domain.exception;

public abstract class BusinessConflictException extends RuntimeException {

    // Это одно из публичных значений общего словаря code,
    // но строкой, чтобы domain layer не тащил зависимость на web DTO.
    private final String code;

    protected BusinessConflictException(String code, String message) {
        super(message);
        this.code = code;
    }

    public String getCode() {
        return code;
    }
}

И конкретное исключение:

package com.example.tasktracker.domain.exception;

import com.example.tasktracker.domain.model.TaskStatus;

public class InvalidStatusTransitionException extends BusinessConflictException {

    // Полезно сохранить исходные значения: их можно отдать в ProblemDetail как extension fields
    public final TaskStatus from;
    public final TaskStatus to;

    public InvalidStatusTransitionException(TaskStatus from, TaskStatus to) {
        // INVALID_STATUS_TRANSITION — это одно из публичных значений общего словаря code.
        // Message — для detail (читаемо человеку), code — для стабильной логики на клиенте.
        super("INVALID_STATUS_TRANSITION",
                "Task with status " + from + " cannot move to " + to);
        this.from = from;
        this.to = to;
    }
}

То есть INVALID_STATUS_TRANSITION — не новая отдельная система кодов, а то же публичное значение, которое клиент увидит в ProblemDetail.code. Просто доменный слой держит его строкой, не притягивая внутрь web‑зависимость.

6. 409 ProblemDetail через @ControllerAdvice

Теперь мы делаем то, ради чего всё затевалось: ловим доменное исключение и превращаем его в единый внешний формат. В предыдущих шагах мы уже договорились, что не размазываем обработку по контроллерам, а делаем это централизованно. И важно, что Spring MVC умеет отдавать Problem Details в JSON формате и позволяет расширять его собственными полями.

Ниже не новый формат ошибки. Для конфликта сохраняется тот же верхний набор type/title/status/detail/instance/code; меняются только значения и, если нужно, conflict-specific extension fields.

Начнём с минимального обработчика.

@ExceptionHandler(BusinessConflictException.class)
public ProblemDetail handleBusinessConflict(
        BusinessConflictException ex,
        HttpServletRequest request
) {
    // Отдаём 409, потому что запрос корректный по форме, но запрещён доменными правилами
    ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());

    // type и title описывают класс проблемы на верхнем уровне контракта
    pd.setType(URI.create("/problems/business-conflict"));
    pd.setTitle("Business conflict");

    // Instance помогает связать ответ с конкретным URI, который дёргал клиент
    pd.setInstance(URI.create(request.getRequestURI()));

    // code нужен клиенту для различения конфликтов программно
    pd.setProperty("code", ex.getCode());

    return pd;
}

Тут есть несколько важных моментов.

Во-первых, статус — HttpStatus.CONFLICT, то есть 409. Мы не притворяемся, что это 400, потому что вход «неплохой», он просто неприменим к текущему состоянию ресурса.

Во-вторых, мы добавляем code, потому что клиент должен отличать INVALID_STATUS_TRANSITION от, скажем, FILE_UPLOAD_NOT_ALLOWED (даже если оба дают 409).

В-третьих, instance мы берём из request.getRequestURI(). Это удобная практика: ответ становится самодостаточным. Даже если логи потерялись, по instance вы понимаете, что именно дёргал клиент.

Небольшое улучшение: разные title для разных конфликтов

Если вы оставите всем конфликтам один title = "Business conflict", технически всё будет работать, но человеку будет грустно. Поэтому обычно делают либо отдельные обработчики, либо, что чаще, добавляют ветвление по типу исключения.

Например, для перехода статуса:

@ExceptionHandler(InvalidStatusTransitionException.class)
public ProblemDetail handleInvalidStatusTransition(
        InvalidStatusTransitionException ex,
        HttpServletRequest request
) {
    // Более конкретный title — чтобы человеку было проще понять проблему
    ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());

    // Для конкретного сценария задаём свой type и title
    pd.setType(URI.create("/problems/invalid-status-transition"));
    pd.setTitle("Invalid status transition");

    // Привязываем ошибку к конкретному endpoint
    pd.setInstance(URI.create(request.getRequestURI()));

    // Стабильный машиночитаемый код ошибки
    pd.setProperty("code", ex.getCode());

    // Conflict-specific детали поверх того же базового каркаса
    pd.setProperty("fromStatus", ex.from.name());
    pd.setProperty("toStatus", ex.to.name());

    return pd;
}

Да, это выглядит как «дублирование». Но на самом деле это инвестиция в читаемость контракта. Когда API станет больше, вы себе скажете спасибо.

7. Детали 409‑ответа

На 409 очень легко «сорваться» в две крайности. Первая крайность — отвечать максимально общо: «Operation not allowed». Клиенту от этого почти нет пользы, потому что он не понимает, что именно не так. Вторая крайность — вываливать слишком много внутренних деталей: весь объект задачи, полный список «возможных переходов», внутренние названия классов, технические причины.

Золотая середина обычно такая: title остаётся коротким и стабильным, code остаётся стабильным, а detail объясняет конкретный случай. Иногда полезно добавить 1–2 extension field, если они действительно помогают клиенту принять решение.

Например, для перехода статуса можно добавить fromStatus и toStatus. Это не fieldErrors (потому что проблема не в том, что поле «не прошло constraint»), а именно доменная информация о конфликте.

// Это именно те extension fields, которые дополняют уже показанный conflict-response
pd.setProperty("fromStatus", ex.from.name());
pd.setProperty("toStatus", ex.to.name());

Если вы добавите такие поля, они должны быть такими же «контрактными», как и всё остальное: с понятными именами, стабильным форматом, без утечки внутренней модели. И главное — не превращайте 409 в свалку полей «на всякий случай». Вы не делаете чат‑бота, который «на всякий случай» говорит всё, что знает. Вы делаете API.

Пример JSON ответа 409 Conflict

Чтобы закрепить, покажу, как примерно может выглядеть ответ на конфликт перехода статуса (условный URI, условный id). Важно не конкретное имя type, а то, что структура остаётся такой же, как и для 404 и 400: это и есть «единый error contract».

{
  "type": "/problems/invalid-status-transition",
  "title": "Invalid status transition",
  "status": 409,
  "detail": "Task with status ARCHIVED cannot move to IN_PROGRESS",
  "instance": "/api/v1/tasks/42/status",
  "code": "INVALID_STATUS_TRANSITION",
  "fromStatus": "ARCHIVED",
  "toStatus": "IN_PROGRESS"
}

Если вы сравните это с validation‑ошибкой из предыдущего шага, вы увидите общий принцип: верхний уровень одинаковый, меняются status, title, detail и code, а также набор extension fields. У validation‑ошибки это fieldErrors, у конфликта — fromStatus/toStatus или вообще ничего.

8. Типичные ошибки при работе с 409 Conflict

Ошибка №1: возвращать 400 Bad Request вместо 409 Conflict для бизнес-конфликтов.
Когда любой неуспех клиента превращается в 400, теряется смысл статусов. Клиент больше не понимает: проблема в данных или в состоянии ресурса. 400 — это про некорректный ввод, а 409 — про корректный запрос, который нельзя выполнить из-за текущего состояния.

Ошибка №2: запихивать бизнес-конфликты в fieldErrors.
Ошибка вроде status: "invalid transition" выглядит как проблема поля, хотя поле может быть валидным. Это смешивает input validation и бизнес-правила. В результате API становится менее предсказуемым: клиент не понимает, где ошибка формата, а где — логики.

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

Ошибка №4: делать detail слишком абстрактным.
Фразы вроде “operation failed” или “conflict occurred” формально корректны, но бесполезны. Клиенту важно понимать, что именно пошло не так: какой был статус, какой переход запрещён. Короткое, но конкретное объяснение намного ценнее “безопасной” абстракции.

Ошибка №5: держать правила выбора статуса внутри контроллера.
Когда контроллер сам решает, какой HTTP-статус вернуть, логика начинает дублироваться и расползаться. Выбор статуса — это часть бизнес-правил и должен определяться в сервисном слое или через централизованную обработку исключений. Контроллеру лучше оставаться тонким и предсказуемым.

1
Задача
Spring REST & MVC, 21 уровень, 3 лекция
Недоступна
409 Conflict при запрещенном переходе статуса задачи
409 Conflict при запрещенном переходе статуса задачи
1
Задача
Spring REST & MVC, 21 уровень, 3 лекция
Недоступна
409 Conflict при запрете отмены завершенного бронирования
409 Conflict при запрете отмены завершенного бронирования
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ