When should you create your own exceptions
You should create custom exceptions when standard Java Development Kit (JDK) exceptions are insufficient to represent your business domain, or when the calling code requires a d...
🟢 Junior Level
You should create custom exceptions when standard Java Development Kit (JDK) exceptions are insufficient to represent your business domain, or when the calling code requires a distinct programmatic response to a specific failure.
The 3 Core Criteria for Creating Custom Exceptions
- Domain Business Invariant Violations: The failure reflects a business rule rather than a technical language defect (
InsufficientFundsException,PromoCodeExpiredException,SeatAlreadyBookedException). - Selective Error Handling: The calling code or a centralized framework handler must intercept this exact failure in a dedicated
catchblock (e.g., mapping to HTTP409 Conflictor404 Not Found). - Structured Context Transfer: The exception needs to transport specific domain attributes (such as entity ID, available balance, or a
retryAfterduration).
If an error is caused by violating a standard method contract (passing null, negative indices, or illegal formats), use standard JDK exceptions: NullPointerException, IllegalArgumentException, IllegalStateException.
Decision Matrix: Standard vs. Custom Exceptions
| Scenario | Recommended Choice | Production Example |
|---|---|---|
| Invalid parameter passed to method (blank string, negative ID) | Standard: IllegalArgumentException |
if (id <= 0) throw new IllegalArgumentException("ID must be positive"); |
Method argument is unexpectedly null |
Standard: NullPointerException |
Objects.requireNonNull(account, "account must not be null"); |
| Method invoked at an invalid object lifecycle state | Standard: IllegalStateException |
Invoking connection.send() before connection.connect() completes |
| Requested domain entity missing in database | Custom: UserNotFoundException |
Enables Spring @ExceptionHandler to return HTTP 404 Not Found |
| Account withdrawal exceeds overdraft limit | Custom: CreditLimitExceededException |
Contains typed fields currentBalance, attemptedAmount, overdraftLimit |
| Third-party payment gateway temporarily unavailable | Custom: PaymentGatewayTimeoutException |
Categorizes failure as retryable with exponential backoff |
Basic Code Example
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
The Exception Translation Pattern
One of the most critical architectural justifications for custom exceptions is hiding implementation details across layer boundaries (Abstraction Layering).
If a repository layer allows java.sql.SQLException or org.hibernate.HibernateException to leak out, it couples service layers and REST controllers to relational database infrastructure. If the team switches to MongoDB or a remote gRPC service, every calling signature breaks:
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) {
// Translate low-level infrastructure failure into a semantic domain exception
throw new OrderPersistenceException("Failed to persist order completion for id: " + orderId, e);
}
}
}
Differentiating Recovery Strategies (Fault Tolerance)
Custom exceptions allow infrastructure layers to establish distinct operational recovery workflows:
ApplicationException
├── NonRetryableException (Validation error / Bad data -> Fail immediately)
│ ├── OrderValidationException
│ └── CustomerSuspendedException
│
└── RetryableException (Transient outage -> Automatic retry with backoff)
├── ExternalServiceTimeoutException
└── DatabaseDeadlockException
// Background message consumer (e.g., Spring Retry or Kafka Consumer):
try {
paymentProcessor.charge(order);
} catch (RetryableException e) {
scheduleRetry(order, Duration.ofSeconds(5)); // Retry transient outage
} catch (NonRetryableException e) {
routeToDeadLetterQueue(order, e); // Route corrupt message to DLQ
}
Anti-Pattern: Exceptions as Control Flow
Never use exceptions for routine conditional branching:
// ❌ HORRIBLE ANTI-PATTERN: Using exceptions instead of conditional checks
public User getUser(Long id) {
try {
return findInCache(id); // Throws UserNotFoundException if cache miss
} catch (UserNotFoundException e) {
return loadFromDatabase(id); // Fallback via expensive exception!
}
}
// ✅ IDIOMATIC & FAST: Use Optional
public User getUser(Long id) {
return findInCache(id)
.orElseGet(() -> loadFromDatabase(id));
}
Why This Is Critical in Highload:
- CPU Latency Overhead: Allocating a
Throwableinvokes the nativefillInStackTrace()method, costing $\approx 1{,}000\text{–}5{,}000\,\text{ns}$ versus $\approx 5\text{–}10\,\text{ns}$ for anOptionalor boolean check. - JIT Deoptimization:
try-catchblocks interfere with HotSpot C2 JIT compiler method inlining and loop vectorization. - Readability: Branching via exceptions violates the Principle of Least Astonishment.
The “Class Bloat” Problem
Avoid creating an explosion of micro-exceptions for every possible message string:
- ❌ Anti-Pattern:
InvalidUserEmailException,InvalidUserAgeException,InvalidUserPhoneException. - ✅ Clean Design: A unified
UserValidationExceptioncontaining a collection of validation violations (List<FieldViolation>).
🔴 Senior Level
Domain-Driven Design (DDD): Invariant Violations
In Domain-Driven Design, an exception represents a domain command failure caused by an invariant violation of an Aggregate Root.
In this architecture, custom exceptions:
- Use the Ubiquitous Language: Class names directly mirror domain business rules (
BookingAlreadyCancelledException,DeliverySlotOccupiedException). - Contain Zero Technical Terms: Never mention
NullPointer,Database,SQL, orTablein domain exceptions. - Capture Aggregate State at the Instant of Refusal:
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;
}
}
Functional Error Handling (Result<T, E>) vs. Exceptions
In modern Java 21 architectures, failures are bifurcated into two distinct categories:
- Expected Alternative Outcomes: Entity missing, invalid coupon, insufficient balance.
- Exceptional Invariant Failures: Hardware failure, database crash, network severance.
For expected domain outcomes, functional Result types (built using Java 21 sealed interfaces and pattern matching) provide superior performance and compile-time exhaustiveness checks:
// Java 21: Functional handling of anticipated domain outcomes without throwing exceptions
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: Compiler guarantees all cases are handled!
switch (result) {
case OrderResult.Success s -> sendConfirmation(s.order());
case OrderResult.InsufficientStock stock -> notifyBackorder(stock.itemId());
case OrderResult.PaymentDeclined dec -> alertPaymentFailed(dec.reason());
}
}
}
Architectural Rule: Use Result types for expected branch outcomes; use Exceptions when operations must abort completely and rollback surrounding transactions.
Observability: Distributed Tracing & Metrics
Custom exceptions must integrate seamlessly with enterprise telemetry:
- Every domain exception should be recorded on the active OpenTelemetry span:
span.recordException(e)andspan.setStatus(StatusCode.ERROR). - Use a machine-readable
ErrorCodestring attached to Micrometer metrics for automated Prometheus alerting:
import io.micrometer.core.instrument.MeterRegistry;
public class MonitoredOrderService {
private final MeterRegistry registry;
public void cancel(Long id) {
try {
// Business logic execution
} catch (DomainException ex) {
registry.counter("business.exceptions.total", "code", ex.getErrorCode()).increment();
throw ex;
}
}
}
4 Tricky Questions
1. When is creating a custom exception justified over using standard JDK exceptions?
Answer:
A custom exception is justified when modeling specific domain business failures (InsufficientFundsException), when the calling code or framework advice needs to catch that specific error exclusively (e.g., mapping to a distinct HTTP 409 status code), or when structured domain metadata (such as an order ID or account balance) must be transported. If the failure is a standard method contract violation (such as passing a negative number or a null value), standard exceptions (IllegalArgumentException, NullPointerException) should be preferred.
2. Why is using exceptions for routine control flow considered a serious design flaw?
Answer:
First, it degrades performance by orders of magnitude: allocating a Throwable invokes the native fillInStackTrace() method, requiring $\approx 1\text{–}5\,\mu\text{s}$ compared to $\approx 5\text{–}10\,\text{ns}$ for an Optional or boolean check. Second, try-catch blocks impede JIT optimizations such as method inlining. Third, it obscures the natural execution flow of the code and violates the Principle of Least Astonishment.
3. What is Exception Translation and why is it applied at architectural boundaries?
Answer:
Exception Translation is the pattern of intercepting low-level technical exceptions from an underlying layer (such as a checked SQLException in a repository) and wrapping them into semantic domain exceptions (OrderStorageException). It prevents implementation details from leaking across architectural boundaries, ensuring that changes to storage technologies (e.g., migrating from SQL to NoSQL) do not break upstream service and controller interfaces.
4. How should you choose in Java 21 between a custom exception and a sealed interface (Result/Either pattern)?
Answer:
Use a sealed interface Result pattern for anticipated, recoverable business outcomes (e.g., user not found, discount expired) where callers can handle alternative outcomes using pattern matching without the CPU overhead of stack trace generation. Reserve custom exceptions for true errors and invariant violations that must abort the current execution pipeline and trigger transaction rollbacks.
🎯 Interview Cheat Sheet
30-Second Summary
Create custom exceptions when standard JDK exceptions cannot express the business domain or when callers require specialized catch logic.
Core Architectural Rules:
- Domain Modeling: Model business failures (
InsufficientFundsException), not programming bugs.- Exception Translation: Catch infrastructure errors at boundaries (
SQLException) and wrap them into domain runtime exceptions.- Avoid Flow Control: Never use exceptions for routine conditional branching; use
Optional<T>or Java 21sealed interface Resulttypes ($100\times\text{–}500\times$ faster).- Avoid Class Bloat: Group related validation errors into a single exception carrying structured metadata.
Standard Exceptions vs Custom
- Method contract violations: Use
IllegalArgumentException,IllegalStateException,NullPointerException. - Domain rule violations: Use custom exceptions (
OrderNotFoundException,CreditLimitExceededException).
Red Flags (What to Avoid)
- ❌ “Create a distinct custom exception class for every single validation rule in the form.” (Creates class bloat; group violations in a list).
- ❌ “Using exceptions for control flow is acceptable in high-throughput services.” (Stack frame traversal causes severe CPU spikes).
- ❌ “Letting Hibernate/SQL exceptions bubble directly to REST controllers.” (Leaks implementation details; use Exception Translation).
- ❌ “Dropping the root cause during exception wrapping:
throw new CustomException(e.getMessage());.” (Destroys original stack trace; passeas cause).
Related Topics
- Can you create custom exceptions — Implementation details and 4 constructors
- What is better: extend Exception or RuntimeException — Checked vs unchecked decision
- What is exception wrapping — Exception chaining mechanics
- What is a stack trace — Performance cost of stack walking
- Why you should not swallow exceptions — Dangers of empty catch blocks