Опирается на правила: R-ERR-WHERE-1R-ERR-WHERE-3 и R-ERR-WHERE-X1R-ERR-WHERE-X3 из Error Handling Style Guide → раздел 2. Где throw, где catch.

Важно знать

  • Throw — везде где нужно. Domain бросает DomainException, validator — ValidationException, out-adapter — IntegrationException.
  • Catch — ровно в трёх местах:
    1. @RestControllerAdvice — REST edge. Превращает исключение в HTTP-response.
    2. Out-adapter — integration boundary. Ловит HttpServerErrorException/SQLException, бросает port-specific.
    3. Резильянс-обёртка@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() — терять различие.

Куда дальше