1. Единый формат ошибки как API-контракт
Если вы раньше писали консольные программы, у вас был один честный способ общения с миром: System.out.println("Ой, ошибка"). В HTTP API такой фокус не работает. Тут у вас есть клиент (Postman, браузер, другой сервис), который хочет понять, что произошло, и желательно понять это одинаково во всех endpoint-ах. И вот тут начинаются проблемы: если ошибки возвращаются как попало, клиентская сторона превращается в «археолога по чужим ответам».
Представьте, что вы тестируете API в Postman. Для успешного ответа вы уже привыкли: приходит JSON определённой формы. А теперь ошибка. Сегодня она приходит так:
"Something went wrong"
Завтра так:
{ "error": "bad id" }
Послезавтра — вообще HTML-страница (да, так тоже бывает, если где-то “случайно” вернули не то). В этот момент любой клиент, даже самый терпеливый, начинает жить по принципу: «Попробую распарсить как JSON, если не получилось — ну… пускай будет строка… если и это не строка — печаль и слёзы». Это не инженерия, это гадание на кофейной гуще.
Единая форма ошибки решает сразу две прикладные задачи. Во-первых, клиент может машинно понять тип проблемы по стабильному коду ошибки (в нашем случае — errorCode), не пытаясь парсить человеческие фразы. Во-вторых, человек (вы, ваш одногруппник, будущий фронтендер) может прочитать message и быстро понять, что не так, без необходимости открывать логи и угадывать смысл по статусу.
И здесь важно не перепутать роли. HTTP-статус отвечает на вопрос «какого класса проблема» (ошибка запроса? не найдено? конфликт?), а тело ошибки отвечает на вопрос «что конкретно пошло не так в рамках нашего API». Эти две части не дублируют, а дополняют друг друга: статус — уровень “категории”, JSON — уровень “деталей”.
До этого момента мы собирали happy-path API: request DTO приходит снаружи, mapping переводит его в домен, а наружу уходит success response. Но на любом из этих шагов может сработать unhappy-path: сломанный JSON, невалидные данные, отсутствие ресурса, внутренний сбой. На границе API всё это должно превратиться не в случайный текст, а в предсказуемый error-контракт: web-слой позже выберет подходящий статус, а тело ошибки у нас уже должно быть единым. Поэтому сначала зафиксируем форму такого error-body.
2. Минимальный дизайн ErrorResponse для ReadLater
Сейчас у нас цель очень «прагматичная»: придумать такую модель ошибки, чтобы её было легко возвращать из любого endpoint-а, легко сериализовать в JSON и легко читать глазами. При этом мы не хотим превращать проект в маленький фреймворк «как у взрослых» — мы делаем bridge-course, и нам важно, чтобы решение было понятным первокурснику… даже если он пришёл на курс случайно и теперь притворяется, что всё понимает.
В ReadLater Starter мы фиксируем три поля:
- errorCode — короткий стабильный код проблемы. Он нужен прежде всего для кода клиента, потому что код не любит поэзию и метафоры, а любит предсказуемые значения.
- message — человекочитаемое сообщение, которое можно показать в Postman, в консоли, в минимальном UI. Это не технический лог и не stack trace.
- details — дополнительные подробности, но в предсказуемой структуре. Очень важно, что details — список, а не строка: сегодня у вас одна проблема (“id не число”), завтра — три (“title пустой”, “author пустой”, “status неизвестный”). Тип оставляем одинаковым.
Давайте зафиксируем DTO в коде. Здесь удобно использовать record: это чистая форма данных, без поведения, без сеттеров, без драматических монологов.
package com.example.readlater.common.error;
import java.util.List;
// DTO для единого формата ошибок в API.
// Важно: это именно "данные", без логики и без сеттеров.
public record ErrorResponse(
// Стабильный код ошибки: по нему клиент может делать switch/if.
String errorCode,
// Короткое сообщение для человека (Postman/UI), без stack trace.
String message,
// Дополнительные уточнения. Всегда список (даже если пусто).
List<String> details
) {}
Обратите внимание на несколько “маленьких” решений, которые на деле экономят нервы. Мы не делаем details nullable и не пытаемся возвращать “то список, то null”. Если деталей нет — это просто пустой список. Клиенту приятно: тип всегда один и тот же.
Вот как примерно будет выглядеть JSON, который мы хотим видеть в любой ошибке:
{
"errorCode": "INVALID_REQUEST",
"message": "Запрос содержит некорректные данные",
"details": ["id must be a number"]
}
И даже если деталей нет, форма должна остаться такой же:
{
"errorCode": "INTERNAL_ERROR",
"message": "Внутренняя ошибка приложения",
"details": []
}
Никаких “то поле есть, то поля нет”. Контракт любит стабильность. А нестабильность любит только хаос (и, возможно, котики, но котики хотя бы милые).
3. Поля errorCode, message, details: назначение
Сейчас легко сделать типичную ошибку новичка: перепутать предназначение полей и начать лепить в errorCode человеческий текст, а в message — технические детали исключения. Поэтому мы аккуратно разберём каждое поле: что туда класть, что туда не класть, и почему это важно. Думайте об этом как о договоре между вами (сервером) и клиентом: если вы договор нарушили, клиент может “обидеться” и начать падать.
Для наглядности — маленькая табличка смыслов:
| Поле | Для кого в первую очередь | Требования к форме | Пример |
|---|---|---|---|
| errorCode | для кода клиента | коротко, стабильно, без пробелов | INVALID_REQUEST |
| message | для человека | кратко, без лишней техничности | Запрос содержит некорректные данные |
| details | для человека и дебага | список строк, без stack trace | ["title must not be blank"] |
Дальше — подробности.
Про errorCode.
Это поле нельзя делать “креативным”. Оно должно быть скучным, как бухгалтерия, и именно поэтому полезным. Хорошая практика — использовать UPPER_SNAKE_CASE, без пробелов и без “красивых фраз”. Почему? Потому что errorCode часто превращается в switch на стороне клиента, в аналитику, в правила обработки. И если вы сегодня вернули "invalid_request", а завтра "INVALID REQUEST" (с пробелом), вы устроили клиенту маленький праздник боли.
Чтобы не плодить “магические строки” в Java-коде, удобно держать набор кодов в enum. В JSON всё равно уйдёт строка, но в коде вы перестанете ошибаться в одной букве (да, ошибка на одну букву — самый популярный баг после NullPointerException).
package com.example.readlater.common.error;
// Перечень стабильных кодов ошибок.
// Эти значения — часть API-контракта, менять их "по настроению" нельзя.
public enum ErrorCode {
INVALID_REQUEST,
READING_ITEM_NOT_FOUND,
EXTERNAL_ID_CONFLICT,
METHOD_NOT_ALLOWED,
INTERNAL_ERROR
}
А когда вы создаёте ErrorResponse, вы берёте code.name():
import com.example.readlater.common.error.ErrorCode;
import com.example.readlater.common.error.ErrorResponse;
import java.util.List;
// Пример: создаём ответ об ошибке из enum-кода.
// code.name() гарантирует "ровно такое же" значение, как в enum.
ErrorResponse err = new ErrorResponse(
ErrorCode.INVALID_REQUEST.name(),
"Запрос содержит некорректные данные",
// Детали — всегда список строк: даже если 1 элемент.
List.of("id must be a number")
);
Так вы сохраняете стабильность и защищаетесь от опечаток.
Про message.
Это сообщение не должно быть “логом” и не должно быть “исключением”. Оно должно быть коротким и человеческим. Можно представлять, что его читает не только разработчик, но и условный пользователь (или будущий UI), который увидит “Элемент списка чтения не найден”, а не “java.lang.IllegalStateException: reading item absent”.
Почему важно не возвращать технический текст исключения? Потому что исключение пишется для разработчика, а не для внешнего мира. Там может оказаться всё: внутренние имена классов, куски путей файлов, намёки на архитектуру. Для учебного проекта это не “секретность”, а дисциплина: мы отделяем user-facing контракт от внутренних деталей.
Про details.
Это поле часто хочется сделать “как получится”. Но если сделать как получится, оно станет бесполезным. Хорошая стратегия — хранить там короткие уточнения, которые помогают понять проблему: какое поле, какое значение, какая причина. Детали могут быть на английском (короче и привычнее для технических сообщений) или на русском — главное, чтобы было единообразно.
Например, детали для ошибки могут выглядеть так:
import com.example.readlater.common.error.ErrorCode;
import com.example.readlater.common.error.ErrorResponse;
import java.util.List;
// Пример: несколько ошибок валидации в одном ответе.
ErrorResponse err = new ErrorResponse(
ErrorCode.INVALID_REQUEST.name(),
"Запрос содержит некорректные данные",
List.of(
// Каждая строка — отдельная "подробность" по причине ошибки.
"title must not be blank",
"status must not be null"
)
);
Тут важная мысль: details — это не место для stack trace. Если вы положите туда 200 строк, клиент вас не поблагодарит, а вы сами потом будете читать это в Postman и думать: «Зачем я это сделал и кто меня обидел?». Stack trace — для логов, а не для ответа клиенту.
4. Где держать ErrorResponse и как создавать ошибки
На этом этапе курса мы уже договорились про структуру проекта: app, config, common, catalog, readinglist. Ошибки локального API — это общая часть будущего серверного режима: они понадобятся и /health, и reading-list endpoint-ам. Поэтому логично вынести модель ErrorResponse в common.error, чтобы не плодить её копии в каждом feature и не получать кучу “почти одинаковых” классов.
Но здесь есть ловушка. Папка common очень легко превращается в “кладбище полезных утилит”, куда складывают всё подряд: StringUtils, DateUtils, SuperHelperFinal2. В итоге проект становится похож на ящик в кухне, куда вы кидаете всё, что лень раскладывать по полкам. Поэтому мы делаем минимум: DTO + маленький helper, если он действительно убирает копипасту.
Если не хочется руками собирать один и тот же ErrorResponse, поверх канонических ErrorResponse и ErrorCode можно добавить маленький helper. Он не создаёт новую модель ошибок и не меняет контракт — только убирает повторяющийся код.
package com.example.readlater.common.error;
import java.util.List;
// Небольшой helper поверх канонических ErrorResponse + ErrorCode.
// Цель — убрать копипасту, а не придумать новую "систему ошибок".
public final class ErrorResponses {
private ErrorResponses() {
// Запрещаем создавать экземпляры: используем только static-методы.
}
public static ErrorResponse internalError() {
return new ErrorResponse(
ErrorCode.INTERNAL_ERROR.name(),
"Внутренняя ошибка приложения",
List.of()
);
}
public static ErrorResponse invalidRequest(String message, String... details) {
return new ErrorResponse(
ErrorCode.INVALID_REQUEST.name(),
message,
List.of(details)
);
}
}
Если helper не нужен, ничего страшного: руками создать new ErrorResponse(...) тоже нормально. Базой всё равно остаются ErrorResponse и ErrorCode.
5. Мини-проверка: JSON ошибки без запуска сервера
Сейчас нам не нужен уже запущенный HttpServer, чтобы проверить форму ошибки. Достаточно убедиться, что ErrorResponse нормально превращается в JSON и действительно выглядит так, как мы договорились. Это снимает кучу вопросов вида “почему у меня JSON вдруг не такой”.
Ниже — мини-пример, который можно выполнить где угодно (хоть в маленьком тестовом main()), просто чтобы увидеть результат.
import com.example.readlater.common.error.ErrorCode;
import com.example.readlater.common.error.ErrorResponse;
import tools.jackson.databind.ObjectMapper;
import java.util.List;
ObjectMapper mapper = new ObjectMapper();
var err = new ErrorResponse(
ErrorCode.INVALID_REQUEST.name(),
"Запрос содержит некорректные данные",
List.of("id must be a number")
);
System.out.println(mapper.writeValueAsString(err));
// {"errorCode":"INVALID_REQUEST","message":"Запрос содержит некорректные данные","details":["id must be a number"]}
Заметьте, что имена полей record (errorCode, message, details) становятся именами полей JSON. Это ровно то, что нам нужно: контракт фиксируется прямо в коде DTO, и не приходится вручную писать маппинг “как назвать поле”.
Ещё один маленький пример для “ошибка без деталей”. Мы хотим убедиться, что details остаётся массивом, а не превращается то в null, то в отсутствие поля.
import com.example.readlater.common.error.ErrorCode;
import com.example.readlater.common.error.ErrorResponse;
import tools.jackson.databind.ObjectMapper;
import java.util.List;
ObjectMapper mapper = new ObjectMapper();
var err = new ErrorResponse(
ErrorCode.INTERNAL_ERROR.name(),
"Внутренняя ошибка приложения",
List.of()
);
System.out.println(mapper.writeValueAsString(err));
// {"errorCode":"INTERNAL_ERROR","message":"Внутренняя ошибка приложения","details":[]}
Это маленькая проверка, но она важная: когда позже вы будете отдавать такие ответы клиенту, вы не хотите сюрпризов в форме JSON.
6. Типичные ошибки
Ошибка №1: разные структуры ошибок в разных местах.
Очень легко “на скорую руку” вернуть строку "bad request" в одном endpoint-е и объект { "error": "..." } в другом. А потом внезапно выясняется, что клиентскую обработку невозможно написать нормально: нужно проверять то одно, то другое. Единый ErrorResponse — это как единый формат чеков: да, скучно, зато работает.
Ошибка №2: details то строка, то список, то null.
Когда тип поля “прыгает”, JSON перестаёт быть контрактом. Сегодня клиент ждёт массив, завтра получает строку и падает при десериализации. Если деталей нет — возвращайте пустой список. Если одна деталь — список из одного элемента. Это та самая дисциплина, которая кажется занудной ровно до первого бага в интеграции.
Ошибка №3: в message или details попадает текст исключения или stack trace.
У новичка есть соблазн: “поймал исключение — вернул e.getMessage() клиенту”. На практике это или бесполезно (“For input string: 'abc'”), или слишком технически, или даже опасно (в реальных проектах). В ответ клиенту лучше отдавать нейтральное сообщение, а подробности — в лог.
Ошибка №4: errorCode превращается в “сообщение для человека”.
Если errorCode начинает выглядеть как "Запрос плохой потому что id не число", вы потеряли смысл. Код должен быть коротким и стабильным. Человеческие объяснения живут в message и details. Клиентский код не должен парсить русские предложения (он и так страдает, не усугубляйте).
Ошибка №5: errorCode меняется от случая к случаю без причин.
Сегодня вы вернули "INVALID_REQUEST_ID", завтра "INVALID_ID", послезавтра "BAD_ID". Для человека это почти одно и то же, а для клиента — три разные ошибки. Лучше иметь один код на класс проблем и уточнять причину через детали. Тогда поведение API будет предсказуемым, а не “в зависимости от настроения автора”.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