SLF4J and Logback
Use the SLF4J API with Logback, the logging pair most Java services and Spring Boot use, with placeholders, MDC context and one config file.
private static final Logger log = LoggerFactory.getLogger(MyClass.class);
log.info("Order {} placed", orderId);
log.error("Payment failed for {}", orderId, exception); // last arg: the exceptionMost Java services do not log through System.Logger directly. They use SLF4J, a logging API, with Logback as the implementation behind it. Spring Boot uses exactly this pair by default through spring-boot-starter-logging, so the code below is what you will meet in almost every Spring codebase.
API and backend are separate
Your code and your libraries depend only on slf4j-api. Exactly one backend is on the runtime classpath: logback-classic in most cases. If there is none, SLF4J prints a warning and discards everything; if there are two, it warns and picks one. Bridges route the other logging APIs into the same place: jul-to-slf4j for java.util.logging (it needs SLF4JBridgeHandler.install() at startup, which Spring Boot does for you), slf4j-jdk-platform-logging for System.Logger, log4j-over-slf4j and jcl-over-slf4j for older libraries. One backend and one config file then control every line the process writes.
Writing log lines
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
log.info("Order {} placed for {}", orderId, customerId);
log.debug("Basket contents: {}", basket);
log.error("Payment failed for order {}", orderId, exception);
{} is the placeholder, filled with String.valueOf, with no locale formatting and no quote rules. The message is only built if the level is enabled, so parameters are cheap; concatenation is not. A Throwable as the last argument is logged as the exception with its stack trace, never used to fill a placeholder.
Request context with MDC
The Mapped Diagnostic Context is a per-thread map whose values the layout can print on every line, written %X{requestId} in a pattern. A filter puts the request id in at the start of a request and removes it at the end. It is backed by a thread-local, so it must be cleared in a finally: on a pooled thread a forgotten value leaks into the next request. Code that hands work to another thread has to copy the map across with MDC.getCopyOfContextMap().
Configuration
Logback reads logback.xml from the classpath (Spring Boot prefers logback-spring.xml, and also accepts logging.level.com.shop.db=debug in application.properties). The file sets appenders (where lines go), the pattern or encoder (how they look) and the level of each logger.
Mistakes people make
- Concatenating:
log.debug("User " + user)builds the string even when DEBUG is off. - Swallowing the stack trace:
log.error("Failed: " + e.getMessage())loses where it failed. Passeitself. - Two backends on the classpath, for example Logback plus
slf4j-simplepulled in by a library. Check the startup warning and exclude one. - Pinning versions inside Spring Boot. The Boot BOM already manages SLF4J and Logback; overriding them can break the pairing.
Example
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
public class OrderService {
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
private final PaymentClient payments;
public OrderService(PaymentClient payments) {
this.payments = payments;
}
public Receipt place(String requestId, Order order) {
// Every line logged inside this block carries requestId via %X{requestId}.
try (MDC.MDCCloseable ignored = MDC.putCloseable("requestId", requestId)) {
log.debug("Placing order {} with {} lines", order.id(), order.lines().size());
try {
Receipt receipt = payments.charge(order.id(), order.totalPence());
log.info("Order {} placed for customer {}", order.id(), order.customerId());
return receipt;
} catch (PaymentException e) {
// Handled here (turned into a failed receipt), so this is the one place it is logged.
log.error("Payment failed for order {}", order.id(), e);
return Receipt.failed(order.id());
}
}
}
}
interface PaymentClient {
Receipt charge(String orderId, long amountPence) throws PaymentException;
}
record Order(String id, String customerId, java.util.List<String> lines, long totalPence) {}
record Receipt(String orderId, boolean paid) {
static Receipt failed(String orderId) {
return new Receipt(orderId, false);
}
}
class PaymentException extends Exception {
PaymentException(String message) {
super(message);
}
}When to use it
- Configure a Spring Boot service's log format and per-package levels in one logback-spring.xml checked into the repository.
- Put the incoming X-Request-Id header into the MDC in a servlet filter so every line for that request can be found together.
- Bridge java.util.logging output from a JDBC driver into Logback so driver warnings appear in the same log stream as the application.
More examples
logback.xml with a request id in every line
The pattern prints the MDC value with %X{requestId}, and the logger element lowers the threshold for one package only. Writing to the console suits containers, where the platform collects stdout.
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{ISO8601} %-5level [%thread] %logger{36} req=%X{requestId} - %msg%n</pattern>
</encoder>
</appender>
<!-- Detail for one package while diagnosing; everything else stays at INFO. -->
<logger name="com.shop.db" level="DEBUG"/>
<root level="INFO">
<appender-ref ref="STDOUT"/>
</root>
</configuration>Dependencies outside Spring Boot
Code compiles only against the API, so the backend can be runtime-scoped. In a Spring Boot project you drop all three versions because the Boot BOM manages them.
<dependencies>
<!-- The API your code compiles against. -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.18</version>
</dependency>
<!-- The backend: only needed at runtime. -->
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.5.38</version>
<scope>runtime</scope>
</dependency>
<!-- Send System.Logger calls from libraries to SLF4J as well. -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-jdk-platform-logging</artifactId>
<version>2.0.18</version>
<scope>runtime</scope>
</dependency>
</dependencies>Key-value pairs with the fluent API
addKeyValue attaches fields that a JSON encoder writes as separate properties, which log search tools can filter on. The Supplier argument is only evaluated when DEBUG is enabled.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class RefundService {
private static final Logger log = LoggerFactory.getLogger(RefundService.class);
public void refund(String orderId, long pence, String reason) {
// SLF4J 2.x: key-value pairs travel as data, not just text in the message.
log.atInfo()
.addKeyValue("orderId", orderId)
.addKeyValue("amountPence", pence)
.addKeyValue("reason", reason)
.log("Refund issued");
log.atDebug()
.setMessage("Refund ledger entry {}")
.addArgument(() -> ledgerSummary(orderId))
.log();
}
private String ledgerSummary(String orderId) {
return "ledger for " + orderId;
}
}
Discussion