AI InterviewSearch…Search problems, topics, or pages…Ctrl KGet started
Skip to content

12 Exceptions / Lesson 6 of 6

Exception handling that survives production

13 min · +10 XP · 2 exercises · 5 questions

Read the text13 min
Exercises0/2
Quiz5 q

Quick answer

What are the best practices for exception handling in Java?

The core Java exception handling best practices are: never swallow an exception with an empty catch; catch the narrowest type you can actually handle; log an exception once, where it is handled, and otherwise wrap it with its cause or let it propagate; validate arguments and fail fast; keep try blocks small; and never use exceptions for ordinary control flow.

The syntax of exceptions is a day's work; the judgement takes longer. Most exception bugs in real systems are not syntax — they are swallowed failures, exceptions used as if statements, catch (Exception e) hiding a NullPointerException, and logs that say "error" with no context. This lesson is the set of practices that experienced Java teams enforce in review, each with the failure it prevents.

1. Never swallow

try { save(order); } catch (Exception e) { }                    // the order was not saved, and nobody will ever know
try { save(order); } catch (Exception e) { e.printStackTrace(); }   // stderr, no context, no handling — nearly as bad

An empty catch turns a failure into silent data loss. If you genuinely intend to ignore an exception (closing a stream you no longer care about), say so with a comment and catch the narrowest type. Otherwise handle it, or let it propagate.

2. Catch the narrowest type you can handle

catch (Exception e) catches NullPointerException, ClassCastException and every other bug along with the IOException you meant. Bugs then look like transient failures and get retried or ignored. Catch IOException; let the bugs crash loudly so they get fixed. catch (Throwable t) catches OutOfMemoryError too — reserve it for a thread's top-level loop that must not die, and rethrow Errors even there.

3. Log or rethrow — not both

catch (IOException e) {
    log.error("load failed", e);
    throw new LoadException("load failed", e);      // now the same failure is logged at every layer that catches it
}

Each layer that logs and rethrows produces another copy of the trace; a single failure becomes five pages. Decide where the exception is handled and log there, once, with the cause. Everywhere else, wrap (with cause) or let it pass.

4. Do not use exceptions for control flow

try { return Integer.parseInt(s); } catch (NumberFormatException e) { return -1; }   // acceptable: parseInt offers no check
try { return array[i]; } catch (ArrayIndexOutOfBoundsException e) { return null; }  // wrong: check i < array.length

Throwing is expensive (a stack walk per construction) and, more importantly, an exception in normal flow hides the real exceptions of that type. Test conditions you can test; use exceptions for the exceptional. When an API gives you only a throwing form (parseInt), catching the specific exception is fine.

5. Fail fast, at the boundary

Validate arguments at the top of public methods and constructors and throw IllegalArgumentException/NullPointerException immediately. A bad value that travels through six calls before exploding is far harder to diagnose than one rejected at the door. Objects.requireNonNull(x, "x") is the one-liner.

6. Preserve the cause and add context

throw new ServiceException("saving order " + id + " for customer " + customerId, e) — the message says what was being attempted with which data; the cause says why it failed. A wrapped exception without a cause deletes the only useful stack trace; a message without context makes the trace the only clue.

7. Keep try blocks small

A try around fifty lines with one catch (Exception e) at the end tells nobody which of the fifty lines failed or which failures were anticipated. Wrap the one call that can throw; handle that; move on. If several calls throw different things, that is several small trys or a method extraction.

8. Clean up with try-with-resources

Anything closeable goes in a try-with-resources header (previous lesson). finally is for cleanup of things that are not AutoCloseable — and even then, a small AutoCloseable wrapper is often cleaner.

9. Do not return null or a sentinel to signal failure when an exception fits — and vice versa

Absent-but-normal (a lookup that may miss) → Optional or a documented null/empty collection. Failure of an operation that should have worked → exception. A method that returns null on error forces every caller to remember a check; a method that throws for a normal miss forces every caller into a try. Match the mechanism to how common and how expected the outcome is.

10. finally never returns or throws

Covered in lesson 3: a return or throw in finally discards the in-flight exception. Reviewers reject it on sight.

Assertions

assert index >= 0 : "negative index " + index;

assert checks an internal invariant and throws AssertionError when it fails — only if the JVM runs with -ea. By default assertions are disabled, so they must never validate user input or arguments (use if + IllegalArgumentException), and code must not depend on their side effects. They are documentation that runs in tests and development.

Interrupts

InterruptedException is special: catching it and ignoring it breaks the thread's cancellation. Either propagate it (throws InterruptedException) or restore the flag: Thread.currentThread().interrupt(); before wrapping. Module 17.

A handling checklist

QuestionAnswer
Can this code fix the problem here?Handle it: retry, fall back, default. Log at most once.
Can it add context?Wrap with a cause and a message naming the attempt; rethrow.
Neither?Do not catch. Let it propagate to a layer that can.
Is it a bug (NPE, IOOBE, ISE)?Do not catch it; fix the code.
Is it cleanup?try-with-resources or try/finally, no catch.

Where exceptions are finally handled

Every application has a few top-level handlers: the request loop of a server that converts exceptions to error responses, the main of a CLI that prints a message and exit code, a thread pool's uncaught-exception handler that logs. Those are the places for catch (Exception e), full logging, and user-facing translation. Everything below them should mostly wrap or ignore, so the trace arrives intact.

Interview angle

  • "What is wrong with an empty catch block?" It hides failures; the program continues in a broken state with no record.
  • "Why not catch (Exception e) everywhere?" It catches programming errors and treats them as recoverable, hiding bugs.
  • "Log and rethrow?" Duplicates logs; log where handled, once.
  • "Are assertions enabled by default?" No — -ea; never use them for argument validation.
  • "Exceptions vs return codes?" Exceptions cannot be ignored silently, carry a trace and separate error handling from logic; return codes are for expected outcomes.

Key takeaways

  • Never swallow; catch the narrowest type; log once where handled, otherwise wrap with cause or let it propagate.
  • Exceptions are for the exceptional — test what you can test; fail fast at the boundary with informative messages.
  • Small try blocks; try-with-resources for cleanup; nothing returns or throws from finally.
  • assert is off by default: internal invariants only. Restore the interrupt flag when catching InterruptedException.

Common questions

Why is an empty catch block bad?

It turns a failure into silent data loss: the program carries on in a broken state and nothing records what went wrong. If ignoring an exception is genuinely intended, catch the narrowest type and say so in a comment; otherwise handle it or let it propagate.

Why should you not catch Exception in Java?

catch (Exception e) also catches NullPointerException, ClassCastException and every other bug, so programming errors are treated as recoverable failures and get retried or ignored. Catch the specific type you can handle, and let bugs crash loudly so they are fixed.

Should you log and rethrow an exception?

No. Every layer that logs and rethrows prints another copy of the same trace, turning one failure into pages of logs. Log once, where the exception is actually handled; everywhere else, wrap it with the cause or let it pass.

Why not use exceptions for control flow in Java?

Throwing is expensive, since constructing an exception walks the stack, and exceptions in normal flow hide the real exceptions of the same type. Test conditions you can test, such as i < array.length; catching is fine only when the API offers nothing else, as with Integer.parseInt.

Are assertions enabled by default in Java?

No. assert statements run only when the JVM is started with -ea, so they must never validate user input or method arguments — use an if and IllegalArgumentException for that. Assertions check internal invariants during tests and development.

Mark as readSign in to keep your place.

Java Exception Handling Best Practices for Production Code

Java exception handling best practices: never swallow, catch the narrowest type, log once, keep the cause, fail fast, and no exceptions for control flow.

  • Course: Java study plan
  • Module: Exceptions
  • Kind: Lesson
  • Reading time: 13 min
  • Runtime: OpenJDK 18

What are the best practices for exception handling in Java?

The core Java exception handling best practices are: never swallow an exception with an empty catch; catch the narrowest type you can actually handle; log an exception once, where it is handled, and otherwise wrap it with its cause or let it propagate; validate arguments and fail fast; keep try blocks small; and never use exceptions for ordinary control flow.

Lesson

The syntax of exceptions is a day's work; the judgement takes longer. Most exception bugs in real systems are not syntax — they are swallowed failures, exceptions used as if statements, catch (Exception e) hiding a NullPointerException, and logs that say "error" with no context. This lesson is the set of practices that experienced Java teams enforce in review, each with the failure it prevents.

1. Never swallow

try { save(order); } catch (Exception e) { }                    // the order was not saved, and nobody will ever know
try { save(order); } catch (Exception e) { e.printStackTrace(); }   // stderr, no context, no handling — nearly as bad

An empty catch turns a failure into silent data loss. If you genuinely intend to ignore an exception (closing a stream you no longer care about), say so with a comment and catch the narrowest type. Otherwise handle it, or let it propagate.

2. Catch the narrowest type you can handle

catch (Exception e) catches NullPointerException, ClassCastException and every other bug along with the IOException you meant. Bugs then look like transient failures and get retried or ignored. Catch IOException; let the bugs crash loudly so they get fixed. catch (Throwable t) catches OutOfMemoryError too — reserve it for a thread's top-level loop that must not die, and rethrow Errors even there.

3. Log or rethrow — not both

catch (IOException e) {
    log.error("load failed", e);
    throw new LoadException("load failed", e);      // now the same failure is logged at every layer that catches it
}

Each layer that logs and rethrows produces another copy of the trace; a single failure becomes five pages. Decide where the exception is handled and log there, once, with the cause. Everywhere else, wrap (with cause) or let it pass.

4. Do not use exceptions for control flow

try { return Integer.parseInt(s); } catch (NumberFormatException e) { return -1; }   // acceptable: parseInt offers no check
try { return array[i]; } catch (ArrayIndexOutOfBoundsException e) { return null; }  // wrong: check i < array.length

Throwing is expensive (a stack walk per construction) and, more importantly, an exception in normal flow hides the real exceptions of that type. Test conditions you can test; use exceptions for the exceptional. When an API gives you only a throwing form (parseInt), catching the specific exception is fine.

5. Fail fast, at the boundary

Validate arguments at the top of public methods and constructors and throw IllegalArgumentException/NullPointerException immediately. A bad value that travels through six calls before exploding is far harder to diagnose than one rejected at the door. Objects.requireNonNull(x, "x") is the one-liner.

6. Preserve the cause and add context

throw new ServiceException("saving order " + id + " for customer " + customerId, e) — the message says what was being attempted with which data; the cause says why it failed. A wrapped exception without a cause deletes the only useful stack trace; a message without context makes the trace the only clue.

7. Keep try blocks small

A try around fifty lines with one catch (Exception e) at the end tells nobody which of the fifty lines failed or which failures were anticipated. Wrap the one call that can throw; handle that; move on. If several calls throw different things, that is several small trys or a method extraction.

8. Clean up with try-with-resources

Anything closeable goes in a try-with-resources header (previous lesson). finally is for cleanup of things that are not AutoCloseable — and even then, a small AutoCloseable wrapper is often cleaner.

9. Do not return null or a sentinel to signal failure when an exception fits — and vice versa

Absent-but-normal (a lookup that may miss) → Optional or a documented null/empty collection. Failure of an operation that should have worked → exception. A method that returns null on error forces every caller to remember a check; a method that throws for a normal miss forces every caller into a try. Match the mechanism to how common and how expected the outcome is.

10. finally never returns or throws

Covered in lesson 3: a return or throw in finally discards the in-flight exception. Reviewers reject it on sight.

Assertions

assert index >= 0 : "negative index " + index;

assert checks an internal invariant and throws AssertionError when it fails — only if the JVM runs with -ea. By default assertions are disabled, so they must never validate user input or arguments (use if + IllegalArgumentException), and code must not depend on their side effects. They are documentation that runs in tests and development.

Interrupts

InterruptedException is special: catching it and ignoring it breaks the thread's cancellation. Either propagate it (throws InterruptedException) or restore the flag: Thread.currentThread().interrupt(); before wrapping. Module 17.

A handling checklist

QuestionAnswer
Can this code fix the problem here?Handle it: retry, fall back, default. Log at most once.
Can it add context?Wrap with a cause and a message naming the attempt; rethrow.
Neither?Do not catch. Let it propagate to a layer that can.
Is it a bug (NPE, IOOBE, ISE)?Do not catch it; fix the code.
Is it cleanup?try-with-resources or try/finally, no catch.

Where exceptions are finally handled

Every application has a few top-level handlers: the request loop of a server that converts exceptions to error responses, the main of a CLI that prints a message and exit code, a thread pool's uncaught-exception handler that logs. Those are the places for catch (Exception e), full logging, and user-facing translation. Everything below them should mostly wrap or ignore, so the trace arrives intact.

Interview angle

  • "What is wrong with an empty catch block?" It hides failures; the program continues in a broken state with no record.
  • "Why not catch (Exception e) everywhere?" It catches programming errors and treats them as recoverable, hiding bugs.
  • "Log and rethrow?" Duplicates logs; log where handled, once.
  • "Are assertions enabled by default?" No — -ea; never use them for argument validation.
  • "Exceptions vs return codes?" Exceptions cannot be ignored silently, carry a trace and separate error handling from logic; return codes are for expected outcomes.

Key takeaways

  • Never swallow; catch the narrowest type; log once where handled, otherwise wrap with cause or let it propagate.
  • Exceptions are for the exceptional — test what you can test; fail fast at the boundary with informative messages.
  • Small try blocks; try-with-resources for cleanup; nothing returns or throws from finally.
  • assert is off by default: internal invariants only. Restore the interrupt flag when catching InterruptedException.

Common questions

Why is an empty catch block bad?

It turns a failure into silent data loss: the program carries on in a broken state and nothing records what went wrong. If ignoring an exception is genuinely intended, catch the narrowest type and say so in a comment; otherwise handle it or let it propagate.

Why should you not catch Exception in Java?

catch (Exception e) also catches NullPointerException, ClassCastException and every other bug, so programming errors are treated as recoverable failures and get retried or ignored. Catch the specific type you can handle, and let bugs crash loudly so they are fixed.

Should you log and rethrow an exception?

No. Every layer that logs and rethrows prints another copy of the same trace, turning one failure into pages of logs. Log once, where the exception is actually handled; everywhere else, wrap it with the cause or let it pass.

Why not use exceptions for control flow in Java?

Throwing is expensive, since constructing an exception walks the stack, and exceptions in normal flow hide the real exceptions of the same type. Test conditions you can test, such as i < array.length; catching is fine only when the API offers nothing else, as with Integer.parseInt.

Are assertions enabled by default in Java?

No. assert statements run only when the JVM is started with -ea, so they must never validate user input or method arguments — use an if and IllegalArgumentException for that. Assertions check internal invariants during tests and development.

Exercises

Fix the handling

The program below swallows failures and uses exceptions for control flow. Rewrite sumValid so that: it validates each token with a check (matches("-?\\d+")) rather than catching NumberFormatException; it counts invalid tokens instead of ignoring them; and any token whose value is negative causes an IllegalArgumentException with the message negative: <token> that propagates to main, where it is caught once and printed as error: <message>.

Read an integer n and n tokens; print sum=<sum> invalid=<count> or the error line.

Example: 4 then 3 x 4 y → sum=7 invalid=2; 2 then 3 -1 → error: negative: -1

Retry with a budget

Simulate a flaky call. Read maxAttempts and n scenarios, each a string of F (fail) and S (succeed) describing what successive attempts would do. Write static String callWithRetry(String outcomes, int maxAttempts) that tries up to maxAttempts times, throwing IllegalStateException("attempt <k> failed") internally on F and catching it to retry; on S return ok after <k> attempts. If the budget is exhausted, rethrow the last failure with the attempts so far as suppressed exceptions and let the caller print gave up: <message> (suppressed=<count>). Never swallow silently.

Example: 3 then 2 then FS FFF →

ok after 2 attempts
gave up: attempt 3 failed (suppressed=2)

In this module: Exceptions

← Custom exceptions and exception chaining · Checkpoint — Exceptions →