Logging is what you have instead of a debugger when the problem happened at 3am on a server you cannot attach to. Java's logging landscape has a confusing number of libraries; the arrangement that matters is simple once you see it.
The facade and the implementation
There are two layers, and conflating them is the source of most logging confusion:
| Layer | What it is | Examples |
|---|---|---|
| Facade | the API your code calls | SLF4J, commons-logging |
| Implementation | what actually writes the output | Logback, Log4j 2, java.util.logging |
Write against SLF4J. Your code depends on the facade; the application chooses the implementation at deployment. That is why a library must never bundle an implementation — it takes the choice away from the application using it.
class OrderService {
// In a real project: org.slf4j.Logger / LoggerFactory
// private static final Logger log = LoggerFactory.getLogger(OrderService.class);
void placeOrder(String orderId, double total) {
// log.info("placing order id={} total={}", orderId, total);
System.out.println("placing order id=" + orderId + " total=" + total);
}
}
The logger is private static final and named after its class — one per class, created
once, and the class name is what lets you filter by package later.
Parameterised messages
The {} placeholders are not cosmetic:
class Demo {
void run(String orderId, Object order) {
// Concatenation happens ALWAYS, even when debug logging is switched off
// log.debug("order " + orderId + " is " + order.toString());
// Placeholders are substituted only if the level is enabled
// log.debug("order {} is {}", orderId, order);
}
}
In the first form, the string is built and toString() is called on every call, then
thrown away if debug is disabled. In a hot loop that is measurable, and it is free to avoid.
Levels, used properly
- ERROR — something failed and needs a human. If nobody would act on it, it is not an error.
- WARN — recovered, but odd. A retry succeeded; a config value was missing and a default was used.
- INFO — milestones. Startup, shutdown, a completed job. Not every request.
- DEBUG — detail you switch on to diagnose something.
- TRACE — everything, rarely enabled.
The failure mode is logging everything at INFO. When every line is INFO, filtering by level tells you nothing and the signal is gone.
Logging an exception
class Demo {
void run(Exception e) {
// Wrong: throws away the stack trace and every "Caused by"
// log.error("failed: " + e.getMessage());
// Right: the exception goes LAST, with no placeholder
// log.error("could not place order id={}", "A-1099", e);
}
}
SLF4J treats a trailing Throwable specially and prints the full trace — including the
Caused by chain that Debugging shows is usually where
the real failure is. Logging only getMessage() is the most common logging mistake there
is.
Configuration
<!-- logback.xml on the classpath -->
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss} %-5level [%thread] %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- Noisy library, quietened; your own code kept verbose -->
<logger name="org.hibernate" level="WARN"/>
<logger name="com.lovemesomecoding" level="DEBUG"/>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>
Levels are set per logger name, and names are hierarchical — org.hibernate covers
everything beneath it. That is the mechanism you use to silence a chatty dependency without silencing
yourself.
Structured logging
If logs go to a searchable system, JSON output is far more useful than text, because fields become queryable rather than needing a regex. MDC attaches context to every line on the current thread:
class RequestHandler {
void handle(String requestId, String userId) {
// MDC.put("requestId", requestId);
// MDC.put("userId", userId);
try {
// every log line in this request now carries both fields
process();
} finally {
// MDC.clear(); // essential: the thread is reused
}
}
void process() { }
}
The finally is not optional. On a pooled thread, values left in the MDC leak into the
next request and attribute one user's logs to another. Note that MDC is
ThreadLocal-based, which is worth remembering when adopting
virtual threads.
Which implementation, and the bridge problem
Logback and Log4j 2 are both good. Logback is the default in Spring Boot and needs no thought; Log4j 2 has an asynchronous appender that measurably outperforms it under very high volume. For most applications the choice does not matter.
What does matter is ending up with two. Dependencies pull in whatever facade they were written against — commons-logging, java.util.logging, Log4j 1 — and the result is log lines disappearing because half your libraries are writing somewhere you are not looking.
The fix is a bridge: a stub that implements the old API and forwards to SLF4J.
<!-- Redirect other logging APIs into SLF4J, then out through one implementation -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>jcl-over-slf4j</artifactId> <!-- commons-logging -->
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>jul-to-slf4j</artifactId> <!-- java.util.logging -->
</dependency>
Two rules keep this sane: exactly one implementation on the classpath, and a bridge for every other API. SLF4J prints a warning at startup when it finds multiple implementations — that warning is worth reading rather than scrolling past, because it means your logging configuration is a coin toss.
A word on Log4Shell
In 2021, a vulnerability in Log4j 2 let attackers execute arbitrary code by getting a crafted string into a log message. It was as severe as vulnerabilities get, and the lesson generalises: logging libraries are code that processes untrusted input. Keep them patched, and be wary of any logging feature that interprets message content.
What makes a log useful
- Identifiers. "Order failed" is useless; "order failed id=A-1099 customer=C-42" finds the record.
- The exception object, not its message.
- No secrets. Passwords, tokens and card numbers must never be logged, and logs are often less protected than the database.
- Never
System.out.println. No level, no timestamp, no filtering, and no way to turn it off.
Next
Encryption and decryption is next.