⚡ Section 7 · Question #15

What is better extend Exception or RuntimeException

In modern enterprise Java development (Spring Boot, cloud microservices, and reactive applications), inheriting from RuntimeException (creating an unchecked exception) is recomm...


🟢 Junior Level

In modern enterprise Java development (Spring Boot, cloud microservices, and reactive applications), inheriting from RuntimeException (creating an unchecked exception) is recommended in 95%+ of scenarios.

Extending java.lang.Exception (creating a checked exception) is justified only in rare situations where a failure is an expected operational branch, the immediate calling code has a concrete, programmatic recovery strategy (such as switching to a backup network channel or retrying with backoff), and the compiler must strictly force the caller to handle it.

In business applications, checked exceptions pollute method signatures, break Stream API lambdas, and lead to dangerous empty catch blocks.

Core Comparison Matrix

Criterion extends RuntimeException (Unchecked) extends Exception (Checked)
Compiler Enforcement Optional (compiles without try-catch or throws) Mandatory (javac enforces catch or throws)
Method Signatures Clean, decoupled interface signatures Signatures cluttered with throws declarations
Primary Domain Business rules, domain invariant violations, bugs Rare, recoverable external infrastructure events
Stream API Compatibility 100% compatible with lambdas and functional interfaces Incompatible without verbose wrapping boilerplate
Spring @Transactional Automatic rollback by default NO rollback by default (requires rollbackFor)
REST API Handling Handled centrally via @RestControllerAdvice Requires handling at intermediate call layers

Code Comparison

// 1. Unchecked (extends RuntimeException) - MODERN ENTERPRISE STANDARD
public class UserNotFoundException extends RuntimeException {
    public UserNotFoundException(String message) {
        super(message);
    }
}

// 2. Checked (extends Exception) - REQUIRES MANDATORY THROWS
public class InvalidCredentialsException extends Exception {
    public InvalidCredentialsException(String message) {
        super(message);
    }
}
public class UserService {
    // Clean signature: Does not force upstream callers to declare throws
    public void deleteUser(Long id) {
        if (id == null) {
            throw new UserNotFoundException("User ID cannot be null");
        }
        userRepository.deleteById(id);
    }

    // Leaks checked exception requirement into the method signature contract
    public void authenticate(String login, String password) throws InvalidCredentialsException {
        if (!checkPassword(login, password)) {
            throw new InvalidCredentialsException("Invalid password for user: " + login);
        }
    }
}

🟡 Middle Level

Why Modern Software Engineering Moved Away From Checked Exceptions

1. Abstraction Leakage Across Layers

If a repository interface declares User findById(Long id) throws SQLException, the interface becomes permanently coupled to relational JDBC storage. If the repository implementation is swapped for MongoDB, Redis, or an external gRPC client, every service method and interface signature across the entire codebase must be refactored:

// ❌ Poor: Leaks JDBC implementation detail across architectural boundaries
public interface AccountRepository {
    Account findById(Long id) throws SQLException;
}

// ✅ Clean: Decoupled domain contract throwing unchecked exceptions
public interface AccountRepository {
    Account findById(Long id);
}

2. Incompatibility with Functional Programming & Stream API

Java’s standard functional interfaces (Function<T, R>, Consumer<T>, Predicate<T>, Supplier<T>) do not declare checked exceptions in their method signatures. Using checked exceptions inside a Stream pipeline forces ugly try-catch blocks:

// Checked exceptions break the elegance of Java 8 Streams:
List<String> results = ids.stream()
    .map(id -> {
        try {
            return client.fetchData(id); // throws checked IOException
        } catch (IOException e) {
            throw new UncheckedIOException(e); // Manual boilerplate wrapping
        }
    })
    .toList();

3. The “Silent Catch” Anti-Pattern (Error Swallowing)

When developers are forced by the compiler to handle checked exceptions that they cannot reasonably recover from, they resort to empty catch blocks:

try {
    service.doOperation();
} catch (Exception e) {
    // "TODO: Fix later" -> Critical error is permanently swallowed!
}

Spring Framework Architecture & The @Transactional Default Trap

The Spring Framework was built in 2003 on the architectural principle of eliminating checked exceptions:

  • Standard JDBC throws checked java.sql.SQLException.
  • Spring translates all JDBC errors into an unchecked hierarchy: org.springframework.dao.DataAccessException (which extends RuntimeException).

The Classic @Transactional Rollback Trap

By default in Spring AOP, transactions only roll back automatically on RuntimeException and Error!

@Service
public class PaymentService {

    // ⚠️ CRITICAL INTERVIEW TRAP:
    // If externalBankService.transfer() throws a checked Exception,
    // THE TRANSACTION WILL NOT ROLL BACK! The account balance remains decremented!
    @Transactional
    public void executePayment() throws Exception {
        accountRepository.decreaseBalance();
        externalBankService.transfer(); // Throws checked Exception!
    }

    // ✅ Correct approach when handling Checked Exceptions in Spring:
    @Transactional(rollbackFor = Exception.class)
    public void safePayment() throws Exception {
        accountRepository.decreaseBalance();
        externalBankService.transfer();
    }
}

🔴 Senior Level

Under the Hood: Checked Exceptions Do Not Exist in JVM Bytecode

Inside the Java Virtual Machine (JVMS §6.5), there is zero distinction between checked and unchecked exceptions:

  • The athrow opcode takes whatever reference is on the operand stack (which must be an instance of java.lang.Throwable) and unwinds the call stack identically for all types.
  • The method’s Exception_table treats all Throwable types identically.
  • Checked exception validation is purely a static compile-time check performed by javac.

