Spring Boot affords us a lot of tools right out of the box as developers. It even ships with a dynamic HTML template engine called Thymleaf to allow us to not have to rely on Javascript frameworks such a React, Vue, or Angular if we wanted to go down that route.
Spring Boot heavily relies on annotations, similar to decorators when building an Angular front end application. Annotations are essentially metadata that are used to add context to what a class, method, or field does. This helps Spring Boot automatically configure Beans, manage dependencies, and handle other application configuration that we’d have to otherwise do manually when compiling our application.
One of these annotations is @Autowired. This annotation is a way for us to achieve dependency injection with next to no boilerplate code needed. It almost seems like magic because of how easy it is, and with that level of abstraction comes several caveats and pit falls when comparing this method of dependency injection versus traditional constructor injection.
Let’s first take a look at some of the benefits of using the @Autowired annotation:
- Reduce boilerplate code
This is probably an obvious advantage and is the reason why some people tend to use @Autowired for any and every dependency needed: It’s so easy! All you need to do is simply add the @Autowired annotation to whatever field, constructor parameters, or setter methods you want to inject and you’re done! This also somewhat improves code readability because it reduces the amount of boilerplate code you have to write. - Loose Coupling
By injecting components at runtime we can achieve loose coupling which is a great advantage when it comes time to test our application via unit testing. Though you will likely have to mock or provide the appropriate test beans when running the unit tests. - Automatic dependency lookup
Spring Boot will find and manage the appropriate beans to inject based on their types and their annotations. You won’t have to create or locate the required Beans yourself while can reduce the likelihood of errors and added complexity. (More on why this could be bad in a little bit)
And let’s discuss some of the drawbacks when using @Autowired:
- Implicit dependencies
Sure, @Autowired helps to reduce the amount of boilerplate required, it also can make it more difficult to truly discern what the class is doing and what exactly it relies on in order to function as intended. This could pose as a real challenge for future developers who are tasked with maintaining/enhancing the application in the future – they will have to take more time to reason about the code you’ve written. - Less control
By reducing the amount of boilerplate code needed, one of the trade offs is giving up some level of control. What if for some reason you have multiple beans of the same type? Spring Boot might not pick the correct bean and as a result cause the application to break or not function correctly. You can get around this by specifying qualifiers, but if you’re going down that route, why not rely on constructor injection? - Circular Dependencies
It is very easy to get yourself into a situation where service A depends on service B, which depends on service A. You have to be very careful not to create a circular dependency and you also have to ensure the order in which bean creation is taking place is correct. This is easier said than done in a large application with dozens and dozens of services, controllers, and repositories.
With that said, when should we be using the @Autowired annotation to handle dependency injection? It’s hard to come up with an iron clad rule – If you’re building a small CRUD application and are just working towards an MVP and you don’t have many services, then probably using constructor injection is the way to go in this case. If you’re working on a larger enterprise-level application, then most likely you will want to avoid using @Autowired in most cases. If you have a service that relies on half a dozen or more other services, there might be no other sensible way aside from using @Autowired – though this indicates code smell: Why does this service rely on so many other dependencies to function? Is it doing too much and can be broken into smaller chunks?
Finally, let’s discuss some of the advantages of using traditional constructor dependency injection, as well as some of its draw backs (You can probably guess what they are based off of the pros and cons of using @Autowired)
Benefits of constructor-based dependency injection:
- Clear dependencies
Dependencies are declared directly in the constructor, which makes them clearly visible and identifiable. This will allow for better readability in the future and reduces the risk of missing or hidden dependencies. Sometimes being more verbose is better, and this is one of those cases. - Fail-Fast
If a mandatory dependency is missing, the constructor initialization fails which prevents the creation of half-baked objects. Even better, you avoid the wrong bean being injected if you wind up with two beans with the same name/type. - Encourages immutability:
If you’re using the functional programming paradigm when building your application or want to stay true to true OOO principles, using constructor-based dependency injection as opposed to @Autowired is basically a must. - Testability
Generally, constructor-based dependency injection is easier to test. You can more easily mock dependencies during unit testing by passing mock objects into the constructor.
And finally, let’s take a brief look at some of the drawbacks of using constructor-based dependency injection (Hint: there really aren’t many):
- Boilerplate code
This is probably the biggest one for most people – it requires a lot more code than just simply using @Autowired to handle dependency injection, but in most cases the additional code is worth it. You could even explore using Lombok if you wanted to still use constructor-based dependency injection but keep the amount of boilerplate code at bay. Just be careful because you’re introducing yet another layer of abstraction by using Lombok. You have to find the right balance for your code base and the team you’re working with. - Limited Flexibility
There is really no way to handle optional dependencies when using constructor injection so situations that require dynamic configuration changes might pose a problem. The answer to this is just more code in the way of more objects and classes. Again, this is a trade-off between how easy the code is to read and maintain and how quickly you can get the code working.