1. 404 недостаточно: ошибка тоже контракт
Когда вы делаете API, вы фактически подписываете договор с клиентом: «если ты пришлёшь вот такой запрос — я отвечу вот так». И этот договор должен быть одинаково понятным и в happy-path, и в ситуации «ой». Если сервер ограничивается «ну вот тебе 404 и иди сам думай», клиенту приходится угадывать: это «не найдено», «не туда постучался», «у вас формат неправильный», или «сервер устал и ушёл в отпуск».
В реальности клиентский код редко выглядит как человек с браузером, который читает «Not Found» и грустит. Чаще это мобильное приложение, фронтенд, интеграция, тесты, мониторинг. Им нужно не художественное описание страданий, а структурированный ответ: что случилось, где случилось и что можно с этим сделать. Поэтому наша задача — построить минимальный, но самодостаточный каркас ошибки, который всегда одинаковый по форме.
И тут важно поймать простую мысль: HTTP-статус — это только вершина айсберга. Он говорит «класс проблемы», но не описывает конкретику. А ошибка без конкретики — это как баг-репорт «не работает» без шагов воспроизведения. Формально не врёте, но толку… как от кнопки «Сделать хорошо».
2. ProblemDetail в Spring MVC
ProblemDetail — это стандартная идея: описывать ошибку не одним текстом, а небольшим JSON-объектом с фиксированными полями. В Spring MVC эта идея материализуется в классе ProblemDetail. Мы не изобретаем «свой собственный формат ошибок с полем message и success=false», а используем понятный каркас, который многие клиенты уже умеют обрабатывать (или хотя бы легко научить).
Если представить обработку запроса как конвейер, то при ошибке он выглядит примерно так: контроллер или сервис бросает исключение, @ControllerAdvice его ловит, превращает в ProblemDetail, а дальше Spring MVC сериализует этот объект в JSON и выставляет Content-Type: application/problem+json. Никакой магии — просто нормальная, взрослая дисциплина обработки негативных сценариев.
flowchart TD
A[HTTP request] --> B[Controller]
B --> C[Service]
C --> D{Ошибка?}
D -->|нет| E[Response DTO]
D -->|да| F[Exception]
F --> G["@ControllerAdvice / @ExceptionHandler"]
G --> H[ProblemDetail]
H --> I[HTTP response: application/problem+json]
Важный нюанс для проекта: Spring Boot умеет работать с Problem Details «из коробки», но вы должны быть последовательны. Если часть ошибок вы возвращаете как ProblemDetail, а часть — как «случайный Map», клиенту будет больно. Ему придётся писать ветвления по формату ответа, и это уже не REST API, а квест «угадай JSON».
Ещё одна практическая деталь: даже если клиент «и так знает, какой URL он вызвал», поле instance в ответе делает ошибку самодостаточной. Это особенно заметно, когда ответы логируются, улетают в трассировку, тесты падают на CI, и вы смотрите на логи без полного контекста. Ошибка, которая несёт в себе минимальные данные о запросе, экономит время на расследование.
3. Минимальный каркас ProblemDetail
Сейчас мы зафиксируем то, что должно быть в каждом error response, если мы хотим называть API «production-like» хотя бы по манерам. У ProblemDetail есть стандартные поля, и именно они — наш минимальный «скелет». Всё дополнительное (code, fieldErrors, timestamp, requestId) — это уже надстройки, которые мы будем аккуратно добавлять позже, не разрушая базовую форму.
Ниже — краткая «шпаргалка» по пяти полям. Не пытайтесь выучить её как таблицу умножения; лучше воспринимать как ответы на пять вопросов клиента: «что это?», «как назвать?», «какой статус?», «что конкретно случилось?», «к какому запросу относится?».
| Поле | Вопрос, на который отвечает | Что важно помнить | Пример значения |
|---|---|---|---|
| type | «Какой это класс проблемы?» | Это идентификатор типа проблемы (URI), а не текст ошибки | /problems/task-not-found |
| title | «Как это коротко назвать?» | Короткое и стабильное название для человека | Task not found |
| status | «Какой HTTP-статус у ошибки?» | В теле дублируем статус, чтобы ответ был самодостаточным | 404 |
| detail | «Что конкретно случилось в этом запросе?» | Здесь уместна конкретика: id, текущее состояние, значение параметра | Task with id ... was not found |
| instance | «К какому запросу это относится?» | URI запроса (или его path), чтобы привязать ошибку к конкретному вызову | /api/v1/tasks/... |
type: идентификатор класса проблемы
Поле type часто воспринимают как «ну давайте туда положим что-нибудь». А потом туда кладут текст сообщения, и смысл стандарта ломается. На самом деле type — это идентификатор типа проблемы, который должен быть стабильным. Он не про конкретный случай, а про категорию: “task not found”, “malformed json”, “unsupported media type”.
Формально type — это URI (в стандарте допускается и относительный URI). В идеальном мире это ссылка на страницу документации, где описано, что это за ошибка и как её обрабатывать. В учебном проекте мы не будем поднимать «портал документации ошибок» (иначе курс превратится в “Spring + static hosting”), но мы всё равно можем задавать type как стабильный идентификатор, например /problems/task-not-found.
Практический критерий хороший: если вы поменяете текст detail (потому что захотели улучшить формулировку), type должен остаться прежним. Это «имя папки», а detail — «имя файла внутри папки». Если вы делаете наоборот — вы вносите хаос.
Ещё один важный момент: не подменяйте type вашим application-specific code. type — часть стандарта Problem Details и обычно выражает «тип проблемы» в терминах URI. code — это внутренняя, машиночитаемая константа приложения. Мы к ней придём дальше, но сейчас важно не смешать роли, чтобы не получилось два поля «про одно и то же», но по-разному.
title: короткое имя проблемы
title — это не место для романа. Это короткая подпись, которую удобно показать человеку (или написать в лог), чтобы сразу понять, о чём речь. Она должна быть достаточно стабильной: если сегодня вы возвращаете title = "Task not found", а завтра title = "No such task", клиенту сложно строить любые пользовательские сообщения или поиск по логам. Да, клиент не должен парсить title как код, но стабильность всё равно полезна.
Хорошая практика: title — это «заголовок категории», а detail — «описание конкретного случая». Поэтому title обычно не содержит ID, не содержит текущий статус, не содержит параметры запроса. Максимум — аккуратная формулировка уровня “Task not found”, “Invalid input”, “Unsupported media type”.
Есть ещё одна человеческая причина: title — это то, что вы увидите в Swagger UI, в Postman, в тестах. Если туда писать всё подряд, через неделю вы сами перестанете понимать, где какая ошибка. Стабильный title делает диагностику проще. И да, это тот редкий случай, когда дисциплина — не скука, а экономия времени.
status: статус и в HTTP, и в теле
На первый взгляд дублирование статуса выглядит странно: «Но ведь статус уже есть в HTTP response!». Да, есть. Но в контрактном мышлении это не «лишние байты», а самодостаточность. Ошибки часто живут дольше, чем один конкретный HTTP-ответ: их логируют, пересылают, сохраняют в отчёты, анализируют в тестах. И там не всегда удобно «доставать статус из метаданных» — иногда вы видите только JSON.
Кроме того, наличие status в теле помогает быстро проверить, что ваш @ControllerAdvice не ошибся. Это как ремень безопасности: вроде бы и так аккуратно ездите, но хорошо, что есть. Если вы случайно вернули HTTP 404, а в теле status: 500, вы сразу заметите несостыковку. А несостыковка статуса — это прямой путь к недоверию клиента: он уже не знает, чему верить.
В Spring ProblemDetail.forStatusAndDetail(...) обычно выставляет статус автоматически. Это удобно, но не освобождает от ответственности. Нужно следить, чтобы реальный HTTP-статус ответа совпадал с тем, что вы сериализуете. Иначе ваш API начинает говорить разными голосами одновременно — как чат, в котором один пишет «всё ок», а другой рядом кричит «мы все умрём».
detail: конкретика текущего случая
detail — это место, где вы описываете конкретный инцидент. Если title — это «как называется проблема», то detail — «что именно случилось прямо сейчас». Именно сюда нормально включать taskId, значение неверного параметра, текущий статус ресурса, ожидаемый формат, и т.д. Но тут важно не перегнуть палку и не начать утекать внутренними подробностями.
Хорошая формула для detail: текст должен быть понятен внешнему клиенту, который не знает ваших Java-классов, ваших пакетов и того, как называется ваш repository. Сообщение "com.example.tasktracker.TaskServiceImpl threw NullPointerException" — это отличный способ рассказать миру, что у вас внутри происходит, и одновременно отличный способ сделать клиента беспомощным: ему всё равно непонятно, что делать.
Ещё один нюанс: detail — не обязан быть суперстабильным. Он может меняться от запроса к запросу, потому что в нём появляются разные ID и разные значения. Это нормально. Стабильность мы обеспечим позже через code, а сейчас достаточно понимать: detail — это полезная конкретика для человека и для быстрой диагностики.
instance: привязка к запросу
instance — это URI, который идентифицирует конкретный случай проблемы. Проще всего в REST API трактовать его как путь запроса, например /api/v1/tasks/{taskId}. Благодаря этому клиент и вы сами (в логах) видите, к какому endpoint относится ошибка. И это помогает даже тогда, когда вы по ошибке отправили запрос не туда или когда один и тот же code/title может возникнуть в разных местах.
В нашем учебном проекте instance часто закрывает потребность в отдельном поле path. Да, можно добавить path как extension field, но это лишнее дублирование, если instance уже показывает URI запроса. Чем меньше дублирующих полей, тем проще поддерживать контракт: меньше мест, где можно случайно разъехаться.
Практический совет: instance должен быть максимально близок к реальному URI запроса. Если вы руками собираете строку и ошиблись (например, забыли /api/v1 или перепутали taskId), вы получите «ошибку про ошибку» — в ответе будет неверная ссылка на запрос. Поэтому в реальном коде часто берут URI из запроса (например, через HttpServletRequest). Мы пока покажем упрощённый, учебный вариант — руками, но с правильной идеей.
4. Примеры и ограничения в Task Tracker API
Минимальная реализация: TaskNotFoundException → ProblemDetail
Сейчас соберём минимальный пример на нашем проекте Task Tracker API, не залезая в дополнительные поля и не усложняя модель. У нас уже есть глобальный слой обработки ошибок на базе @ControllerAdvice. Мы добавим (или уточним) обработчик TaskNotFoundException, который возвращает ProblemDetail с заполненными type, title, status, detail, instance.
Ниже — именно минимальный base-set: только стандартные поля ProblemDetail. Сверху на него можно навесить code, fieldErrors и диагностические поля, но сам каркас type/title/status/detail/instance отсюда уже не меняется.
Начнём с доменного исключения. Оно живёт в com.example.tasktracker.domain.exception и должно уметь сообщить taskId, чтобы @ControllerAdvice мог собрать человекочитаемый detail. Обратите внимание: мы храним taskId отдельным полем, а не пытаемся парсить его из текста сообщения (парсить строки — это всегда путь страдания).
public class TaskNotFoundException extends RuntimeException {
// Идентификатор задачи, которую не нашли — нужен для формирования ProblemDetail.detail
private final String taskId;
public TaskNotFoundException(String taskId) {
// Храним id отдельным полем, чтобы не парсить его из текста сообщения
this.taskId = taskId;
}
public String getTaskId() {
// Достаём id, чтобы обработчик ошибок мог собрать человекочитаемую детализацию
return taskId;
}
}
Теперь обработчик в нашем GlobalExceptionHandler (он у нас в web-layer, например в пакете com.example.tasktracker.api.error). Здесь мы создаём ProblemDetail и заполняем поля. status и detail выставляются через forStatusAndDetail, а остальные — вручную, чтобы каркас был полностью определён.
@ExceptionHandler(TaskNotFoundException.class)
public ProblemDetail handleTaskNotFound(TaskNotFoundException ex) {
// Базу (status + detail) удобно выставлять через фабрику ProblemDetail
ProblemDetail pd = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND,
"Task with id " + ex.getTaskId() + " was not found"
);
// type — стабильный идентификатор класса проблемы
pd.setType(URI.create("/problems/task-not-found"));
// title — короткое, стабильное имя для человека
pd.setTitle("Task not found");
// instance — привязка к конкретному запросу/ресурсу
pd.setInstance(URI.create("/api/v1/tasks/" + ex.getTaskId()));
return pd;
}
Если теперь клиент вызовет GET /api/v1/tasks/{taskId} с несуществующим id, он получит ответ примерно такой формы (упрощённый пример JSON). И это уже выглядит как договор: понятно, что случилось, к какому запросу относится, какой статус, какой тип проблемы.
{
"type": "/problems/task-not-found",
"title": "Task not found",
"status": 404,
"detail": "Task with id 7f3c2e52-1a1b-4f03-9c3d-2c9d8a8a8e12 was not found",
"instance": "/api/v1/tasks/7f3c2e52-1a1b-4f03-9c3d-2c9d8a8a8e12"
}
И ещё один маленький, но важный штрих: клиент должен видеть, что это именно problem details. То есть Content-Type ответа будет application/problem+json. Spring MVC умеет это делать, если вы идёте по правильному пути ProblemDetail и включили поддержку problem details в конфигурации проекта.
Один каркас для 404, 400 и 409
Сейчас хочется, конечно, сразу сделать «идеальную модель ошибок на все случаи жизни». Но сегодняшняя цель скромнее и важнее: убедиться, что базовый каркас одинаково подходит для разных типов проблем. И 404 Not Found, и 400 Bad Request, и 409 Conflict — это разные смыслы, но форма ответа должна оставаться узнаваемой. Клиент должен «узнать» ошибку по форме так же легко, как вы узнаёте JSON-ответ списка по полю items.
То есть меняются значения и дополнительные extension fields, а базовый набор type/title/status/detail/instance остаётся тем же. Например, тот же самый каркас можно использовать и для конфликта. Мы пока не разбираем, когда именно нужен 409, но показываем, что структура не меняется: меняются только status, type, title, detail, instance.
// Пример: конфликт бизнес-состояния, когда операция недопустима в текущем статусе задачи
ProblemDetail pd = ProblemDetail.forStatusAndDetail(
HttpStatus.CONFLICT,
"Current task state does not allow this operation"
);
// Классифицируем проблему стабильным type, чтобы её можно было группировать
pd.setType(URI.create("/problems/state-conflict"));
pd.setTitle("State conflict");
// Привязываем ошибку к конкретному ресурсу/запросу
pd.setInstance(URI.create("/api/v1/tasks/42"));
И то же самое для 400, когда запрос в целом «не той формы» (например, тело запроса не проходит базовую проверку). Опять же, без углубления в fieldErrors — только каркас.
// Пример: запрос невалидный по форме (например, не проходит базовую валидацию)
ProblemDetail pd = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST,
"Request body is invalid"
);
// type и title держим стабильными, чтобы клиенту было проще понимать категорию ошибки
pd.setType(URI.create("/problems/invalid-input"));
pd.setTitle("Invalid input");
// instance помогает понять, к какому endpoint относится ошибка (особенно в логах/тестах)
pd.setInstance(URI.create("/api/v1/tasks"));
Главное, что нужно почувствовать: мы не делаем отдельный «формат 404», отдельный «формат 409» и третий «формат 400». Мы делаем один язык ошибок, где меняется содержание, но не грамматика.
Что не должно попасть в ProblemDetail
Даже самый красивый ProblemDetail можно испортить одной привычкой: «а давайте просто отдадим наружу ex.getMessage()». Иногда это безопасно (если исключение ваше, и сообщение заранее подготовлено), но часто это приводит к утечке внутренних деталей. И это не про паранойю, это про контракт: клиент не должен зависеть от ваших внутренних текстов и точно не должен видеть технические названия классов и стек вызовов.
Если вы отдаёте stack trace, вы одновременно делаете две вещи. Первая — выдаёте потенциально чувствительную информацию о системе. Вторая — делаете клиента зависимым от случайного текста: завтра вы чуть изменили сообщение в исключении, и вдруг фронтенд-тесты начали падать, потому что кто-то (не вы, конечно, а «кто-то») сравнивал строки. Это как построить интеграцию на цвете кнопки в чужом приложении: пока кнопка синяя — вы счастливы, как только она стала зелёной — всё развалилось.
Поэтому в минимальном каркасе мы намеренно держим внешний текст под контролем. title и type — стабильные. detail — конкретный, но внешний. instance — привязан к запросу. А внутренние подробности остаются в логах сервера, где им и место.
5. Типичные ошибки при сборке ProblemDetail
В этом месте обычно хочется сказать «ну тут всё просто». И да, механически — просто. Но именно простые вещи чаще всего и ломают контракт: не потому что сложно, а потому что «да ладно, потом поправим». В ошибках API «потом» быстро превращается в «никогда», потому что клиенты уже привыкли к кривому формату, а менять его становится больно.
Ошибка №1: путать title и detail.
Очень распространённая картина: в title кладут «Task with id ... not found», а detail делают общим «Not found». В итоге заголовок каждый раз разный, его нельзя использовать как стабильную подпись, а detail теряет смысл конкретики. Лечится просто: title короткий и общий, detail конкретный и «про этот случай».
Ошибка №2: не заполнять instance, потому что «клиент и так знает URL».
Да, клиент знает. Но когда ошибка попадает в логи, в баг-репорт, в отчёт тестов, в алерт мониторинга — там URI может быть потерян или неочевиден. instance делает ошибку самодостаточной. Это недорогая привычка, которая экономит много времени.
Ошибка №3: оставлять type по умолчанию и не думать о классификации ошибок.
Если type всегда одинаковый или не задаётся, вы теряете возможность по-человечески группировать ошибки. Даже если вы не делаете страницу документации по type, сам факт наличия стабильных type уже помогает: и в логах, и в тестах, и в будущей документации.
Ошибка №4: пытаться запихнуть в detail сырое сообщение исключения, особенно фреймворкового.
Сообщения фреймворка могут быть длинными, техническими и нестабильными между версиями. Сегодня одно, завтра другое — и вы сами же потом будете удивляться, почему у вас «сломался контракт ошибок» после обновления зависимостей. detail должен быть вашим, внешним и контролируемым.
Ошибка №5: делать разные формы ошибок для разных контроллеров.
Один контроллер отдаёт ProblemDetail, другой — { "error": "..." }, третий — вообще строку. Клиенту остаётся только плакать и писать три парсера. Единственный нормальный выход — централизовать обработку и держать каркас одинаковым везде. Тогда API действительно становится предсказуемым.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