The problem: an error that vanished
A payment service calls a currency-rate client. The client throws an IOException when the rate provider is unreachable. A developer, annoyed by the checked exception, wraps the call like this:
try { rate = rateClient.fetch(currency);} catch (IOException e) { rate = BigDecimal.ONE; // "temporary" fallback}Nothing is logged, nothing is reported, and every payment during the outage is converted at a rate of one. The bug is not found in tests because the happy path passes. It is found weeks later in an accounting reconciliation. The exception was the system telling the truth, and the code silenced it.
Good exception design is mostly about two things: making sure failures are visible to the right layer, and making sure the information needed to understand them is not lost on the way.
Checked versus unchecked
Java has two families. Checked exceptions (subclasses of Exception that are not RuntimeException) must be declared or caught; the compiler enforces it. Unchecked exceptions (RuntimeException and its subclasses, plus Error) need no declaration.
The original idea was that checked exceptions describe recoverable conditions the caller can reasonably handle, and unchecked ones describe programming errors. In application code the line is blurrier, and modern Java style, including what Spring does, leans towards unchecked exceptions for most failures, for several reasons:
- Checked exceptions leak implementation details through signatures. If a repository starts using a different storage, every caller's
throwsclause changes. - They interact poorly with lambdas and streams, because standard functional interfaces do not declare them.
- Callers often cannot do anything meaningful, so they catch and ignore, which is worse than letting the error travel.
A practical rule: use unchecked exceptions by default. Use a checked exception only when the caller has a realistic recovery path and you want the compiler to force them to think about it.
Wrap with a cause, do not lose the original
When you translate a low-level exception into a higher-level one, always pass the original as the cause. The stack trace of the cause shows where the failure really started.
public BigDecimal rateFor(String currency) { try { return rateClient.fetch(currency); } catch (IOException e) { throw new RateUnavailableException( "Cannot fetch rate for " + currency, e); // cause kept }}Creating a new exception with only e.getMessage() throws away the stack trace and often the most useful detail. Log output such as "Caused by: ..." in a stack trace exists precisely because wrapping is normal.
Never swallow
Three patterns hide errors:
- An empty
catchblock. catch (Exception e) { e.printStackTrace(); }, which writes to standard error, bypasses the logging configuration and then continues as if nothing happened.- Catching
ThrowableorExceptionbroadly where a specific type is meant.
If you truly intend to ignore an exception, say why in a comment and name the variable ignored. If you catch and log, then do not also rethrow the same exception at every layer, or one failure produces five identical stack traces. Pick one place to log: usually where the error is finally handled.
Also remember InterruptedException. If you catch it and cannot propagate it, restore the flag with Thread.currentThread().interrupt() so code higher up can still see the interruption. Swallowing it silently breaks shutdown and cancellation.
Domain exceptions
Throwing RuntimeException("not found") forces every caller to parse strings. Define a small set of meaningful exceptions that describe business outcomes.
public class OrderNotFoundException extends RuntimeException { private final long orderId; public OrderNotFoundException(long orderId) { super("Order " + orderId + " not found"); this.orderId = orderId; } public long orderId() { return orderId; }}Guidelines for such classes: name them after the business situation, not the technology (InsufficientFundsException, not SqlConstraintException); carry the data needed to handle or report the problem; and keep the hierarchy shallow. A common base class such as DomainException helps a single handler recognise them all. Do not use exceptions for ordinary control flow, such as signalling the end of a loop; they are slower than a normal return and obscure intent.
Use the standard exceptions where they fit: IllegalArgumentException for a bad argument, IllegalStateException for a call at the wrong time, NullPointerException through Objects.requireNonNull for unexpected nulls, UnsupportedOperationException for unsupported operations.
Translate to HTTP at the edge
Domain code should not know about HTTP status codes. Keep the mapping at the boundary, where the web layer lives. In Spring Boot that is a @RestControllerAdvice.
@RestControllerAdviceclass ApiExceptionHandler { @ExceptionHandler(OrderNotFoundException.class) ProblemDetail notFound(OrderNotFoundException e) { var p = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage()); p.setProperty("orderId", e.orderId()); return p; } @ExceptionHandler(Exception.class) ProblemDetail unexpected(Exception e) { log.error("Unhandled error", e); // logged once, with the stack trace return ProblemDetail.forStatusAndDetail( HttpStatus.INTERNAL_SERVER_ERROR, "Unexpected error"); }}Two points. First, the generic handler returns a neutral message: stack traces, SQL and internal class names must not reach clients. Second, an unexpected error is logged on the server with the full exception, while a known business outcome like "not found" usually needs no stack trace in the logs.
try-with-resources
Anything that must be closed (streams, connections, statements) should use try-with-resources. The resource is closed automatically in reverse order, even if the body throws.
try (var in = Files.newBufferedReader(path); var out = Files.newBufferedWriter(target)) { in.transferTo(out);}It also handles a subtle case that a manual finally gets wrong: if both the body and close() throw, the body's exception is the primary one and the close() exception is attached as suppressed (getSuppressed()), instead of replacing the original error. Resources must implement AutoCloseable.
Testing the failure paths
Failure behaviour is part of the contract, so test it. Assert the exception type, the message data that callers rely on, and that the cause is preserved. Tests for the edge should check the HTTP status and body for each mapped exception, because a missing handler turns a clean 404 into a generic 500 without any compile error.
@Testvoid unknownOrderIsReportedWithItsId() { var e = assertThrows(OrderNotFoundException.class, () -> service.find(42L)); assertEquals(42L, e.orderId());}A test that only covers the happy path leaves the most dangerous code, the error handling, unverified.
Common mistakes
- Catching
Exceptionto "be safe", which also catches programming errors you want to see. - Returning
nullor a default value instead of signalling a failure, as in the opening story. - Throwing from
finally, which can hide the original exception. - Logging and rethrowing at every layer, producing duplicate noise.
- Putting secrets or personal data into exception messages that end up in logs and responses.
- Using exception messages as an API contract. Give clients a stable error code instead.
- Declaring
throws Exceptionon every method, which removes any information from the signature.
Trade-offs
Unchecked exceptions reduce boilerplate but move the burden of knowing what can fail from the compiler to documentation and tests. Checked exceptions make failure modes explicit but add friction. Domain exceptions add classes, so create them for situations the caller or the edge will treat differently, not for every message. A rich hierarchy that nobody handles differently is just noise.
Checklist
- No empty catch blocks; every ignored exception explained.
- Original exception always passed as the cause.
- Domain exceptions named after business situations.
- One central place maps exceptions to HTTP responses.
- Internal details never reach the client.
- try-with-resources for everything closeable.
InterruptedExceptionhandled by propagating or restoring the flag.
Where to go next
Look at choosing collections for more core Java decisions, at concurrency basics to see how exceptions behave in executor tasks, and at the middle Java developer roadmap for the next skills in line.