Logging is the most-used feature of any Java application and one of the most consistently mishandled. Bad logging — too much, too little, unstructured, expensive — costs production time when nobody can debug an issue or when log volume drives infrastructure cost.
This page covers the working patterns for Java logging at scale.
SLF4J is the de facto standard logging facade for Java. Library code should depend on slf4j-api; the application picks the implementation (Logback, Log4j2).
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class OrderService {
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
public void process(Order order) {
log.info("Processing order {}", order.id());
}
}
Key points:
Logger is static final — one logger per classLoggerFactory.getLogger(Class) form is conventional{}), not string concatenation// Wrong: string concat happens always
log.debug("Processing order " + order.id());
// Right: format only happens if debug is enabled
log.debug("Processing order {}", order.id());
The parameterized form skips the format work when the level is disabled. For DEBUG/TRACE messages, this matters at scale.
The SLF4J levels: TRACE, DEBUG, INFO, WARN, ERROR.
The most common failure: logging too much at INFO level. INFO should be sparse — major events, not every step.
For modern observability, structured logs (JSON-formatted with key-value fields) beat free-form text. They're queryable; they integrate with log aggregation systems.
With Logback + JSON encoder:
log.atInfo()
.addKeyValue("orderId", order.id())
.addKeyValue("amount", order.amount())
.log("Order processed");
The output is JSON; tools like Datadog, Splunk, ELK can index by key.
For typical use, MDC (Mapped Diagnostic Context) provides per-thread context that's added to every log message:
MDC.put("requestId", requestId);
try {
// work
} finally {
MDC.clear();
}
Combined with structured logging, every log message in a request is correlated by requestId. Essential for distributed tracing.
try {
process(order);
} catch (ProcessingException e) {
log.error("Failed to process order {}", order.id(), e);
}
The exception parameter goes last; SLF4J recognizes it and logs the full stack trace. Anti-patterns:
log.error(e.getMessage()) — no stack tracelog.error("Failed: " + e) — stack trace lostlog.error("Failed", e) — fine, but missing contextNever log:
Use redaction filters or structured logging that explicitly marks sensitive fields. Logs persist; sensitive data in logs becomes a compliance liability.
For high-throughput applications, synchronous logging becomes a bottleneck. Logback's AsyncAppender queues messages on a separate thread.
The trade-off: messages may be lost on crash; the async queue can fill up. For most production applications, async logging is the right default.
Even with parameterized logging, building the parameter can be expensive:
// expensive computation happens always
log.debug("Order detail: {}", expensiveSerialization(order));
Guard with isDebugEnabled():
if (log.isDebugEnabled()) {
log.debug("Order detail: {}", expensiveSerialization(order));
}
Or use the lambda-based logging API (modern SLF4J):
log.atDebug().log(() -> "Order detail: " + expensiveSerialization(order));
Externalize log configuration. logback.xml (or logback-spring.xml in Spring Boot) for Logback. Settings should be: