Hook: In production, logs are your app’s black box recorder: when something breaks at 2 a.m., they are often the fastest way to see what really happened.
Question: What are logging best practices in Spring Boot?
Answer: Good logging means writing messages that help you debug real problems without slowing the app down or leaking secrets. In Spring Boot, you should log at the right level, keep messages short but useful, add request context like a correlation ID, and avoid logging passwords, tokens, or large payloads. The goal is to make production issues easy to trace while keeping logs cheap, safe, and searchable.
Interview-Ready Answer: I treat logging as a production debugging tool, not a place to dump everything. In Spring Boot, I use the right log level, parameterized messages like log.info('orderId={}', id), and a correlation ID in MDC so one request can be traced across services. I also avoid sensitive data and duplicate stack traces, because clean, structured logs are much more useful than noisy ones.
Detailed Explanation: A good log answers three questions fast: what happened, where it happened, and how to trace it. Spring Boot gives you a logging stack out of the box, usually SLF4J as the logging API and Logback as the default implementation, so your code should focus on message quality and context, not on low-level plumbing.
{}, so strings are built only when needed.ERROR for failures, WARN for suspicious but recoverable events, INFO for business milestones, and DEBUG for detailed diagnostics you may turn on briefly.log.info('orderId={}', id) is better than string concatenation because the message is not fully built when the level is disabled.orderId, userId, and correlationId so you can filter quickly.| Style | Best use | Gotcha |
|---|---|---|
| Plain text | Small apps, local dev | Harder to query at scale |
| JSON logs | Production search/aggregation | Needs consistent fields |
| Concatenated strings | Rarely | Eager work, noisy logs |
Logging is usually O(1) work per event from your code’s perspective, but the real cost is I/O: writing to disk, console, or a remote sink. A single line may take microseconds in memory but much longer if it blocks on a slow appender. That is why high-volume systems often use async logging, which puts events into a queue and writes them on another thread.
Spring Boot’s default root level is typically INFO, which is a safe production default. Be careful with DEBUG in hot paths, loops, or high-traffic endpoints: a tiny message printed 5,000 times per second becomes a real outage when the log volume explodes. Also remember that MDC is thread-local, so context does not automatically flow into new threads, async executors, or reactive pipelines unless you propagate it deliberately.
Memory hook: think of logging like a black box recorder, not a diary — record the flight, not every thought in the cockpit.
Real-World Story: Imagine a checkout service in an e-commerce app. Each request gets a correlation ID, the service logs orderId, payment status, and timing, and the exception handler logs stack traces only for unexpected failures. When production is healthy, engineers can search one correlation ID and reconstruct the whole request across gateway, checkout, and payment services.
Now the bad version: a developer leaves very noisy DEBUG logs on in production and logs the full request body for every checkout. Traffic spikes during a sale, log volume explodes, disk usage climbs, pods restart, and customers start seeing checkout failures. In the incident review, the symptoms are obvious: repeated log lines, no useful request IDs, and a log platform that is full of noise instead of clues. That is why best practices are not cosmetic; they directly affect uptime, cost, and incident response speed.
package com.example.loggingbestpractices;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.UUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.filter.OncePerRequestFilter;
@SpringBootApplication
public class LoggingBestPracticesApplication {
public static void main(String[] args) {
SpringApplication.run(LoggingBestPracticesApplication.class, args);
}
// Boot auto-registers Filter beans. We use a filter so every request gets a traceable ID.
@Bean
public OncePerRequestFilter correlationIdFilter() {
return new OncePerRequestFilter() {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String incoming = request.getHeader("X-Correlation-Id");
// If the client does not send one, generate it so every request can still be traced.
String correlationId = (incoming == null || incoming.isBlank())
? UUID.randomUUID().toString()
: incoming.trim();
MDC.put("correlationId", correlationId);
response.setHeader("X-Correlation-Id", correlationId);
try {
filterChain.doFilter(request, response);
} finally {
// Always clear MDC to prevent data from leaking into the next request on the same thread.
MDC.remove("correlationId");
}
}
};
}
@RestController
@RequestMapping("/orders")
static class OrderController {
private static final Logger log = LoggerFactory.getLogger(OrderController.class);
private final OrderService orderService;
OrderController(OrderService orderService) {
this.orderService = orderService;
}
@GetMapping("/{id}")
public ResponseEntity<OrderResponse> getOrder(@PathVariable long id,
@RequestHeader(value = "X-Correlation-Id", required = false) String correlationId) {
// Parameterized logging keeps the message cheap when the level is disabled.
log.info("Received request for orderId={} correlationId={}", id, correlationId);
if (id <= 0) {
// Validation failures are not server bugs, so returning 400 is better than a stack trace.
throw new IllegalArgumentException("order id must be positive");
}
return ResponseEntity.ok(orderService.findOrder(id));
}
}
@org.springframework.stereotype.Service
static class OrderService {
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
OrderResponse findOrder(long id) {
log.debug("Looking up orderId={}", id);
// Simulate a missing record to show a normal failure path.
if (id == 404) {
throw new OrderNotFoundException(id);
}
return new OrderResponse(id, "CONFIRMED", MDC.get("correlationId"));
}
}
record OrderResponse(long id, String status, String correlationId) {}
static class OrderNotFoundException extends RuntimeException {
OrderNotFoundException(long id) {
super("Order not found: " + id);
}
}
@RestControllerAdvice
static class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(OrderNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(OrderNotFoundException ex) {
// Log once at the boundary: a not-found is expected, so WARN is enough.
log.warn("Business failure: {} correlationId={}", ex.getMessage(), MDC.get("correlationId"));
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("NOT_FOUND", ex.getMessage(), MDC.get("correlationId")));
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorResponse> handleBadRequest(IllegalArgumentException ex) {
log.warn("Bad request: {} correlationId={}", ex.getMessage(), MDC.get("correlationId"));
return ResponseEntity.badRequest()
.body(new ErrorResponse("BAD_REQUEST", ex.getMessage(), MDC.get("correlationId")));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnexpected(Exception ex) {
// Unexpected errors deserve stack traces, but only once.
log.error("Unexpected error correlationId={}", MDC.get("correlationId"), ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("INTERNAL_ERROR", "Something went wrong", MDC.get("correlationId")));
}
}
record ErrorResponse(String code, String message, String correlationId) {}
}Follow-up & Tricky Questions:
finally so it never leaks to the next request.System.out.println okay in Spring Boot? It is fine for a quick local test, but not for real production because it lacks log levels, context, routing, and consistent formatting.Common Mistakes:
Memory Hook: Logs are the black box, not the diary. Keep enough detail to reconstruct the flight, but not so much that the recorder becomes the crash.
Cheat Sheet:
INFO for important milestones, WARN for recoverable problems, ERROR for real failures.log.info("id={}", id) over string concatenation.finally.Practice Tasks:
X-User-Id, to the MDC and include it in logs.404 and bad-request paths and inspect how the correlation ID appears in both the response and the logs.