DEV Community

Cover image for How to create your own dependency injection framework in Java
Roberto Gentili for Burningwave

Posted on • Updated on • Originally published at dev.to

How to create your own dependency injection framework in Java

Overview

This article will guide you to understand and build a lightweight Java application using your own Dependency Injection implementation.

Dependency Injection… DI… Inversion Of Control… IoC…, I guess you might have heard these names so many times while your regular routine or specially interview preparation time that you wonder what exactly it is.

But if you really want to understand how internally it works then continue reading here.

So, what is dependency injection?

Dependency injection is a design pattern used to implement IoC, in which instance variables (ie. dependencies) of an object got created and assigned by the framework.

To use DI feature a class and it's instance variables just need to add annotations predefined by the framework.

The Dependency Injection pattern involves 3 types of classes.

  • Client Class: The client class (dependent class) depends on the service class.
  • Service Class: The service class (dependency class) that provides service to the client class.
  • Injector Class: The injector class injects the service class object into the client class.

In this way, the DI pattern separates the responsibility of creating an object of the service class out of the client class. Below are a couple more terms used in DI.

  • Interfaces that define how the client may use the services.
  • Injection refers to the passing of a dependency (a service) into the object (a client), this is also referred to as auto wire.

So, what is Inversion of Control?

In short, "Don't call us, we'll call you."

  • Inversion of Control (IoC) is a design principle. It is used to invert different kinds of controls (ie. object creation or dependent object creation and binding ) in object-oriented design to achieve loose coupling.
  • Dependency injection one of the approach to implement the IoC.
  • IoC helps to decouple the execution of a task from implementation.
  • IoC helps it focus a module on the task it is designed for.
  • IoC prevents side effects when replacing a module.
Class diagram of dependency injection design pattern:

Class diagram of dependency injection design pattern

In the above class diagram, the Client class that requires UserService and AccountService objects does not instantiate the UserServiceImpl and AccountServiceImpl classes directly.

Instead, an Injector class creates the objects and injects them into the Client, which makes the client independent of how the objects are created.

Types of Dependency Injection

  • Constructor injection: the injector supplies the service (dependency) through the client class constructor. In this case, Autowired annotation added on the constructor.
  • Property injection: the injector supplies the service (dependency) through a public property of the client class. In this case Autowired annotation added while member variable declaration.
  • Setter method injection: the client class implements an interface that declares the method(s) to supply the service (dependency) and the injector uses this interface to supply the dependency to the client class.

In this case, Autowired annotation added while method declaration.

How it works?

To understand Dependency Injection implementation, refer code snippets here, or download/clone the tutorial shared here on GitHub.

Prerequisite

For a better understanding of this tutorial, it's good to have basic knowledge of Annotations and reflection in advance.

Required Java libraries

Before begin with the coding steps, you can create new maven project in the eclipse and add Burningwave Core dependency in pom.xml:

Create user-defined annotations

As described above DI implementation has to provide predefined annotations, which can be used while declaration of client class and service variables inside a client class.

Let's add basic annotations, which can be used by client and service classes:

package org.di.framework.annotations;

import java.lang.annotation.*;

/**
 * Client class should use this annotation
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Component {

}
Enter fullscreen mode Exit fullscreen mode
package org.di.framework.annotations;

import java.lang.annotation.*;
import static java.lang.annotation.ElementType.*;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

/**
 * Service field variables should use this annotation
 */
@Target({ METHOD, CONSTRUCTOR, FIELD })
@Retention(RUNTIME)
@Documented
public @interface Autowired {

}
Enter fullscreen mode Exit fullscreen mode
package org.di.framework.annotations;

import java.lang.annotation.*;

/**
 *  Service field variables should use this annotation
 *  This annotation Can be used to avoid conflict if there are multiple implementations of the same interface
 */
