DEV Community

Martin Häusler
Martin Häusler

Posted on

Understanding Dependency Injection by writing a DI Container - from scratch! (Part 2)

This is the second part of my "DI From Scratch" series. In the previous article, we discussed our basic example, and the problems there are with the "manual" approach. Now, we want to automate the wiring of our service network.

DI Stage 4: Automating the wiring

Find the source code of this section on github

Let's focus on the main() method, and try to find a more automatic way of creating the service network. Certainly, forgetting to call a setter is a real threat here, and the compiler won't even be able to warn you about it. But our services are structurally different, and there is no uniform way to access them... or is there? Java Reflection to the rescue!

All we essentially want to provide our setup with is a list of our service classes. From there, the setup should construct and wire the service network. In particular, our main() method is only really interested in ServiceA, it doesn't even need ServiceB. Let's rewrite it like so:

    public static void main(String[] args) throws Exception {
        Set<Class<?>> serviceClasses = new HashSet<>();
        serviceClasses.add(ServiceAImpl.class);
        serviceClasses.add(ServiceBImpl.class);

        ServiceA serviceA = createServiceA(serviceClasses);

        // call business logic
        System.out.println(serviceA.jobA());
    }

But how can we implement the "magic" createServiceA method? Turns out, it's not that hard...

    private static ServiceA createServiceA(Set<Class<?>> serviceClasses) throws Exception{
        // step 1: create an instance of each service class
        Set<Object> serviceInstances = new HashSet<>();
        for(Class<?> serviceClass : serviceClasses){
            Constructor<?> constructor = serviceClass.getConstructor();
            constructor.setAccessible(true);
            serviceInstances.add(constructor.newInstance());
        }
        // step 2: wire them together
        for(Object serviceInstance : serviceInstances){
            for(Field field : serviceInstance.getClass().getDeclaredFields()){
                Class<?> fieldType = field.getType();
                field.setAccessible(true);
                // find a suitable matching service instance
                for(Object matchPartner : serviceInstances){
                    if(fieldType.isInstance(matchPartner)){
                        field.set(serviceInstance, matchPartner);
                    }
                }
            }
        }
        // step 3: from all our service instances, find ServiceA
        for(Object serviceInstance : serviceInstances){
            if(serviceInstance instanceof ServiceA){
                return (ServiceA)serviceInstance;
            }
        }
        // we didn't find the requested service instance
        return null;
    }

Let's break it down. In Step 1 we iterate over our classes, and for each class, we attempt to get the default constructor (i.e. the constructor with no arguments). Since neither ServiceAImpl nor ServiceBImpl specifies any constructor (we deleted them when introducing the getters/setters), the Java compiler provides a public default constructor - so that will work fine. Then, we make this constructor accessible. That's just defensive programming to make sure that private constructors will work too. Finally, we call newInstance() on the constructor to create the instance of the class, and add it to our set of instances.

In Step 2 we want to wire together our individual service instances. To do so, we look at each service object one by one. We retrieve it's Java class via getClass(), and ask that class for all of its declaredFields (declared means that private fields will be returned too). Just like for the constructor, we make sure that the field is accessible, and then we check the Type of the field. This will provide us with the service class we need to put into the field. All that's left to do is to find a suitable matchParter, an object which is of the type specified by the field. Once we find one, we call field.set(...) and assign the match partner to the field. Note that the first parameter of the field.set(...) method is the object which will have its field value changed.

In Step 3, the network is already complete; all that's left to do is to find the instance of ServiceA. We can simply scan through or instances and check if we found the right one by using instanceof ServiceA.

This might be a little daunting, so maybe try to read this once more. Also, you might want to brush up on your knowledge of Java reflection basics if any of that seems weird to you.

