Exceptions, logging and failing loudly
When something fails in production — and it will — the difference between a quick fix and a long investigation is whether your application handled the error honestly and told you what happened. This lesson is exception handling and logging done well in Spring: throwing meaningful exceptions, not swallowing them, and logging deliberately so a production failure is visible rather than silent.
Do not swallow exceptions
The most damaging habit, in Spring as anywhere: catching an exception and doing nothing useful.
// WRONG — the failure vanishes
try {
paymentGateway.charge(parcel);
} catch (Exception e) {
// swallowed — you will never know this failed
}
A bare catch that logs nothing and rethrows nothing makes failures invisible: the charge silently did
not happen, and there is no trace to investigate. Never swallow an exception you are not genuinely
handling. Either handle it meaningfully (retry, fall back, translate it) or let it propagate so it is
logged and turned into a proper HTTP error. Catching everything to make an error "go away" does not fix it —
it hides it until it costs far more.
Throw meaningful exceptions; let the advice map them
The clean Spring pattern (from the error-handling lesson): services throw meaningful, specific
exceptions, and a @RestControllerAdvice maps each to the right HTTP status. So a service does not catch
and swallow, and does not build HTTP responses — it throws:
@Service
public class ParcelService {
public Parcel get(Long id) {
return repository.findById(id)
.orElseThrow(() -> new NotFoundException("No parcel with id " + id)); // meaningful, specific
}
}
Prefer specific custom exceptions (NotFoundException, DuplicateTrackingCodeException) over generic
RuntimeException — they carry meaning, map cleanly to statuses in the advice, and read well. And catch
narrowly: catch the specific exception you expect and can handle (a TimeoutException from an external
call, to retry or fall back), and let unexpected exceptions propagate rather than catching Exception and
treating a bug as if it were the anticipated failure. Narrow catches handle what you planned for; broad
catches hide what you did not.
Never leak internals to the client
A security-and-clarity rule (the security module): in production, a client must never see a stack trace
or internal exception message. Those leak your class names, library versions, SQL and structure — a map for
an attacker — and are useless to the client. The @RestControllerAdvice returns a generic, safe
ProblemDetail to the client, while the detail (the stack trace) goes to your logs:
@ExceptionHandler(Exception.class)
ProblemDetail handleUnexpected(Exception ex) {
log.error("Unexpected error handling request", ex); // full detail to the logs
return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
"An unexpected error occurred"); // safe message to the client
}
So the traceback does not disappear — it moves from the response to the logs, where you see it and the client does not.
Logging: SLF4J, levels, and no System.out
Spring Boot uses SLF4J (over Logback) for logging. Get a logger per class and log at the right level:
private static final Logger log = LoggerFactory.getLogger(ParcelService.class);
log.info("Parcel {} delivered to {}", parcel.getId(), parcel.getDestinationCity());
log.warn("Partner API slow, retrying (attempt {})", attempt);
log.error("Payment failed for parcel {}", parcel.getId(), ex); // pass the exception as the last arg
Two habits that matter:
- Use parameterised logging (
"...{}...", value), not string concatenation. The message is only formatted if that level is enabled (so alog.debugwith an expensive argument costs nothing when debug is off), and it keeps logs consistent. Pass an exception as the last argument (no{}for it) and SLF4J logs its full stack trace — exactly what you want for an error. - Never use
System.out.printlnfor logging. It has no levels, no timestamps, no destination control, and does not integrate with the log configuration — it is invisible where real logs go. Use the logger.
The levels, used meaningfully so you can filter: TRACE/DEBUG (diagnostic detail, dev), INFO (normal
significant events), WARN (unexpected but handled), ERROR (a failure needing attention). Configure
production to log INFO and above and route ERROR somewhere you will see it.
Make failures visible in production
The point of all this is that when something breaks in production, you find out — from your monitoring, not from an angry user:
- Log errors with context.
log.error("Payment failed for parcel {}", id, ex)— the id tells you which one, the exception gives the trace. A barelog.error("error")is nearly useless. - Send errors to a tool. In production, route logs to a system you watch (ELK, CloudWatch) and errors to an alerting/error-tracking service (Sentry) — so an exception surfaces with its context and alerts you, rather than sitting unread in a file.
- Set it up before you deploy — logging and error tracking are worthless if configured after the incident that needed them.
The principle tying it together: throw meaningful exceptions and let the advice map them; catch narrowly and never swallow; return safe generic errors to clients while logging full detail; log deliberately with SLF4J at the right level, with context; and route errors somewhere you will see them. That is the difference between a failure you fix in minutes and one you never even learn about.
Check your work
Do not swallow. A bare catch that neither handles nor rethrows makes failures invisible — handle
meaningfully or let it propagate.
Throw meaningful, catch narrowly. Services throw specific custom exceptions (NotFoundException), a
@RestControllerAdvice maps them to statuses; catch the specific exception you can handle, let unexpected
ones propagate (do not catch Exception and hide bugs).
No leaks to clients. Return a generic, safe ProblemDetail; log the stack trace. The traceback moves to
the logs, not the response.
Logging. SLF4J logger per class; parameterised messages ("...{}", val) not concatenation; pass the
exception as the last arg for its stack trace; never System.out. Levels DEBUG/INFO/WARN/ERROR used
meaningfully.
Visible in production. Log errors with context (the id, the exception); route logs/errors to a watched tool (Sentry/ELK); set it up before deploying.
Practice
- Write a
try/catchthat swallows an exception, make it fail, and confirm you get no signal; replace it with handling that logs and translates the error. - Throw a specific
NotFoundExceptionfrom a service and map it to 404 in the advice; confirm the client gets 404, not a stack trace. - Add a catch-all
@ExceptionHandler(Exception.class)returning a generic 500 and logging the exception; trigger an unexpected error and confirm no internals leak but the log has the trace. - Use SLF4J parameterised logging with an id and confirm the message includes context; pass an exception as the last argument and confirm the stack trace is logged.
- Replace a
System.out.printlnwithlog.debug(...)and reason about why the logger is right for production. - Configure log levels so production logs INFO+ and describe how you would route ERROR to an alerting tool.
Official documentation
- Spring Boot — Logging — SLF4J/Logback, levels, configuration.
- Spring — Error handling (@ControllerAdvice) — Mapping exceptions to responses.
- SLF4J manual — Parameterised logging and passing exceptions.
Next: reviewing Spring code.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship