Приложение может «упасть» по двум совершенно разным причинам: нарушено бизнес-правило или что-то сломалось в инфраструктуре. Перепутать их — значит показать пользователю «500 Internal Server Error» там, где должно быть «Баланс недостаточен», и наоборот.
Ниже — один и тот же платёж по заказу #4021: списать 1500 ₽ при балансе 300 ₽, два способа сообщить об этом.
Нарушение бизнес-правила, отданное как null, доезжает до клиента пятисоткой без единого числа. То же правило, брошенное типизированным исключением, доезжает как 422 с суммами — и пользователь чинит платёж сам.
Два вида ошибок
Доменная ошибка — это ожидаемая ситуация внутри бизнес-логики. Пользователь пытается купить билет, которого нет; счёт уходит в минус; дата в прошлом. Такие ошибки предсказуемы и формируют часть API: клиент должен получить внятный ответ, а не стек вызовов.
Технический сбой — нечто непредвиденное: БД недоступна, истёк таймаут, закончилась память. Приложение не виновато, пользователь тут ни при чём — задача логировать и вернуть нейтральный «что-то пошло не так».
Короткая формула: доменная ошибка = бизнес говорит «нельзя»; технический сбой = окружение говорит «не могу».
Доменные исключения: как объявить
Создайте базовый класс для всех доменных ошибок и наследуйте конкретные случаи:
public abstract class DomainException extends RuntimeException {
protected DomainException(String message) {
super(message);
}
protected DomainException(String message, Throwable cause) {
super(message, cause);
}
}
public final class InsufficientBalanceException extends DomainException {
public InsufficientBalanceException(BigDecimal required, BigDecimal available) {
super("Недостаточно средств: требуется %s, доступно %s"
.formatted(required, available));
}
}
public final class OrderNotFoundException extends DomainException {
public OrderNotFoundException(long orderId) {
super("Заказ #%d не найден".formatted(orderId));
}
}
Так это выглядит в доменном сервисе, который и бросает ошибку:
public void pay(long orderId, BigDecimal amount) {
Order order = orders.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
Account account = accounts.findByCustomerId(order.customerId());
if (account.balance().compareTo(amount) < 0) {
throw new InsufficientBalanceException(amount, account.balance());
}
account.debit(amount);
}
Каждое исключение несёт конкретные данные — что именно пошло не так. Это важно: при обработке на границе (контроллер, @RestControllerAdvice) вы сможете сформировать содержательный ответ.
Главный довод за непроверяемые исключения
Кроме удобства есть причина, которая в Spring-приложении решает вопрос: @Transactional по умолчанию откатывает транзакцию на непроверяемом исключении и не откатывает на проверяемом.
То есть доменное правило, выраженное проверяемым исключением, даёт худший из возможных исходов: операция не выполнена (исключение вылетело), а транзакция зафиксирована — с тем, что успело записаться до броска. Данные остаются в промежуточном состоянии, и находят это обычно через недели.
// так получается половина записанного
@Transactional
public void cancel(OrderId id) throws OrderCannotBeCancelledException { // проверяемое
Order order = orders.require(id);
audit.record(id, "cancel-attempt"); // запишется
order.cancel(); // бросит — и транзакция закоммитится
}
Поправить можно и вручную (rollbackFor = Exception.class), но проще не создавать проблему: доменные исключения делают непроверяемыми. Тогда откат происходит сам, а сигнатуры методов не обрастают объявлениями. Разбор самого правила откатов — в статье про @Transactional.
Исключение должно нести машинный код
Иерархия сама по себе даёт обработчику только тип. Чтобы в ответ попал устойчивый код ошибки, исключение обязано его нести — иначе всё, что можно собрать, это человеческая строка.
public enum ErrorCode {
ORDER_NOT_FOUND(HttpStatus.NOT_FOUND),
ORDER_CANNOT_BE_CANCELLED(HttpStatus.UNPROCESSABLE_ENTITY),
PAYMENT_LIMIT_EXCEEDED(HttpStatus.UNPROCESSABLE_ENTITY),
LOGIN_ALREADY_TAKEN(HttpStatus.CONFLICT);
private final HttpStatus status;
ErrorCode(HttpStatus status) { this.status = status; }
public HttpStatus status() { return status; }
}
public abstract class DomainException extends RuntimeException {
private final ErrorCode code;
protected DomainException(ErrorCode code, String message) {
super(message);
this.code = code;
}
public ErrorCode code() { return code; }
}
Выигрыш двойной. Обработчику достаточно одного метода на все доменные исключения: он берёт код, из кода — статус, и собирает ответ. И каталог кодов оказывается в одном месте — перечислении, из которого можно сгенерировать раздел документации.
Второе, что стоит нести в исключении, — данные для ответа: идентификатор объекта, текущий статус, предел, который превышен. Тогда сообщение собирается в обработчике, а не склеивается строкой в домене (и потому может быть локализовано).
Сколько типов исключений заводить
Первое решение после статьи, и у него два крайних варианта.
Класс на каждое правило (OrderCannotBeCancelledException, PaymentLimitExceededException) — максимальная ясность: по типу видно, что случилось, можно поймать конкретное, в тестах читается. Цена — десятки классов, каждый из которых содержит одну строку смысла.
Один класс с кодом (BusinessRuleException плюс ErrorCode) — компактно, каталог кодов в одном перечислении, обработчик один. Цена — поймать конкретное правило можно только по коду, а не по типу, и это неудобно в тестах и в местах, где нужна реакция на определённое нарушение.
Рабочий компромисс, который встречается чаще всего: отдельные классы для того, на что кто-то реагирует, и общий класс с кодом для остального. То есть «не найдено», «конфликт состояния», «нет прав» — свои типы (на них реагируют и обработчик, и вызывающий код); а конкретные бизнес-правила внутри одного типа с разными кодами. Ориентир простой: заводить свой класс стоит, когда где-то есть catch именно на него.
Спорные случаи: доменная ошибка или технический сбой
Разобранные примеры очевидны («нет денег» против «база упала»). Ошибаются на других, и вот как их различать по одному признаку: может ли клиент что-то сделать с этим ответом.
Таймаут внешнего платёжного сервиса. Технический сбой, но клиенту от этого не легче. Правильный ответ — 503 или 504 с признаком повторяемости: клиент может повторить. А вот если платёжный сервис ответил «отказано банком» — это доменная ошибка (422), потому что повторять бессмысленно, надо менять карту.
Нарушение уникальности при гонке. Технически это исключение уровня доступа к данным, по смыслу — конфликт состояния. Отвечают 409: запрос был правильным, состояние изменилось. Превращать это в 500 — самая частая ошибка, и она делает вид, что виноват сервер.
Конфликт версий при оптимистичной блокировке. То же: 409, повтор осмысленен (перечитать и попробовать снова).
Отказ по бизнес-причине от чужого сервиса. Признак — понятная причина в ответе партнёра («лимит превышен», «адрес не обслуживается»). Такие отказы переводят в свои доменные ошибки с понятными кодами, а не пробрасывают чужие. Иначе контракт вашего API начинает зависеть от того, что придумал партнёр.
Ошибка сериализации ответа. Технический сбой, и его уже нельзя красиво отдать: часть ответа ушла. Единственное лечение — не допускать (не держать логику в геттерах модели ответа).
Логировать 4xx как ошибку не надо
Правило, которому самое место здесь, где вводится деление: доменная ошибка — не повод для записи уровня ошибки и не повод для оповещения. Отказ «нельзя отменить отгруженный заказ» — это штатная работа системы: правило сработало, клиент получил внятный ответ. Если писать такие случаи как ошибки, журнал заполняется шумом, а настоящие сбои в нём теряются.
Раскладка простая. Доменные ошибки (4xx) — уровень отладки или информации, без стектрейса (он ничего не добавляет: причина в правиле, а не в коде). Технические сбои (5xx) — уровень ошибки, со стектрейсом и с оповещением.
И обратная сторона, о которой забывают: раз доменные ошибки не видно в журнале ошибок, их надо считать. Счётчик по коду ошибки в метриках даёт то, чего не даёт журнал: всплеск отказов «карта отклонена» или «лимит превышен» виден на графике, хотя в журнале ошибок тишина. Без этого сломанная интеграция выглядит как затишье.
Почему не null и не коды ошибок
Возвращать null — это молчаливая ошибка. Вызывающий код обязан помнить проверить результат; если забудет — NullPointerException в случайном месте. Исключение же нельзя проигнорировать: оно прервёт выполнение там, где оно возникло.
Коды ошибок (int status, String errorCode в возвращаемом объекте) — это шаблон из эпохи C, когда исключений не было. В Java это лишний груз: нужно каждый раз проверять результат, логика «счастливого пути» перемешивается с обработкой ошибок, а тип возврата засоряется служебными полями.
Типизированное исключение прерывает выполнение сразу, а не через несколько вызовов, несёт тип и данные вместо строки с кодом и не требует проверки после каждого вызова.
Есть два вида исключений, и для доменных ошибок берут unchecked, наследников RuntimeException. Checked-исключения заставляют всю цепочку вызовов декларировать throws: контроллер, который вызывает сервис, который вызывает репозиторий, получает сигнатуру вроде throws InsufficientFundsException, OrderNotFoundException, хотя сам про эти ошибки ничего не знает, и так в каждом слое. С unchecked сигнатуры чистые, а ловит ошибку тот слой, которому есть что с ней делать.
От исключения до ответа клиенту
Доменное исключение, брошенное в обработчике, «всплывает» до контроллерного слоя и перехватывается глобальным обработчиком @RestControllerAdvice. Там оно превращается в структурированный ответ Problem Details (RFC 9457):
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(InsufficientBalanceException.class)
public ProblemDetail handleInsufficientBalance(InsufficientBalanceException ex) {
ProblemDetail problem = ProblemDetail
.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage());
problem.setTitle("Недостаточно средств");
problem.setProperty("code", "INSUFFICIENT_BALANCE");
return problem;
}
}
Статус ответа Spring берёт из самого ProblemDetail — отдельно помечать метод аннотацией @ResponseStatus не нужно, а если два значения разойдутся, победит то, что лежит в ProblemDetail. Рядом с текстом кладут code — машинный код, по которому клиент ветвит логику, не разбирая сообщение по словам.
Подробно о формате ответа — в статье Ошибки REST API и Problem Details. Здесь нам важен принцип: доменное исключение не обрабатывается внутри бизнес-логики — оно пробрасывается и перехватывается на границе слоя.
Как соотнести ошибки с HTTP-статусами
Доменные ошибки и технические сбои отображаются на разные диапазоны статусов:
| Вид ошибки | HTTP-статус | Пример |
|---|---|---|
| Не найден объект | 404 Not Found | OrderNotFoundException |
| Нарушение бизнес-правила | 422 Unprocessable Entity | InsufficientBalanceException |
| Некорректный запрос | 400 Bad Request | ошибки валидации |
| Технический сбой | 500 Internal Server Error | DataAccessException |
Ключевое правило: 4xx — проблема на стороне клиента (он прислал невалидный запрос или нарушил правило), 5xx — проблема на стороне сервера (инфраструктура упала).
Границу между 4xx и 5xx проводит вопрос, кто может исправить: конфликт состояния это 409, таймаут партнёра 503 или 504, а 500 остаётся только для сбоя в своём коде.
Глубже: какие ошибки можно повторятьрасширенное
Клиент, получивший ошибку, решает одно: повторить или нет. Если модель ошибок не отвечает на этот вопрос, клиенты повторяют всё подряд и устраивают шторм, либо не повторяют ничего и падают на первой сетевой заминке.
Первый ответ даёт статус. 4xx кроме двух исключений повторять нельзя: тот же запрос даст ту же ошибку. Исключения это 429, где сервер просит подождать, и 408 с 409 для конфликта версии, где повтор имеет смысл после перечитывания.
Из 5xx преходящими считают 502, 503 и 504, за ними обычно балансировщик, перезапуск или таймаут, а 500 неоднозначен: это может быть и упавший узел, и ошибка в коде, которая повторится. Заголовок Retry-After при 429 и 503 это прямое указание, сколько ждать, и его уважают вместо своего расписания повторов.
Второй ответ даёт тело. Когда статуса мало, в формат ошибки добавляют признак: расширение retryable: true в теле application/problem+json рядом с code. Так сервер сообщает, что 500 был временным (упала зависимость, сработал предохранитель), а 409 окончательным (заказ уже отменён). Признак ставит тот, кто знает причину, обработчик исключений, по типу исключения: таймаут зависимости и CallNotPermittedException предохранителя дают true, доменные ошибки false.
Третий ответ про безопасность повтора, и он не зависит от статуса. Повторять GET, PUT и DELETE можно всегда, они идемпотентны по определению. POST, создающий заказ или платёж, повторять можно только с ключом идемпотентности из статьи про заголовки: без него ответ «таймаут» не говорит, создался заказ или нет, и повтор создаст второй. Поэтому у операций с деньгами ключ идемпотентности обязателен в контракте, а не рекомендован.
Клиенту это складывается в правило: повторять при 429, 502–504, при сетевом таймауте и при retryable: true, конечное число раз с растущей паузой и разбросом; не повторять 400, 401, 403, 404, 422 и всё с retryable: false; POST без ключа идемпотентности не повторять никогда. Что это значит для отправителя вебхуков, где повторяете уже вы, разбирает статья про вебхуки в разделе REST.
Глубже: ошибки в GraphQL: 200 с массивом errorsрасширенное
Всё, что выше, стоит на HTTP-статусах, а GraphQL их почти не использует, и клиент, который проверяет response.ok, считает любой ответ успешным.
В GraphQL один запрос может вернуть часть данных и часть ошибок одновременно: профиль загрузился, а список заказов упал. Поэтому ответ приходит с 200 и двумя полями: data с тем, что удалось, и errors с тем, что нет, где у каждой ошибки есть message, path до поля, которое не посчиталось, и extensions с машинным кодом:
{
"data": { "customer": { "name": "Иван", "orders": null } },
"errors": [
{ "message": "Сервис заказов недоступен", "path": ["customer", "orders"],
"extensions": { "code": "UNAVAILABLE", "retryable": true } }
]
}
Поле orders стало null, и это допустимо, только если оно объявлено в схеме допускающим null; для обязательного поля null поднимается вверх до ближайшего необязательного предка и затирает больше данных, чем хотелось, поэтому обязательность в схеме GraphQL это решение про то, какие части ответа гибнут вместе.
HTTP-статус остаётся для ошибок транспорта: 400 на синтаксически неверный запрос, 401 на отсутствующий токен, 413 на слишком большой запрос. Всё доменное, NOT_FOUND, FORBIDDEN, VALIDATION, живёт в extensions.code, и это тот же список кодов, что в поле code REST-ошибки, только без статуса рядом. В Spring for GraphQL доменные исключения превращает в такие ошибки DataFetcherExceptionResolverAdapter, где вы сопоставляете исключение с ErrorType и кладёте свои расширения; без него любое исключение уходит клиенту как INTERNAL_ERROR с сообщением по умолчанию. Клиент же обязан смотреть в errors при каждом ответе, а не только при не-200, и это первое, что забывают при переходе с REST.
Коротко
- Разделяй ошибки на доменные (бизнес-правило нарушено) и технические (инфраструктура недоступна) — они обрабатываются по-разному.
- Исключение несёт машинный код (перечисление со статусом) и данные для ответа — тогда обработчику хватает одного метода, а каталог кодов живёт в одном месте; свой класс заводят там, где на него кто-то реагирует.
- Доменные исключения делают непроверяемыми: они не засоряют сигнатуры, а главное —
@Transactionalоткатывает транзакцию только на них, тогда как на проверяемом фиксирует половину записанного. - Не возвращай
nullи коды ошибок — исключение прерывает выполнение немедленно и несёт тип. - Доменное исключение пробрасывается до границы слоя (
@RestControllerAdvice) и там превращается в ответ. - 4xx — ошибка клиента, 5xx — ошибка сервера. Спорные случаи различают по вопросу «что клиент может сделать»: таймаут партнёра
503, отказ банка422, гонка на уникальности и конфликт версий409. - Повторяемость это часть модели:
429и502–504повторяют сRetry-Afterи паузой,4xxнет, спорный500помечают в телеretryable;POSTповторяют только с ключом идемпотентности. - GraphQL отвечает
200сdataиerrorsодновременно; коды вextensions.code,nullдля обязательного поля затирает предка; клиент смотрит вerrorsвсегда. - Доменные ошибки не пишут уровнем ошибки и не оповещают по ним, но обязательно считают счётчиком по коду — иначе сломанная интеграция выглядит как затишье.
Что почитать дальше
- Глобальная обработка ошибок в Spring Boot — как устроен
@RestControllerAdviceи как перехватывать разные типы исключений. - Типичные ошибки при работе с исключениями — антипаттерны: проглатывание, оборачивание, злоупотребление checked.
- Ошибки REST API и Problem Details — формат RFC 9457, поля
type/title/detail, Spring Boot 3 из коробки.