This is proven by “Sneaky Throws,” which leverages generic type erasure to throw checked exceptions without declaring throws:

public class SneakyThrowsExample {
    @SuppressWarnings("unchecked")
    public static <E extends Throwable> void sneakyThrow(Throwable e) throws E {
        throw (E) e; // Type E is erased to Throwable in bytecode!
    }

    public static void execute() {
        // Compiles cleanly, yet throws checked IOException at runtime without throws!
        sneakyThrow(new java.io.IOException("Direct from JVM bytecode"));
    }
}

Industry Precedent: Modern Language Design

Languages developed after Java analyzed the checked exception experiment and universally rejected it:

  • C#: Anders Hejlsberg (lead architect of C#) deliberately omitted checked exceptions, citing versioning and interface scalability failures.
  • Kotlin: Completely removed checked exceptions. All methods compile without throws.
  • Rust / Go / Swift: Replaced exception-based control flow with explicit typed return types (Result<T, E> in Rust, multiple return values (val, err) in Go).

Modern Java 21 Alternative: Sealed Result Interfaces

When an outcome is an expected, recoverable business scenario that requires compile-time verification, modern Java 21 applications favor sealed Result interfaces over checked exceptions:

public sealed interface TransferResult {
    record Success(String transactionId) implements TransferResult {}
    record InsufficientFunds(BigDecimal balance, BigDecimal required) implements TransferResult {}
    record AccountBlocked(String reason) implements TransferResult {}
}

public class TransferService {
    public TransferResult transfer(String from, String to, BigDecimal amount) {
        if (isBlocked(from)) {
            return new TransferResult.AccountBlocked("Card reported lost");
        }
        if (getBalance(from).compareTo(amount) < 0) {
            return new TransferResult.InsufficientFunds(getBalance(from), amount);
        }
        return new TransferResult.Success(UUID.randomUUID().toString());
    }
}

Advantages Over Checked Exceptions:

  1. Zero Stack Walking Overhead: No expensive native calls to fillInStackTrace().
  2. Compile-Time Exhaustiveness: In Java 21 switch expressions, the compiler guarantees that every branch is handled.
  3. Seamless FP Integration: Works cleanly inside Stream pipelines and CompletableFuture chains.

4 Tricky Questions

1. What happens if a checked Exception is thrown inside a method annotated with @Transactional without additional parameters?

Answer:
The transaction does not roll back. By default, Spring’s declarative transaction management rolls back transactions only upon encountering unchecked exceptions (RuntimeException and its subclasses) or fatal JVM errors (Error). Any unhandled checked Exception will allow the transaction to commit successfully. To trigger a rollback on checked exceptions, you must explicitly declare @Transactional(rollbackFor = Exception.class).

2. Do checked exceptions exist at the JVM bytecode level?

Answer:
No. The Java Virtual Machine specification makes no distinction between checked and unchecked exceptions. In bytecode, all exceptions are thrown using the identical athrow instruction, and caught using standard entries in the method’s Exception_table. The checked exception contract is strictly enforced by the javac compiler during the static analysis phase of compilation.

3. Why do checked exceptions cause friction with the Java Stream API?

Answer:
The standard functional interfaces in java.util.function (Function, Consumer, Predicate, Supplier) do not declare throws clauses in their abstract method signatures (e.g., R apply(T t)). If a method invoked inside a lambda throws a checked exception, it cannot escape the lambda, forcing developers to write cumbersome try-catch blocks inside the stream or wrap the failure into an unchecked RuntimeException.

4. When in a modern enterprise project does extending Exception still make architectural sense?

Answer:
Only when designing standalone public SDKs or low-level utility libraries without frameworks, where a failure is an expected external condition and the library author must strictly prevent downstream callers from forgetting to handle it (e.g., hardware driver communication, low-level network protocol handshakes). For application business logic and REST microservices, RuntimeException is the universal standard.


🎯 Interview Cheat Sheet

30-Second Summary

In modern Java, extend RuntimeException for 95%+ of custom exceptions (business domain errors, validation failures, REST APIs).

Why Checked Exceptions Are Avoided:

  1. Interface Pollution: They force throws declarations into interfaces, leaking implementation details (SQLException).
  2. Stream Incompatibility: They cannot be thrown inside Stream API lambdas without wrapping.
  3. The @Transactional Trap: By default, Spring @Transactional does not roll back on checked exceptions (only on RuntimeException and Error).

For recoverable domain alternatives in Java 21, use sealed Result interfaces instead of checked exceptions for zero-overhead, compile-time exhaustive branching.

Default Spring Rollback Behavior

  • RuntimeException $\to$ ✅ Rollback
  • Error $\to$ ✅ Rollback
  • Exception (Checked) $\to$ ❌ COMMIT (No Rollback) unless rollbackFor = Exception.class is set!

Red Flags (What to Avoid)

  • ❌ “Checked exceptions are more reliable, so all business exceptions should extend Exception.” (Pollutes signatures, breaks streams, and causes empty catch blocks).
  • ❌ “Spring automatically rolls back transactions on any exception.” (It only rolls back on RuntimeException and Error by default).
  • ❌ “Wrapping a checked exception into an unchecked exception without passing the cause.” (throw new RuntimeException(e.getMessage()); destroys the stack trace; use throw new RuntimeException(e);).
  • ❌ “Checked exceptions are slower in the JVM than unchecked exceptions.” (Both execute identical athrow bytecode instructions).