So what did we gain?

  • Our services are wired together automatically.
  • We can no longer forget to call a setter (in fact, we don't need them anymore).
  • Our application will fail on startup if the wiring fails, not during the business logic.

The primary pain that we need to treat next is the fact that we do not want to repeat this whole procedure every time we want to get a hold of a service; we want to have the ability to access every service in the network, not just one.

DI Stage 5: Encapsulating the Context

Find the source code of this section on github

The object which is responsible for holding the service network is called the Dependency Injection Container, or (in Spring terms) the Application Context. I'm going to use the "context" terminology, but the terms are really synonyms. The primary job of the context is to provide a getServiceInstance(...) method which accepts a service class as parameter, and returns the (finished and wired) service instance. So here we go:

public class DIContext {

    private final Set<Object> serviceInstances = new HashSet<>();

    public DIContext(Collection<Class<?>> serviceClasses) throws Exception {
        // create an instance of each service class
        for(Class<?> serviceClass : serviceClasses){
            Constructor<?> constructor = serviceClass.getConstructor();
            constructor.setAccessible(true);
            Object serviceInstance = constructor.newInstance();
            this.serviceInstances.add(serviceInstance);
        }
        // wire them together
        for(Object serviceInstance : this.serviceInstances){
            for(Field field : serviceInstance.getClass().getDeclaredFields()){
                Class<?> fieldType = field.getType();
                field.setAccessible(true);
                // find a suitable matching service instance
                for(Object matchPartner : this.serviceInstances){
                    if(fieldType.isInstance(matchPartner)){
                        field.set(serviceInstance, matchPartner);
                    }
                }
            }
        }
    }

    @SuppressWarnings("unchecked")
    public <T> T getServiceInstance(Class<T> serviceClass){
        for(Object serviceInstance : this.serviceInstances){
            if(serviceClass.isInstance(serviceInstance)){
                return (T)serviceInstance;
            }
        }
        return null;
    }

}

As you can see, the code didn't change much from the previous step, except that we now have an object to encapsulate the context (DIContext). Internally, it manages a set of serviceInstances which is created just like before from a collection of service classes. The Step 3 from above has moved into its own getServiceInstance method, which accepts the class to retrieve as a parameter. Since we cannot use instanceof anymore (it requires a hard-coded class, not a dynamic variable value), we have to fall back to serviceClass.isInstance(...) to do the same thing.

We can use this class in our new main():

    public static void main(String[] args) throws Exception {
        DIContext context = createContext();
        doBusinessLogic(context);
    }

    private static DIContext createContext() throws Exception {
        Set<Class<?>> serviceClasses = new HashSet<>();
        serviceClasses.add(ServiceAImpl.class);
        serviceClasses.add(ServiceBImpl.class);
        return new DIContext(serviceClasses);
    }

    private static void doBusinessLogic(DIContext context){
        ServiceA serviceA = context.getServiceInstance(ServiceA.class);
        ServiceB serviceB = context.getServiceInstance(ServiceB.class);
        System.out.println(serviceA.jobA());
        System.out.println(serviceB.jobB());
    }

As you can see, we can now easily pull out complete service instances from the context by calling getServiceInstance as often as we need to, with different input classes. Also note that the services itself can access each other simply by declaring a field of the proper type - they don't even have to know about the DIContext object.

There are still some problems though. For example, what if we want to have a field in our services which does not refer to another service (say, an int field)? We need a way to tell our algorithm which fields we want it to set - and which ones to leave alone.

DI Stage 6: Annotating fields

Find the source code of this section on github

So how can we tell our algorithm which fields it needs to assign? We could introduce some fancy naming scheme and parse the field.getName(), but that's a very error prone solution. Instead, we will use an Annotation:

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Inject {

}

@Target tells the compiler on which elements we can use this annotation - we want it to be applicable on fields. With @Retention we instruct the compiler to keep this annotation until runtime, and not to discard it during compilation.

Let's annotate our fields:

public class ServiceAImpl implements ServiceA {

    @Inject
    private ServiceB serviceB;

    // rest is the same as before

}
public class ServiceBImpl implements ServiceB {

    @Inject
    private ServiceA serviceA;

    // rest is the same as before

}

An annotation in and on itself does nothing. We need to actively read the annotation. So let's do it in the constructor of our DIContext:

       // wire them together
       for(Object serviceInstance : this.serviceInstances){
           for(Field field : serviceInstance.getClass().getDeclaredFields()){
               // check that the field is annotated
               if(!field.isAnnotationPresent(Inject.class)){
                   // this field is none of our business
                   continue;
               }
               // rest is the same as before

Run the main() method again; it should work just like before. However, now you are free to add more fields to your services, and the wiring algorithm won't break.

Closing words

So far, we have created a DI container which is very basic but functional. It relies on us providing it with the collection of service classes. In the next part, we will discuss how we can actually discover our service classes.

Top comments (0)