Spring Framework: Why I prefer a simpler solution nowadays

Hydra from DnD, by Wizards of the Coast Once upon a time, the Spring Framework provided a much more lightweight and flexible solution than J2EE. Even in around 2013 I was happy to learn in detail about the then new Spring 4. Nowadays, 7 years later, when I see Spring, I get a panic attack. Annotations and @ComponentScan have replaced XML with something nicer - that requires a visualization tool to understand your system. And Spring has become a hydra that keeps on growing (and changing) heads. I have suffered through taking over and trying to understand a Spring application written by others. And, last but not least, Clojure has taught me how simple code can/should be. So what are my main issues with Spring?

(To make it clear, when I say Spring I mean mostly Spring Inversion of Control (IoC) and its subset of Dependency Injection.)

Disadvantages of Spring

Incomprehensibility

The black box of system composition

Annotations and component scan are awesome for getting your application up and running quickly. But they are a nightmare when you try to understand said application. With @Configurations and @Beans coming from anywhere in your codebase, company libraries, and possibly anywhere from the ±100MB of Spring dependencies (true story), it is impossible to have a clear picture of your application’s structure and configuration. You essentially need to RTFM for Spring and all the libraries - again and again so that you will remember all you might ever need - and read through the whole code base. Tools such as the IntelliJ Spring Beans view might help (if you get it working).

Here I wholeheartedly agree with the Zen of Python's "Explicit is better than implicit."

In a typical Clojure project, I go to the core/main namespace where the main function starts a server and gives it a handler function, likely (manually) wrapped with some middleware, and possibly internally using a library for routing. I also likely read in configuration and pass it around. I can easily click-navigate through the code and see exactly what pieces there are and how they work together. Even in a big system such as cljdoc.org there is a main function that starts the server (here Integrant) and supplies it with configuration and "system definition" (similar to a tree of Spring beans). Everything is explicit and navigable.

Programming by annotations

Being able to add metadata to methods and classes is great. I have nothing against the likes of @GetMapping("/"). But often it is used to bypass the limitations of Java and to implement cross-cutting concerns with annotations such as @Scheduled and @Transactional. I was once a strong proponent of AOP, and it is still an indispensable tool for a Java developer due to the language limitations, but I also came to realize its non-negligible cost. The problem is that you cannot easily see what it is doing (because it is doing nothing - it is just data). To give you a perspective, to replace @Transactional Person findPersonInDb(String personId) {..} in Clojure, I would simply wrap it with a custom macro, for example thus:

(defn find-person-in-db [person-id]
  (transactional
    ...))

The substantial difference is that I can control-click navigate to the macro and see what it is doing so all the behavior is right there, for me to inspect and understand. Good luck trying to figure that out in Spring!

Troubleshooting is a pain in the…​ everything

Spring is great for getting a lot of things off the ground quickly - until something fails or does not work as expected. Spring is a big ball of loosely coupled spaghetti and troubleshooting it is very hard, in my painful experience. There is a lot of documentation - but I often failed to find the answers I needed. Perhaps the official documentation is simply too shallow and sometimes relies on a lot of pre-existing knowledge. And searching the internet provided sometimes a solution, sometimes at least useful pointers, sometimes misleading/old information, sometimes nothing. When troubleshooting, you need to trudge through this huge complex of classes that somehow work (or are supposed to work) together, influenced in (seemingly) mysterious ways by what JARs and @Configurations you have on the classpath, and hope you will stumble upon the cause of your problems.

For example I spent quite a while understanding how error handling worked in our Spring MVC application. We have a @ControllerAdvice(["myapp.endpoint.api"]) myapp.endpoint.api.advice.ErrorHandler for API calls (if you remember to throw the correct exception type(s)), @Component myapp.spring.ErrorPagesCustomizer implements WebServerFactoryCustomizer<ConfigurableServletWebServerFactory> that does container.addErrorPages(new ErrorPage("/error")) to send errors to the org.springframework.boot.autoconfigure.web.servlet.error.BasicErrorController, which will then magically render our error.html on the classpath. And I realize I do not understand anymore (if I ever did) how exactly it actually works. But it was certainly a struggle to get errors displayed in the desired way.

Another pain point was understanding why Spring was returning 404 instead of the expected file. I have (had) a ton of hard-won knowledge about its request processing that might one day make it into a blog post.

