Опирается на правила:
R-ERR-HIER-1…R-ERR-HIER-5иR-ERR-HIER-X1…R-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 Guide → JS-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».
Куда дальше
- Error Handling Style Guide → раздел 1. Иерархия исключений — нормативные формулировки.
- Где throw, где catch — что куда летит после throw.
- Mapping в ProblemDetails — как доменное исключение становится HTTP-ответом.
- Java Style Guide → JS-6.3 — про Lombok
@Getter.