Приложение может «упасть» по двум совершенно разным причинам: нарушено бизнес-правило или что-то сломалось в инфраструктуре. Перепутать их — значит показать пользователю «500 Internal Server Error» там, где должно быть «Баланс недостаточен», и наоборот.
Два вида ошибок
Доменная ошибка — это ожидаемая ситуация внутри бизнес-логики. Пользователь пытается купить билет, которого нет; счёт уходит в минус; дата в прошлом. Такие ошибки предсказуемы и формируют часть API: клиент должен получить внятный ответ, а не стек вызовов.
Технический сбой — нечто непредвиденное: БД недоступна, истёк таймаут, закончилась память. Приложение не виновато, пользователь тут ни при чём — задача логировать и вернуть нейтральный «что-то пошло не так».
Короткая формула: доменная ошибка = бизнес говорит «нельзя»; технический сбой = окружение говорит «не могу».
Доменные исключения: как объявить
Создайте базовый класс для всех доменных ошибок и наследуйте конкретные случаи:
public abstract class DomainException extends RuntimeException {
protected DomainException(String message) {
super(message);
}
}
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));
if (order.balance().compareTo(amount) < 0) {
throw new InsufficientBalanceException(amount, order.balance());
}
order.debit(amount);
}
Каждое исключение несёт конкретные данные — что именно пошло не так. Это важно: при обработке на границе (контроллер, @RestControllerAdvice) вы сможете сформировать содержательный ответ.
Почему не null и не коды ошибок
Возвращать null — это молчаливая ошибка. Вызывающий код обязан помнить проверить результат; если забудет — NullPointerException в случайном месте. Исключение же нельзя проигнорировать: оно прервёт выполнение там, где оно возникло.
Коды ошибок (int status, String errorCode в возвращаемом объекте) — это шаблон из эпохи C, когда исключений не было. В Java это лишний груз: нужно каждый раз проверять результат, логика «счастливого пути» перемешивается с обработкой ошибок, а тип возврата засоряется служебными полями.
Типизированное исключение:
- прерывает выполнение немедленно, а не через несколько вызовов,
- несёт тип и данные — не строку с кодом,
- не требует проверки после каждого вызова.
Unchecked (RuntimeException) предпочтительнее checked для доменных ошибок: checked-исключения заставляют всю цепочку вызовов декларировать throws, что приводит к шаблонному коду и нарушению инкапсуляции слоёв.
От исключения до ответа клиенту
Доменное исключение, брошенное в обработчике, «всплывает» до контроллерного слоя и перехватывается глобальным обработчиком @RestControllerAdvice. Там оно превращается в структурированный ответ Problem Details (RFC 9457):
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(InsufficientBalanceException.class)
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
public ProblemDetail handleInsufficientBalance(InsufficientBalanceException ex) {
ProblemDetail problem = ProblemDetail
.forStatusAndDetail(HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage());
problem.setTitle("Недостаточно средств");
return problem;
}
}
Подробно о формате ответа — в статье Ошибки REST API и Problem Details. Здесь нам важен принцип: доменное исключение не обрабатывается внутри бизнес-логики — оно пробрасывается и перехватывается на границе слоя.
Как соотнести ошибки с HTTP-статусами
Доменные ошибки и технические сбои отображаются на разные диапазоны статусов:
| Вид ошибки | HTTP-статус | Пример |
|---|---|---|
| Не найден объект | 404 Not Found | OrderNotFoundException |
| Нарушение бизнес-правила | 422 Unprocessable Entity | InsufficientBalanceException |
| Некорректный запрос | 400 Bad Request | ошибки валидации |
| Технический сбой | 500 Internal Server Error | DataAccessException |
Ключевое правило: 4xx — проблема на стороне клиента (он прислал невалидный запрос или нарушил правило), 5xx — проблема на стороне сервера (инфраструктура упала).
Коротко
- Разделяй ошибки на доменные (бизнес-правило нарушено) и технические (инфраструктура недоступна) — они обрабатываются по-разному.
- Создавай типизированные исключения для каждой доменной ошибки с конкретными данными в конструкторе.
- Используй
unchecked-исключения (RuntimeException) — не засоряют сигнатуры и не требуют явногоthrowsв каждом слое. - Не возвращай
nullи коды ошибок — исключение прерывает выполнение немедленно и несёт тип. - Доменное исключение пробрасывается до границы слоя (
@RestControllerAdvice) и там превращается в ответ. - 4xx — ошибка клиента, 5xx — ошибка сервера: не путай.
Что почитать дальше
- Глобальная обработка ошибок в Spring Boot — как устроен
@RestControllerAdviceи как перехватывать разные типы исключений. - Типичные ошибки при работе с исключениями — антипаттерны: проглатывание, оборачивание, злоупотребление checked.
- Ошибки REST API и Problem Details — формат RFC 9457, поля
type/title/detail, Spring Boot 3 из коробки.