⚡ Розділ 7 · Питання #15

Що краще наслідуватись від Exception чи RuntimeException

У сучасній промисловій Java-розробці (Spring Boot, мікросервіси, хмарні застосунки) у 95% випадків рекомендується наслідуватися від RuntimeException (створюючи неперевірюваний /...


🟢 Junior Level

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

У сучасній промисловій Java-розробці (Spring Boot, мікросервіси, хмарні застосунки) у 95% випадків рекомендується наслідуватися від RuntimeException (створюючи неперевірюваний / unchecked виняток).

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


Порівняльна таблиця

Критерій extends RuntimeException (Unchecked) extends Exception (Checked)
Перевірка компілятором Ні (компілюється без обов’язкового try-catch) Так (компілятор вимагає catch або throws)
Сигнатури методів Чисті сигнатури методів та інтерфейсів Засмічення контрактів директивою throws
Основне призначення Помилки логіки, інваріантів домену, баги Рідкісні відновлювані сценарії зовнішніх систем
Сумісність з лямбдами Повна сумісність зі Stream API та 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 без catch і throws!
        sneakyThrow(new java.io.IOException("Surprise from JVM bytecode!"));
    }
}

Досвід сучасних мов програмування

Творці сучасних системних та прикладних мов проаналізували досвід 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. Природна композиція у функціональних ланцюжках і реактивних потоках без блоків try-catch.

🎯 Шпаргалка для інтерв’ю

Питання для швидкої перевірки

  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 або низькорівневих бібліотек без фреймворків для критичних відновлюваних сценаріїв, коли архітектурно необхідно змусити клієнтського програміста обов’язково реалізувати fallback на місці виклику.

Типові помилки та Red Flags

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

Пов’язані теми