⚡ Раздел 7 · Вопрос #14

Когда стоит создавать свои исключения

Собственные (кастомные) исключения стоит создавать тогда, когда стандартных классов JDK недостаточно для выражения смысла ошибки в предметной области, либо когда вызывающему код...


🟢 Junior Level

Краткий ответ для собеседования (30 секунд)

Собственные (кастомные) исключения стоит создавать тогда, когда стандартных классов JDK недостаточно для выражения смысла ошибки в предметной области, либо когда вызывающему коду требуется специфическая реакция на конкретный сбой.

Главные критерии для создания своего исключения:

  1. Бизнес-инвариант домена: Ошибка отражает нарушение правил бизнеса (InsufficientFundsException, PromoCodeExpiredException), а не технический сбой языка.
  2. Необходимость раздельной обработки: Вызывающий код или глобальный обработчик должен перехватить именно эту ошибку в отдельном блоке catch (например, вернуть HTTP 409 Conflict вместо 500).
  3. Передача структурированного контекста: Нужно передать специфические атрибуты (идентификатор сущности, доступный баланс, время повтора retryAfter).

Если же ошибка вызвана нарушением базового контракта метода (передан null, отрицательный индекс, неверный формат аргумента), следует использовать стандартные исключения JDK: NullPointerException, IllegalArgumentException, IllegalStateException.


Матрица принятия решений: стандартное vs кастомное

Ситуация Рекомендуемое решение Пример из практики
В метод передан некорректный параметр (пустая строка, отрицательный ID) Стандартное: IllegalArgumentException if (id <= 0) throw new IllegalArgumentException(...)
Аргумент метода неожиданно оказался null Стандартное: NullPointerException Objects.requireNonNull(client, "client must not be null")
Метод вызван в неподходящий момент жизненного цикла объекта Стандартное: IllegalStateException Вызов connection.send() до завершения connection.connect()
Запрошенный доменный объект не найден в хранилище Кастомное: UserNotFoundException Позволяет @ExceptionHandler вернуть статус 404 Not Found
Попытка списания средств при превышении кредитного лимита Кастомное: CreditLimitExceededException Содержит поля currentBalance, attemptedAmount, limit
Внешний платежный шлюз временно недоступен Кастомное: PaymentGatewayTimeoutException Помечает сбой как пригодный для повторной попытки (retryable)

Базовый пример кастомного бизнес-исключения

import java.math.BigDecimal;

public class InsufficientFundsException extends RuntimeException {
    private final BigDecimal currentBalance;
    private final BigDecimal requestedAmount;

    public InsufficientFundsException(BigDecimal currentBalance, BigDecimal requestedAmount) {
        super("Cannot withdraw %s: available balance is %s".formatted(requestedAmount, currentBalance));
        this.currentBalance = currentBalance;
        this.requestedAmount = requestedAmount;
    }

    public BigDecimal getCurrentBalance() {
        return currentBalance;
    }

    public BigDecimal getRequestedAmount() {
        return requestedAmount;
    }
}

🟡 Middle Level

Паттерн Exception Translation (Трансляция исключений)

Одной из важнейших архитектурных причин для создания своих исключений является сокрытие деталей реализации на границе слоев (Abstraction Layering).

Если сервисный слой пробрасывает java.sql.SQLException или org.hibernate.HibernateException, он раскрывает вызывающему слою (контроллерам, API) внутренние детали хранилища данных. При смене базы данных на NoSQL или внешний REST-сервис контракт сломается.

public class OrderService {
    private final OrderRepository repository;

    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }

    public void completeOrder(Long orderId) {
        try {
            repository.updateStatus(orderId, OrderStatus.COMPLETED);
        } catch (SQLException e) {
            // Транслируем техническое низкоуровневое исключение в доменное
            throw new OrderPersistenceException("Failed to persist order completion for id: " + orderId, e);
        }
    }
}

Дифференциация стратегий восстановления (Recovery Strategies)

Кастомные исключения создают для того, чтобы клиентский код мог реализовать различные сценарии обработки (Fault Tolerance):

