Introduction
Software development velocity is a major concern for many teams and tech companies. Software is expensive to write and maintain. Businesses find themselves under siege by competition and feel pressure to move more quickly. In this article, I am going to discuss how we can move faster as developers by removing unnecessary dependencies, such as runtime DI frameworks like Spring.
When designing a professional race car, one builds around the engine entirely. The engine is the heaviest component of the vehicle, but also the most important, as it provides all the power to propel the rest of the vehicle forward. All other components are designed to support the engine but otherwise get out of the way.
By analogy, a service implements and executes some key business functionality, which we can compare to our race car's engine. Any frameworks, libraries, and infrastructure we use must support the core business functions of the service, but hopefully otherwise get out of the way entirely.
By analogy, a service implements and executes some key business functionality, which we can compare to our race car's engine. Any frameworks, libraries, and infrastructure we use must support the core business functions of the service, but hopefully otherwise get out of the way entirely.
In the Java community, runtime dependency injection (DI) frameworks like Spring are ubiquitous, and are used almost without a second thought as to alternatives. I believe that these frameworks do not support our core business functionality well but actually get in the way and slow us down considerably. By removing them from our services, we enable ourselves to move faster and ship features more quickly.
This article assumes that you, the reader, have some background in the Java programming language, the Spring framework, and general programming language and software design principles. While I would love to explain everything as I go, the article would simply be too long.
What is DI?
Dependency injection is such a simple concept that it can be explained in one sentence. Unfortunately, DI frameworks like Spring tend to over-complicate the concept to the point that many software developers miss the mark entirely. In my career I have given multiple presentations on DI to help clear up confusion and to help people use DI frameworks more effectively.DI framework tutorials will often focus on things like not using the "new" keyword, or decoupling object uses from object creation. While there is truth in these statements, they miss the higher-level meaning of DI. DI is not something specific to OOP or a single programming language. Simply put, dependency injection is parameter passing, and the opposite of dependency injection is explicitly referencing global variables. We "pass" dependencies to code most commonly by passing arguments to functions. That's it! Every software developer understands parameter-passing and global variables, so every software developer understands DI to some extent.
How does the above treatment of DI relate to the "new" keyword or object creation in an OOP language like Java? Java doesn't have anything explicitly called a "global variable" in the language, but it has multiple constructs that are morally equivalent to global variables, including:
- Static fields
- Static Methods
- Class definitions
- Constructors
The last point in particular relates to why the "new" keyword is evil. When an object calls "new" in Java, it is referencing a global variable, a constructor, in order to create an object. The opposite of DI is parameter-passing, so, if we wanted to create an object via DI In java, we would "pass" a Factory object (or simply a Java 8 Supplier or Function object), that would do the creation for us. Or we, the caller of said code, could create the object how we see fit and pass it in directly.
Note that it is both impossible and undesirable to use DI everywhere. A discussion on when to use DI and not to, and the global versus non-global dependencies any piece of code has, is an interesting enough topic to warrant its own blog post. Suffice it to say, we make code more modular and reduce its dependency surface area by passing in dependencies, and we reduce modularity and increase dependency surface area by having code explicitly reference global variables.
Note that it is both impossible and undesirable to use DI everywhere. A discussion on when to use DI and not to, and the global versus non-global dependencies any piece of code has, is an interesting enough topic to warrant its own blog post. Suffice it to say, we make code more modular and reduce its dependency surface area by passing in dependencies, and we reduce modularity and increase dependency surface area by having code explicitly reference global variables.
Spring Discourages DI
That's right. Our favorite java DI framework actually makes it harder for you to do DI properly, which means it fails at its primary purpose! To explain how, I am going to provide an example of doing "DI" in Spring versus not doing DI at all, and then compare the two to show that we are basically doing the same thing in both cases, explicitly referencing global variables. In the one case, we are referencing them using Spring constructs. In the other case, we are referencing them using Java language constructs.
Spring "DI" Example
For our DI example, we are going to show you two classes, one of which will depend on the other. First, we have our Database class:@Component
public class Database {
public Data getData();
}
Note first the @Component annotation. This is used in conjunction with Spring's component scanning whereby Spring will automatically create a bean definition for this class. Now, we define a dependency bean:
Now, whenever our program requests a bean of type Service, Spring will know how to automatically create it. It will also know it needs to create the Database bean first, then autowire it into the constructor of Service. This may look great on the surface. Spring just wrote a bunch of code so we don't have to, and we are passing injecting our dependencies, right?!
@Component public class Service { private final Database database; public Service(Database database) { this.database = database; } public void doWork() { Data data = database.getData(); //do something interesting with data } }
Now, whenever our program requests a bean of type Service, Spring will know how to automatically create it. It will also know it needs to create the Database bean first, then autowire it into the constructor of Service. This may look great on the surface. Spring just wrote a bunch of code so we don't have to, and we are passing injecting our dependencies, right?!
Non-DI Example
public class Database { public static Data getData(); } public class Service { public static void doWork() { Data data = Database.getData(); //do something interesting with data } }
We are now entirely avoiding DI and using global variables everywhere. We could have written this differently. For instance, we could have made these classes regular objects with instance methods, and then created them using the "new" keyword. Or, we could have used the singleton object pattern. However, the above construction suffices for our illustration. When our application requests to use the Service class, the JVM classloader knows how to load the Service class if it hasn't done so already, and as part of loading that class, it knows to load the Database class first and then link the two together.
Spring is a Fancy Classloader
The grand lie of Spring is that it lets you write code that looks like you're using dependency injection, when in practice your code is explicitly using global variables everywhere. These two code examples end up doing almost exactly the same thing. The difference is that, in the first example, explicit global references are occurring through the Spring context and Spring domain-specific language, whereas the explicit global variable references in the second example are occurring through the JVM and plain java programming language constructs. The below table gives the roughly isomorphic components between the two systems:Spring Construct | JVM Construct |
---|---|
Application Context | Classloader |
Component Scanning | Classpath Scanning |
Bean Definition | Constructor, Factory Method |
Singleton Bean | Static Field |
Prototype Bean | Constructor, Factory Method |
Autowiring | Class-loading |
In Spring, everything, every bean definition, every singleton bean, is stored in a single global variable called the application context. Every bean reference is an explicit global variable reference into that context. Every autowiring, whether by type or by ID, amounts to an explicit global variable reference into the same data structure. When you use pretty much any Spring feature, you are doing the opposite of DI!
There are many counterarguments to our illustrated truth, all of them easily defeated. First, you may claim that application contexts aren't really global variables. But they are global, just as much as anything in your application is global.. You can argue, for instance, that an application can have multiple spring contexts, including hierarchical contexts, yet the exact same is true of Classloaders; you can have multiple independent classloader hierarchies. You can load the same class in each hierarchy and have them be entirely unrelated!
You may say that you get better testability because dependency objects can be mocked, but not static fields and methods. This, of course is not true either. In java, you can use a java agent for testing that hijacks the classloader and lets you mock static fields, methods, constructors, and objects. Libraries like EasyMock, JMockit, and PowerMock do exactly that. The reality is that the power and flexibility of the JVM Classloader is equivalent to the power and flexibility you get from using Spring DI.
You may say that Spring gives you lookup ability beyond that of a classloader, like the ability to autowire an interface type. While this is certainly true, you could just as easily build equivalent functionality using Classloaders without having to write an entirely new framework. Coding with and wiring objects by interface is generally a good DI practice, but also one that could just aseasily be done via Classloader mechanisms as with Spring.
The strongest argument for Spring is that it is a collection of frameworks and libraries, and not just a DI framework. This argument is true, but, in my experience, these libraries and frameworks are generally sub-par and have better non-spring alternatives. Spring's property configuration system for instance has major fundamental design flaws. Spring AOP in my experience is a disaster that should be avoided. And there is no reason to use simple libraries like Spring Retry or Spring JDBC when non-Spring alternatives exist that don't have anything to do with the flawed foundation that is Spring DI itself.
Spring is Dead Weight
At the end of the day, Spring is a DI framework that provides you a DSL for pretending to do DI without actually doing DI! This means Spring has all the downsides of not using DI and none of the upsides. I have seen, for instance, otherwise very intelligent, competent engineers struggle for days to figure out how to most effectively solve problems like:- How do I resolve a conflict from two libraries exposing two conflicting beans of the same type, resulting in autowiring conflicts everywhere?
- How do I autowire one object to one sub-graph of my Spring object graph but autowire another of the same type to another sub-graph?
- How do I import libraries into another application without accidentally picking up all the beans in the library via component scanning?
All of the above problems have solutions in Spring. I mention them only because they are all problems related to the fact that everything in Spring is a global variable, and nothing in Spring is composable or modular.
I could probably write an entire article on how Spring slows you down as a software developer, but this already long post would then double in length. Suffice it to say, not only does Spring utterly fail at its primary goal, to better enable dependency injection, it brings with it a host of downsides:- It adds an incredible amount of complexity and overhead to your project. Spring is a complex, nuanced framework with many features, and it especially slows down junior developers as they learn it.
- Spring is notoriously difficult to debug. Everyone who has used Spring has dealt with the massive Spring stacktrace, for instance.
- Spring is hard to reason about. Few people in my experience are able to build an accurate mental model as to what is actually happening on startup.
- Spring slows application startup time immensely. Slow startup times make for less productive developers, slower build pipelines, and slower software deployments.
- Spring encourages bad software design. In my experience, developers who use Spring heavily tend to write code that is more highly-coupled, brittle, and overly-complicated.
- Spring is not modular and is very bad for libraries. Being forced to use Spring when pulling in a library can lead to all sorts of problems.
The Solution: Manual DI
Returning to our race car example, we should only want to include the essential libraries and frameworks that support our business critical functionality, the engine of our racecar, and throw out everything that doesn't justify its added weight. Runtime DI frameworks such as Spring provide few advantages but have large overhead, making the ideal candidates for removal. In their absence, we are then forced to wire our application objects manually.The one major upside to Spring is that it clearly separates application-wiring code from the rest of your code. In its absence, your team will need some discipline to keep this separation. I personally like to do this by having a separate package for application wiring. No business logic should go in this package, only logic related to instantiating the objects that will drive your application.
Now that you have to write your code manually, you may balk at the hundreds or even thousands of lines of code you are now responsible for writing and maintaing. With Spring, you were getting this for free, and now suddenly you have a bunch of boilerplate on your hands!
My answer to the boilerplate concern is to point to our friends, our fellow GoLang engineers. Dynamic languages have been deriding java for years as being too boilerplate-heavy. Then, Go arrives, a language so feature anemic it actually causes more boilerplate than Java! Why then would anyone prefer Go to Java?
In my opinion, the biggest lesson the Java community can learn from the GoLang community is that boilerplate is not the worst problem you can have as a software developer. Boilerplate code may annoy us, but it is usually very predictable and easy to both read and write, and rarely is it ever a source of bugs or significant maintenance burden. On the other hand, by manually wiring your application together, you get many benefits, such as:
- Help from your static type system. Many more bugs will be caught at compile-time.
- A vastly simplified system architecture. Even your junior developers will be able to read and understand your application startup.
- Significantly improved startup time. Fast feedback cycles are a critical component of software development velocity.
- Improved debugging
- The ability to write truly modular and reusable application wiring code, using features like:
- lexical scoping
- package namespacing
- The ability to define true modules via Java classes
So, join hands with our fellow Gophers, and recognize that there are worse things in life than having to write some boilerplate code. It is time for the Java community, which is built on the strong foundations of the JVM and related technologies, to throw out dead weight to become faster and more agile, and Spring is as good of a dead weight as any to start with!
Most of us don't have a choice in the matter of choosing our technology stack; we are forced to use whatever stack is standard to our company. If you're like me and stuck with Spring or some other runtime DI framework, then the next best thing you can do is advocate for using it properly. The most important thing you can do is to ensure your business logic classes operate entirely independently of Spring. This means in practice:Addendum: I Have to use Spring! What do I do?
- Your classes have no Spring or javax inject annotations of any kind, nor do they depend on any Spring types or anything else in the Spring library at all.
- All injectable dependencies are passed in via constructor arguments, or, in the worst case, setter method arguments.
- You concentrate all your Spring logic into a a few XML files and/or Java configuration classes that do nothing but wire things together for Spring.
There is also another consequence; component-scanning will be disallowed because you can't annotate your application classes. This means you'll have to write a little bit more boilerplate in your Spring configuration. The payoff is well worth it, though. You'll have isolated your coupling to Spring to the smallest region of your code possible and made it so that the rest of your application is as modular, composable, and loosely-coupled as possible. Debugging Spring and understanding it's startup sequence will also become a lot easier than before.
I also recommend against using any kind of Spring AOP, except, maybe CGLIB-generated proxies. Even in that case, I would consider caution. It can be incredibly difficult to debug or troubleshoot any issue related to runtime-generated bytecode, and AOP, like Spring, adds a lot of complexity to your project that usually isn't worth it. The topic of Java AOP is worthy of its own separate article.
Comments
Post a Comment