⚡ Розділ 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; }
}

Проєктування ієрархії винятків застосунку

Замість десятків розрізнених класів рекомендується будувати чітку деревоподібну ієрархію з єдиним базовим винятком для модуля або сервісу. Це дозволяє інфраструктурним шарам (фільтрам, інтерцепторам або глобальним обробникам) перехоплювати всі збої підсистеми на верхньому рівні:

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 створення самого Java-об’єкта винятку коштує недорого, проте метод Throwable.fillInStackTrace() є високовартісним нативним викликом ядра C++ HotSpot. Він призупиняє потік і здійснює лінійний обхід усіх стекових фреймів поточного потоку, виділяючи пам’ять під масив StackTraceElement[].

Якщо виняток генерується в інтенсивному циклі (сотні тисяч RPS) або використовується для контролю виконання, створення стек-трейсів спричиняє деградацію пропускної здатності в десятки разів.

Рішення 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 без виклику нативного методу обходу стека JVM
        return this;
    }
}

Результат: Швидкість створення об’єкта винятку зростає у 30–50 разів, навантаження на Garbage Collector у Young Generation зводиться практично до нуля.


Небезпека Singleton Exceptions (Static Final Instances)

Іноді інженери намагаються кешувати винятки як статичні константи:

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

Чому це категорично заборонено:

  1. Якщо виняток має ввімкнений стек-трейс (writableStackTrace = true), цей стек буде зафіксований у момент ініціалізації класу. У логах завжди відображатиметься рядок оголошення статичної змінної, а не реальне місце виникнення збою.
  2. Якщо в багатопотоковому середовищі різні потоки одночасно викличуть INSTANCE.initCause(...) або addSuppressed(), виникне стан гонки (Race Condition) або IllegalStateException, що пошкодить стан глобального об’єкта.
  3. Кешування Singleton-винятків допустиме виключно за умови, що вони є абсолютно незмінними (immutable), безіменними та не містять стек-трейсу (writableStackTrace = false і заборонений initCause).

Архітектурний антипатерн: Витік кастомних винятків через межі мікросервісів

Спроба передати бінарно серіалізований об’єкт кастомного винятку (наприклад, com.bank.core.domain.UserNotFoundException) через протоколи RPC/HTTP/Kafka іншому мікросервісу створює жорстку зв’язність (tight coupling) за Classpath.

  • Якщо клієнтський сервіс не має цього .jar у classpath, десеріалізація зазнає краху з ClassNotFoundException / NoClassDefFoundError.
  • Ідіоматичне рішення: На межі сервісу (API Gateway, контролер, Kafka Producer) кастомний виняток зобов’язаний транслюватися в нейтральний контракт: JSON (RFC 7807 ProblemDetail), Protobuf або стандартні статуси gRPC (Status.NOT_FOUND).

Безпека: CWE-209 та маскування персональних даних (PII)

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

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