log4j

August 14, 20264 min readUpdated 8/20/2026

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:

LayerWhat it isExamples
Facadethe API your code callsSLF4J, commons-logging
Implementationwhat actually writes the outputLogback, 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.