Опирается на правила: R-ERR-HIER-1R-ERR-HIER-5 и R-ERR-HIER-X1R-ERR-HIER-X2 из Error Handling Style Guide → раздел 1. Иерархия исключений.

Важно знать

  • Четыре базовых типа в проекте: DomainException, ValidationException, IntegrationException, TechnicalException. Всё остальное — наследники.
  • Все четыре — RuntimeException-наследники. Checked exceptions не используются — командное правило.
  • Имена доменных исключений — по бизнес-смыслу: OrderAlreadyShippedException, InsufficientFundsException. Не BusinessException, не IllegalStateException.
  • IntegrationException-наследники с префиксом системы: SberRegisterException, CatalogPortException. Edge-handler различает «у платёжки» vs «у каталога».
  • Конструктор фиксирует контекст: new InsufficientFundsException(customerId, requested, available), не new InsufficientFundsException(). Лог + структурированный контекст одновременно.
  • throw new RuntimeException("...") запрещён — тип теряется, edge-handler не различит тип.
  • IllegalStateException в доменном коде — запрещён. Это сигнал «программистская ошибка», ловится unit-тестом, не endpoint'ом.

Иерархия исключений — это не «как удобнее в коде», это контракт обработки. Каждое исключение в публичной сигнатуре имеет тип, документированный смысл, и однозначный handler в edge-слое. Без типизированной иерархии все ошибки сливаются в Exception → 500 → log.error("Failed", e) — никакой осмысленной реакции. Раскрытие правил R-ERR-HIER-* ниже.

Четыре типа

R-ERR-HIER-1: всё разнообразие ошибок в типичном сервисе делится на четыре непересекающиеся категории.

ТипКогда бросаетсяHTTP-статусRetry-safe
DomainExceptionНарушено бизнес-правило (нельзя отменить отгруженный заказ, недостаточно средств)409 / 422❌ Нет
ValidationExceptionНевалидный input на edge (поле пустое, формат не тот)400❌ Нет
IntegrationExceptionВнешняя система отвечает неожиданно (5xx, timeout, malformed)502 / 503 / 504✅ Обычно (при идемпотентности)
TechnicalExceptionНаша внутренняя проблема (БД unreachable, OOM, proxy)500✅ Возможно

Где они живут физически:

  • DomainException и наследники — в core/. Это часть domain — бизнес-правила формулируются здесь.
  • ValidationException — в edge (in-adapter). Это про невалидный HTTP-input, не про domain.
  • IntegrationException — в каждом *-out-adapter/. Наследник в своём пакете: sber-out-adapter имеет SberRegisterException, etc.
  • TechnicalException — в core, но используется редко. 90% технических ошибок ловит catch-all в edge без явного объявления.

Только RuntimeException

R-ERR-HIER-2: все четыре наследуют RuntimeException, никаких checked exceptions.

Почему checked не используются:

  • Засорение сигнатур. throws DomainException, ValidationException, IntegrationException на каждом методе через 5 уровней call stack.
  • Wrapping для протаскивания. Если метод не объявляет throws X, исключение приходится обернуть в RuntimeException — тип теряется. Те же проблемы, что мы хотели избежать.
  • Stream API не дружит с checked. Лямбды и method references не пробрасывают checked-exception без вспомогательных утилит.
  • Все исключения в UCP идут до edge. Domain handler не ловит, не оборачивает — он просто бросает. @RestControllerAdvice ловит на границе. Checked для этого не нужен.

Это командное решение, обсуждению не подлежит. См. также R-ERR-2 в самом гайде про «только три места catch».

Имена — по бизнес-смыслу

R-ERR-HIER-3: имя исключения должно отвечать на вопрос «что нарушено», не «как упало».

// ХОРОШО
public class OrderAlreadyShippedException extends DomainException { /* ... */ }
public class InsufficientFundsException extends DomainException { /* ... */ }
public class CustomerNotEligibleForRefundException extends DomainException { /* ... */ }

// ПЛОХО
public class BusinessException { /* ... */ }                  // ← что именно?
public class ValidationFailedException { /* ... */ }          // ← какое поле, почему?
public class IllegalStateException { /* ... */ }              // ← техническое имя без бизнес-смысла

