Dependency Injection (DI) can be a very difficult topic to grasp, since there seems to be a lot of "magic" going on. Usually it involves a bunch of annotations scattered all over the place, with objects appearing seemingly out of nowhere. I certainly know that it took me a long while to really understand the concept. If you ever find it hard to understand what Spring and Java EE are doing behind the curtains (and why!), read on!
In this tutorial, we are going to build a very bare-bones, yet fully functional dependency injection container from scratch in Java. Here are some rules to keep things manageable:
- Absolutely no libraries allowed, except for the JDK itself.
- No pre-prepared code dumps. We will go through everything and reason about it.
- No bells & whistles, just the basics.
- Performance doesn't matter, no optimizations.
- Executable
main()
methods at every step.
In contrast to other articles, I will not explain up-front what DI is or why it is useful. Instead, we'll start with a simple example and let it "evolve".
DI Stage 0: Basic example
Find the source code of this section on github
We start with a very basic example. We want two classes, let's call them ServiceA
and ServiceB
, and ServiceA
needs ServiceB
to do its work. Well, you might implement it with static
methods and be done with it! So here goes:
public class ServiceA {
public static String jobA(){
return "jobA(" + ServiceB.jobB() + ")";
}
}
public class ServiceB {
public static String jobB(){
return "jobB()";
}
}
public class Main {
public static void main(String[] args) {
System.out.println(ServiceA.jobA());
}
}
If we run this code, it will print:
jobA(jobB())
Cool! So why even bother with more than this? Well...
- The code is very poor in terms of OO principles. ServiceA and ServiceB should, at the very least, be objects.
- The code is tightly coupled and very hard to test in isolation.
- We have absolutely no chance of swapping neither ServiceA nor ServiceB with a different implementation. Imagine one of them is doing credit card billings; you don't want that to actually happen in your test suite.
DI Stage 1: Getting rid of static references
Find the source code of this section on github
The main problem we identified in Stage 0 is that there are only static methods, resulting in extremely tight coupling. We would like our services to be objects talking to each other, such that we may replace them as needed. Now the question arises: how does ServiceA know which ServiceB to talk to? The most basic idea is to simply give our instance of ServiceB to the constructor of ServiceA:
public class ServiceA {
private ServiceB serviceB;
public ServiceA(ServiceB serviceB){
this.serviceB = serviceB;
}
public String jobA(){
return "jobA(" + this.serviceB.jobB() + ")";
}
}
Service B didn't change much:
public class ServiceB {
public String jobB() {
return "jobB()";
}
}
... and Main
now needs to assemble the objects before it can call the jobA
method:
public static void main(String[] args) {
ServiceB serviceB = new ServiceB();
ServiceA serviceA = new ServiceA(serviceB);
System.out.println(serviceA.jobA());
}
Nothing fancy here, right? We certainly improved some things:
- We can now replace the implementation of
ServiceB
which is used byServiceA
by providing another object, potentially even of another subclass. - We can test both services in isolation with a proper test mock for ServiceA.
So all cool? Not quite:
- It's hard to create mocks, as we require a class.
- It would be much nicer if we required interfaces instead. Also, this would further reduce the coupling.
DI Stage 2: Using interfaces
Find the source code of this section on github
So let's use one interface for each of our services. They're about as simple as it gets (I renamed the actual classes to ServiceAImpl
and ServiceBImpl
, respectively):
public interface ServiceA {
public String jobA();
}
public interface ServiceB {
public String jobB();
}
Now, in ServiceAImpl
, we can actually use the interface:
public class ServiceAImpl implements ServiceA {
private final ServiceB serviceB;
public ServiceAImpl(ServiceB serviceB){
this.serviceB = serviceB;
}
// jobA() is the same as before
}
That also makes our main()
method a bit nicer:
public static void main(String[] args) {
ServiceB serviceB = new ServiceBImpl();
ServiceA serviceA = new ServiceAImpl(serviceB);
System.out.println(serviceA.jobA());
}
This would be perfectly acceptable for simple use cases from an OO perspective. If you can get away with this, by all means, stop here. However, as your project gets bigger...
- it will become more and more complex to create the network of services inside your
main()
method. - you will encounter cycles in your service dependencies which cannot be resolved using constructors as shown in our example.
DI Stage 3: Breaking the cycle with setters
Find the source code of this section on github
Let's assume that not only ServiceA
needs ServiceB
, but also the other way around - we have a cycle. Of course, we may still declare a parameter in the constructor of the *Impl
classes, like so:
// constructor examples
public ServiceAImpl(ServiceB serviceB) { ... }
public ServiceBImpl(ServiceA serviceA) { ... }
... but that will do us no good: we will be unable to create an actual instance of either of the two classes. To create an instance of ServiceAImpl
, we would first require an instance of ServiceB
, and to create an instance of ServiceBImpl
we first require an instance of ServiceA
. We're deadlocked.
Side note: there is actually a way out of this by using proxies. However, we're not going to take that route here.
So what do we do instead? Well, since we are dealing with cyclic dependencies, we need the ability to first create the services and then wire them together. We do this with setters:
public class ServiceAImpl implements ServiceA {
private ServiceB serviceB;
// no constructor anymore here!
@Override // <- added getter to ServiceA interface
public ServiceB getServiceB() { return serviceB; }
@Override // <- added setter to ServiceA interface
public void setServiceB(final ServiceB serviceB) { this.serviceB = serviceB; }
// jobA() same as before
}
Our main()
method looks like this:
public static void main(String[] args) {
// create instances
ServiceB serviceB = new ServiceBImpl();
ServiceA serviceA = new ServiceAImpl();
// wire them together
serviceA.setServiceB(serviceB);
serviceB.setServiceA(serviceA);
// call business logic
System.out.println(serviceA.jobA());
}
Can you see the pattern? First create the objects, then connect them to form the service graph (i.e. service network).
So why not stop here?
- Doing the wiring part manually is error prone. You might forget to call a setter and then it's
NullPointerException
galore. - You might accidentally use a service instance that is still under construction, so it would be beneficial to encapsulate the network construction somehow.
Up next
In the next blog post, we will discuss how we can automate the wiring we are doing manually right now.
Top comments (0)