RizTech Academy logo
RizTech Academy
Writing Spring Worth ReadingLesson 4 of 525 min

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 a log.debug with 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.println for 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 bare log.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

  1. Write a try/catch that swallows an exception, make it fail, and confirm you get no signal; replace it with handling that logs and translates the error.
  2. Throw a specific NotFoundException from a service and map it to 404 in the advice; confirm the client gets 404, not a stack trace.
  3. 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.
  4. 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.
  5. Replace a System.out.println with log.debug(...) and reason about why the logger is right for production.
  6. Configure log levels so production logs INFO+ and describe how you would route ERROR to an alerting tool.

Official documentation

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