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

Що таке загортання винятків

Загортання розв'язує три ключові інженерні завдання:


🟢 Junior Level

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

Загортання винятків (Exception Wrapping) — це патерн проєктування в Java, за якого низькорівневий або технологічно специфічний виняток перехоплюється в блоці catch і передається в конструктор нового високорівневого винятку як першопричина (cause): throw new ServiceException("Failed to process order", e).

Загортання розв’язує три ключові інженерні завдання:

  1. Збереження ланцюжка причинності (Exception Chaining): Оригінальний стек-трейс і тип вихідної помилки не втрачаються, а фіксуються в логах у секції Caused by:.
  2. Дотримання меж абстракції: Бізнес-шар приховує технічні деталі реалізації (наприклад, SQLException замінюється на OrderPersistenceException).
  3. Збагачення контекстом: До системного повідомлення додаються доменні метадані (ідентифікатор сутності, параметри запиту).

Базовий приклад на Java 21

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

public class ConfigurationLoader {

    public String loadConfig(String filename) {
        try {
            return Files.readString(Path.of(filename));
        } catch (IOException e) {
            // Огортаємо низькорівневе checked IOException у високорівневий unchecked виняток
            throw new ConfigurationException("Failed to read configuration from file: " + filename, e);
        }
    }
}

Згенерований стек-трейс пов’яже обидві помилки:

ConfigurationException: Failed to read configuration from file: app.yaml
    at ConfigurationLoader.loadConfig(ConfigurationLoader.java:13)
    at App.main(App.java:5)
Caused by: java.nio.file.NoSuchFileException: app.yaml
    at java.base/sun.nio.fs.UnixException.translateToIOException(UnixException.java:92)
    ... 2 more

🟡 Middle Level

Внутрішня будова механізму Chaining у Throwable

У класі java.lang.Throwable підтримка ланцюжків винятків реалізована через внутрішнє поле:

private Throwable cause = this; // Значення "this" сигналізує, що причина ще не була ініціалізована

Методи керування першопричиною:

  1. Конструктори: Throwable(String message, Throwable cause) та Throwable(Throwable cause) автоматично викликають внутрішню ініціалізацію причини.
  2. Метод initCause(Throwable cause): Дозволяє встановити першопричину постфактум (актуально для застарілих класів JDK 1.0–1.3, де не було конструкторів із cause).
    • Важливе правило: initCause() можна викликати суворо один раз. Повторний виклик або виклик на об’єкті, де cause вже було задано в конструкторі, викидає IllegalStateException: Can't overwrite cause.
  3. Метод getCause(): Повертає посилання на вихідний виняток (або null, якщо причини немає).

Патерн Exception Translation у чистій архітектурі

У багатошарових архітектурах (Clean Architecture, DDD, Hexagonal) кожен шар зобов’язаний оперувати винятками свого рівня абстракції:

[ Presentation Layer (Controllers) ]  <-- ловить ApplicationException -> формує HTTP 4xx/5xx
                 ▲
                 │ (Exception Translation)
[ Domain / Service Layer ]           <-- викидає OrderProcessingException
                 ▲
                 │ (Exception Translation)
[ Infrastructure Layer (DAO/Clients) ]<-- перехоплює SQLException / SocketTimeoutException
public class PaymentGatewayAdapter {
    public void executeCharge(PaymentDetails details) {
        try {
            httpClient.post("/charges", details);
        } catch (SocketTimeoutException e) {
            // Перетворюємо технічний таймаут мережі на зрозумілу доменну подію
            throw new PaymentProviderTimeoutException("Payment provider timeout for account " + details.accountId(), e);
        }
    }
}

Антипатерни при загортанні винятків

1. Втрата першопричини (Loss of Cause)

// ❌ ГРУБА ПОМИЛКА: передано лише рядкове повідомлення!
try {
    dao.save(entity);
} catch (SQLException e) {
    throw new ServiceException(e.getMessage()); // Стек-трейс і клас SQLException ВТРАЧЕНІ!
}

// ✅ ПРАВИЛЬНО: передаємо об'єкт винятку e повністю
try {
    dao.save(entity);
} catch (SQLException e) {
    throw new ServiceException("Failed to save entity " + entity.getId(), e);
}

2. Матрьошкове надлишкове загортання (Wrap-a-Wrap)

Огортання винятку без зміни рівня абстракції та без додавання корисного контексту:

// ❌ АНТИПАТЕРН: беззмістовне роздування стека
try {
    userService.findUser(id);
} catch (UserNotFoundException e) {
    throw new UserLookupException("Failed to find user", e); // Зайвий шар шуму
}

🔴 Senior Level

Загортання в стандартних підсистемах JDK

1. Reflection API: InvocationTargetException

Під час виклику методів через рефлексію (Method.invoke()) будь-які винятки (як checked, так і unchecked), що виникли всередині цільового методу, компілятор примусово загортає в java.lang.reflect.InvocationTargetException. Для отримання реальної помилки викликають e.getCause() або e.getTargetException().

2. Асинхронні межі: ExecutionException vs CompletionException

  • У класичному Future.get() будь-яка помилка робочого потоку огортається в перевірюване java.util.concurrent.ExecutionException.
  • У CompletableFuture.join() помилка огортається в неперевірюване java.util.concurrent.CompletionException.
CompletableFuture.supplyAsync(() -> {
    throw new IllegalArgumentException("Bad input");
}).join(); // Викине CompletionException із причиною IllegalArgumentException

Алгоритм безпечного вилучення першопричини (Root Cause Extraction)

Під час розслідування інцидентів та централізованої обробки помилок часто потрібно знайти найглибший вихідний виняток (Root Cause). При наївній реалізації в циклі while (e.getCause() != null) існує ризик зациклення, якщо граф винятків містить циклічні посилання.

Ідіоматичний алгоритм із захистом від циклів:

import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.Set;

public class ExceptionUtils {

    public static Throwable getRootCause(Throwable throwable) {
        if (throwable == null) {
            return null;
        }

        Set<Throwable> seen = Collections.newSetFromMap(new IdentityHashMap<>());
        Throwable current = throwable;

        while (current.getCause() != null && seen.add(current)) {
            current = current.getCause();
        }
        return current;
    }
}

Накладні витрати пам’яті та легковажні обгортки

Кожне створення обгортки через new CustomException(msg, cause) викликає нативний метод fillInStackTrace(). У ланцюжку з 4 винятків JVM обходить стек викликів 4 рази!

Якщо проміжний виняток слугує виключно транспортом між шарами, його можна оптимізувати, вимкнувши створення власного стек-трейсу (writableStackTrace = false), оскільки оригінальний глибокий стек уже надійно збережений усередині cause:

public class LightweightServiceException extends RuntimeException {
    public LightweightServiceException(String message, Throwable cause) {
        // Вимикаємо формування власного стека для обгортки, зберігаючи cause!
        super(message, cause, true, false);
    }
}

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

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

  1. Що станеться, якщо в об’єкта винятку викликати initCause(), коли cause уже було передано в конструктор? Відповідь: Буде викинуто IllegalStateException із повідомленням Can't overwrite cause with [Throwable]. Метод initCause() можна викликати лише один раз і тільки якщо причина не була встановлена раніше.
  2. У чому фатальна різниця між throw new MyException(e.getMessage()) та throw new MyException(e)? Відповідь: У першому випадку створюється повністю новий виняток зі стек-трейсом, що починається з поточної точки перехоплення. Тип, повідомлення та весь первинний стек оригінального винятку e безповоротно втрачаються. У другому випадку зберігається повний ланцюжок причинності (Caused by:).
  3. Чому рефлексивний виклик Method.invoke() викидає InvocationTargetException? Відповідь: Сигнатура методу Method.invoke() не може знати заздалегідь, які перевірювані винятки кине цільовий метод. Тому рефлексія інкапсулює будь-який виняток усередині методу в універсальну перевірювану обгортку InvocationTargetException.
  4. Як безпечно знайти першопричину (Root Cause) з багаторазово загорнутого винятку? Відповідь: Проходом по ланцюжку getCause() у циклі while (t.getCause() != null) t = t.getCause(), обов’язково використовуючи IdentityHashMap для захисту від можливих циклічних посилань.

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

  • ❌ Втрата першопричини: Конструкція throw new CustomException(e.getMessage()) замість throw new CustomException(msg, e).
  • ❌ Загортання без додавання контексту: Створення проміжних класів-пустушок, які не змінюють рівень абстракції і не додають доменних полів.
  • ❌ Спроба передати null як cause і забути про оригінал: Позбавляє команду підтримки можливості з’ясувати справжню причину аварії в продакшені.
  • ❌ Підміна першопричини на неспецифічний generic Exception: Наприклад, перехоплення SQLException і викидання new RuntimeException().

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