ApplicationException
    ├── NonRetryableException (Клиентская ошибка / Баг в данных -> сразу отказ)
    │       ├── OrderValidationException
    │       └── CustomerBlockedException
    │
    └── RetryableException (Временный инфраструктурный сбой -> повтор запроса)
            ├── ExternalServiceTimeoutException
            └── DatabaseDeadlockException
// Обработчик в фоновом воркере (например, Spring Retry или Kafka Consumer)
try {
    paymentProcessor.charge(order);
} catch (RetryableException e) {
    scheduleRetry(order, Duration.ofSeconds(5)); // повторяем через 5 секунд
} catch (NonRetryableException e) {
    sendAlertToSupport(order, e);                // отправляем в Dead Letter Queue (DLQ)
}

Антипаттерн “Exception as Flow Control” (Исключения для управления логикой)

Никогда не используйте исключения для обычного ветвления бизнес-процессов:

// ❌ АНТИПАТТЕРН: использование исключения вместо if/else
public User getUser(Long id) {
    try {
        return findInCache(id); // Бросает UserNotFoundException, если нет в кэше
    } catch (UserNotFoundException e) {
        return loadFromDatabase(id); // Обычный fallback через тяжелое исключение!
    }
}

// ✅ ИДИОМАТИЧНО: использование Optional или явных проверок
public User getUser(Long id) {
    return findInCache(id)
        .orElseGet(() -> loadFromDatabase(id));
}

Почему это критично:

  1. Производительность: Создание обычного Throwable обходится в сотни раз дороже вызова метода (~1–5 мкс против ~5–10 нс у Optional), так как требует прохода по стеку в нативном слое JVM.
  2. JIT-оптимизации: Блоки try-catch мешают агрессивному инлайнингу (method inlining) и векторизации инструкций JIT-компилятором C2.
  3. Читаемость: Код, использующий исключения для нормального потока, нарушает принцип наименьшего удивления (Principle of Least Astonishment) и запутывает отладку.

Проблема “Class Bloat” (Раздувание иерархии)

Не стоит создавать отдельный класс исключения под каждую строку ошибки:

  • ❌ Плохо: InvalidUserEmailException, InvalidUserAgeException, InvalidUserPhoneException.
  • ✅ Отлично: Один класс UserValidationException, принимающий список нарушенных полей (List<Violation>), или использование стандартного биндинга Spring (MethodArgumentNotValidException).

🔴 Senior Level

Domain-Driven Design (DDD): Исключение как факт нарушения инварианта агрегата

В парадигме DDD исключение представляет собой невозможность выполнения доменной команды из-за нарушения бизнес-инварианта корня агрегата (Aggregate Root).

В таком подходе исключение:

  1. Именуется в терминах единого языка (Ubiquitous Language): OrderAlreadyCancelledException, DeliverySlotOccupiedException.
  2. Не содержит технических терминов: Никаких NullPointer, Database, Sql в доменных классах.
  3. Фиксирует состояние агрегата на момент отказа:
public class BookingAggregate {
    private BookingStatus status;
    private final Instant bookingDeadline;

    public void confirm(Instant now) {
        if (this.status == BookingStatus.CANCELLED) {
            throw new BookingAlreadyCancelledException(
                "Cannot confirm booking %s because it is already cancelled".formatted(id)
            );
        }
        if (now.isAfter(bookingDeadline)) {
            throw new BookingDeadlineExpiredException(id, bookingDeadline, now);
        }
        this.status = BookingStatus.CONFIRMED;
    }
}

Функциональный подход к ошибкам (Result / Either) vs Exceptions

В современных архитектурах на Java 21 разработчики разделяют ошибки на две категории:

  1. Expected Failures (Ожидаемые альтернативные результаты): Пользователь не найден, неверный пароль, купон просрочен.
  2. Unexpected Exceptional Failures (Исключительные катастрофы): Падение сети, отказ СУБД, OutOfMemory.

Для первой категории функциональный шаблон Result<T, E> (на запечатанных интерфейсах sealed interface в Java 21) часто превосходит исключения:

// Java 21: Функциональная обработка ожидаемых доменных отказов без бросания Throwable
public sealed interface OrderResult {
    record Success(Order order) implements OrderResult {}
    record InsufficientStock(String itemId, int available) implements OrderResult {}
    record PaymentDeclined(String reason) implements OrderResult {}
}

public class OrderWorkflow {
    public void process(OrderCommand cmd) {
        OrderResult result = orderService.createOrder(cmd);
        
        // Pattern Matching: компилятор гарантирует обработку всех сценариев
        switch (result) {
            case OrderResult.Success s -> sendConfirmation(s.order());
            case OrderResult.InsufficientStock stock -> notifyOutOfStock(stock.itemId());
            case OrderResult.PaymentDeclined dec -> alertPaymentFailed(dec.reason());
        }
    }
}

Вывод: Исключения создаются тогда, когда сбой прерывает дальнейшее выполнение цепочки операций и должен быть обработан централизованно (на уровне транзакции или HTTP-контроллера).


Наблюдаемость: Distributed Tracing и OpenTelemetry

Кастомные исключения должны быть согласованы с системой мониторинга:

  • Каждое доменное исключение связывается со спаном OpenTelemetry: span.recordException(e) и span.setStatus(StatusCode.ERROR).
  • Исключение должно содержать машиночитаемый строковый код (ErrorCode), который отправляется в метрики Micrometer:
import io.micrometer.core.instrument.MeterRegistry;

public class MonitoredOrderService {
    private final MeterRegistry registry;

    public void cancel(Long id) {
        try {
            // бизнес-логика
        } catch (DomainException ex) {
            registry.counter("business.exceptions.total", "code", ex.getErrorCode()).increment();
            throw ex;
        }
    }
}

🎯 Шпаргалка для интервью

Вопросы для быстрой проверки

  1. Когда оправдано создание кастомного исключения, а когда нужно предпочесть стандартное? Ответ: Кастомное исключение создается для доменных бизнес-ошибок (OrderNotFoundException), если вызывающему коду требуется особый блок catch или если нужно передать структурированный контекст (ID сущности, баланс). Если же речь идет о нарушении контракта метода (null, неверный аргумент, несовместимое состояние), следует использовать стандартные исключения JDK (IllegalArgumentException, IllegalStateException).
  2. Почему использование исключений для ветвления логики (Flow Control) считается грубой ошибкой? Ответ: Это на порядок медленнее обычных проверок (if/else, Optional) из-за накладных расходов на вызов fillInStackTrace(), деоптимизирует JIT-компилятор C2 (препятствует инлайнингу) и делает поток выполнения программы запутанным для чтения и поддержки.
  3. Что такое Exception Translation и зачем она применяется? Ответ: Это паттерн оборачивания низкоуровневых исключений одного слоя (например, SQLException в репозитории) в высокоуровневые исключения вызывающего слоя (DataStorageException). Она скрывает технические детали реализации и изолирует зависимости слоев архитектуры.
  4. Что выбрать в Java 21: кастомное исключение или sealed interface (Result/Either)? Ответ: Для ожидаемых бизнес-сценариев (пользователь не найден, скидка не применима) и чистой функциональной композиции эффективнее использовать запечатанные интерфейсы (sealed interface) с pattern matching, так как это дешевле по ресурсам и компилятор проверяет полноту обработки. Исключения оставляют для критических сбоев, прерывающих выполнение и требующих отката транзакций.

Типичные ошибки и Red Flags

  • ❌ Создание отдельного класса исключения на каждую строковую ошибку: Раздувание Metaspace и комбинаторный взрыв вспомогательных классов (Class Bloat).
  • ❌ Использование исключений вместо валидации входящих параметров через if-else: Потеря читаемости и производительности.
  • ❌ Протекание технологических исключений (Hibernate/SQL) наружу сервисного слоя: Нарушение принципа сокрытия информации и архитектурных границ.
  • ❌ Потеря первопричины (cause) при трансляции: Написание throw new CustomException(e.getMessage()) вместо throw new CustomException("...", e) уничтожает оригинальный стектрейс.

Связанные темы