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

Можно ли создавать кастомные исключения

Да, в Java можно и рекомендуется создавать собственные (кастомные) исключения для точного моделирования бизнес-логики и ошибок предметной области. Кастомное исключение — это обы...


🟢 Junior Level

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

Да, в Java можно и рекомендуется создавать собственные (кастомные) исключения для точного моделирования бизнес-логики и ошибок предметной области. Кастомное исключение — это обычный Java-класс, который наследуется от java.lang.RuntimeException (для непроверяемых/бизнес-ошибок) или от java.lang.Exception (для проверяемых ошибок, требующих обязательной обработки). Создание кастомного класса позволяет отделить технические сбои от доменных событий (например, OrderNotFoundException вместо безликого IllegalArgumentException) и передавать в исключении структурированный контекст.


Правило 4 канонических конструкторов

Хорошим тоном в Java является предоставление 4 стандартных конструкторов, повторяющих конструкторы базового класса Throwable, чтобы обеспечить максимальную гибкость для вызывающего кода:

public class InsufficientFundsException extends RuntimeException {
    // 1. Конструктор по умолчанию
    public InsufficientFundsException() {
        super();
    }

    // 2. Конструктор с текстовым сообщением
    public InsufficientFundsException(String message) {
        super(message);
    }

    // 3. Конструктор с сообщением и первопричиной (Exception Chaining)
    public InsufficientFundsException(String message, Throwable cause) {
        super(message, cause);
    }

    // 4. Конструктор только с первопричиной
    public InsufficientFundsException(Throwable cause) {
        super(cause);
    }
}

Базовый пример использования

import java.math.BigDecimal;

public class BankAccount {
    private BigDecimal balance;

    public BankAccount(BigDecimal initialBalance) {
        this.balance = initialBalance;
    }

    public void withdraw(BigDecimal amount) {
        if (amount.compareTo(BigDecimal.ZERO) <= 0) {
            // Для некорректных аргументов достаточно стандартного исключения JDK
            throw new IllegalArgumentException("Withdrawal amount must be positive");
        }
        if (balance.compareTo(amount) < 0) {
            // Для доменного инварианта выбрасываем кастомное исключение
            throw new InsufficientFundsException(
                "Cannot withdraw %s: current balance is %s".formatted(amount, balance)
            );
        }
        this.balance = this.balance.subtract(amount);
    }
}

Когда использовать стандартные, а когда кастомные исключения?

Не создавайте кастомные исключения без необходимости. JDK уже содержит богатый набор стандартных типов:

  • IllegalArgumentException: передан невалидный аргумент метода (например, отрицательное число или пустая строка).
  • IllegalStateException: состояние объекта не позволяет выполнить операцию (например, попытка чтения из закрытого сокета).
  • NullPointerException: аргумент или состояние неожиданно оказалось null.
  • UnsupportedOperationException: операция не поддерживается (например, мутация List.of()).

Создавайте кастомное исключение, если:

  1. Вызывающему коду требуется избирательно перехватывать данную ошибку в блоке catch.
  2. Ошибка относится к бизнес-логике домена (UserBlockedException, PromoCodeExpiredException).
  3. Необходимо прикрепить к ошибке специфические доменные метаданные (идентификатор заказа, код ошибки, лимиты).

🟡 Middle Level

Исключение как носитель контекста (Contextual Exceptions)

В реальных корпоративных системах текстового сообщения message недостаточно для локализации проблемы. Кастомное исключение должно выступать в роли типизированного DTO, инкапсулирующего доменные атрибуты:

import java.time.Instant;
import java.util.Collections;
import java.util.Map;

public class BusinessException extends RuntimeException {
    private final String errorCode;
    private final Instant timestamp;
    private final Map<String, Object> details;

    public BusinessException(String errorCode, String message, Map<String, Object> details) {
        super(message);
        this.errorCode = errorCode;
        this.details = details != null ? Map.copyOf(details) : Collections.emptyMap();
        this.timestamp = Instant.now();
    }

    public BusinessException(String errorCode, String message, Throwable cause, Map<String, Object> details) {
        super(message, cause);
        this.errorCode = errorCode;
        this.details = details != null ? Map.copyOf(details) : Collections.emptyMap();
        this.timestamp = Instant.now();
    }

    public String getErrorCode() { return errorCode; }
    public Instant getTimestamp() { return timestamp; }
    public Map<String, Object> getDetails() { return details; }
}

Проектирование иерархии исключений приложения

Вместо разрозненных классов рекомендуется строить стройную иерархию с единым корневым базовым исключением для модуля/сервиса. Это позволяет инфраструктурным слоям (например, фильтрам или middleware) перехватывать все доменные сбои на верхнем уровне:

RuntimeException
    └── ApplicationException (абстрактный базовый класс)
            ├── DomainException (бизнес-правила)
            │       ├── EntityNotFoundException
            │       │       └── UserNotFoundException
            │       └── PaymentValidationException
            └── InfrastructureException (внешние интеграции)
                    ├── ExternalGatewayTimeoutException
                    └── S3StorageUnavailableException

Сериализация и serialVersionUID

Класс java.lang.Throwable реализует интерфейс java.io.Serializable. Если кастомное исключение передается через сеть (например, по протоколу RMI, сериализуется в сессию, сохраняется в кэш Redis или передается через распределенные фреймворки вроде Hazelcast/Kafka), несовпадение serialVersionUID приведет к аварийному java.io.InvalidClassException.

Всегда объявляйте явное поле serialVersionUID:

public class PaymentException extends RuntimeException {
    private static final long serialVersionUID = 1L;
    // ...
}

Интеграция со Spring Boot: REST API и RFC 7807 (ProblemDetail)

В REST API кастомные исключения транслируются в понятные клиенту HTTP-ответы с использованием стандартизированного формата RFC 7807 (Problem Details for HTTP APIs) с помощью @RestControllerAdvice:

import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.net.URI;
import java.time.Instant;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(InsufficientFundsException.class)
    public ProblemDetail handleInsufficientFunds(InsufficientFundsException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.BAD_REQUEST,
            ex.getMessage()
        );
        problem.setTitle("Insufficient Funds");
        problem.setType(URI.create("https://api.mybank.com/errors/insufficient-funds"));
        problem.setProperty("timestamp", Instant.now());
        return problem;
    }
}

🔴 Senior Level

Высокопроизводительные Stackless Exceptions для Highload

В HotSpot JVM создание объекта исключения само по себе дешево, но метод Throwable.fillInStackTrace() является тяжелым нативным C++ системным вызовом. Он приостанавливает поток и совершает линейный обход всех стековых фреймов текущего потока ядра, аллоцируя массив StackTraceElement[].

Если исключение используется в высоконагруженном цикле (миллионы RPS) или для управления потоком (flow control), создание стектрейсов вызывает деградацию производительности на порядки.

Решение 1: Пятый конструктор Java 7+ (writableStackTrace = false)

public class FastBusinessException extends RuntimeException {
    public FastBusinessException(String message) {
        // message, cause, enableSuppression, writableStackTrace
        super(message, null, false, false);
    }
}

Решение 2: Переопределение fillInStackTrace()

public class OptimizedValidationException extends RuntimeException {
    public OptimizedValidationException(String message) {
        super(message);
    }

    @Override
    public synchronized Throwable fillInStackTrace() {
        // Возвращаем this без вызова нативного C++ метода HotSpot
        return this;
    }
}

Результат: Скорость инстанцирования вырастает в 30–50 раз, аллокации памяти в куче сводятся к минимуму.


Опасность Singleton Exceptions (Static Final Instances)

Иногда разработчики пытаются кэшировать исключения:

// АНТИПАТТЕРН для стандартных исключений со стектрейсом
public static final MyException INSTANCE = new MyException("Generic failure");

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

  1. Если исключение имеет заполненный стектрейс (writableStackTrace = true), этот стектрейс будет зафиксирован в момент инициализации класса. В логах всегда будет отображаться точка объявления константы, а не реальное место сбоя.
  2. Если в многопоточной среде один поток вызовет INSTANCE.initCause(...) или применит addSuppressed(), возникнет либо IllegalStateException, либо состояние гонки (Race Condition) с повреждением данных.
  3. Допустимо кэшировать Singleton-исключения только при условии, что они абсолютно неизменяемы, безымянны и не имеют стектрейса (writableStackTrace = false и запрещен initCause).

Архитектурный антипаттерн: Протекание кастомных исключений через границы микросервисов

Попытка передать бинарно сериализованный объект кастомного исключения (например, ru.bank.core.domain.UserNotFoundException) через HTTP/gRPC/Kafka другому микросервису создает жесткое связывание (tight coupling) по Classpath.

  • Если у клиента нет этого .jar в classpath, десериализация завершится ClassNotFoundException / NoClassDefFoundError.
  • Идиоматическое решение: На границе микросервиса (API Gateway, HTTP Controller, Kafka Producer) кастомное исключение обязано переводиться в нейтральный контракт: JSON (RFC 7807), Protobuf status или канонические коды ошибок gRPC (Status.NOT_FOUND).

Безопасность: CWE-209 и маскирование PII (Personally Identifiable Information)

Кастомные исключения часто попадают в логи (ELK, Datadog, Loki) и внешние ответы клиентам. Помещение конфиденциальных данных в message исключения — грубое нарушение требований PCI DSS, HIPAA и GDPR.

// ГРУБОЕ НАРУШЕНИЕ: утечка пароля и номера карты в логи
throw new AuthenticationException("Failed for user " + user + " with token " + rawSecretToken);

// БЕЗОПАСНО: контекст обезличен, секреты хешируются или маскируются
throw new AuthenticationException("Authentication failed for principalId: " + principalId);

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

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

  1. Зачем в кастомном исключении реализовывать конструктор с параметром Throwable cause? Ответ: Для поддержки механизма цепочек исключений (Exception Chaining). Это позволяет обернуть низкоуровневое техническое исключение (например, SQLException) в высокоуровневое бизнес-исключение (OrderProcessingException), не потеряв оригинальную причину сбоя и её стектрейс.
  2. Как оптимизировать кастомное исключение, если оно выбрасывается сотни тысяч раз в секунду? Ответ: Использовать 4-аргументный конструктор super(message, cause, enableSuppression, false) либо переопределить метод fillInStackTrace(), возвращая this. Это отключает дорогостоящий нативный обход стековых фреймов потока.
  3. Почему опасно создавать public static final MyException ERROR = new MyException()? Ответ: Если не отключен стектрейс, в логах всегда будет отображаться стек момента загрузки класса, а не места выброса. Кроме того, конкурентный вызов initCause() или addSuppressed() из нескольких потоков приведет к гонкам данных и падениям.
  4. В чем разница между наследованием от Exception и RuntimeException при создании своего класса? Ответ: Наследование от Exception создает проверяемое (checked) исключение, которое компилятор требует явно декларировать в throws или оборачивать в try-catch. Наследование от RuntimeException создает непроверяемое исключение, не требующее обязательного объявления в сигнатурах методов.

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

  • ❌ Создание параллельной иерархии исключений для каждого метода: Приводит к комбинаторному взрыву классов и избыточному boilerplate-коду.
  • ❌ Утечка PII и секретных ключей в сообщении исключения: Попадание паролей, номеров карт или токенов в системы сбора логов.
  • ❌ Использование исключений для стандартного ветвления бизнес-логики (Flow Control): Исключения предназначены для нештатных ситуаций; их использование вместо if-else ухудшает производительность и читаемость.
  • ❌ Потеря первопричины: Написание throw new MyException(e.getMessage()) вместо throw new MyException(e.getMessage(), e) уничтожает исходный стектрейс.

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