@Target({ ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.TYPE, ElementType.ANNOTATION_TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface Qualifier {
    String value() default "";
}
Enter fullscreen mode Exit fullscreen mode

Service interfaces

package com.useraccount.services;

public interface UserService {
    String getUserName();
}
Enter fullscreen mode Exit fullscreen mode
package com.useraccount.services;

public interface AccountService {
    Long getAccountNumber(String userName);
}
Enter fullscreen mode Exit fullscreen mode

Service classes

These classes implement service interfaces and use DI annotations.

package com.useraccount.services.impl;

import org.di.framework.annotations.Component;

import com.useraccount.services.UserService;

@Component
public class UserServiceImpl implements UserService {

    @Override
    public String getUserName() {
        return "username";
    }
}
Enter fullscreen mode Exit fullscreen mode
package com.useraccount.services.impl;

import org.di.framework.annotations.Component;

import com.useraccount.services.AccountService;

@Component
public class AccountServiceImpl implements AccountService {

    @Override
    public Long getAccountNumber(String userName) {
        return 12345689L;
    }
}
Enter fullscreen mode Exit fullscreen mode

Client class

For using the DI features client class has to use predefined annotations provided by DI framework for the client and service class.

package com.useraccount;

import org.di.framework.annotations.*;

import com.useraccount.services.*;

/**
 * Client class, havin userService and accountService expected to initialized by
 * CustomInjector.java
 */
@Component
public class UserAccountClientComponent {

    @Autowired
    private UserService userService;

    @Autowired
    @Qualifier(value = "accountServiceImpl")
    private AccountService accountService;

    public void displayUserAccount() {
        String username = userService.getUserName();
        Long accountNumber = accountService.getAccountNumber(username);
        System.out.println("\n\tUser Name: " + username + "\n\tAccount Number: " + accountNumber);
    }
}
Enter fullscreen mode Exit fullscreen mode

Injector class

Injector class plays a major role in the DI framework. Because it is responsible to create instances of all clients and autowire instances for each service in client classes.
Steps:

  1. Scan all the clients under the root package and all sub packages
  2. Create an instance of client class.
  3. Scan all the services using in the client class (member variables, constructor parameters, method parameters)
  4. Scan for all services declared inside the service itself (nested dependencies), recursively
  5. Create instance for each service returned by step 3 and step 4
  6. Autowire: Inject (ie. initialize) each service with instance created at step 5
  7. Create Map all the client classes Map
  8. Expose API to get the getBean(Class classz)/getService(Class classz).
  9. Validate if there are multiple implementations of the interface or there is no implementation
  10. Handle Qualifier for services or autowire by type in case of multiple implementations.

This class heavily uses basic method provided by the java.lang.Class and org.burningwave.classes.ClassHunter:

package org.di.framework;

import java.io.IOException;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

import javax.management.RuntimeErrorException;

import org.burningwave.core.assembler.ComponentContainer;
import org.burningwave.core.classes.ClassCriteria;
import org.burningwave.core.classes.ClassHunter;
import org.burningwave.core.classes.SearchConfig;
import org.di.framework.annotations.Component;
import org.di.framework.utils.InjectionUtil;

/**
 * Injector, to create objects for all @CustomService classes. autowire/inject
 * all dependencies
 */
public class Injector {
    private Map<Class<?>, Class<?>> diMap;
    private Map<Class<?>, Object> applicationScope;

    private static Injector injector;

    private Injector() {
        super();
        diMap = new HashMap<>();
        applicationScope = new HashMap<>();
    }

    /**
     * Start application
     * 
     * @param mainClass
     */
    public static void startApplication(Class<?> mainClass) {
        try {
            synchronized (Injector.class) {
                if (injector == null) {
                    injector = new Injector();
                    injector.initFramework(mainClass);
                }
            }
        } catch (Exception ex) {
            ex.printStackTrace();
        }
    }

    public static <T> T getService(Class<T> classz) {
        try {
            return injector.getBeanInstance(classz);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return null;
    }

    /**
     * initialize the injector framework
     */
    private void initFramework(Class<?> mainClass)
            throws InstantiationException, IllegalAccessException, ClassNotFoundException, IOException {
        Class<?>[] classes = getClasses(mainClass.getPackage().getName(), true);
        ComponentContainer componentConatiner = ComponentContainer.getInstance();
        ClassHunter classHunter = componentConatiner.getClassHunter();
        String packageRelPath = mainClass.getPackage().getName().replace(".", "/");
        try (ClassHunter.SearchResult result = classHunter.findBy(
            SearchConfig.forResources(
                packageRelPath
            ).by(ClassCriteria.create().allThoseThatMatch(cls -> {
                return cls.getAnnotation(Component.class) != null;
            }))
        )) {
            Collection<Class<?>> types = result.getClasses();
            for (Class<?> implementationClass : types) {
                Class<?>[] interfaces = implementationClass.getInterfaces();
                if (interfaces.length == 0) {
                    diMap.put(implementationClass, implementationClass);
                } else {
                    for (Class<?> iface : interfaces) {
                        diMap.put(implementationClass, iface);
                    }
                }
            }

            for (Class<?> classz : classes) {
                if (classz.isAnnotationPresent(Component.class)) {
                    Object classInstance = classz.newInstance();
                    applicationScope.put(classz, classInstance);
                    InjectionUtil.autowire(this, classz, classInstance);
                }
            }
        };  

    }

    /**
     * Get all the classes for the input package
     */
    public Class<?>[] getClasses(String packageName, boolean recursive) throws ClassNotFoundException, IOException {
        ComponentContainer componentConatiner = ComponentContainer.getInstance();
        ClassHunter classHunter = componentConatiner.getClassHunter();
        String packageRelPath = packageName.replace(".", "/");
        SearchConfig config = SearchConfig.forResources(
            packageRelPath
        );
        if (!recursive) {
            config.findInChildren();
        }

        try (ClassHunter.SearchResult result = classHunter.findBy(config)) {
            Collection<Class<?>> classes = result.getClasses();
            return classes.toArray(new Class[classes.size()]);
        }   
    }


    /**
     * Create and Get the Object instance of the implementation class for input
     * interface service
     */
    @SuppressWarnings("unchecked")
    private <T> T getBeanInstance(Class<T> interfaceClass) throws InstantiationException, IllegalAccessException {
        return (T) getBeanInstance(interfaceClass, null, null);
    }

    /**
     * Overload getBeanInstance to handle qualifier and autowire by type
     */
    public <T> Object getBeanInstance(Class<T> interfaceClass, String fieldName, String qualifier)
            throws InstantiationException, IllegalAccessException {
        Class<?> implementationClass = getImplimentationClass(interfaceClass, fieldName, qualifier);

        if (applicationScope.containsKey(implementationClass)) {
            return applicationScope.get(implementationClass);
        }

        synchronized (applicationScope) {
            Object service = implementationClass.newInstance();
            applicationScope.put(implementationClass, service);
            return service;
        }
    }

    /**
     * Get the name of the implimentation class for input interface service
     */
    private Class<?> getImplimentationClass(Class<?> interfaceClass, final String fieldName, final String qualifier) {
        Set<Entry<Class<?>, Class<?>>> implementationClasses = diMap.entrySet().stream()
                .filter(entry -> entry.getValue() == interfaceClass).collect(Collectors.toSet());
        String errorMessage = "";
        if (implementationClasses == null || implementationClasses.size() == 0) {
            errorMessage = "no implementation found for interface " + interfaceClass.getName();
        } else if (implementationClasses.size() == 1) {
            Optional<Entry<Class<?>, Class<?>>> optional = implementationClasses.stream().findFirst();
            if (optional.isPresent()) {
                return optional.get().getKey();
            }
        } else if (implementationClasses.size() > 1) {
            final String findBy = (qualifier == null || qualifier.trim().length() == 0) ? fieldName : qualifier;
            Optional<Entry<Class<?>, Class<?>>> optional = implementationClasses.stream()
                    .filter(entry -> entry.getKey().getSimpleName().equalsIgnoreCase(findBy)).findAny();
            if (optional.isPresent()) {
                return optional.get().getKey();
            } else {
                errorMessage = "There are " + implementationClasses.size() + " of interface " + interfaceClass.getName()
                        + " Expected single implementation or make use of @CustomQualifier to resolve conflict";
            }
        }
        throw new RuntimeErrorException(new Error(errorMessage));
    }
}
Enter fullscreen mode Exit fullscreen mode

This class heavily uses basic method provided by the java.lang.reflect.Field.
The autowire() method in this class is recursive method because it takes care of injecting dependencies declared inside the service classes (ie. nested dependencies):

package org.di.framework.utils;

import static org.burningwave.core.assembler.StaticComponentContainer.Fields;

import java.util.*;

import org.burningwave.core.classes.FieldCriteria;
import org.di.framework.Injector;
import org.di.framework.annotations.*;

import java.lang.reflect.Field;

public class InjectionUtil {

    private InjectionUtil() {
        super();
    }

    /**
     * Perform injection recursively, for each service inside the Client class
     */
    public static void autowire(Injector injector, Class<?> classz, Object classInstance)
            throws InstantiationException, IllegalAccessException {
        Collection<Field> fields = Fields.findAllAndMakeThemAccessible(
            FieldCriteria.forEntireClassHierarchy().allThat(field ->
                field.isAnnotationPresent(Autowired.class)
            ), 
            classz
        );
        for (Field field : fields) {
            String qualifier = field.isAnnotationPresent(Qualifier.class)
                    ? field.getAnnotation(Qualifier.class).value()
                    : null;
            Object fieldInstance = injector.getBeanInstance(field.getType(), field.getName(), qualifier);
            Fields.setDirect(classInstance, field, fieldInstance);
            autowire(injector, fieldInstance.getClass(), fieldInstance);
        }
    }

}
Enter fullscreen mode Exit fullscreen mode

Application main class:

package com.useraccount;

import org.di.framework.Injector;

public class UserAccountApplication {

    public static void main(String[] args) {
        long startTime = System.currentTimeMillis();
        Injector.startApplication(UserAccountApplication.class);
        Injector.getService(UserAccountClientComponent.class).displayUserAccount();
        long endime = System.currentTimeMillis();
    }
}
Enter fullscreen mode Exit fullscreen mode

Below is the comparison with dependencies added by Spring.

Spring dependencies:

Spring dependencies

Your own DI framework dependencies:

Alt Text

Conclusion

This article should give a clear understanding of how DI or autowire dependencies work.

With the implementation of your own DI framework, you don't need heavy frameworks, if you are really not using most of their features in your application, like Bean Life cycle Management method executions and much more heavy stuff.

You can do a lot of things which are not mentioned here like:

Top comments (0)