Коли варто створювати свої винятки
Власні (кастомні) винятки варто створювати тоді, коли стандартних класів JDK недостатньо для вираження сенсу помилки в предметній області, або коли викликаючому коду потрібна сп...
🟢 Junior Level
Коротка відповідь для співбесіди (30 секунд)
Власні (кастомні) винятки варто створювати тоді, коли стандартних класів JDK недостатньо для вираження сенсу помилки в предметній області, або коли викликаючому коду потрібна специфічна реакція на конкретний збій.
Головні критерії для створення власного винятку:
- Бізнес-інваріант домену: Помилка відображає порушення правил бізнесу (
InsufficientFundsException,PromoCodeExpiredException), а не технічний збій мови програмування. - Необхідність роздільної обробки: Викликаючий код або глобальний обробник повинен перехопити саме цю помилку в окремому блоці
catch(наприклад, повернути HTTP 409 Conflict замість 500). - Передача структурованого контексту: Необхідно передати специфічні доменні атрибути (ідентифікатор сутності, доступний баланс, інтервал повтору
retryAfter).
Якщо ж помилка спричинена порушенням базового контракту методу (передано null, від’ємний індекс, невірний формат аргументу), слід використовувати стандартні винятки JDK: NullPointerException, IllegalArgumentException, IllegalStateException.
Матриця прийняття рішень: стандартне vs кастомне
| Ситуація | Рекомендоване рішення | Приклад із практики |
|---|---|---|
| У метод передано некоректний параметр (порожній рядок, від’ємний ID) | Стандартне: IllegalArgumentException |
if (id <= 0) throw new IllegalArgumentException(...) |
Аргумент методу неочікувано виявився null |
Стандартне: NullPointerException |
Objects.requireNonNull(client, "client must not be null") |
| Метод викликано у невідповідний момент життєвого циклу об’єкта | Стандартне: IllegalStateException |
Виклик connection.send() до завершення connection.connect() |
| Запитаний доменний об’єкт не знайдено у сховищі | Кастомне: UserNotFoundException |
Дозволяє @ExceptionHandler повернути статус 404 Not Found |
| Спроба списання коштів за перевищення кредитного ліміту | Кастомне: CreditLimitExceededException |
Містить поля currentBalance, attemptedAmount, limit |
| Зовнішній платіжний шлюз тимчасово недоступний | Кастомне: PaymentGatewayTimeoutException |
Позначає збій як придатний для повторної спроби (retryable) |
Базовий приклад кастомного бізнес-винятку
import java.math.BigDecimal;
public class InsufficientFundsException extends RuntimeException {
private final BigDecimal currentBalance;
private final BigDecimal requestedAmount;
public InsufficientFundsException(BigDecimal currentBalance, BigDecimal requestedAmount) {
super("Cannot withdraw %s: available balance is %s".formatted(requestedAmount, currentBalance));
this.currentBalance = currentBalance;
this.requestedAmount = requestedAmount;
}
public BigDecimal getCurrentBalance() {
return currentBalance;
}
public BigDecimal getRequestedAmount() {
return requestedAmount;
}
}
🟡 Middle Level
Патерн Exception Translation (Трансляція винятків)
Однією з найважливіших архітектурних причин для створення власних винятків є приховування деталей реалізації на межах шарів (Abstraction Layering).
Якщо сервісний шар прокидає назовні java.sql.SQLException або org.hibernate.HibernateException, він розкриває викликаючому шару (контролерам, зовнішньому API) внутрішні деталі технології збереження даних. При зміні реляційної бази даних на NoSQL або зовнішній REST-сервіс публічний контракт буде зламано.
public class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository;
}
public void completeOrder(Long orderId) {
try {
repository.updateStatus(orderId, OrderStatus.COMPLETED);
} catch (SQLException e) {
// Транслюємо технічний низькорівневий виняток у доменний
throw new OrderPersistenceException("Failed to persist order completion for id: " + orderId, e);
}
}
}
Диференціація стратегій відновлення (Recovery Strategies)
Кастомні винятки створюють для того, щоб клієнтський код міг реалізувати різні сценарії обробки відмов (Fault Tolerance):
ApplicationException
├── NonRetryableException (Помилка клієнта / Дефект даних -> негайна відмова)
│ ├── OrderValidationException
│ └── CustomerBlockedException
│
└── RetryableException (Тимчасовий інфраструктурний збій -> повтор запиту)
├── ExternalServiceTimeoutException
└── DatabaseDeadlockException
// Обробник у фоновому воркері (наприклад, Spring Retry або Kafka Consumer)
try {
paymentProcessor.charge(order);
} catch (RetryableException e) {
scheduleRetry(order, Duration.ofSeconds(5)); // повторюємо через 5 секунд
} catch (NonRetryableException e) {
sendAlertToSupport(order, e); // відправляємо в Dead Letter Queue (DLQ)
}
Антипатерн “Exception as Flow Control” (Винятки для управління логікою)
Ніколи не використовуйте винятки для звичайного розгалуження бізнес-процесів:
// ❌ АНТИПАТЕРН: використання винятку замість if/else
public User getUser(Long id) {
try {
return findInCache(id); // Викидає UserNotFoundException, якщо немає в кеші
} catch (UserNotFoundException e) {
return loadFromDatabase(id); // Звичайний fallback через важкий виняток!
}
}
// ✅ ІДІОМАТИЧНО: використання Optional або явних перевірок
public User getUser(Long id) {
return findInCache(id)
.orElseGet(() -> loadFromDatabase(id));
}
Чому це критично:
- Продуктивність: Створення об’єкта
Throwableобходиться в сотні разів дорожче за виклик методу (~1–5 мкс проти ~5–10 нс уOptional), оскільки вимагає нативного проходу по стеку ядра JVM. - JIT-оптимізації: Блоки
try-catchзаважають агресивному інлайнінгу (method inlining) та векторизації інструкцій JIT-компілятором C2. - Читабельність: Код, який використовує винятки для звичайного потоку керування, порушує принцип найменшого здивування (Principle of Least Astonishment) і сильно ускладнює налагодження.
Проблема “Class Bloat” (Роздування ієрархії)
Не варто створювати окремий клас винятку під кожен рядок помилки:
- ❌ Погано:
InvalidUserEmailException,InvalidUserAgeException,InvalidUserPhoneException. - ✅ Чудово: Один клас
UserValidationException, що приймає список порушених полів (List<Violation>), або використання стандартного валідатора Spring (MethodArgumentNotValidException).
🔴 Senior Level
Domain-Driven Design (DDD): Виняток як факт порушення інваріанта агрегата
У парадигмі DDD виняток репрезентує неможливість виконання доменної команди через порушення бізнес-інваріанта кореня агрегата (Aggregate Root).
За такого підходу виняток:
- Іменується в термінах єдиної мови (Ubiquitous Language):
OrderAlreadyCancelledException,DeliverySlotOccupiedException. - Не містить технічних термінів: Жодних
NullPointer,Database,Sqlу доменних класах. - Фіксує стан агрегата на момент відмови:
public class BookingAggregate {
private BookingStatus status;
private final Instant bookingDeadline;
public void confirm(Instant now) {
if (this.status == BookingStatus.CANCELLED) {
throw new BookingAlreadyCancelledException(
"Cannot confirm booking %s because it is already cancelled".formatted(id)
);
}
if (now.isAfter(bookingDeadline)) {
throw new BookingDeadlineExpiredException(id, bookingDeadline, now);
}
this.status = BookingStatus.CONFIRMED;
}
}
Функціональний підхід до помилок (Result / Either) vs Exceptions
У сучасних архітектурах на Java 21 помилки поділяють на дві принципові категорії:
- Expected Failures (Очікувані альтернативні результати): Користувача не знайдено, невірний пароль, промокод прострочений.
- Unexpected Exceptional Failures (Виняткові катастрофи): Збій мережі, відмова СУБД, OutOfMemory.
Для першої категорії функціональний патерн Result<T, E> (на базі sealed interface у Java 21) часто перевершує винятки:
// Java 21: Функціональна обробка очікуваних доменних відмов без викидання Throwable
public sealed interface OrderResult {
record Success(Order order) implements OrderResult {}
record InsufficientStock(String itemId, int available) implements OrderResult {}
record PaymentDeclined(String reason) implements OrderResult {}
}
public class OrderWorkflow {
public void process(OrderCommand cmd) {
OrderResult result = orderService.createOrder(cmd);
// Pattern Matching: компілятор гарантує вичерпну обробку всіх гілок
switch (result) {
case OrderResult.Success s -> sendConfirmation(s.order());
case OrderResult.InsufficientStock stock -> notifyOutOfStock(stock.itemId());
case OrderResult.PaymentDeclined dec -> alertPaymentFailed(dec.reason());
}
}
}
Висновок: Винятки створюються тоді, коли збій перериває подальше виконання ланцюжка операцій і повинен бути оброблений централізовано (на рівні транзакції або HTTP-контролера).
Спостережуваність: Distributed Tracing та OpenTelemetry
Кастомні винятки повинні бути інтегровані в систему телеметрії:
- Кожен доменний виняток пов’язується зі спаном OpenTelemetry:
span.recordException(e)таspan.setStatus(StatusCode.ERROR). - Виняток повинен містити машиночитаний рядковий код (
ErrorCode), що передається в метрики Micrometer:
import io.micrometer.core.instrument.MeterRegistry;
public class MonitoredOrderService {
private final MeterRegistry registry;
public void cancel(Long id) {
try {
// бізнес-логіка
} catch (DomainException ex) {
registry.counter("business.exceptions.total", "code", ex.getErrorCode()).increment();
throw ex;
}
}
}
🎯 Шпаргалка для інтерв’ю
Питання для швидкої перевірки
- Коли виправдане створення кастомного винятку, а коли слід надати перевагу стандартному?
Відповідь: Кастомний виняток створюється для доменних бізнес-помилок (
OrderNotFoundException), якщо викликаючому коду потрібен окремий блокcatchабо якщо необхідно передати структурований контекст (ID сутності, баланс). Якщо йдеться про порушення контракту методу (null, невірний аргумент, несумісний стан), слід використовувати стандартні винятки JDK (IllegalArgumentException,IllegalStateException). - Чому використання винятків для розгалуження логіки (Flow Control) вважається грубою помилкою?
Відповідь: Це на кілька порядків повільніше за звичайні перевірки (
if/else,Optional) через накладні витрати на викликfillInStackTrace(), деоптимізує JIT-компілятор C2 (перешкоджає інлайнінгу) та робить потік виконання програми заплутаним для читання й супроводу. - Що таке Exception Translation і навіщо вона застосовується?
Відповідь: Це патерн огортання низькорівневих винятків одного шару (наприклад,
SQLExceptionу репозиторії) у високорівневі винятки викликаючого шару (OrderPersistenceException). Вона приховує технічні деталі реалізації та ізолює залежності між шарами архітектури. - Що обрати в Java 21: кастомний виняток чи sealed interface (Result/Either)?
Відповідь: Для очікуваних бізнес-сценаріїв (користувача не знайдено, знижка не застосовується) та чистої функціональної композиції ефективніше використовувати запечатані інтерфейси (
sealed interface) з pattern matching, оскільки це дешевше за ресурсами, а компілятор гарантує повноту обробки всіх варіантів. Винятки залишають для непередбачених збоїв, які вимагають переривання потоку керування та відкату транзакцій.
Типові помилки та Red Flags
- ❌ Створення окремого класу винятку під кожен рядковий текст помилки: Роздування Metaspace та комбінаторний вибух класів (Class Bloat).
- ❌ Використання винятків замість звичайної валідації вхідних параметрів через if-else: Втрата продуктивності та погіршення читабельності коду.
- ❌ Протікання технологічних винятків (Hibernate/SQL) назовні сервісного шару: Порушення принципу приховування інформації та архітектурних меж.
- ❌ Втрата першопричини (cause) під час трансляції: Написання
throw new CustomException(e.getMessage())замістьthrow new CustomException("...", e)знищує первинний стек-трейс.