Understanding how @Scheduled jobs are actually scheduled and trying to find out why a job is not running when expected and what is the magical invocation to increase the thread pool size so that it would not be stopped by slower jobs anymore took a couple of days and a number of failed attempts, and I even gave up once or twice. Searching the internets provided some help but certainly far from enough. And that is a general experience of mine, as related to Spring.

Searching for sudden ClassNotFound runtime errors after a Spring upgrade, trying to figure out which Spring JAR has the class and which is the right version or how to change configuration to stop requiring the change…​ No, thank you.

Why Spring IoC?

This is a two-part question: Why IoC? Why Spring?

Spring is a mature and popular solution, many developers know it. It also provides a solution for almost anything, and those solutions typically work well together. You also get started quickly. On the other hand, you could argue that it is overblown, suffers from legacy, and - by trying to do everything, for everyone - does nothing perfectly and smaller, focused solutions might be better (though you will need to integrate them).

Why Dependency Injection and IoC? You can find a number of reasons at SO: What are the benefits of using Dependency Injection and IoC Containers?. Some of those are:

  1. Simplification - your classes do not need to know how to create their dependencies (and what those need in turn). You separate out the concern of instantiating and wiring together classes.

  2. Flexibility - you can now provide different/decorated implementations. Thus in testing you can supply mock implementations, in a big and complex system it makes it easier to refactor it gradually by swapping in new implementations (this partly depends on programming to interfaces), you can wrap dependencies with troubleshooting decorators.

  3. Life cycle control

See the advantages and disadvantages listed at Wikipedia. But the fact that it has clear benefits does not mean you should use it everywhere, and for everything. Remember the cost and disadvantages and make well-founded decisions.

We normally use dependency injection in Clojure - f.ex. the database access library next.jdbc requires that you pass the target datasource to each call. (Which leaves you free to make your own wrapper that controls the data source and passes it on to the library, if you wished so.) When you construct an AWS client in the Cognitect AWS API, you can either let it create the default underlying HTTP client or supply your own - so you can override the default dependency. We even have dependency-injection frameworks such as Component and context that some people like to use while other, seasoned developers find them unnecessary. (There is a recent, enlightening discussion of this topic at ClojureVerse - How to replace DI in Clojure?.)

Alternatives

When speaking about the Spring ecosystem as a whole, you should always apply the Alex' Justified Library Principle: Don’t add a library until the pain of not having the library is so great, you cannot bear to live without it (and after having properly explored alternatives).

When you need Dependency Injection, prefer to compose your system manually. The current age of small microservices is quite different from the era of behemoth applications that gave birth to Spring. You can do it yourself (why not?!) or use a light-weight, focused solution to the problem that is more manual, for example Feather. Prefer programmatic configuration as much as possible. Feather is still annotation-driven with CDI’s @Provides but at least you declare those inside a "module" class that you explicitly register with Feather and you explicitly ask Feather for the instances you want. In the past we have used Guice with manual calls to bind inside the Main class of each microservice. It seemed weird and wrong to me, an experienced Spring user at that time, but I came to understand and appreciate it. There is even JaFu, the (experimental) programmatic configuration DSL for Spring Boot (there is also one for Kotlin, KoFu).

According to some people, Jakarta EE (descendant of Java EE) is a cleaner, smaller, nicer alternative to the Spring ecosystem. You can also search for individual solutions for particular needs.

I have heard good things from a respected colleague about Micronaut, which provides low overhead DI and AOP, REST client/server, reactive, circuit breaker etc. But it still relies (it seems) on classpath scanning for configuration and thus keeps the main issue I have with Spring. There is also the reactive Helidon SE with “_[t]ransparent "no magic" development experience; pure java application development with no annotations and no dependency injections_” ❤️. In the same domain as the two is Quarkus but its IoC is based on CDI and thus has the same issue as Spring. Eclipse Vert.X, with focus on reactive, event-driven applications is somewhat different but offers similar features (HTTP client/server, OpenAPI, GraphQL, DB access, config, circuit breaker, security, metrics) - and has programmatic configuration; contrary to all the other, it doesn’t provide dependency injection (but you might not need it (though even busses have issues of their own)).

What others are saying

While doing a research for this article, I found a number of experiences and opinions worth sharing.

