Moving Dependendency Injection Wiring to Build Time in Java with Avaje
At a recent Java conference in Auckland, Rob Bygrave gave a talk about moving more of Java processes’ dependency injection startup wiring to build-time rather than do it at runtime each time the application starts. He and others have written a system called Avaje which provides a set of tools and libraries which support this methodology. His session at the conference inspired me to take a closer look at these libraries, understand better how they work and to give them a go in a simple example.
What is meant by “wiring”?
Within the context of a Java dependency injection framework, some Java class instances are known as “beans”. Unfortunately the term “bean” has a complicated history which muddies the definition, but in essence, a bean embodies logic that takes responsibility for a distinct part of the functionality of the application. The beans have an interplay with each other and so need to be connected together. It’s the connection between the beans that we mean by “wiring up”. Structuring a software system into discrete beans and wiring the beans together leads to patterns that are recognized as beneficial to the quality and maintainability of the software.
To find the classes that can be treated as beans, a dependency injection system, such as the popular Spring framework, will scan the classes available at start time looking for those with specific “annotations”. Classes decorated with annotations such as @Service and @Component are discovered up by Spring and used as beans. Once a bean is found, a proxy is created which wraps the bean class and then the framework establishes which bean proxies need to be connected and wires them up.
The author of the software also has the option to explicitly specify some bean instances, but even then proxies and wiring is still employed.
There are many complexities and specific details about wiring, but this is enough to give an overview.
What are the problems?
Although the startup wiring process in a dependency injection framework brings many advantages and represents a powerful engineering construct, it typically shifts burden onto the application runtime.
- The first burden is that it takes time to perform the startup wiring. Although complex applications inherently take some time to start anyway, the startup wiring process adds to the delay before the application can start handling work.
- A second burden is concerning memory. In order to perform the wiring at start time, the framework is a large piece of logic that has to be in memory to run the process. The proxies created for each bean also add to the weight of material in memory. This means each copy of the running application has a portion of its memory footprint in a server which is dedicated to the wiring.
- Because the framework needs to be able to introspect the classes to perform the wiring process, the classes need to introspectable. The behavior of introspecting classes in Java is known as “reflection”. Reflection can pose challenges for leveraging more recent Java technologies such as GraalVM.
Why is this a problem now but maybe less so in the past?
Many years ago, Java applications were typically run on an in-house server and left running for a long time before the next deployment. More recently, companies deploy many instances of smaller applications, scaling them up and down and deploying new versions more frequently than was the case in the past. If an application runs for 10 days then a long startup time is immaterial, but if many instances of the application are frequently started and stopped, startup time becomes more interesting.
This is shown in the diagram below where the green blocks show larger longer running applications whereas the lower lavender-colored blocks represent the behavior of smaller applications that run in larger numbers for a shorter length of time.
For the same reason, memory footprint becomes interesting too. It’s a bit like filling a sack will balls; you can fit more tennis balls into a sack than basket balls. Likewise, if your Applications are smaller and use less memory then you can pack more of them into your servers. The same goes for container images; smaller is better.
Dependency injection at build time with Avaje
A typical build with a popular dependency injection framework proceeds as indicated in the diagram below. The build system takes the application sources plus the large framework and builds them together into a build product.
At runtime, as the application starts, the dependency injection framework performs the wiring.
The build of an Avaje application progresses somewhat differently. The application sources are introspected using Java annotation processors that generate source code based on annotations it finds in the source code such as @Service. This generated code will perform the wiring process later when the application runs.
The build process compiles the original application code plus the generated code to produce the built application.
As the application starts, the Avaje runtime framework does not need to do the introspection, proxying and wiring when the application starts; instead it more simply executes the generated logic locked-in during the build process to do the wiring. Important too is that the Avaje-generated code does not require a system of proxies or reflection to work.
See the Avaje documentation for supported annotations. They will look quite familiar to somebody who has worked in this space before; @Singleton, @PostConstruct and @Bean for example are supported. Avaje also provides support for REST annotations such as @Controller.
Giving it a go with HaikuDepotServer Graphics Server
The HaikuDepotServer system includes a small Java application for processing graphics. Although a systems language would have been arguably a more fitting technology, for consistency, the graphics server was implemented with Java + Spring Boot. The resulting application was predictably heavy on memory considering its simple requirements. This situation seemed like a good candidate for trialing the Avaje technology owing to the low complexity and ease of backing out if it did not work out.
You can see the Maven module here. Assuming you have a Java 25 JDK installed + available, you can clone the project (currently tag haikudepotserver-1.0.184) and build this application by running the following command from the top level of the project;
./mvnw -pl haikudepotserver-server-graphics compile
The Maven maven-compiler-plugin plugin defined in haikudepotserver-server-graphics/pom.xml has two annotation processors configured which generate source code;
avaje-inject-generatorfor generating code for dependency injectionavaje-http-jex-generatorfor generating code for interfacing a REST API
After the build is complete, take a look at the material at haikudepotserver-server-graphics/target/generated-sources to find generated sources for performing the dependency injection wiring and the REST API interfacing.
I did have to contribute a handful of minor patches to the Avaje system to make it work for my application plus I ran into some challenges with transitive dependencies but in general, the system has worked well.
The graphics server application is a poor example to use for size-comparison because the minimal business logic footprint will naturally be dwarfed by the supporting framework, but nonetheless, the original Spring Boot implementation is a 27 MB self-contained .jar and the Avaje equivalent is a 3.6 MB .jar (13%). The Spring Boot implementation start time on a developer laptop is circa 2036 ms compared to the Avaje equivalent which starts in 253 ms (12%). At runtime the Spring Boot implementation uses around 17.2 MB and the Avaje equivalent around 5.5 MB (32%).
The following graph shows these differences graphically with the original Spring Boot implementation shown at 100% in yellow and the Avaje percentage of that measure in green.
The refactored graphics server has been running for some time in production without any issues.
Conclusion
The dependency injection architecture embodied in Avaje is different from the likes of Spring. The Avaje framework takes a clever path of using code-generation to shift the dependency injection process from runtime to build time and also reduces both memory and compute resource consumption.
Avaje also removes a source of widespread runtime introspection from an application’s dependency injection meaning that a typical application has a much reduced use of the Java Reflection system which is arguably more elegant and should ease the use of modern Java technologies.
There is however a lot of momentum around frameworks like Spring and, Spring Boot conveniently integrates a lot of different technologies. There’s a lot of value in that capability and a lot of as-built software that leverages that capability. There’s also a lot of industry know-how around these frameworks. For these reasons, I can see the argument for the status quo.
However, as in the example case above, it seems like there will be many cases where Avaje will make sense and, it’s clever benefits can be realized.
Postscript / feedback
- Not called out above, Avaje is also able to perform code-generation for other aspects of an application infrastructure too such as JSON serialization / deserialization and web-routing.
- Investigation into using GraalVM and the benefits it can bring.