You could have invented it yourself: Dependency Injection

Why do we need Dependency Injection?

Dependency Injection (DI) addresses a simple but critical problem: keeping your code manageable. Without it, you’re stuck with tightly coupled components, like a Car class that directly creates its own Engine.

Initially, this seems fine. But what happens when you need different engines, gas, electric, or something new? Suddenly, your Car class has to handle creation logic, breaking its focus and making changes a nightmare.

DI changes the approach. Instead of creating its own dependencies, the Car gets its Engine from the outside. This keeps your code focused, flexible, and easy to test. Swap an engine? Update a configuration? No problem.

You don’t need a fancy framework to do DI. But frameworks like Spring automate the wiring, saving time. The payoff? Cleaner code, fewer headaches, and a codebase that doesn’t fight you every time something changes.

From the outside, it might seem like magic. Here’s how you can implement it yourself.

The canonical Spring example

The simplest example consists of a (Spring) component MessageConsumer which depends on another (Spring) component MessageProvider, which in turn does not have any further dependencies. When starting the application, the run method from the consumer is called since it implements CommandLineRunner:

 1// Main.java
 2import org.springframework.boot.SpringApplication;
 3import org.springframework.boot.autoconfigure.SpringBootApplication;
 4
 5@SpringBootApplication
 6public class Main {
 7    public static void main(String... args) {
 8        SpringApplication.run(Main.class, args);
 9    }
10}
11
12
13// MessageConsumer.java
14import org.springframework.boot.CommandLineRunner;
15import org.springframework.stereotype.Component;
16
17@Component
18public class MessageConsumer implements CommandLineRunner {
19    private final MessageProvider messageProvider;
20
21    public MessageConsumer(MessageProvider messageProvider) {
22        this.messageProvider = messageProvider;
23    }
24
25    @Override
26    public void run(String... args) {
27        String message = messageProvider.getMessage();
28        System.out.println(message);
29    }
30}
31
32
33// MessageProvider.java
34import org.springframework.stereotype.Component;
35
36@Component
37public class MessageProvider {
38    public String getMessage() {
39        return "Hello, world";
40    }
41}

In the following sections, we will implement our own dependency injection framework. Our goal is to allow seamless switching of import statements to our implementation without any other changes. The example should still function as expected.

Our goal:

Our own implementation

Spring’s DI framework offers extensive functionality, much of which is beyond the scope of this educational example. Let’s begin by clarifying what we won’t implement, though much of it is straightforward:

… and, as always, we implement minimal error and edge case handling – definitely not production-ready. ;-)

Having said all that, let’s start with modifications to our Main class. Since the name SpringApplication is already reserved, and summer follows on spring, let’s call it … SummerApplication:

1import com.mlesniak.boot.SummerApplication;
2
3public class Main {
4    public static void main(String... args) {
5        SummerApplication.run(Main.class, args);
6    }
7}

The remaining files stay the same, though, as promised, we change the imports:

 1// MessageProvider.java
 2import com.mlesniak.boot.Component;
 3
 4@Component
 5public class MessageProvider {
 6    public String getMessage() {
 7        return "Hello, world";
 8    }
 9}
10
11// MessageConsumer.java
12import com.mlesniak.boot.CommandLineRunner;
13import com.mlesniak.boot.Component;
14
15@Component
16public class MessageConsumer implements CommandLineRunner {
17  private final MessageProvider messageProvider;
18
19  public MessageConsumer(MessageProvider messageProvider) {
20    this.messageProvider = messageProvider;
21  }
22
23  @Override
24  public void run(String... args) {
25    String message = messageProvider.getMessage();
26    System.out.println(message);
27  }
28}

Marking things…

Since we do not use Controllers but still want an entrypoint, let’s define our own marker interface:

1package com.mlesniak.boot;
2
3/// Marker interface to determine where our application
4/// starts since we do not have typical controllers
5/// waiting for HTTP requests.
6public interface CommandLineRunner {
7    void run(String... args);
8}

