1. Overview

A logging facade lets us write logging calls independently of the framework that processes them. In this lesson, we’ll use SLF4J to switch between Log4j2 and Logback while keeping our application code unchanged.

The relevant module we need to import when starting this lesson is: slf4j-start.

If we want to reference the fully implemented lesson, we can import: slf4j-end.

2. SLF4J and the Problem It Solved

Simple Logging Facade for Java (SLF4J) provides a unified abstraction for logging in Java applications, making it easier to manage and switch between different logging frameworks.

2.1. The Problem: A Mix of Logging Frameworks

Let’s imagine a growing Java application that initially uses Logback for logging. As it evolves, new dependencies are added. For example, a third-party library might use the Log4j2 API, while an older internal library uses java.util.logging.

As dependencies accumulate, the application may end up with multiple logging frameworks on the classpath, each with its own configuration style and output format. We now have to manage separate configuration files, such as logback.xml, log4j2.xml, and logging.properties, just to control how logs are generated.

The result is inconsistent log output across the system, making the consolidated logs hard to read, search, or analyze. Moreover, these overlapping dependencies can introduce version conflicts, leading to dependency hell.

A common logging API reduces the coupling between our code and a particular logging framework. Libraries that use other APIs still need bridges, which we’ll discuss below.

2.2. SLF4J and Its Benefits

Instead of tying our code to a specific logging library, SLF4J acts as an intermediary between our application and the underlying logging implementation (such as Logback or Log4j2).

By using SLF4J, we can switch between different logging frameworks simply by changing the binding dependency in the build configuration. This flexibility helps decouple the logging logic from a particular framework and promotes cleaner, more maintainable code.

For instance, we can swap Log4j2 for Logback by replacing the provider dependency and supplying the new backend’s configuration, without modifying our logging calls.

When our application and its dependencies log through SLF4J to the same backend, we can control their output through that backend’s configuration. Finally, it promotes better library design, as library authors who rely on SLF4J enable their users to choose whichever logging framework they prefer, without imposing one.

3. The SLF4J Architecture: API and Bindings

SLF4J’s architecture consists of two main parts: an API and a provider, also called a binding. The examples here use SLF4J 2.x providers.

  • The API (slf4j-api.jar): This is the facade. It’s a single JAR file containing the interfaces and classes our application code interacts with, primarily org.slf4j.Logger and org.slf4j.LoggerFactory. Our application code should only ever have a compile-time dependency on slf4j-api.
  • The binding: This is the adapter that connects the SLF4J API to a concrete logging framework. The binding is a separate JAR file specific to the logging framework we want to use.

Some common bindings include:

  • logback-classic.jar: the native binding for Logback
  • log4j-slf4j2-impl.jar: the SLF4J 2.x provider for Log4j2
  • slf4j-jdk14.jar: the binding for java.util.logging

The most important rule for using SLF4J is that we must have one and only one binding on our classpath at runtime. If we have no binding, SLF4J will print a warning, and SLF4J logging calls will produce no output. If we have multiple bindings, SLF4J will print a warning, pick one unpredictably, and might not behave as we expect.

4. A Practical Example: Using SLF4J With Log4j2

First, let’s declare the Log4j2 version property in our pom.xml (inside the existing properties block) so the dependency below can resolve:

<log4j2.version>2.26.0</log4j2.version>

Now, let’s add the log4j-slf4j2-impl dependency to the pom.xml:

<dependency>
    <groupId>org.apache.logging.log4j</groupId>
    <artifactId>log4j-slf4j2-impl</artifactId>
    <version>${log4j2.version}</version>
</dependency>

The SLF4J binding (log4j-slf4j2-impl) transitively includes the Log4j2 core engine (log4j-core). The binding adapts SLF4J calls to Log4j2, while the core provides the actual logging functionality.

Furthermore, we need to provide a configuration file that Log4j2 understands. Let’s create a log4j2.xml file in our src/main/resources directory with a different console pattern to clearly show that this configuration is in action:

<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
    <Appenders>
        <Console name="Console" target="SYSTEM_OUT">
            <PatternLayout pattern="LOG4J2: %d{HH:mm:ss} [%t] %-5level - %msg%n"/>
        </Console>
    </Appenders>
    <Loggers>
        <Root level="info">
            <AppenderRef ref="Console"/>
        </Root>
    </Loggers>
</Configuration>

Next, let’s define a new test in the JavaLoggingUnitTest class:

public class JavaLoggingUnitTest {

    private static final Logger logger = LoggerFactory.getLogger(JavaLoggingUnitTest.class);

    @Test
    void whenLoggingMessage_thenDisplayUsingFacade() {
        logger.info("Application started");
    }
}

The test above logs “Application started” to the console.

Here’s the new output:

LOG4J2: 10:32:15 [main] INFO  - Application started

5. Switching to Logback

The true power of a facade becomes evident when we switch the underlying implementation. Let’s see how easy it is to replace Log4j2 with another popular framework, Logback.

First, we need to update our pom.xml by removing the Log4j2 dependency from earlier and adding the required dependencies for Logback. Logback implements the SLF4J API natively.

Let’s open the pom.xml file in our start module and declare a Logback version property (inside the existing properties block):

<logback.version>1.5.31</logback.version>

Now, let’s add the logback-classic dependency:

<dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-classic</artifactId>
    <version>${logback.version}</version>
</dependency>

The logback-classic library acts as both the SLF4J binding and the logging implementation. Since it depends transitively on slf4j-api, adding this single dependency provides everything we need.

With Log4j2 removed, the log4j2.xml file is now ignored. We can leave it in place or delete it. Either is fine for the rest of the lesson.

Next, let’s create a logback.xml file in src/main/resources, with a unique pattern so we can see it in action:

<configuration>
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>LOGBACK: %-5level - %msg%n</pattern>
        </encoder>
    </appender>
    <root level="info">
        <appender-ref ref="STDOUT" />
    </root>
</configuration>

Next, let’s rerun the same JavaLoggingUnitTest, without changing a single line of its code.

Here’s the log output:

LOGBACK: INFO  - Application started

When we run this test, SLF4J finds the logback-classic binding and routes the logs to Logback, which uses our logback.xml to format the output. The logging calls remain unchanged when we replace the backend.

6. How Libraries Handle Logging

In most Java projects, libraries use logging in different ways, and understanding these scenarios helps maintain consistent log output across the entire application.

Libraries that use SLF4J should depend only on slf4j-api and avoid shipping a binding. This is the standard and recommended practice, because it allows applications to choose the logging implementation.

However, some libraries bypass SLF4J and use another framework directly (such as Log4j or java.util.logging). In such cases, bridges redirect calls from those APIs to SLF4J. Log4j provides log4j-to-slf4j for its API, while SLF4J provides jul-to-slf4j for JUL.

When using these bridges, we typically exclude the original logging engine (for example, remove log4j-core when using log4j-to-slf4j) so that all logs flow through SLF4J. For JUL, jul-to-slf4j redirects records through an installed SLF4JBridgeHandler rather than replacing JUL itself.

Occasionally, a library might mistakenly include a specific SLF4J binding as a transitive dependency. If this happens, we can simply exclude it in our pom.xml to ensure our application’s chosen binding remains the only active one.

Finally, it’s good practice to include the SLF4J binding for the implementation we want to use (for example, log4j-slf4j2-impl when using Log4j2 or logback-classic for Logback), even if our own code doesn’t call the SLF4J API directly. This ensures that all SLF4J-based libraries can log properly.

7. Tradeoffs of Using a Facade

SLF4J provides a common logging API, but it doesn’t expose every backend-specific feature. Some frameworks offer advanced capabilities that aren’t part of SLF4J.

For instance, Log4j2 supports custom log levels that aren’t available through the SLF4J Logger interface.

Using a framework’s own API for these features couples that code to the framework. We can’t assume an SLF4J Logger can be cast to the backend’s native logger.

We need to decide whether to keep portable logging calls or depend on a framework’s API for specialized functionality. In most cases, adhering to the SLF4J API is the better choice, since the flexibility and maintainability it provides far outweigh the benefits of an occasional framework-specific feature.

Furthermore, we need to keep in mind that even when SLF4J exposes a feature (such as markers or MDC), the final behavior depends on the underlying backend. Logback and Log4j2 support most SLF4J features well, but others (such as JUL) may ignore or only partially implement them.

8. Conclusion

In this lesson, we looked at the critical role of a logging facade in modern Java development. SLF4J’s simple API plus binding architecture lets us treat the logging engine as a swappable detail rather than a structural commitment, and that flexibility is what makes it the default choice for any non-trivial Java codebase.