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

Что лучше наследоваться от Exception или RuntimeException

В современной промышленной Java-разработке (Spring Boot, микросервисы, облачные приложения) в 95% случаев рекомендуется наследоваться от RuntimeException (создавая непроверяемое...


🟢 Junior Level

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

В современной промышленной Java-разработке (Spring Boot, микросервисы, облачные приложения) в 95% случаев рекомендуется наследоваться от RuntimeException (создавая непроверяемое / unchecked исключение).

Наследование от Exception (проверяемое / checked исключение) оправдано только в редких сценариях: когда ошибка является ожидаемой частью бизнес-процесса, вызывающий код имеет реальный алгоритм восстановления прямо на месте вызова (например, повторный запрос или резервный канал), и компилятор должен принудительно заставить разработчика написать обработку. В остальных случаях checked-исключения загромождают код сигнатурами throws, ломают функциональные интерфейсы Java Streams и часто приводят к антипаттерну «пустого перехвата».


Сравнительная таблица

Критерий extends RuntimeException (Unchecked) extends Exception (Checked)
Проверка компилятором Нет (компилируется без try-catch) Да (компилятор требует catch или throws)
Сигнатуры методов Чистые сигнатуры интерфейсов Засорение методов директивой throws
Основное назначение Ошибки логики, инвариантов домена, баги Редкие восстановимые сценарии внешних систем
Совместимость с лямбдами Полная совместимость с Stream и Optional Несовместимы без хаков и оберток
Поведение @Transactional Автоматический откат (Rollback) по умолчанию НЕТ отката по умолчанию (требуется rollbackFor)
Обработка в Web/REST Централизованно в @RestControllerAdvice Требует явной обработки на каждом слое

Базовые примеры на Java 21

// 1. Unchecked (наследуемся от RuntimeException) - СОВРЕМЕННЫЙ СТАНДАРТ
public class UserNotFoundException extends RuntimeException {
    public UserNotFoundException(String message) {
        super(message);
    }
}

// 2. Checked (наследуемся от Exception) - ТРЕБУЕТ ОБЯЗАТЕЛЬНОГО THROWS
public class InvalidCredentialsException extends Exception {
    public InvalidCredentialsException(String message) {
        super(message);
    }
}
public class UserService {
    // Чистая сигнатура: не заставляет слои выше пробрасывать исключение
    public void deleteUser(Long id) {
        if (id == null) {
            throw new UserNotFoundException("User id cannot be null");
        }
    }

    // Вынуждает метод объявлять throws в сигнатуре
    public void authenticate(String login, String password) throws InvalidCredentialsException {
        if (!checkPassword(login, password)) {
            throw new InvalidCredentialsException("Invalid password for user: " + login);
        }
    }
}

🟡 Middle Level

Почему индустрия отказалась от Checked Exceptions?

1. Утечка деталей реализации (Abstraction Leakage)

Если интерфейс репозитория объявляет User findById(Long id) throws SQLException, то интерфейс привязывается к реляционным базам данных. Если завтра хранилище заменят на NoSQL (MongoDB, Redis) или внешний gRPC-клиент, придется менять сигнатуры методов во всех интерфейсах и вызывающих сервисах.

// Плохо: утечка реализации базы данных наружу
public interface AccountRepository {
    Account findById(Long id) throws SQLException;
}

// Хорошо: чистый доменный интерфейс, бросающий RuntimeException
public interface AccountRepository {
    Account findById(Long id);
}

2. Несовместимость с Functional Programming и Stream API (Java 8+)

Стандартные функциональные интерфейсы Java (Function<T, R>, Consumer<T>, Predicate<T>, Supplier<T>) не декларируют проверяемых исключений в своих абстрактных методах. Использование checked-исключений внутри Stream API превращает лаконичный код в громоздкие конструкции:

// Checked-исключение ломает лаконичность лямбды:
List<String> results = ids.stream()
    .map(id -> {
        try {
            return client.fetchData(id); // throws IOException (Checked)
        } catch (IOException e) {
            throw new UncheckedIOException(e); // Приходится оборачивать вручную!
        }
    })
    .toList();

3. Провокация антипаттерна «Silent Catch» (Проглатывание ошибок)

Разработчики, уставшие от бесконечных требований компилятора обработать Exception, начинают писать пустые блоки перехвата:

try {
    service.doOperation();
} catch (Exception e) {
    // "Сделаю потом" -> критическая ошибка проглочена, система переходит в неконсистентное состояние
}

Архитектурный выбор Spring Framework

Spring Framework с момента своего создания в 2003 году отказался от проверяемых исключений:

  • Стандартный JDBC выбрасывает checked java.sql.SQLException.
  • Spring инкапсулирует его в богатую иерархию org.springframework.dao.DataAccessException, которая является наследником RuntimeException.
  • Это позволяет сервисному слою оставаться независимым от технологии хранения данных.

Ловушка @Transactional по умолчанию

Один из самых коварных подводных камней на собеседованиях:

@Service
public class PaymentService {

    // ВНИМАНИЕ: Rollback произойдет ТОЛЬКО при RuntimeException или Error!
    @Transactional
    public void executePayment() throws Exception {
        accountRepository.decreaseBalance();
        externalBankService.transfer(); // Бросает checked Exception!
        // ТРАНЗАКЦИЯ НЕ ОТКАТИТСЯ! Баланс уменьшится, изменения зафиксируются в БД!
    }

    // Правильное решение при использовании Checked Exceptions:
    @Transactional(rollbackFor = Exception.class)
    public void safePayment() throws Exception {
        // Теперь откат произойдет и для checked Exception
    }
}

🔴 Senior Level

Взгляд из JVM: Checked Exceptions не существуют в байткоде

На уровне виртуальной машины HotSpot (JVM Specification) нет разделения на checked и unchecked исключения.

  • Байткод-инструкция athrow принимает любую ссылку на java.lang.Throwable.
  • Механизм обработки исключений использует таблицу Exception_table метода, которая работает одинаково для любых типов.
  • Проверка throws — это исключительно статическая проверка компилятора javac, использующая атрибут класса Exceptions.

Доказательством этого служит техника “Sneaky Throws”, реализованная в библиотеке Lombok (@SneakyThrows) или через generics erasure:

public class SneakyThrowsExample {
    // Обход проверок javac через Generic Type Erasure: бросаем Checked без throws!
    @SuppressWarnings("unchecked")
    public static <E extends Throwable> void sneakyThrow(Throwable e) throws E {
        throw (E) e;
    }

    public static void execute() {
        // Компилируется успешно, хотя бросается checked IOException!
        sneakyThrow(new java.io.IOException("Surprise from JVM bytecode!"));
    }
}

Опыт современных языков программирования

Создатели современных языков на базе JVM и системного стека проанализировали многолетний опыт Java и пришли к однозначному выводу:

  • C#: С самого начала отказался от checked exceptions (Андерс Хейлсберг назвал их главной архитектурной неудачей Java из-за проблем версионирования библиотек).
  • Kotlin: Не имеет понятия checked exceptions вовсе. Все методы компилируются без throws.
  • Rust / Go / Swift: Заменили исключения на явные типы возвращаемых значений (Result<T, E> в Rust, кортежи (result, error) в Go).

Архитектурный подход Java 21: Result Pattern на Sealed Interfaces

Если бизнес-сценарий действительно восстановим и требует явной проверки компилятора, в современном коде на Java 21 вместо устаревших checked exceptions используют запечатанные интерфейсы (sealed interfaces):

// Java 21: Явное моделирование альтернативных исходов вместо Exception
public sealed interface TransferResult {
    record Success(String transactionId) implements TransferResult {}
    record InsufficientFunds(BigDecimal balance, BigDecimal required) implements TransferResult {}
    record AccountBlocked(String reason) implements TransferResult {}
}

public class TransferService {
    public TransferResult transfer(String from, String to, BigDecimal amount) {
        if (isBlocked(from)) {
            return new TransferResult.AccountBlocked("Card reported lost");
        }
        if (getBalance(from).compareTo(amount) < 0) {
            return new TransferResult.InsufficientFunds(getBalance(from), amount);
        }
        return new TransferResult.Success(UUID.randomUUID().toString());
    }
}

Преимущества перед Checked Exception:

  1. Никаких накладных расходов на fillInStackTrace().
  2. Компилятор в switch операторе требует обработать все возможные ветви (exhaustiveness check).
  3. Полная поддержка в функциональных цепочках и реактивных потоках.

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

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

  1. Что произойдет при выбросе checked Exception внутри метода, помеченного @Transactional без параметров? Ответ: Транзакция не откатится. По умолчанию Spring AOP транзакции настроены на откат (rollback) только при выбросе RuntimeException или Error. Чтобы транзакция откатилась при Exception, нужно явно указать @Transactional(rollbackFor = Exception.class).
  2. Существуют ли checked-исключения на уровне байткода JVM? Ответ: Нет. На уровне байткода инструкция athrow работает одинаково для всех наследников Throwable. Проверка checked-исключений — это исключительно фаза статического анализа компилятора javac.
  3. Почему checked-исключения создают проблемы в Stream API Java 8+? Ответ: Сигнатуры стандартных функциональных интерфейсов (Function, Consumer, Predicate) не содержат секции throws. Любое checked-исключение внутри лямбды требует громоздкого блока try-catch или оборачивания в RuntimeException.
  4. Когда в новом проекте все-таки имеет смысл наследоваться от Exception? Ответ: Только при написании автономных публичных SDK/библиотек без фреймворков для критических восстановимых сценариев, когда архитектурно требуется гарантировать, что вызывающий программист не сможет забыть обработать специфический отказ внешнего устройства или протокола.

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

  • ❌ Утверждение: “Checked exceptions надежнее, поэтому я делаю все исключения checked”: Приводит к загрязнению всех сигнатур throws, утечке деталей реализации и пустому проглатыванию ошибок catch (Exception e) {}.
  • ❌ Незнание поведения Spring @Transactional в отношении Checked Exceptions: Классическая причина потери финансовой консистентности в базах данных.
  • ❌ Оборачивание checked исключения в unchecked без сохранения cause: Написание throw new RuntimeException(e.getMessage()) вместо throw new RuntimeException(e) полностью стирает исходный стек вызовов.

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