⚡ Section 7 · Question #14

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

  1. Domain Business Invariant Violations: The failure reflects a business rule rather than a technical language defect (InsufficientFundsException, PromoCodeExpiredException, SeatAlreadyBookedException).
  2. Selective Error Handling: The calling code or a centralized framework handler must intercept this exact failure in a dedicated catch block (e.g., mapping to HTTP 409 Conflict or 404 Not Found).
  3. Structured Context Transfer: The exception needs to transport specific domain attributes (such as entity ID, available balance, or a retryAfter duration).

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:

  1. CPU Latency Overhead: Allocating a Throwable invokes the native fillInStackTrace() method, costing $\approx 1{,}000\text{–}5{,}000\,\text{ns}$ versus $\approx 5\text{–}10\,\text{ns}$ for an Optional or boolean check.
  2. JIT Deoptimization: try-catch blocks interfere with HotSpot C2 JIT compiler method inlining and loop vectorization.
  3. 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 UserValidationException containing 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:

  1. Use the Ubiquitous Language: Class names directly mirror domain business rules (BookingAlreadyCancelledException, DeliverySlotOccupiedException).
  2. Contain Zero Technical Terms: Never mention NullPointer, Database, SQL, or Table in domain exceptions.
  3. 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:

  1. Expected Alternative Outcomes: Entity missing, invalid coupon, insufficient balance.
  2. 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) and span.setStatus(StatusCode.ERROR).
  • Use a machine-readable ErrorCode string 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:

  1. Domain Modeling: Model business failures (InsufficientFundsException), not programming bugs.
  2. Exception Translation: Catch infrastructure errors at boundaries (SQLException) and wrap them into domain runtime exceptions.
  3. Avoid Flow Control: Never use exceptions for routine conditional branching; use Optional<T> or Java 21 sealed interface Result types ($100\times\text{–}500\times$ faster).
  4. 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; pass e as cause).