Checked exceptions extend Exception directly; the compiler forces handling.
Unchecked exceptions extend RuntimeException; the compiler leaves you alone.
The split exists to keep signal-to-noise ratio sane: external failures vs programming bugs.
Rule: Checked = realistic external failure caller must plan for. Unchecked = programmer error or contract violation.
Biggest mistake: wrapping a checked exception without preserving the cause — kills debugging.
Plain-English First
Imagine you're booking a flight. The airline knows there's a real chance your preferred seat might be taken, so they force you to acknowledge that before you even finish booking — that's a checked exception, the compiler forces you to deal with a problem that's genuinely likely. An unchecked exception is more like someone trying to divide a restaurant bill by zero people — that's a programming blunder, not something the system should force every caller to prepare for. Checked exceptions say 'this realistic problem will happen, plan for it.' Unchecked exceptions say 'you wrote something wrong, fix your code.'
Every Java application that touches the outside world — files, databases, networks, APIs — is one bad moment away from something going wrong. The file doesn't exist. The database is down. The network times out. Java's exception system is how your code communicates those failures, but not all failures are created equal. The language designers made a deliberate, architectural choice: split exceptions into two categories with very different rules, and that choice shapes how you design APIs, how you write business logic, and ultimately how maintainable your codebase is.
The problem checked exceptions solve is straightforward: when a method does something risky that the caller absolutely must prepare for, the compiler becomes your teammate and refuses to let you ship code that ignores that risk. Unchecked exceptions solve the opposite problem — if every method that might accidentally receive a null pointer forced every caller to write a try-catch block, Java code would be unreadable noise. The split exists to keep the signal-to-noise ratio sane.
By the end of this article you'll know the exact inheritance hierarchy that separates the two categories, why the language was designed this way, how to write your own custom exceptions correctly, and — most importantly — how to make the judgment call about which type to throw in your own APIs. You'll also see the most common mistakes developers make and how to sidestep them cleanly.
The Inheritance Tree That Controls Everything
Every exception in Java lives inside a class hierarchy, and your position in that tree determines whether the compiler watches you or leaves you alone.
At the top sits Throwable. It has two direct children: Error and Exception. Errors (like OutOfMemoryError) represent JVM-level catastrophes you can't reasonably recover from — ignore them for now. Everything we care about lives under Exception.
Here's the rule that governs everything: any class that extends Exception directly is a checked exception. Any class that extends RuntimeException — which itself extends Exception — is an unchecked exception.
That's the whole rule. There's no annotation, no keyword. It's purely about which class you extend.
RuntimeException was introduced because the designers recognised a class of bugs — null dereferences, bad array indices, illegal arguments — that are caused by programmer mistakes rather than environmental conditions. Wrapping those in try-catch blocks would punish correct code for the sins of incorrect code elsewhere. Checked exceptions are for recoverable, external conditions. Unchecked exceptions are for programming errors.
Keep this hierarchy in your head and every other rule falls out naturally.
ExceptionHierarchyDemo.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
publicclassExceptionHierarchyDemo {
publicstaticvoidmain(String[] args) {
// --- Checked Exception Example ---// IOException extends Exception directly, so the compiler// FORCES us to handle or declare it. Removing the try-catch// causes a compile error: "unreported exception IOException".try {
readConfigFile("/etc/app/config.properties");
} catch (java.io.IOException ioException) {
// We handle the realistic possibility that the file isn't thereSystem.out.println("Config file problem: " + ioException.getMessage());
}
// --- Unchecked Exception Example ---// NumberFormatException extends RuntimeException, so no compile// error if we skip the try-catch. The compiler trusts us.// But at runtime, passing a bad string will blow up here.String rawUserInput = "42"; // pretend this came from a form field
int parsedAge = Integer.parseInt(rawUserInput); // safe with "42"System.out.println("Parsed age: " + parsedAge);
// Now intentionally cause an unchecked exception to show the outputString corruptInput = "forty-two";
try {
int badParse = Integer.parseInt(corruptInput);
} catch (NumberFormatException numberFormatException) {
// NumberFormatException is unchecked — we only catch it// at the boundary where raw input enters our systemSystem.out.println("Bad input caught at boundary: " + numberFormatException.getMessage());
}
// Demonstrate the hierarchy programmaticallyNumberFormatException nfe = newNumberFormatException("demo");
System.out.println("IsRuntimeException? " + (nfe instanceof RuntimeException)); // trueSystem.out.println("IsException? " + (nfe instanceof Exception)); // true
java.io.IOException ioe = new java.io.IOException("demo");
System.out.println("IOException is RuntimeException? " + (ioe instanceof RuntimeException)); // falseSystem.out.println("IOException is Exception? " + (ioe instanceof Exception)); // true
}
// 'throws IOException' is mandatory — omitting it is a compile errorstaticvoidreadConfigFile(String filePath) throws java.io.IOException {
java.nio.file.Files.readAllBytes(java.nio.file.Paths.get(filePath));
}
}
Output
Config file problem: /etc/app/config.properties (No such file or directory)
Parsed age: 42
Bad input caught at boundary: For input string: "forty-two"
Is RuntimeException? true
Is Exception? true
iIOException is RuntimeException? false
IOException is Exception? true
The One-Line Rule:
Extends Exception directly → checked (compiler enforces handling). Extends RuntimeException → unchecked (compiler stays silent). That's the entire distinction. Everything else is a consequence of this.
Production Insight
I've seen teams confuse unchecked vs checked when using Spring's DataAccessException hierarchy — it's all unchecked, but some developers still add throws clauses.
The real cost: when you mark a method as throws a checked exception that never actually occurs, you create unnecessary coupling. Every caller must handle it.
Rule: if callers can't productively respond to the exception, make it unchecked.
IfFailure is environmental (file missing, network down, DB unavailable)
→
UseUse checked exception — the caller should plan for it.
IfFailure is a programming bug (null arg, invalid state, illegal index)
→
UseUse unchecked exception — fix the code, don't catch.
IfFailure can be handled by the immediate caller with alternate logic
→
UseUse checked exception to force handling.
IfFailure cannot be meaningfully handled by any layer (e.g., config error)
→
UseUse unchecked exception — propagate to global handler for logging.
Writing Custom Exceptions That Actually Communicate Intent
Throwing Exception or RuntimeException directly is the exception equivalent of logging 'something went wrong'. Custom exceptions are how you make failures self-documenting.
The decision of which to extend is a design contract. When you extend Exception, you're telling every caller: 'this failure mode is realistic and environmental — you need to have a plan.' A PaymentGatewayException should be checked because a payment gateway being unreachable is a real-world condition your caller must handle gracefully.
When you extend RuntimeException, you're saying: 'this is a programming contract violation — if you use my API correctly, this never fires.' An InvalidOrderStateException for a state machine, where transitioning from SHIPPED back to PENDING is logically impossible, belongs as unchecked. It means the calling code has a bug.
A practical pattern: create a checked base exception for your domain (e.g., InventoryException) and let specific subtypes inherit it. This lets callers catch broadly when they need to, or narrowly when they can recover from specific cases. Always provide a constructor that accepts a cause parameter — wrapping lower-level exceptions preserves the stack trace and is critical for debugging production issues.
CustomExceptionDesign.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
// ── Checked: realistic external failure the caller must plan for ──classPaymentGatewayExceptionextendsException {
privatefinalint httpStatusCode;
publicPaymentGatewayException(String message, int httpStatusCode) {
super(message);
this.httpStatusCode = httpStatusCode;
}
// Always include a cause constructor — wrapping preserves stack tracespublicPaymentGatewayException(String message, int httpStatusCode, Throwable cause) {
super(message, cause); // passes the original exception up the chainthis.httpStatusCode = httpStatusCode;
}
publicintgetHttpStatusCode() {
return httpStatusCode;
}
}
// ── Unchecked: programming contract violation, not environmental failure ──classInvalidOrderStateExceptionextendsRuntimeException {
privatefinalString fromState;
privatefinalString toState;
publicInvalidOrderStateException(String fromState, String toState) {
super(String.format(
"Illegal state transition: cannot move order from [%s] to [%s]",
fromState, toState
));
this.fromState = fromState;
this.toState = toState;
}
publicStringgetFromState() { return fromState; }
publicStringgetToState() { return toState; }
}
// ── Service that uses both ──classOrderService {
enumOrderStatus { PENDING, CONFIRMED, SHIPPED, DELIVERED }
// 'throws PaymentGatewayException' is REQUIRED by the compiler// because PaymentGatewayException is checked. This is the contract// telling every caller: handle the gateway being unavailable.publicvoidchargeCustomer(String customerId, double amountInDollars)
throwsPaymentGatewayException {
boolean gatewayReachable = simulateGatewayCall();
if (!gatewayReachable) {
// Wrap the low-level HTTP detail with a meaningful domain exceptionthrownewPaymentGatewayException(
"Payment gateway timed out for customer: " + customerId,
504
);
}
System.out.println("Charged $" + amountInDollars + " to customer " + customerId);
}
// No 'throws' declaration needed — unchecked exceptions are silent contractspublicvoidtransitionOrderStatus(OrderStatus current, OrderStatus next) {
// SHIPPED → PENDING is a programmer error, not an environmental conditionif (current == OrderStatus.SHIPPED && next == OrderStatus.PENDING) {
thrownewInvalidOrderStateException(current.name(), next.name());
}
System.out.println("Order moved: " + current + " → " + next);
}
privatebooleansimulateGatewayCall() {
return false; // simulating an unreachable gateway
}
}
publicclassCustomExceptionDesign {
publicstaticvoidmain(String[] args) {
OrderService orderService = newOrderService();
// Checked exception — compiler REQUIRES this try-catchtry {
orderService.chargeCustomer("CUST-9912", 149.99);
} catch (PaymentGatewayException paymentException) {
// Caller handles the recoverable failure: maybe retry, or alert the userSystem.out.println("Payment failed (HTTP " + paymentException.getHttpStatusCode()
+ "): " + paymentException.getMessage());
System.out.println("Queuing payment for retry...");
}
// Unchecked exception — no try-catch required by compiler// Only catch it at your application's outermost error boundary
orderService.transitionOrderStatus(
OrderService.OrderStatus.CONFIRMED,
OrderService.OrderStatus.SHIPPED
);
// This next line would throw InvalidOrderStateException at runtime// because SHIPPED → PENDING is a logic bug in the calling codetry {
orderService.transitionOrderStatus(
OrderService.OrderStatus.SHIPPED,
OrderService.OrderStatus.PENDING
);
} catch (InvalidOrderStateException stateException) {
System.out.println("Bug caught: " + stateException.getMessage());
}
}
}
Output
Payment failed (HTTP 504): Payment gateway timed out for customer: CUST-9912
Queuing payment for retry...
Order moved: CONFIRMED → SHIPPED
Bug caught: Illegal state transition: cannot move order from [SHIPPED] to [PENDING]
Pro Tip:
Always add a constructor that accepts a Throwable cause to your custom exceptions — even if you don't use it today. Wrapping a low-level SQLException inside your RepositoryException without the cause discards the original stack trace, making production debugging nearly impossible.
Production Insight
In one production incident, a team created a checked FileProcessingException but forgot to chain the cause. When an IOException occurred inside, the log showed only their custom message, but the original 'File not found' line number was lost.
Debugging took 2 hours because the wrapper exception pointed at the wrong line in the catch block.
Always, always chain the cause.
Key Takeaway
Custom exceptions are documentation.
A checked exception says 'plan for this'.
A runtime exception says 'your code has a bug'.
Custom Exception Design Decision Tree
IfWill callers need to catch this and take alternate action?
→
UseMake it checked. Extend Exception directly.
IfDoes this exception represent a bug in client code?
→
UseMake it unchecked. Extend RuntimeException.
IfAre you exposing internal implementation details?
→
UseWrap the infrastructure exception in an unchecked domain exception.
The Real-World Pattern: Where Each Exception Type Belongs
Knowing the definition is one thing. Knowing where to put each type in a layered application is what separates a junior from a mid-level engineer.
In a typical web application you have an infrastructure layer (database, HTTP clients, file I/O), a service layer (business logic), and a presentation layer (controllers, API endpoints). Checked exceptions are native to the infrastructure layer — SQLException, IOException, SSLException. These are environmental realities.
Here's the key pattern: you should almost always catch the checked infrastructure exception at the boundary between infrastructure and service layers and wrap it in an unchecked domain exception before rethrowing. Why? Because your service layer shouldn't be coupled to java.sql.SQLException. It should speak domain language. And if you force every service method to throws SQLException, that implementation detail leaks all the way up to your controller.
Modern frameworks like Spring lean heavily on this — Spring Data wraps SQLExceptions into unchecked DataAccessExceptions precisely so your business logic stays clean. This wrapping pattern also means the original cause is preserved for your logs, while callers aren't burdened with handling infrastructure concerns they can't meaningfully recover from anyway.
Use checked exceptions when your direct caller can realistically take a different action based on the failure. Use unchecked when the failure means 'the code calling me has a bug' or 'no caller can meaningfully recover from this.'
LayeredExceptionPattern.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
import java.sql.*;
// ── Domain-level unchecked exception — service layer speaks in domain terms ──classUserRepositoryExceptionextendsRuntimeException {
publicUserRepositoryException(String message, Throwable cause) {
super(message, cause); // ALWAYS preserve the cause for stack trace logging
}
}
classUserNotFoundExceptionextendsRuntimeException {
privatefinallong userId;
publicUserNotFoundException(long userId) {
super("No user found with ID: " + userId);
this.userId = userId;
}
publiclonggetUserId() { return userId; }
}
// ── Infrastructure layer: wraps checked JDBC exceptions into unchecked domain ones ──classUserRepository {
privatefinalConnection databaseConnection;
publicUserRepository(Connection databaseConnection) {
this.databaseConnection = databaseConnection;
}
publicStringfindUsernameById(long userId) {
String query = "SELECT username FROM users WHERE id = ?";
try {
PreparedStatement statement = databaseConnection.prepareStatement(query);
statement.setLong(1, userId);
ResultSet results = statement.executeQuery();
if (!results.next()) {
// Business rule violation — unchecked, the caller should validate inputthrownewUserNotFoundException(userId);
}
return results.getString("username");
} catch (SQLException sqlException) {
// KEY PATTERN: catch the infrastructure checked exception here,// wrap it in our domain unchecked exception, preserve the cause.// The service layer never sees SQLException — it stays decoupled.thrownewUserRepositoryException(
"Database error while fetching user ID: " + userId,
sqlException // <-- cause preserved for logging
);
}
}
}
// ── Service layer: clean business logic, no SQL concerns ──classUserProfileService {
privatefinalUserRepository userRepository;
publicUserProfileService(UserRepository userRepository) {
this.userRepository = userRepository;
}
// Notice: no 'throws' clause. The service layer is clean.// If UserRepositoryException propagates, the controller's global// error handler catches it and returns a 500. Clean separation.publicStringbuildWelcomeMessage(long userId) {
String username = userRepository.findUsernameById(userId); // no try-catch neededreturn"Welcome back, " + username + "!";
}
}
// ── Presentation layer: only catches what it can meaningfully respond to ──classUserController {
privatefinalUserProfileService profileService;
publicUserController(UserProfileService profileService) {
this.profileService = profileService;
}
publicStringhandleGetProfile(long userId) {
try {
return profileService.buildWelcomeMessage(userId);
} catch (UserNotFoundException notFoundException) {
// Caught specifically — return a 404 responsereturn"404: User " + notFoundException.getUserId() + " not found";
}
// UserRepositoryException (database failure) is NOT caught here.// It propagates to the framework's global exception handler → 500 response.// That's the correct behaviour: the controller can't fix a database outage.
}
}
publicclassLayeredExceptionPattern {
publicstaticvoidmain(String[] args) throwsException {
// Simulate the lookup of a non-existent user (no real DB needed)// We'll mock the behaviour directly to show the flow// Simulating UserNotFoundException pathUserController controller = buildMockController(false);
String response = controller.handleGetProfile(99L);
System.out.println("Controller response: " + response);
}
// Creates a controller backed by a fake repository for demo purposesstaticUserControllerbuildMockController(boolean userExists) {
UserRepository mockRepo = newUserRepository(null) {
@OverridepublicStringfindUsernameById(long userId) {
if (!userExists) {
thrownewUserNotFoundException(userId);
}
return"alice";
}
};
returnnewUserController(newUserProfileService(mockRepo));
}
}
Output
Controller response: 404: User 99 not found
Watch Out:
Never let SQLException or IOException leak through your service layer interface. The moment your UserService.findById() signature says throws SQLException, your business logic is coupled to your database driver — a change of DB technology means rewriting every caller. Wrap and rethrow as unchecked domain exceptions at the repository boundary.
Production Insight
Teams using Spring Data often forget that DataAccessException is unchecked. I've seen controllers with try-catch for DataAccessException that just log and return 500 — that's fine, but it's redundant because the global handler would do the same.
The real risk is the opposite: ignoring DataAccessException thinking it's checked. Spring won't force you to handle it, and if you don't, your transaction might roll back but the user sees a 200 with missing data.
Rule: always have a global exception handler for unhandled runtime exceptions.
Key Takeaway
Infrastructure exceptions stay in infrastructure.
Wrap them at the boundary.
Your business logic should never see SQLException.
Common Mistakes That Trip Up Intermediate Developers
Even developers who understand the theory make these mistakes under pressure. Here are the three that cause the most damage in real codebases.
Swallowing exceptions is the silent killer. An empty catch block turns a detectable failure into a ghost — the system behaves wrongly with no evidence of why. If you genuinely can't handle an exception, log it and rethrow, or convert it to an unchecked exception. Never leave a catch block empty in production code.
Exception pollution is the checked-exception version of the problem. When a method deep in the stack throws a checked exception, inexperienced developers propagate it up every method signature rather than wrapping it. You end up with controllers declaring throws SQLException — a leaky abstraction that defeats the whole layered architecture.
Catching Exception or Throwable too broadly masks completely different failure modes under one handler. Catching Exception to log-and-continue will silently swallow NullPointerException from your own bugs alongside the IOException you intended to catch. Catch the most specific type you can act on.
ExceptionMistakesAndFixes.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
import java.io.*;
import java.util.logging.*;
publicclassExceptionMistakesAndFixes {
privatestaticfinalLogger logger =
Logger.getLogger(ExceptionMistakesAndFixes.class.getName());
// ════════════════════════════════════// MISTAKE 1: Swallowing the exception// ════════════════════════════════════staticvoidbadReadFile(String filePath) {
try {
newFileInputStream(filePath);
} catch (FileNotFoundException e) {
// ❌ WRONG: empty catch block. The failure disappears.// The caller gets null behaviour with zero explanation.
}
}
staticvoidgoodReadFile(String filePath) throwsFileNotFoundException {
try {
newFileInputStream(filePath);
} catch (FileNotFoundException fileNotFoundException) {
// ✅ RIGHT: log it, then either handle it or rethrow
logger.severe("Cannot open config file at: " + filePath);
throw fileNotFoundException; // rethrow so the caller knows
}
}
// ═══════════════════════════════════════════════// MISTAKE 2: Catching Exception too broadly// ═══════════════════════════════════════════════staticvoidbadBroadCatch(String[] items, int index, String filePath) {
try {
String item = items[index]; // might throw ArrayIndexOutOfBoundsException
new FileInputStream(filePath); // might throw FileNotFoundExceptionInteger.parseInt(item); // might throw NumberFormatException
} catch (Exception e) {
// ❌ WRONG: all three failures look identical here.// A bug in your index logic is hidden alongside a missing file.System.out.println("Something failed: " + e.getMessage());
}
}
staticvoidgoodSpecificCatch(String[] items, int index, String filePath) {
// ✅ RIGHT: handle each failure mode where you can act on it specificallyif (index < 0 || index >= items.length) {
// Validate input before use — don't rely on catching your own bugthrownewIllegalArgumentException(
"Index " + index + " is out of range for array of size " + items.length
);
}
String item = items[index];
try {
newFileInputStream(filePath);
} catch (FileNotFoundException fileNotFoundException) {
logger.warning("File not found: " + filePath);
// Handle the file-missing case specifically — maybe use a default
}
try {
Integer.parseInt(item);
} catch (NumberFormatException numberFormatException) {
logger.warning("Item '" + item + "' is not a valid integer");
// Handle the bad-format case specifically
}
}
// ═══════════════════════════════════════════════════════// MISTAKE 3: Losing the original cause when wrapping// ═══════════════════════════════════════════════════════staticclassDataLoadExceptionextendsRuntimeException {
publicDataLoadException(String message, Throwable cause) {
super(message, cause);
}
}
staticvoidbadWrap(String filePath) {
try {
newFileInputStream(filePath);
} catch (FileNotFoundException fileNotFoundException) {
// ❌ WRONG: the original FileNotFoundException and its stack// trace are thrown away. In production logs, you'll only see// DataLoadException with no trace back to the root cause.thrownewDataLoadException("Could not load data", null);
}
}
staticvoidgoodWrap(String filePath) {
try {
newFileInputStream(filePath);
} catch (FileNotFoundException fileNotFoundException) {
// ✅ RIGHT: pass the original exception as the cause.// Both exceptions appear in the stack trace.thrownewDataLoadException(
"Could not load data from: " + filePath,
fileNotFoundException // <-- cause preserved
);
}
}
publicstaticvoidmain(String[] args) {
// Demonstrate the cause-preservation differencetry {
goodWrap("/no/such/file.csv");
} catch (DataLoadException dataLoadException) {
System.out.println("Top-level message: " + dataLoadException.getMessage());
System.out.println("Root cause: " + dataLoadException.getCause().getMessage());
}
}
}
Output
Top-level message: Could not load data from: /no/such/file.csv
Root cause: /no/such/file.csv (No such file or directory)
The Golden Rule:
If you catch it and can't fully handle it, you must either log-and-rethrow, or wrap-and-rethrow with the original as the cause. A catch block that just prints a message and lets execution continue is almost always a bug waiting to surface in the worst possible moment.
Production Insight
Empty catch blocks are the most common code smell in legacy systems. I've triaged incidents where a FileNotFoundException was swallowed during a config load, and the app ran with default settings for weeks — until a critical feature relied on a config that silently defaulted to null.
The fix was always: never catch unless you can do something about it. If you can't, rethrow (possibly wrapped).
The second most damaging mistake is catching Exception too broadly — it turns NullPointerException into a harmless log line while the system continues with corrupted state.
Key Takeaway
Empty catch blocks are ghost bugs.
Catch broadly and you mask programming errors.
Always rethrow or wrap with cause.
Checked vs Unchecked in Modern Java Frameworks: Why Spring Prefers Unchecked
If you look at modern Java frameworks like Spring Boot, you'll notice they almost never throw checked exceptions. Spring's DataAccessException is unchecked. JPA's EntityNotFoundException is unchecked. Even the @Transactional annotation doesn't force you to handle commit failures at each call site.
This isn't an accident. The framework designers made a deliberate choice: most failures that originate from infrastructure are not recoverable at the point where they occur. If the database is down, what is a controller supposed to do? Retrying might help, but that should be handled at the repository or service level, not forced on every endpoint.
Checked exceptions make sense when the caller can actually react in a different way — like choosing a different file path, or skipping a non-critical service. But in a typical web application, the caller (controller) cannot fix a database outage or a broken network. So forcing it to catch SQLException or IOException is just boilerplate that obscures the actual business logic.
That's why modern best practice leans heavily toward unchecked exceptions for most application-level use cases. Checked exceptions are reserved for API boundaries where the caller is a different team or system, and where the failure mode is both predictable and recoverable.
SpringExceptionPattern.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Repository;
import org.springframework.web.bind.annotation.*;
// ── Spring Repository: throws unchecked DataAccessException ──
@RepositoryclassUserRepository {
publicStringfindUsernameById(Long userId) {
// Spring Data JPA would wrap SQLExceptions into DataAccessException// Here we simulate it:if (userId == null || userId < 1) {
throw new IllegalArgumentException("userId must be positive"); // unchecked, programming bug
}
// Simulating a database failure:
boolean databaseDown = Math.random() < 0.1; // 10% chanceif (databaseDown) {
throw new DataAccessException("Cannot connect to database") {}; // unchecked
}
// Simulating user not found:if (userId > 1000) {
throw new jakarta.persistence.EntityNotFoundException("User not found with id: " + userId); // unchecked
}
return"alice" + userId;
}
}
// ── Controller: no checked exception handling needed ──
@RestControllerclassUserController {
privatefinalUserRepository repository;
publicUserController(UserRepository repository) {
this.repository = repository;
}
@GetMapping("/users/{id}/username")
publicStringgetUsername(@PathVariableLong id) {
// No try-catch for DataAccessException or EntityNotFoundException// Spring's global handler (@ControllerAdvice) will handle themreturn repository.findUsernameById(id);
}
// But we CAN optionally catch specific unchecked exceptions if we want to// @ExceptionHandler(EntityNotFoundException.class)// public ResponseEntity<String> handleNotFound(EntityNotFoundException e) {// return ResponseEntity.status(404).body(e.getMessage());// }
}
Output
No fixed output — depends on random condition. On database failure, controller returns 500 with error message; on missing user, returns 500 unless @ExceptionHandler is added.
Framework Design Insight:
Spring could have made DataAccessException checked. But the team understood that forcing every repository client to catch it would create massive boilerplate for no benefit — 99% of callers can't do anything useful with a database connection failure. That's why they chose unchecked.
Production Insight
Teams new to Spring often add 'throws DataAccessException' to their service methods because they're used to checked exceptions. That works but is pointless — the throws clause is meaningless for runtime exceptions.
Worse, some teams catch DataAccessException in every controller method and log it, thinking they're being thorough. They're just duplicating the global exception handler.
Rule: if you're using a framework that uses unchecked exceptions, trust the global handler. Add controller-specific handling only when you need custom HTTP response codes.
Key Takeaway
Modern frameworks prefer unchecked for a reason.
Most callers can't fix infrastructure failures.
Reserve checked exceptions for truly recoverable, caller-actionable failures.
● Production incidentPOST-MORTEMseverity: high
The Silent Payment Failure: When a Checked Exception Was Swallowed
Symptom
Customers reported duplicate charges. The payment logs showed no errors — just successful transactions with no corresponding orders.
Assumption
The team assumed the payment gateway's response was always valid; the IOException catch block was meant for 'rare network glitches' and was left empty.
Root cause
A checked IOException from the HTTP client was caught with an empty catch block. The exception never reached any monitoring, and the code continued as if the payment succeeded. The second charge came from an automatic retry because the first response was never captured.
Fix
Replace the empty catch block with a logging statement and rethrow a custom unchecked PaymentException with the original cause. Add a global exception handler to catch all PaymentExceptions and roll back the transaction. Also add a health check on the gateway connection before charging.
Key lesson
A catch block without at least a log is a bug. Period.
Checked exceptions signal recoverable failures — ignoring them is not recovery.
Always preserve the original exception as the cause when wrapping.
Production debug guideSymptom -> Action guide to fix the most common exception handling defects4 entries
Symptom · 01
Application logs show 'Exception caught' but no stack trace
→
Fix
Check for empty catch blocks or catch (Exception e) { e.getMessage() } without logging. Add a logger.error("message", e) with the exception as second argument.
Symptom · 02
Service method signature declares throws SQLException, leaking database details
→
Fix
Catch SQLException at the repository boundary, wrap in an unchecked domain exception (e.g., DataAccessException) with the original cause, and remove SQLException from the service signature.
Symptom · 03
NullPointerException is caught broadly and logged as 'something went wrong'
→
Fix
Don't catch NullPointerException at all — fix the code that caused it. Use Optional or explicit null checks. Only catch at the very top of the request pipeline for a generic 500 response.
Symptom · 04
Custom exception is thrown but the original cause is lost in logs
→
Fix
Verify custom exception constructors always take Throwable cause and pass it to super(message, cause). grep for 'new MyException("message", null)' or single-argument constructors.
★ Exception Handling Quick Debug Cheat SheetRun these commands and checks when an exception handling bug surfaces in production
Checked exception silently ignored−
Immediate action
Check catch blocks for empty or non-logging code. Use grep on the catch clause to find 'catch.*{}$' or 'catch.*
$'.
Add logging: e.printStackTrace() or logger.error("error", e) — never empty.
Fix now
Replace empty catch with: logger.error("Failed to ...", e); throw new RuntimeException(e);
Leaking implementation exception (e.g., SQLException) in service layer+
Immediate action
Check method signatures for throws clauses that include JDBC or IO exceptions. Search for 'throws SQLException' or 'throws IOException' in service interfaces.
Appears in 'throws' clause — part of the public API
Invisible in signature — implicit contract
Where to catch
Wherever you can take meaningful recovery action
At system boundaries (global error handlers)
Custom exception pattern
Extend Exception; use for recoverable domain failures
Extend RuntimeException; use for API contract violations
Spring framework approach
Rare — Spring wraps most into unchecked
Preferred — DataAccessException hierarchy is all unchecked
Best practice in 2026
Use sparingly, only when caller can take alternative action
Preferred for most application code; handle via global handlers
Key takeaways
1
The entire distinction is in the inheritance tree
extends Exception directly = checked (compiler enforces it); extends RuntimeException = unchecked (compiler ignores it). No other magic.
2
Checked exceptions are a compiler-enforced contract saying 'this realistic external failure will happen
you must have a plan.' Unchecked exceptions say 'your code has a bug — fix it, don't catch it.'
3
Wrap checked infrastructure exceptions (SQLException, IOException) into unchecked domain exceptions at your layer boundaries
this keeps business logic clean and prevents implementation details from leaking through your API signatures.
4
Always pass the original exception as the 'cause' when wrapping
losing the cause makes production debugging brutally difficult because the root stack trace is discarded forever.
5
Modern frameworks like Spring prefer unchecked exceptions for most cases because callers can't meaningfully recover from infrastructure failures at the point of call.
Common mistakes to avoid
5 patterns
×
Swallowing exceptions with empty catch blocks
Symptom
The program appears to run but produces wrong results silently, with no log entry and no stack trace to debug from
Fix
At minimum add a logger.error() call; either rethrow the exception or wrap it in an unchecked exception before rethrowing. Never leave a catch block empty.
×
Propagating checked exceptions across architectural boundaries
Symptom
Your service method ends up with 'throws SQLException', coupling your business logic to your database driver and leaking infrastructure details through every layer
Fix
Catch the checked infrastructure exception at the repository/DAO layer, wrap it in an unchecked domain exception (passing the original as the cause), and let that propagate instead.
×
Losing the original cause when wrapping exceptions
Symptom
You throw 'new MyException(message, null)' instead of passing the caught exception as the cause — your production logs show the wrapper message but the original stack trace pointing to the real failure line is gone forever
Fix
Always pass the caught exception as the second argument: 'new MyException(message, originalException)'.
×
Catching Exception or Throwable too broadly
Symptom
A catch(Exception e) block silently swallows NullPointerException from your own bugs alongside the IOException you intended to catch, masking programming errors
Fix
Catch the most specific exception type you can act on. Use multi-catch for unrelated exception types: catch (IOException | SQLException e).
×
Using checked exceptions for programming contract violations
Symptom
A method that validates input throws a checked InvalidInputException, forcing every caller to handle what is fundamentally a bug in the calling code
Fix
Use IllegalArgumentException or a custom unchecked exception for input validation failures. Reserve checked exceptions for truly external, recoverable failures.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01JUNIOR
Can you explain the difference between checked and unchecked exceptions ...
Q02SENIOR
If you're building a repository layer that calls JDBC and throws SQLExce...
Q03SENIOR
RuntimeException is a subclass of Exception, so why doesn't catching Exc...
Q04SENIOR
When would you choose to create a custom checked exception vs a custom u...
Q01 of 04JUNIOR
Can you explain the difference between checked and unchecked exceptions in Java, and give a design reason why both exist rather than just having one type?
ANSWER
Checked exceptions extend Exception directly and the compiler enforces handling or declaration. Unchecked exceptions extend RuntimeException — the compiler stays silent. Both exist to keep the signal-to-noise ratio sane. If every possible error (including null pointer dereferences) required a try-catch, code would be unreadable. Checked exceptions are for realistic external failures that a caller can plan for. Unchecked exceptions are for programming mistakes that should be fixed, not caught. The language designers deliberately split them so that callers aren't forced to handle issues they can't meaningfully recover from.
Q02 of 04SENIOR
If you're building a repository layer that calls JDBC and throws SQLException, would you expose that checked exception in your service layer interface? Why or why not — and how would you handle it?
ANSWER
No, you should never expose SQLException in your service layer. It couples your business logic to your database driver and forces every consumer to handle a low-level detail they can't do anything about. Instead, catch SQLException at the repository boundary and wrap it in an unchecked domain exception (e.g., DataAccessException) preserving the original cause. This keeps your service layer clean and allows a global exception handler to return appropriate HTTP responses.
Example: catch (SQLException e) { throw new RepositoryException("Failed to fetch user", e); }
Q03 of 04SENIOR
RuntimeException is a subclass of Exception, so why doesn't catching Exception also force you to catch RuntimeException — and does catching Exception actually catch unchecked exceptions at runtime?
ANSWER
RuntimeException is a subclass of Exception, so catching Exception at runtime DOES catch unchecked exceptions — that's why catching Exception broadly is dangerous. The compiler doesn't force you to catch RuntimeException because that would defeat the purpose of having unchecked exceptions. The distinction is compile-time only: checked exceptions must be handled or declared; unchecked exceptions are not checked at compile time. At runtime, they are all just Exception instances. That's why always catching the most specific type is critical.
Q04 of 04SENIOR
When would you choose to create a custom checked exception vs a custom unchecked exception? Give a concrete example of each.
ANSWER
Use a checked exception when the failure is environmental and the caller can take meaningful alternative action. Example: PaymentGatewayException — if the payment gateway is unreachable, the caller can retry or switch to a fallback payment method. Use an unchecked exception when the failure represents a programming contract violation. Example: InvalidOrderStateException — transitioning a shipped order back to pending is a code bug, not an environmental condition. The caller should fix the code, not catch the exception. When in doubt, modern Java best practice leans toward unchecked exceptions with global error handlers.
01
Can you explain the difference between checked and unchecked exceptions in Java, and give a design reason why both exist rather than just having one type?
JUNIOR
02
If you're building a repository layer that calls JDBC and throws SQLException, would you expose that checked exception in your service layer interface? Why or why not — and how would you handle it?
SENIOR
03
RuntimeException is a subclass of Exception, so why doesn't catching Exception also force you to catch RuntimeException — and does catching Exception actually catch unchecked exceptions at runtime?
SENIOR
04
When would you choose to create a custom checked exception vs a custom unchecked exception? Give a concrete example of each.
SENIOR
FAQ · 5 QUESTIONS
Frequently Asked Questions
01
Can you convert a checked exception to an unchecked exception in Java?
Yes — and this is a common, recommended pattern. Catch the checked exception and rethrow it wrapped inside a class that extends RuntimeException, passing the original as the cause constructor argument. This is exactly what Spring does with SQLException → DataAccessException. The key is to always pass the original exception as the cause so the full stack trace is preserved in your logs.
Was this helpful?
02
Should I use checked or unchecked exceptions for my custom business exceptions?
Use checked exceptions when the failure is environmental and the direct caller can take a meaningful alternative action — for example, a payment gateway being unreachable. Use unchecked exceptions when the failure represents a programming contract violation — for example, passing a null order ID to a method that explicitly requires one. When in doubt, modern Java practice (and frameworks like Spring) leans toward unchecked to avoid polluting method signatures.
Was this helpful?
03
Does catching Exception catch both checked and unchecked exceptions?
Yes — at runtime, catching Exception catches everything except Errors, because RuntimeException is a subclass of Exception. This is precisely why catching Exception broadly is dangerous: you'll accidentally swallow NullPointerExceptions and other programming bugs alongside the specific checked exception you intended to handle. Always catch the most specific exception type you can meaningfully act on.
Was this helpful?
04
Why do most Spring framework exceptions extend RuntimeException?
Because Spring's designers recognised that at the point where infrastructure failures occur (database, network, file I/O), the immediate caller — usually a controller or service — cannot take meaningful recovery action. Forcing every caller to catch and handle SQLException would create massive boilerplate with no benefit. Using unchecked exceptions allows the framework to provide a global exception handler (@ControllerAdvice) that centralises error handling, keeping business logic clean.
Was this helpful?
05
What is the difference between throws and throw in relation to checked exceptions?
'throws' is used in a method signature to declare that the method might throw a checked exception. It is mandatory for checked exceptions that are not caught inside the method. 'throw' is the actual statement that throws an exception instance. For checked exceptions, you must either catch them (try-catch) or declare them (throws). For unchecked exceptions, neither is required, though you may still use throws for documentation purposes (compiler ignores it).