Exception handling in production: the complete guide
Everything you need to handle failures in a real Java service: the basics done right, eight habits, classifying errors by who can fix them, mapping them to HTTP at one boundary, safe retries with backoff and jitter, circuit breakers, async and transactional edge cases, testing, and how to explain it all in an interview.
On this page
🛡️ What is production-grade exception handling?
It's the set of decisions that turn "something threw" into the right outcome for everyone involved: the caller gets a clear answer, transient failures are retried safely, bugs reach an engineer with the full story, and nothing is lost silently. In code it's ordinary try, catch and throw. In production it's the difference between one clean 503 with Retry-After: 5 and a retry storm that turns a 30-second blip into a 40-minute outage.
Interviewers love this topic because it sits right where syntax meets judgement. Anyone can write a catch block. Fewer people can say which layer should catch, whether to retry, what status code the client sees, what gets logged and how often, and what happens to the interrupt flag. Every one of those questions has an incident behind it, so your answers tell the interviewer very quickly whether you've owned a service on call.
What you’ll learn
- Use
throw,try,catch,finally, multi-catch, cause chains and suppressed exceptions correctly, and explain where eachThrowablebranch belongs - Apply the eight production habits: never swallow silently, catch specific types, pass the exception to the logger, log or rethrow (not both), restore the interrupt flag, and close with try-with-resources
- Classify any failure by who can fix it (the caller, time, or an engineer) and map it to an HTTP status at a single boundary with
ProblemDetail - Design safe retries: in-process versus deferred, exponential backoff with full jitter, idempotency keys,
Retry-After, and Resilience4jRetry - Combine timeouts, retries, circuit breakers, bulkheads and fallbacks without amplifying an outage
- Handle exceptions in
Future,CompletableFuture, virtual threads and Spring@Transactionalcode, and test that errors are translated, preserved and not over-retried - Explain the cost of an exception (stack capture is linear in depth, roughly microseconds) and give a confident interview talk track
For this lesson, we'll build the whole picture in one place: a fast refresher on the mechanics, eight habits that keep production quiet, a way to classify any failure by who can fix it, translating exceptions to HTTP at one boundary, retries done properly, the resilience patterns around them, and the edge cases senior rounds probe (async code, transactions, finally, testing, cost). We'll keep it generic Java 21 and Spring-style code you can use anywhere.
Below the line (out of scope)
- The bytecode, exception table and exact
finallyrules: How exceptions work in Java - The compiler's checked-exception rules and their history: Checked vs unchecked exceptions
- Close order and suppressed exceptions in depth: try-with-resources
- Designing a full domain exception hierarchy: Custom exceptions and best practices
The 30-second answer
I classify every failure by who can fix it. If the caller can (bad input, not found, a broken business rule), I return a 4xx and never retry. If time can (a timeout, a reset connection, a 503), I retry a few times with exponential backoff and jitter, but only for idempotent operations, then return 503 with Retry-After. If only an engineer can (a null pointer, an illegal state), I fail loudly with a 500 and an alert. Low layers translate low-level exceptions and always keep the cause; one handler at the edge maps types to status codes and logs once. I never swallow an exception silently, never catch Error, always restore the interrupt flag, close resources with try-with-resources, and put timeouts and a circuit breaker around every remote call so retries can't amplify an outage.
Interview radar
Asked very often: "What's the difference between checked and unchecked exceptions?", "Why pass the cause?", "What do you do with InterruptedException?" and "When would you retry?"
Senior rounds probe: who can fix a failure, where you log, how exceptions become HTTP responses, backoff with jitter, idempotency, circuit breakers, and errors inside CompletableFuture or @Transactional code.
Production angle: retry storms, a swallowed exception that hid data loss, one timeout logged five times, and a connection pool drained by leaked connections.
01Picture it first: the waiter and the empty egg tray
Think of a waiter taking your breakfast order. Most mornings it's routine: order in, plate out. Today the kitchen is out of eggs. The waiter has three honest options. If a substitute is fine, he handles it right there ("scrambled tofu instead?"). If it's a decision above his pay grade, he passes it up to the manager. And if the stove is on fire, nobody at the table can fix that: he gets everyone out and calls the fire brigade.
An exception is the waiter walking away from the normal path. throw means "I can't carry on normally, here's what went wrong". Each method up the call stack gets a chance to handle it, exactly like the waiter's chain of command. What he must never do is shrug, say nothing, and bring you an empty plate. That's the swallowed exception, and it's the most expensive bug in this lesson.
Here's what the JVM actually does when a method throws: it pops frames one by one, running any finally on the way, until it finds a catch whose type matches.
From throw to catch: parse() throws, its frame is popped, load()'s finally runs, and handle()'s matching catch resumes execution; main() never sees the exception
Notice three things. The catch happens in the closest frame with a matching type, not the most sensible one, so where you put your catch blocks is a design decision. finally runs during unwinding, which is why cleanup belongs there (or better, in try-with-resources). And if nothing matches, the thread dies, which in a server usually means one request fails and in a background worker can mean the work silently stops.
02The foundations in ten minutes
You probably know the syntax. Let's make sure the details that interviewers poke at are solid, because the production habits build on them.
throw, try, catch, finally
Try it yourself first
Before reading on, write a method that reads a config file, falls back to defaults when the file doesn't exist, and fails with a meaningful exception for any other I/O problem. Which catch comes first?
Here's how I'd write it:
public String readConfig(Path path) {
try {
return Files.readString(path);
} catch (NoSuchFileException e) { // specific first
log.info("No config at {}, using defaults", path);
return DEFAULT_CONFIG;
} catch (IOException e) { // then the general parent
throw new UncheckedIOException("Could not read config " + path, e);
} finally {
log.debug("Config lookup finished for {}", path);
}
}
Notice:
- Catch order matters. Java tries the
catchclauses top to bottom and takes the first match.NoSuchFileExceptionis a subclass ofIOException, so if you swap them the compiler rejects the code: "exception NoSuchFileException has already been caught". - The missing file is expected, so it's handled at
INFOand returns a value. Every otherIOExceptionis not, so it's translated intoUncheckedIOExceptionwitheas the cause. finallyruns on every path: the normal return, the handled miss, and the rethrow.
| Keyword | Plain meaning |
|---|---|
throw |
"I can't continue normally. Here's the problem." |
try |
"Run this, and watch for problems." |
catch (T e) |
"If a T (or subclass) escapes, I'll deal with it here." |
finally |
"Run this no matter how we leave." |
throws T |
"Callers, be warned: I may hand you a T." |
Multi-catch (Java 7+) gives several unrelated types the same handling:
public Quote parseQuote(String body) {
try {
return mapper.readValue(body, Quote.class); // Quote's constructor parses dates
} catch (JsonProcessingException | DateTimeParseException e) {
throw new MalformedResponseException("Unreadable quote from pricing", e);
}
}
The variable e is implicitly final, and its static type is the closest common supertype (Exception here, since one is checked and one isn't). The alternatives can't be subclasses of each other: catch (IOException | FileNotFoundException e) is a compile error, because the second one is already covered.
The Throwable family tree
Everything you can throw extends Throwable, and the tree has three branches that production code treats very differently.
The Throwable family: Error (don't catch, let the process restart), checked Exception (catch where you have context and translate) and RuntimeException (a bug to fix or a rule to map to 4xx)
Error(OutOfMemoryError,StackOverflowError,NoClassDefFoundError) means the JVM or its environment is broken. It's unchecked, and you almost never catch it.- Checked exceptions (
IOException,SQLException,InterruptedException) are everything underExceptionthat isn't aRuntimeException. The compiler forces you to catch them or declare them withthrows. RuntimeException(NullPointerException,IllegalArgumentException,IllegalStateException) is unchecked. It usually signals a bug, or a rule the caller broke.
The checked versus unchecked split is a compile-time rule only. At runtime the JVM treats every Throwable identically. We go deep on that in Checked vs unchecked exceptions.
Wrapping with a cause, and suppressed exceptions
When you catch a low-level exception and throw a more meaningful one, you pass the original as the cause. That builds a chain, like Russian dolls: each exception holds the one that caused it. A second, separate mechanism, suppressed exceptions, records failures that happened while you were already failing, typically in close().
Left: a cause chain where ReportImportException wraps DataAccessException which wraps the root SQLException; right: a primary ParseException with an IOException from close() attached as suppressed; below, the Caused by and Suppressed lines each produces in a log
try {
rows.forEach(repository::insert);
} catch (DataAccessException e) {
throw new ReportImportException("Import of report " + reportId + " failed", e); // e is the cause
}
The difference matters when you read logs. Caused by: answers why this failed: walk it with getCause() until you hit the root. Suppressed: answers what else went wrong during cleanup, and you read it with getSuppressed(). try-with-resources adds suppressed exceptions for you; when you collect failures from parallel workers you can do it by hand:
List<Throwable> failures = collectFailures(futures); // from Future.get() calls
if (!failures.isEmpty()) {
var batch = new BatchFailedException(failures.size() + " of " + futures.size() + " shards failed",
failures.getFirst()); // first one is the cause
failures.stream().skip(1).forEach(batch::addSuppressed); // nothing gets lost
throw batch;
}
Making your own exception
A custom exception earns its place when a caller or a handler will treat it differently from everything else. Give it a name that says what went wrong, the constructors you need, and fields for the data a handler will read:
public class DependencyUnavailableException extends RuntimeException {
private final String dependency;
public DependencyUnavailableException(String dependency, Throwable cause) {
super("Dependency '%s' is unavailable".formatted(dependency), cause);
this.dependency = dependency;
}
public String dependency() { return dependency; }
}
How to design a whole hierarchy of these, with error codes and a retryable flag, is the subject of Custom exceptions and best practices.
03Eight habits that keep production quiet
These are the rules I'd want on the wall of any Java team. None of them is clever. All of them come from incidents.
1. Never swallow an exception silently
Try it yourself first
You see catch (Exception e) { } in a code review. What could go wrong, and when is ignoring an exception actually correct?
// ❌ the failure vanishes; nobody will ever know this line ran
try { publishEvent(order); } catch (Exception e) { }
// ✅ ignoring is the right call here, and the comment says why
try {
Files.delete(tempFile);
} catch (NoSuchFileException e) {
// Already gone, which is the state we wanted.
}
An empty catch is sometimes correct, but only for a specific type, with a comment that proves you thought about it. A bare catch (Exception e) {} also eats NullPointerException, IllegalStateException and every bug you'll write next year.
Premium lesson
Keep reading with the Java Pass
The rest of this lesson is part of Java One-Stop Interview Prep: in-depth explanations, diagrams, model answers and how to say them in the interview.
- Classify every failure by who can fix it
- Retries done right
- Real world: four incidents worth learning from
- Advanced: what senior rounds probe
- How to say it in the interview
From ₹199 · one-time payment · No auto-renewal
Further reading (19)
- JLS §11: Exceptions
- JLS §14.20: The try statement
- Throwable (Java SE 21 Javadoc)
- CompletableFuture (Java SE 21 Javadoc)
- Thread.UncaughtExceptionHandler (Java SE 21 Javadoc)
- Oracle Java Tutorials: Exceptions
- Effective Java, 3rd ed., Items 69 to 77 (Exceptions)
- Resilience4j documentation: Retry
- Resilience4j documentation: CircuitBreaker
- Marc Brooker: Exponential Backoff and Jitter
- RFC 9457: Problem Details for HTTP APIs
- RFC 9110 §10.2.3: Retry-After
- IETF draft: The Idempotency-Key HTTP Header Field
- Spring Framework reference: Rolling back a declarative transaction
- Spring Framework reference: Error responses (ProblemDetail)
- JEP 453: Structured Concurrency (Preview)
- Aleksey Shipilev: The Exceptional Performance of Lil' Exception
- Baeldung: Exception Handling in Java
- Baeldung: Guide to Resilience4j