Опирается на правила:
R-ERR-WHERE-1…R-ERR-WHERE-3иR-ERR-WHERE-X1…R-ERR-WHERE-X3из Error Handling Style Guide → раздел 2. Где throw, где catch.
Важно знать
- Throw — везде где нужно. Domain бросает
DomainException, validator —ValidationException, out-adapter —IntegrationException.- Catch — ровно в трёх местах:
@RestControllerAdvice— REST edge. Превращает исключение в HTTP-response.- Out-adapter — integration boundary. Ловит
HttpServerErrorException/SQLException, бросает port-specific.- Резильянс-обёртка —
@CircuitBreaker(fallbackMethod = ...),@Retry,@Bulkhead. Это формальный catch через конфиг.- В UseCase Handler / Domain Service / Aggregate — ноль try-catch. Исключения проходят насквозь до edge.
catch (Exception e) { log.error("Failed", e); }без re-throw — главный антипаттерн всего гайда. Глушит ошибку, возвращает «успех» вызывающему.catch (Exception e) { throw new RuntimeException(e); }— тип теряется. Edge получает generic Throwable → 500 на всё.catch (Exception e) { return Optional.empty(); }илиreturn null— то же, что силент-фейл, только без логирования.
Главный принцип: исключение — это часть контракта, не неожиданность. Domain-метод явно объявляет, какие исключения он бросает (через типы в иерархии); вызывающий не пытается их «обработать» в каждом месте, потому что это не его задача — это задача edge-слоя. Чем меньше try-catch в коде, тем чётче поток управления и тем меньше мест, где ошибка может потеряться. Раскрытие правил R-ERR-WHERE-* ниже.
Throw — без церемоний
R-ERR-WHERE-1: бросаем исключение там, где обнаружили проблему.
// Domain — бросает DomainException
public void cancel(CancellationReason reason) {
if (this.status == OrderStatus.SHIPPED) {
throw new OrderAlreadyShippedException(this.id);
}
this.status = OrderStatus.CANCELLED;
this.cancellationReason = reason;
registerEvent(new OrderCancelledEvent(this.id, reason));
}
// Validator — бросает ValidationException (или Spring сам делает через @Valid)
@PostMapping("/orders")
public ResponseEntity<OrderJson> createOrder(@Valid @RequestBody CreateOrderRequest req) {
// ↑ @Valid бросит MethodArgumentNotValidException автоматически
}
// Out-adapter — бросает IntegrationException-наследник
public RegisterResult register(RegisterCommand cmd) {
try {
return /* ... вызов внешней системы ... */;
} catch (HttpServerErrorException ex) {
throw new SberRegisterException("Sber 5xx on register", ex);
}
}
Не пытаемся «изобретать» Result<T, E> или sealed-interface для возврата ошибок (R-ERR-RESULT-X1 — см. Result-types vs exceptions). Java исключения и иерархия типов — это контракт обработки, и его достаточно.
Точка 1 — @RestControllerAdvice
R-ERR-WHERE-2-a: один GlobalExceptionHandler на сервис, per-type @ExceptionHandler-методы.
@RestControllerAdvice
@Slf4j
@RequiredArgsConstructor
public class GlobalExceptionHandler {
@ExceptionHandler(DomainException.class)
public ResponseEntity<ProblemDetail> handleDomain(DomainException ex) {
log.warn("Domain rule violated: {}", ex.getMessage(), ex); // WARN
var pd = buildProblemDetail(HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage(), ex);
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(pd);
}
@ExceptionHandler(ValidationException.class)
public ResponseEntity<ProblemDetail> handleValidation(ValidationException ex) {
var pd = buildProblemDetail(HttpStatus.BAD_REQUEST, "Validation failed", ex);
return ResponseEntity.badRequest().body(pd);
}
@ExceptionHandler(IntegrationException.class)
public ResponseEntity<ProblemDetail> handleIntegration(IntegrationException ex) {
log.warn("Integration failed: {}", ex.getMessage(), ex); // WARN или ERROR
var pd = buildProblemDetail(HttpStatus.BAD_GATEWAY,
"External system temporarily unavailable", ex);
return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body(pd);
}
@ExceptionHandler(Throwable.class) // catch-all
public ResponseEntity<ProblemDetail> handleUnexpected(Throwable ex) {
log.error("Unexpected error", ex); // ERROR + stacktrace
var pd = buildProblemDetail(HttpStatus.INTERNAL_SERVER_ERROR,
"Internal server error", ex);
return ResponseEntity.internalServerError().body(pd);
}
private ProblemDetail buildProblemDetail(HttpStatus status, String detail, Throwable ex) {
var pd = ProblemDetail.forStatusAndDetail(status, detail);
pd.setProperty("traceId", MDC.get("traceId"));
return pd;
}
}
Что важно:
- catch-all (
Throwable) — не глушит, не возвращает 200. Логирует ERROR + полный stacktrace, отдаёт 500. - Per-type handler — разные
HttpStatus, разные log-levels, разныеProblemDetails. Подробности — в Mapping в ProblemDetails. @RestControllerAdviceживёт в каждом*-in-adapter, не в bootstrap — потому что HTTP-обработка ошибок это деталь in-adapter.
Точка 2 — out-adapter (integration boundary)
R-ERR-WHERE-2-b: out-adapter ловит низкоуровневые exceptions внешней системы и бросает port-specific.
@Component
@RequiredArgsConstructor
public class SberClientAdapter implements PaymentPort {
private final SberApi sberApi;
private final SberMapper mapper;
@Override
@CircuitBreaker(name = "sber", fallbackMethod = "registerFallback")
@Bulkhead(name = "sber")
public RegisterResult register(RegisterCommand cmd) {
try {
var response = sberApi.register(mapper.toApi(cmd));
return mapper.toDomain(response);
} catch (HttpServerErrorException ex) {
// 5xx — внешка сломана, retry safe при идемпотентности
throw new SberRegisterException("Sber 5xx on register: " + ex.getStatusCode(), ex);
} catch (HttpClientErrorException.BadRequest ex) {
// 4xx — мы послали что-то не то, retry не поможет
throw new InvalidPaymentRequestException(cmd.orderId(), ex.getResponseBodyAsString());
}
// Прочее (timeout, ConnectException) — улетит вверх как есть, Resilience4j обработает
}
private RegisterResult registerFallback(RegisterCommand cmd, CallNotPermittedException ex) {
// CB открыт — бросаем port-specific exception, edge решит
throw new SberUnavailableException(cmd.orderId(), ex);
}
}
Что важно:
- Out-adapter — единственное место, где мы знаем про
HttpServerErrorException,SQLException,KafkaException. Эти типы — детали инфраструктуры, не должны утекать в core. - Mapping в port-specific даёт core возможность работать с типизированными ошибками:
SberRegisterException,InvalidPaymentRequestException,SberUnavailableException. - 4xx vs 5xx — разная семантика. 4xx превращаем в
DomainException-наследник (частоInvalid<X>Exception), 5xx — вIntegrationException-наследник. Это влияет на retry — см. Retry-семантика.
Точка 3 — резильянс-обёртки
R-ERR-WHERE-2-c: @CircuitBreaker, @Retry, @Bulkhead — это формальный catch через конфиг.
@CircuitBreaker(name = "sber", fallbackMethod = "registerFallback")
@Retry(name = "sber")
@Bulkhead(name = "sber")
public RegisterResult register(RegisterCommand cmd) {
// ...
}
private RegisterResult registerFallback(RegisterCommand cmd, CallNotPermittedException ex) {
throw new SberUnavailableException(cmd.orderId(), ex);
}
Внутри registerFallback:
- Не пишем свой try-catch на тот же тип — Resilience4j уже его поймал, мы получили exception как параметр.
- Бросаем доменно-осмысленный exception (
SberUnavailableException), а не возвращаем «успех» с null-полями. - Подробности — см. Resilience Style Guide → R-RES-FB-*.
Нигде больше — никакого try-catch
R-ERR-WHERE-3: в UseCase Handler / Domain Service / Aggregate — ноль try-catch.
// ХОРОШО
@UseCaseHandler
@Transactional
@RequiredArgsConstructor
public class CancelOrderCommandHandler {
private final OrderRepository orderRepository;
private final NotificationPort notificationPort;
public Order handle(CancelOrderCommand cmd) {
var order = orderRepository.findById(cmd.id(), SelectMode.FOR_UPDATE)
.orElseThrow(() -> new OrderNotFoundException(cmd.id()));
order.cancel(cmd.reason()); // ← бросит OrderAlreadyShippedException если что
orderRepository.save(order);
notificationPort.notifyCancelled(order); // ← бросит IntegrationException если что
return order;
}
}
Что важно:
- Никаких
try { order.cancel(...) } catch (...). Если правило нарушено, edge-handler превратит в 422. - Никаких
try { notificationPort.notify(...) } catch (Exception). Если notification сломан, edge превратит в 502. - Логирование — на edge, один раз. Не на каждом уровне. См. Логирование исключений.
Handler тонкий — он только оркестрирует. Логика — в Order.cancel(...), обработка ошибок — в GlobalExceptionHandler.
Что запрещено
R-ERR-WHERE-X1: catch (Exception e) { log.error("Failed", e); } без re-throw.
// ПЛОХО
public Order createOrder(CreateOrderCommand cmd) {
try {
// ...
return order;
} catch (Exception e) {
log.error("Failed to create order", e); // ← глушит
return null; // ← возвращает «успех» с null
}
}
Что катастрофически не так:
- Возвращает успех вызывающему. Handler выше думает, что order создан — продолжает работу с null. Возможны NullPointerException через 10 строк, в совершенно другом месте.
- Stacktrace в строку.
log.errorсериализует stacktrace в текст. Параметры исключения (InsufficientFundsException.requested) теряются. - Не виден в метриках.
app_errors_totalне увеличится — exception «не случился». - Не виден в trace. Span не помечается как ERROR.
Это главный антипаттерн всего гайда. Каждый раз, когда видишь такой catch — это место скрытой ошибки.
R-ERR-WHERE-X2: catch (Exception e) { throw new RuntimeException(e); } — тип теряется.
// ПЛОХО
try {
paymentPort.register(cmd);
} catch (Exception e) {
throw new RuntimeException(e); // ← обернули в generic
}
Edge видит RuntimeException (или Throwable если catch-all) — отдаёт 500 «Internal Server Error». А по сути могла быть SberUnavailableException → 502, или InvalidPaymentRequestException → 422. Информация потеряна.
Если действительно нужно обернуть (например, в callback'е Stream API, который не пропускает checked) — оборачиваем в типизированный наследник:
try {
return paymentPort.register(cmd);
} catch (Exception e) {
throw new PaymentSystemUnavailableException(cmd.orderId(), e); // ← типизированный
}
R-ERR-WHERE-X3: catch (Exception e) { return Optional.empty(); } или return null.
То же, что X1, только без логирования. Ещё хуже — никаких следов вообще.
// ПЛОХО
public Optional<Order> findOrder(Long id) {
try {
return Optional.of(orderRepository.findById(id, SelectMode.NO_LOCK)
.orElseThrow(() -> new OrderNotFoundException(id)));
} catch (Exception e) {
return Optional.empty(); // ← теряем всё
}
}
Если orderRepository.findById вернул empty — это нормально (Optional). Если БД упала — это ошибка, которую edge должен превратить в 503. Объединять оба в Optional.empty() — терять различие.
Куда дальше
- Error Handling Style Guide → раздел 2. Где throw, где catch — нормативные формулировки.
- Иерархия исключений — какие типы бросать.
- Mapping в ProblemDetails — что делает
@RestControllerAdviceс пойманными. - Retry-семантика — какие исключения retry-safe.
- Resilience Style Guide → R-RES-FB-* — про fallback-методы Resilience4j.