Что даёт правильное имя:

  • Stack trace читается как история. Видишь OrderAlreadyShippedException в логе — сразу понятно, что произошло. С BusinessException пришлось бы лезть в getMessage().
  • Edge-handler различает. Можно сделать отдельный @ExceptionHandler(OrderAlreadyShippedException.class) с уточнённым ответом и type-URL в ProblemDetails (https://api.example.com/errors/order-already-shipped).
  • Метрики разделимы. app_errors_total{exception="OrderAlreadyShippedException"} — отдельная серия в Prometheus. Можно алёртить на рост.

Имя — это публичный контракт. Менять его — breaking change, как переименование API-поля.

Префикс системы для Integration

R-ERR-HIER-4: наследники IntegrationException имеют префикс системы.

public class SberRegisterException extends IntegrationException { /* ... */ }
public class SberPollException extends IntegrationException { /* ... */ }
public class CatalogPortException extends IntegrationException { /* ... */ }
public class SmsProviderException extends IntegrationException { /* ... */ }

Что это даёт edge-handler'у:

@ExceptionHandler(SberRegisterException.class)
ProblemDetail handleSberRegister(SberRegisterException ex) {
    return buildProblemDetail(HttpStatus.BAD_GATEWAY,
        "Payment provider temporarily unavailable", ex);
}

@ExceptionHandler(CatalogPortException.class)
ProblemDetail handleCatalog(CatalogPortException ex) {
    return buildProblemDetail(HttpStatus.SERVICE_UNAVAILABLE,
        "Product catalog temporarily unavailable", ex);
}

Сообщение клиенту разное — «платёжка недоступна, попробуйте позже» vs «каталог недоступен, попробуйте позже». Это про наблюдаемость, не про техническую разницу: оператор по логам сразу видит, что упало.

Конструктор фиксирует контекст

R-ERR-HIER-5: не голый new InsufficientFundsException(), а с полным контекстом.

public final class InsufficientFundsException extends DomainException {

    private final CustomerId customerId;
    private final Money requested;
    private final Money available;

    public InsufficientFundsException(CustomerId customerId, Money requested, Money available) {
        super("Insufficient funds: customer=%s, requested=%s, available=%s"
            .formatted(customerId, requested, available));
        this.customerId = customerId;
        this.requested = requested;
        this.available = available;
    }

    @Getter public CustomerId getCustomerId() { return customerId; }
    @Getter public Money getRequested() { return requested; }
    @Getter public Money getAvailable() { return available; }
}

Что даёт фиксация в конструкторе:

  • Лог-сообщение есть сразу. getMessage() вернёт полный контекст — без дополнительного log.error("customer={}, requested={}", customerId, requested, e).
  • Edge-handler достаёт поля для ProblemDetails. pd.setProperty("customerId", ex.getCustomerId()) — структурированный контекст для клиента.
  • Stacktrace + структура в одной точке. Контекст не теряется при throw через 5 уровней call stack — он уже в самом exception-объекте.

Геттеры — через Lombok @Getter (см. Java Style GuideJS-6.3).

Что запрещено

R-ERR-HIER-X1: голый throw new RuntimeException("Что-то сломалось").

// ПЛОХО
if (order.getStatus() == OrderStatus.SHIPPED) {
    throw new RuntimeException("Order already shipped");        // ← тип теряется
}

Edge-handler не сможет различить это от технической проблемы — оба попадут в catch-all Throwable → 500. Метрики app_errors_total будут показывать «много RuntimeException», что бесполезно.

Правильно — throw new OrderAlreadyShippedException(order.id()).

R-ERR-HIER-X2: throw new IllegalStateException(...) в доменном коде.

// ПЛОХО
public void cancel() {
    if (this.status == OrderStatus.SHIPPED) {
        throw new IllegalStateException("Cannot cancel shipped order");   // ← техническое имя
    }
}

IllegalStateException в Java семантически — «программа в невозможном состоянии, это баг». Если бизнес-сценарий допускает попытку отменить отгруженный заказ (например, пользователь нажал кнопку), это не баг, это бизнес-правило — нужен DomainException-наследник.

// ХОРОШО
public void cancel() {
    if (this.status == OrderStatus.SHIPPED) {
        throw new OrderAlreadyShippedException(this.id);
    }
}

IllegalStateException остаётся для настоящих invariant violation внутри агрегата — таких, которые ловит unit-тест, а не endpoint. Например, «попытка перевести агрегат в состояние, которое заранее запрещено state-machine».

Куда дальше