← назад к разделу

Приложение может «упасть» по двум совершенно разным причинам: нарушено бизнес-правило или что-то сломалось в инфраструктуре. Перепутать их — значит показать пользователю «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 FoundOrderNotFoundException
Нарушение бизнес-правила422 Unprocessable EntityInsufficientBalanceException
Некорректный запрос400 Bad Requestошибки валидации
Технический сбой500 Internal Server ErrorDataAccessException

Ключевое правило: 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 из коробки.