A word on recent Slf4j and Spring incompatibility
Slf4j is a popular logging facade for Java ecosystem. It separates libraries from the logging backend by providing a common logging API. It is the developer who picks up the actual backend. This setup has been working great for years. Many developers have already forgotten, how shattered the world of Java was in its early days because of loggers. The new Slf4j 2.0 promised backward compatibility of the API. Unfortunately something went wrong. It turned out that the popular Spring Framework does not work well with the new Slf4j. And it’s not Slf4j’s fault! Or maybe it is? Let’s find out.
You can’t mix Slf4j 2.x and Spring 2.x in the same project. This is due to how Spring was integrated with the underlying logging backends. This also means that you can’t mix Spring 2.x with any library that uses Slf4j 2.0!
Let’s take a short look, how Slf4j solves the logging issue in Java applications:
There’s no much philosophy here. The original issue with loggers in Java was that each logging framework provided its own logging API. If we used a framework and several libraries, we could end up with having two or three logging frameworks. Each of them required separate configuration. The facade solves this issue. The libraries depend only on the facade API. It is us who configure the actual backend behind it.
Of course, Slf4j is not the only logging facade in Java ecosystem. Log4j is both a backend and another facade. For Kotlin, we have Kotlin-logging. However, the facade concept allows creating bridges in all directions. Effectively, you can have a single logging backend in your application, no matter what API is used by your dependencies.
Here comes Slf4j 2.0
After a couple of years of development, Slf4j 2.0 arrived in August 2022. It provides a couple of new features, including a new fluent logging API. Despite the “2.0” number, the existing API works as before. There are no breaking changes here. In theory, it should provide a graceful migration path. You just bump the version to 2.0 and the libraries should not notice anything.
However, people quickly noticed that this update does not work for Spring applications. They just crash at launch. Meanwhile, a couple of projects had already bumped their own Slf4j versions. If you happened to use them, Spring also crashed! Personally, I experienced such an issue with Testcontainers 1.7.4. It was a patch version, but switched to Slf4j 2.0. I spent several hours before I realized what’s going on. Eventually, the authors of Testcontainers released another version 1.7.5 which reverted the change with Slf4j. The same happens with Archunit 1.0.0. It also depends on the new Slf4j, making it effectively impossible to use in Spring projects.
Let’s ask a question. Slf4j 2.0 was supposed to be backward compatible. If everything fails with Spring 2.x, what went wrong?
Slf4j 2.0 was supposed to be backward compatibly, but something went horribly wrong in Spring Framework…
Looking at the internals…
I was very curious why the new version of Slf4j fails with Spring. I started digging into the code and I noticed that the issue is caused by Logback, the official backend behind Slf4j. Logback does not have its own public API at all, relying solely on Slf4j. There are two new versions of Logback intended to be used with Slf4j 2.0:
- Logback 1.3: supports deprecated Java EE API
- Logback 1.4: supports the new Jakarta EE API
While the API of Slf4j is backward compatible, the internal API of Logback – is not. However, this fact should not cause the issue alone, because everything should be hidden behind Slf4j. There must be some other reason and there is one. We can find it in the sources of Spring Framework.
Slf4j and Spring Framework
One day, Spring developers decided to create its own logging system for Spring internals. The implementation detects one of the several supported backends and applies some opinionated configuration to it. Logback is one of those backends. If we look at the code, we notice that Spring uses Logback directly through the internal API from 1.2 version (targeting Slf4j 1.x). The new versions 1.3/1.4 use roughly the same class and package names, but change many details. For this reason, an attempt to load them causes the crash. If we force other libraries to use the old version, they may crash when relying on new Slf4j features.
That’s the root cause of the problem. Within days, we expect the general availability of Spring Boot 3.0 / Spring Framework 6. It provides the support for the new Slf4j, but it is a major release with many changes (including moving from Java EE to Jakarta EE API). One way to resolve the issue is moving to the new Spring. I also found an ugly workaround for Spring Boot 2.x. We can disable its logging subsystem through the JVM option. Then, Spring does not load problematic classes and starts correctly. However, we loose some internal logs from the framework and extra metadata appended to the log entries. Therefore I do not recommend it.
To get rid of the problem, you have to migrate to Spring Boot 3. If you are not able to do it, e.g. due to the lack of support for it or Jakarta EE in one of your dependencies, you’re stuck.
This situation is a great example why you should not hook into the internal API of another project. I know why developers do it. In short term, it is a tempting option, because we can gain access to extra features that are otherwise inaccessible. However, in long term it is a gamble that may put you or the others in the trouble. Over time, more and more libraries are going to switch to the new Slf4j. From their perspective, the new version is backward compatible. So far, you could have bumped them independently of Spring. Now, you will have to perform a complex migration to Spring Boot 3, or you may get stuck.
The consequences should not be underestimated. We don’t know how much time it will take the companies to adopt Spring Boot 3. Until then, some libraries may stop receiving updates. What if you won’t be able to patch a security issue because of that?
However, there’s also another side of the problem. Like I said, I know why developers could make this choice. The existing logging API focused only of developers who wanted to log something. There are no extension points for the frameworks to apply some common configuration, or inject some metadata. For example, in microservices we often transparently inject trackingId, so that we could easily search all logs related to a single HTTP request. Maybe it’s time to create such an API?
It happened before…
Before we end, let’s notice that there was a similar situation in Java not so long ago. At one point, many libraries hooked into the internals of JVM to gain access to
sun.misc.Unsafe and some other dangerous API-s. The approach was different here. Java maintainers were aware of that. They started blocking the access to JVM internals as of Java 9, but they intentionally left some exceptions. Meanwhile, they started developing new public API-s for those use cases, and eventually gave some time to migrate to them.
Lessons for us
Concluding this entry, let’s pick up some valuable lessons for us:
- avoid using internal API-s of other projects,
- if you need a feature not present in the public API, try to discuss your use case with the authors to create such an API,
- if someone happens to use your internal API, don’t ignore it. Listen carefully to the use case and figure out the solution.
Great article as always! 🙂 I am sorry for all the developers who wasted hours cause of that issue. Hope they will find this article! 🙂