Adetayo Akinsanya unkletayo.dev

Building a Custom IoC Container in Java: Reflection-Based Dependency Wiring

Hands-on tutorial creating custom annotations, package scanners, and dependency injection in pure Java.

Part 4 in Series — Catch up on the previous article: Dependency Injection Mechanics: Constructor, Field, and Setter Injection Trade-offs (Part 3) before diving into this post.

To understand how frameworks like Spring work under the hood, you should build an Inversion of Control (IoC) Container from scratch.

Many developers view Spring’s @Component and @Autowired annotations as black magic.

In reality, an IoC container is just a Java class that scans package metadata using Reflection, instantiates objects, and wires field references inside a hash map.

In this article, we will step through a hands-on tutorial to construct MiniSpring—a functional, reflection-based IoC container written in 100 lines of pure Java without external library dependencies.


1. Defining Our Custom Annotations

First, we create custom annotations to mark component classes and injection points.

Custom Component Annotation (@MyComponent)

package minispring;

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

@Retention(RetentionPolicy.RUNTIME) // Must be retained at runtime for Reflection!
@Target(ElementType.TYPE)           // Applied to classes
public @interface MyComponent {
}

Custom Injection Annotation (@MyAutowired)

package minispring;

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

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)          // Applied to fields
public @interface MyAutowired {
}

2. Implementing the IoC Container Engine (MyApplicationContext.java)

Our container engine executes a 2-pass initialization algorithm:

[ Package Component Classes ]
              |
              v
+-------------------------------------------------------------------+
| PASS 1: BEAN INSTANTIATION                                        |
| Reflectively creates raw bean instances via newInstance()         |
| Stores instances in Map<Class<?>, Object> beanMap                 |
+-------------------------------------------------------------------+
              |
              v
+-------------------------------------------------------------------+
| PASS 2: DEPENDENCY INJECTION (WIRING)                             |
| Scans fields for @MyAutowired annotation                          |
| Injects dependency reference via field.set(instance, dependency)  |
+-------------------------------------------------------------------+
              |
              v
[ Ready-to-Use Application Context ]

The Container Engine Source Code

package minispring;

import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;

public class MyApplicationContext {
    private final Map<Class<?>, Object> beanMap = new HashMap<>();

    public MyApplicationContext(Class<?>... componentClasses) {
        try {
            // PASS 1: Instantiate all raw bean instances
            for (Class<?> clazz : componentClasses) {
                if (clazz.isAnnotationPresent(MyComponent.class)) {
                    Object instance = clazz.getDeclaredConstructor().newInstance();
                    beanMap.put(clazz, instance);
                    System.out.println("[MiniSpring] Instantiated Bean: " + clazz.getSimpleName());
                }
            }

            // PASS 2: Inject dependencies into @MyAutowired fields
            for (Object beanInstance : beanMap.values()) {
                injectDependencies(beanInstance);
            }

        } catch (Exception e) {
            throw new RuntimeException("Failed to initialize MiniSpring Context", e);
        }
    }

    private void injectDependencies(Object targetBean) throws IllegalAccessException {
        Class<?> clazz = targetBean.getClass();
        Field[] fields = clazz.getDeclaredFields();

        for (Field field : fields) {
            if (field.isAnnotationPresent(MyAutowired.class)) {
                Class<?> dependencyType = field.getType();
                Object dependencyInstance = beanMap.get(dependencyType);

                if (dependencyInstance == null) {
                    throw new RuntimeException("No matching bean found for type: " + dependencyType.getName());
                }

                field.setAccessible(true); // Bypass private visibility
                field.set(targetBean, dependencyInstance);
                System.out.println("[MiniSpring] Injected " + dependencyType.getSimpleName() 
                        + " into " + clazz.getSimpleName() + "." + field.getName());
            }
        }
    }

    @SuppressWarnings("unchecked")
    public <T> T getBean(Class<T> clazz) {
        T bean = (T) beanMap.get(clazz);
        if (bean == null) {
            throw new RuntimeException("No bean registered for class: " + clazz.getName());
        }
        return bean;
    }
}

3. Creating Test Component Services

Now let’s define two application classes to test our IoC container:

Dependency Service (PaymentRepository.java)

package minispring;

@MyComponent
public class PaymentRepository {
    public void savePayment(double amount) {
        System.out.println("[Database] Payment record of $" + amount + " saved to DB.");
    }
}

Target Service (PaymentService.java)

package minispring;

@MyComponent
public class PaymentService {

    @MyAutowired
    private PaymentRepository paymentRepository; // Private field!

    public void processPayment(double amount) {
        System.out.println("[Business Logic] Processing payment of $" + amount);
        paymentRepository.savePayment(amount);
    }
}

4. Running and Verifying MiniSpring

Let’s write a main class to boot MyApplicationContext and execute a business method:

package minispring;

public class MiniSpringApplication {
    public static void main(String[] args) {
        System.out.println("==================================================");
        System.out.println("   INITIALIZING MINISPRING IOC CONTAINER ENGINE  ");
        System.out.println("==================================================\n");

        // Boot container with target component classes
        MyApplicationContext context = new MyApplicationContext(
                PaymentRepository.class, 
                PaymentService.class
        );

        System.out.println("\n[Container Ready] Fetching PaymentService Bean...");
        PaymentService service = context.getBean(PaymentService.class);

        // Execute business method on fully-wired bean
        service.processPayment(250.00);

        System.out.println("\n==================================================");
        System.out.println("   MINISPRING VERIFICATION COMPLETE               ");
        System.out.println("==================================================");
    }
}

Runtime Output Trace:

==================================================
   INITIALIZING MINISPRING IOC CONTAINER ENGINE  
==================================================

[MiniSpring] Instantiated Bean: PaymentRepository
[MiniSpring] Instantiated Bean: PaymentService
[MiniSpring] Injected PaymentRepository into PaymentService.paymentRepository

[Container Ready] Fetching PaymentService Bean...
[Business Logic] Processing payment of $250.0
[Database] Payment record of $250.0 saved to DB.

==================================================
   MINISPRING VERIFICATION COMPLETE               
==================================================

Summary & Next Steps

Building a custom IoC container clarifies the mechanics of modern application frameworks:

  • Custom Annotations (@Retention(RetentionPolicy.RUNTIME)) preserve metadata tags for JVM reflection queries.
  • Pass 1 (Instantiation) uses reflection (clazz.getDeclaredConstructor().newInstance()) to populate an in-memory bean map.
  • Pass 2 (Wiring) queries fields for @MyAutowired, fetches matching dependency references from the bean map, and injects references via field.set().
  • Spring Core ApplicationContext expands on this exact pattern with component package scanning, bean scopes, proxy generation, and lifecycle callbacks.

In the next article, we transition to Module 2 and explore ApplicationContext vs BeanFactory: The Architecture of Spring’s IoC Container.

References & Further Reading

  1. Spring.io. Spring API Documentation: BeanPostProcessor, BeanFactoryPostProcessor. Spring Docs.
  2. Spring.io. Spring Reference Manual — Customizing the Nature of a Bean & Lifecycle Callbacks. Spring Docs.
  3. Walls, C. (2022). Spring in Action (6th Edition). Manning.

Up Next in Series →

Part 5: ApplicationContext vs BeanFactory: The Architecture of Spring's IoC Container

Continue to Part 5 →