The famous Norwegian SW architect Johannes Brodwall wrote (in 2013 - once again, well ahead of me):
So I found that some of the instincts that the DI container had given me made me improve the design, but at the same time, I found that when I removed the container, the solution became smaller (which is good!), easier to navigate and understand and easier to test. [..] I found that the cost of using the container is very high – it creates a force towards increasing complexity and size and reduced coherence.
— Johannes Brodwall
Why I stopped using Spring
The problem with this is that Spring kind of messes with the simplicity benefits we get from using Java. Spring discreetly introduces complexity to your project, the framework is simple on the surface, when it works, but honestly how many people can explain what is happening under the hood in Spring? Debugging Spring errors often seems like black magic, it’s 90% guess work and pattern matching. […​] Spring can often save you weeks of work at the beginning of a project, you may feel you are getting these benefits for free, you aren’t. In some ways you can draw an analogy to the early benefits of using a dynamic programming language for a project. In the initial stages of a project it speeds you up massively, but the complexity and technical debt hits you at a much later stage.
— Courtney Frederick

(Though of course his comparison with dynamic programming languages is contrary to my and others' experiences with Clojure.)

So they changed to an annotation-based configuration, and was forced to learn it, but I still don’t like it. It’s still not really Java. Many things happen by magic — and when they fail to happen, they all fail in the same way, with nothing happening. So you edit and recompile, and the same nothing happens. It’s impossible to tell if you’ve annotated the wrong thing, or if your annotation says something different than you thought it said, or if you’re building your test wrong.

And if there is a decent documentation, I haven’t seen it. I was recently looking at RequestParam, a ubiquitous part of Spring MVC, to understand its semantics. The page is effectively useless. Javadoc is the primary way that APIs are communicated; even if better documentation exists somewhere, this page doesn’t tell me much.

— Joshua Engel
On what a modern Java application looks like:
Main method with a single line starting Spring and lots of classes with 5 annotations each. How they get instantiated, in what order, how to debug this process if something goes wrong - […​]
— Dmitry Murashenkov

Now here are a few particular things I hate about Spring:

  1. It really slows down your app’s startup time

  2. You won’t find out if your app works or not, until you run it - in large commercial environments it can mean up to an hour build and deployment time

  3. …then you get a massive stacktrace, half of which concerns internal spring classes

  4. Once you go beyond a simple singleton wiring scenario, Spring can get very ugly, very flaky and unpredictable

  5. It helps you start, but the further you go, the more of a maintenance nightmare it becomes.

— Anonymous

Annotations and autowire (or (at)Inject if you use standards based)……are the ANTI PATTERN to “Composition Root”. Composition Root by Mark Seemann. Composition Root basically says “Let’s create a SINGLE top layer place to define how our application is composed”.. It is like a Table of Contents or Index for a reference book. If I ask a non-critical-thinking java developer “should reference books have a ToC and/or index to find things?” they say “Yes”. then I say “Ok, so why would we create an application (whose metaphor is the book)…….withOUT a ToC/index for the DI definitions? (crickets) and then “autowire is easy peezey”. […​] Component Scan and autowire .. is a bunch of wheresWaldo voodoo that ends up wasting time (tracking down) waldo on any significant sized code base…and when things are not working.

— Granada Coder

It makes it very easy to do some very complex things. That’s awesome.

In the right hands it can mean a fully function application is up and running in days, not weeks. That’s awesome.

In the wrong hands it can mean putting up an application that is almost completely opaque to the people running it. That can be less awesome. Sometimes a lot less awesome.

— Dave Bee
This one included because I love its poeticism:
Spring is a nasty piece of work. It makes the trivial easy to do, but the non-trivial becomes a prison where the condemned programmer is forced to suffer with inscrutable errors, poor documentation, and lousy performance. Run away.
— John McGinty

Conclusion

So is Spring evil and should it be avoided at any cost? No. It enables one to overcome Java’s limitations and offers a number of libraries solving real problems. But it is also huge, complex, and comes at a high maintenance cost. Be skeptical about introducing libraries, look at multiple solution and pick the best one for your case, not simply the Spring one. Even when using DI and IoC - Spring’s or other - strive for maximal transparency and prefer programmatic configuration over classpath scanning. If everything else fails, write good (java)doc so that your successors will be able to understand your system.


Tags: architecture opinion experience java


Copyright © 2024 Jakub Holý
Powered by Cryogen
Theme by KingMob