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

Приложение может «упасть» по двум совершенно разным причинам: нарушено бизнес-правило или что-то сломалось в инфраструктуре. Перепутать их — значит показать пользователю «500 Internal Server Error» там, где должно быть «Баланс недостаточен», и наоборот.

Ниже — один и тот же платёж по заказу #4021: списать 1500 ₽ при балансе 300 ₽, два способа сообщить об этом.

POST /orders/4021/pay — списать 1500 ₽, на счёте 300 ₽ null или код ошибки типизированное исключение return null; // нет средстввызывающий не проверилNullPointerExceptionдвумя слоями выше throw newInsufficientBalanceException(1500, 300) — данные внутрив логике не ловим catch (Exception e)500 Internal Server Error @RestControllerAdvice422 Unprocessable Entity «что-то пошло не так»чинить нечего: причина в логахидёт дежурныйтребуется 1500, доступно 300пополнить на 1200 и повторитьчинит пользовательразница не в сбое, а в типе: 500 зовёт дежурного, 422 — пользователя

Нарушение бизнес-правила, отданное как 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 FoundOrderNotFoundException
Нарушение бизнес-правила422 Unprocessable EntityInsufficientBalanceException
Некорректный запрос400 Bad Requestошибки валидации
Технический сбой500 Internal Server ErrorDataAccessException

Ключевое правило: 4xx — проблема на стороне клиента (он прислал невалидный запрос или нарушил правило), 5xx — проблема на стороне сервера (инфраструктура упала).

ошибка на границе кто может исправить дошла до advice 4xx: 400, 404, 422 клиент прислал не то 4xx: 409 состояние изменилось 5xx: 503, 504 партнёр не ответил вовремя 5xx: 500 сбой в нашем коде

Границу между 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 всегда.
  • Доменные ошибки не пишут уровнем ошибки и не оповещают по ним, но обязательно считают счётчиком по коду — иначе сломанная интеграция выглядит как затишье.

Что почитать дальше