We also want to annotate service classes with our own Component annotation

 1package com.mlesniak.boot;
 2
 3import java.lang.annotation.ElementType;
 4import java.lang.annotation.Retention;
 5import java.lang.annotation.RetentionPolicy;
 6import java.lang.annotation.Target;
 7
 8///
 9/// Marker interface to determine components of our application.
10///
11/// For every class marked as a component, we try to resolve all
12/// dependencies in its constructor.
13@Retention(RetentionPolicy.RUNTIME)
14@Target(ElementType.TYPE)
15public @interface Component {
16}

The interesting part

The actual magic is implemented in the following 140 lines of code. We walk through them step by step.

 1package com.mlesniak.boot;
 2
 3import com.mlesniak.Main;
 4
 5import java.io.IOException;
 6import java.lang.reflect.InvocationTargetException;
 7import java.net.URI;
 8import java.net.URISyntaxException;
 9import java.nio.file.*;
10import java.util.*;
11import java.util.stream.Collectors;
12
13/// Core dependency injection resolution.
14public class SummerApplication {
15  // ... to be filled out in this section ...
16}

Our sole public method is run, which serves to purposes:

  1. Create all necessary singletons
  2. Figure out the component implementing CommandLineRunner and start them.

Therefore, the code is straightforward:

 1  /// Entry point into dependency injection.
 2  ///
 3  /// @param mainClass The main class of the application, ideally placed at the root package.
 4  /// @param args      Command line args.
 5  public static void run(Class<Main> mainClass, String[] args) {
 6      List<Class<?>> components = getComponents(mainClass);
 7
 8      // For our example, we support only singletons.
 9      Map<Class<?>, Object> instances = new HashMap<>();
10      components.forEach(component -> createSingleton(instances, new HashSet<>(), component));
11
12      // Find entry point by looking for the class implementing CommandLineRunner. 
13      var entryClasses = instances
14              .keySet().stream()
15              .filter(SummerApplication::hasCommandLineRunnerInterface)
16              .toList();
17      if (entryClasses.isEmpty()) {
18          throw new IllegalStateException("No entry point defined via CommandLineRunner");
19      }
20      if (entryClasses.size() > 1) {
21          throw new IllegalStateException("Ambiguous entry points defined via CommandLineRunner");
22      }
23      var entryClass = entryClasses.getFirst();
24
25      ((CommandLineRunner) instances.get(entryClass)).run(args);
26  }
27
28  private static boolean hasCommandLineRunnerInterface(Class<?> clazz) {
29    return Arrays.stream(clazz.getInterfaces())
30            .anyMatch(i -> i == CommandLineRunner.class);
31  }

A key function is getComponents. We need to collect all classes annotated with our custom component annotation on the classpath:

 1  /// Get a list of all components based on the passed package of the class.
 2  ///
 3  /// @param mainClass the root class to start scanning.
 4  private static List<Class<?>> getComponents(Class<Main> mainClass) {
 5      try {
 6          return findAllClassesInPackage(mainClass.getPackageName()).stream()
 7                  .filter(c -> c.getAnnotation(Component.class) != null)
 8                  .collect(Collectors.toList());
 9      } catch (IOException | URISyntaxException e) {
10          throw new IllegalStateException("Error retrieving components, starting at " + mainClass.getSimpleName(), e);
11      }
12  }

This is slightly complicated since we can either run our application with an unpacked classpath, e.g. on the command line or via an IDE, or as a packed (fat) . jar-file.

Thanks to the abstraction provided by java.nio, this can be handled quite elegantly:

 1  /// Retrieve a list of all classes in a package (or its children). This method supports both unpacked
 2  /// (target/classes) and packed (.jar) class containers.
 3  private static Set<Class<?>> findAllClassesInPackage(String packageName) throws IOException, URISyntaxException {
 4      String path = packageName.replace('.', '/');
 5      URI uri = SummerApplication.class.getResource("/" + path).toURI();
 6
 7      if (uri.getScheme().equals("jar")) {
 8          // We have to create a "virtual" filesystem to access the class files stored in the
 9          // .jar file.
10          try (FileSystem fileSystem = FileSystems.newFileSystem(uri, Collections.emptyMap())) {
11              return findClassesInPath(fileSystem.getPath(path), packageName);
12          }
13      }
14
15      // We're running the injection code from an unpacked archive and can directly access the .class files.
16      return findClassesInPath(Paths.get(uri), packageName);
17  }

Therefore, when looking for classes in findClassesInPath, we do not care if we walk through the archived files of the .jar or are actually looking on real files on our filesystem. Retrieving the actual class definitions consists now of just

 1  /// Iterate through all .class files in the given path for the given package.
 2  private static Set<Class<?>> findClassesInPath(Path path, String packageName) throws IOException {
 3      try (var walk = Files.walk(path, 1)) {
 4          return walk
 5                  .filter(p -> !Files.isDirectory(p))
 6                  .filter(p -> p.toString().endsWith(".class"))
 7                  .map(p -> getClass(p, packageName))
 8                  .filter(Objects::nonNull)
 9                  .collect(Collectors.toSet());
10      }
11  }
12
13  /// Retrieve a class based on the path and package name.
14  private static Class<?> getClass(Path classPath, String packageName) {
15      try {
16          String className = packageName + "." + classPath.getFileName().toString().replace(".class", "");
17          return Class.forName(className);
18      } catch (ClassNotFoundException e) {
19          return null;
20      }
21  }

I know that we do not recursively descend into subdirectories – good enough for the example ;-).

Once we have a list of class definitions, we can finally instantiate them and call component constructors with the instantiated classes. The dependency resolution and object instantiation happens in createSingleton. To simplify our implementation, we have a very basic dependency resolution. This function is called recursively for all constructor arguments to find singleton instances. If they are not yet available, we try to construct them while resolving their dependencies as well. To keep track of cycles, we keep track of already visited classes.

This could of course be done more clever for the price of blowing up the number of lines of code, hence, good enough for this demonstration.

 1  /// Creates a new instance for the passed class using its constructor.
 2  ///
 3  /// We resolve all dependent constructor parameters.
 4  private static void createSingleton(Map<Class<?>, Object> singletons, Set<Class<?>> visited, Class<?> clazz) {
 5      // Cycle detection. We've been called to resolve a parameter dependency, but already tried to resolve the
 6      // dependencies for this class. When trying to resolve clazz' dependencies, we will run into an infinite cycle.
 7      if (!visited.add(clazz)) {
 8          var names = visited.stream().map(Class::getSimpleName).collect(Collectors.joining(", "));
 9          throw new IllegalStateException("Cycle detected. Visited classes=" + names);
10      }
11      var cs = clazz.getDeclaredConstructors();
12      if (cs.length > 1) {
13          throw new IllegalArgumentException("No unique constructor found for " + clazz.getSimpleName());
14      }
15
16      var constructor = cs[0];
17      var expectedInjections = constructor.getParameterTypes();
18
19      // For every dependent dependency, generate a new instance. Note that we implicitly handle the case for
20      // parameter-less constructors here as well.
21      Arrays.stream(expectedInjections).forEach(depClass -> {
22          createSingleton(singletons, visited, depClass);
23      });
24
25      var params = Arrays.stream(expectedInjections).map(singletons::get).toArray();
26      try {
27          singletons.put(clazz, constructor.newInstance(params));
28      } catch (InstantiationException | IllegalAccessException | InvocationTargetException e) {
29          throw new IllegalStateException("Unable to create instance for " + clazz.getSimpleName(), e);
30      }
31  }

Conclusion

… and well, that’s actually all you need to implement. As stated above, it’s far from complete or error-prone. The important thing, though, is: dependency injection is not some magical thing that happens behind the curtains of famous and advanced frameworks. Instead, it’s something that you could have invented yourself.

Source code

The whole source code can be found on GitHub.