Open any Spring application and you will see an OrderService that receives a PaymentGateway and a Notifier through its constructor, with no new anywhere in sight. Who creates those objects, in what order, and why can you swap implementations in tests without touching business code? The answer is Dependency Injection (DI) and an IoC container behind it. This article explains Dependency Injection with an order example, then builds an 83-line container in Java 21 with reflection that supports constructor injection, singletons, interface bindings, and circular dependency detection. Finally we run the same scenario on Spring Framework 6.1 to compare and see what ApplicationContext does for you. All output below comes from real runs on an Apple M4 with 16 GB RAM and JDK 21.
This article follows Mediator pattern and Memento: coordinating components and saving state. Mediator reduces cross dependencies between components, while Dependency Injection answers a more fundamental question: who is responsible for creating and wiring objects. If you know the SOLID principles in Java, DI is the systematic way to put the Dependency Inversion principle into practice.
The problem: a class creates its own dependencies
Start with code everyone has written:
class OrderService {
private final PaymentGateway gateway = new MomoGateway();
private final Notifier notifier = new EmailNotifier("smtp.internal", 587);
private final OrderRepository repo = new JdbcOrders(DriverManager.getConnection(URL));
...
}Three problems show up immediately, and each is a reason to use Dependency Injection. First, OrderService is tightly bound to Momo, SMTP, and JDBC: adding VNPay means editing a business class. Second, it cannot be tested: every unit test calls a real payment gateway and opens a real database connection. Third, OrderService has to know how to configure its dependencies (the SMTP host, the DB URL), so business logic gets mixed with infrastructure details. Martin Fowler describes the fix as separating "configuration" from "use", and in his classic article on Inversion of Control and Dependency Injection he writes that the choice between injection styles is "less important than the principle of separating configuration from use".
What Dependency Injection is, and how it differs from Inversion of Control
Inversion of Control (IoC) is the broad principle: the framework calls your code instead of you calling the framework. Dependency Injection is a specific form of IoC for obtaining dependencies. The Spring documentation defines it briefly: Dependency Injection is the process in which "objects define their dependencies only through constructor arguments, arguments to a factory method, or properties that are set on the object instance after it is constructed", and the container injects them when it creates the bean (Spring Framework Reference, Introduction to the IoC Container). Instead of the class calling new or looking things up through a Service Locator, control is inverted to the container.
Fowler distinguishes three injection styles and one alternative:
| How dependencies are obtained | Mechanism | Pros | Cons |
|---|---|---|---|
| Constructor injection | Dependencies are passed through the constructor | The object is always complete, can be immutable (final), easy to test | Long constructor when there are many dependencies |
| Setter injection | The container calls setters after construction | Fits optional dependencies, can be reconfigured | The object may be partially initialized; needs null checks |
| Interface injection | The class implements an interface defined by the container | Explicit | Most intrusive, rarely used today |
| Service Locator | The class asks a central registry | Simple to start with | Dependencies are hidden in method bodies, every class depends on the locator |
Spring's guidance favors constructor injection for mandatory dependencies and setters for optional ones: constructors allow immutable components, guarantee non-null dependencies, and return a fully initialized object. The documentation also gives a sign worth remembering: if a constructor has too many arguments, the class is carrying too many responsibilities and should be split (Dependency Injection, Spring Framework Reference).
Manual Dependency Injection: no container needed yet
The key point to see first: Dependency Injection does not require a framework. A class only needs to receive its dependencies through its constructor, and there must be one single place that wires them together (usually called the composition root, typically main):
class OrderService {
private final PaymentGateway gateway;
private final Notifier notifier;
private final OrderRepository repo;
OrderService(PaymentGateway gateway, Notifier notifier, OrderRepository repo) {
this.gateway = gateway; this.notifier = notifier; this.repo = repo;
}
}
// composition root
var service = new OrderService(new MomoGateway(), new EmailNotifier(), new InMemoryOrders());For a small application, this hand wiring is already complete Dependency Injection. A container only pays off when the object graph is large enough that wiring by hand becomes tedious and error prone: dozens of classes, several scopes, per-environment configuration. That is when we want to hand the job of "read the constructor, find the dependencies, create them in the right order" to a machine.
Dependency Injection with a hand-written IoC container: four steps
The idea behind a Dependency Injection container: take a type to create, use reflection to read its constructor, recursively create each parameter, then call newInstance. We add a cache so each type is created only once (singleton), a table that maps interfaces to implementations, and tracking of the creation path to catch circular dependencies.
Step 1: an annotation that marks the constructor. Like @Autowired or @Inject (Jakarta Dependency Injection), this annotation is retained until runtime so reflection can read it:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.CONSTRUCTOR)
public @interface Inject {}Step 2: the container skeleton with three state tables.
public class MiniContainer {
private final Map<Class<?>, Class<?>> bindings = new HashMap<>(); // interface -> implementation
private final Map<Class<?>, Supplier<?>> factories = new HashMap<>(); // beans created by hand
private final Map<Class<?>, Object> singletons = new HashMap<>(); // cache
private final Set<Class<?>> creating = new LinkedHashSet<>(); // creation path
public <T> MiniContainer bind(Class<T> type, Class<? extends T> impl) {
bindings.put(type, impl); return this;
}
public <T> MiniContainer bean(Class<T> type, Supplier<? extends T> factory) {
factories.put(type, factory); return this;
}
public <T> T get(Class<T> type) { return type.cast(resolve(type)); }
}Step 3: the resolve algorithm. This is the heart of the container. Note the cycle detection: if a type that is currently being created is requested again, we have gone around in a circle:
private Object resolve(Class<?> type) {
Object cached = singletons.get(type);
if (cached != null) return cached;
if (creating.contains(type)) {
List<String> path = new ArrayList<>();
for (Class<?> c : creating) path.add(c.getSimpleName());
path.add(type.getSimpleName());
throw new IllegalStateException("Circular dependency: " + String.join(" -> ", path));
}
creating.add(type);
try {
Object bean = create(type);
singletons.put(type, bean);
return bean;
} finally {
creating.remove(type);
}
}
private Object create(Class<?> type) {
Supplier<?> f = factories.get(type);
if (f != null) return f.get();
Class<?> impl = bindings.getOrDefault(type, type);
if (impl.isInterface() || Modifier.isAbstract(impl.getModifiers()))
throw new IllegalStateException("No implementation for " + type.getSimpleName() + " (missing bind)");
Constructor<?> ctor = pickConstructor(impl);
Class<?>[] paramTypes = ctor.getParameterTypes();
Object[] args = new Object[paramTypes.length];
for (int i = 0; i < args.length; i++) args[i] = resolve(paramTypes[i]);
ctor.setAccessible(true);
return ctor.newInstance(args); // (wrapped in try/catch ReflectiveOperationException)
}Step 4: choosing a constructor. Prefer the constructor annotated with @Inject; if the class has only one constructor, use it, just as Spring since version 4.3 no longer requires @Autowired when there is a single constructor:
private Constructor<?> pickConstructor(Class<?> impl) {
Constructor<?>[] all = impl.getDeclaredConstructors();
for (Constructor<?> c : all) if (c.isAnnotationPresent(Inject.class)) return c;
if (all.length == 1) return all[0];
throw new IllegalStateException(impl.getSimpleName() + " has multiple constructors, @Inject required");
}This container has no thread safety, no scopes other than singleton, no qualifier-based injection, and no @PostConstruct lifecycle; still, the core above is enough to illustrate the mechanism.
Running it: wiring, swapping implementations in tests, circular dependencies
The demo scenario has an OrderService that takes three interfaces. We wire the real implementations, then replace Notifier with a fake for testing, then deliberately create a circular dependency A → B → C → A and a missing binding. Command: cd code/s6-20/mini && javac -d out src/*.java && java -cp out Demo.
== 1. Normal wiring ==
OrderService created
[db] saved DH-1001 = 1250000
[mail -> khach@example.com] Order DH-1001 paid: MOMO-OK-1250000
singleton: true
== 2. Swap an implementation for tests, without editing OrderService ==
OrderService created
[db] saved DH-2002 = 99000
Fake notifier received: khach@example.com|Order DH-2002 paid: MOMO-OK-99000
== 3. Circular dependency ==
Circular dependency: A -> B -> C -> A
== 4. Missing binding ==
No implementation for Notifier (missing bind)The result shows three concrete benefits of Dependency Injection. One, OrderService contains no new and still works, because the container reads the constructor and builds all three dependencies. Two, scenario 2 replaces Notifier with a lambda that writes to a StringBuilder without touching a single line of OrderService: this is exactly how unit testing works with DI. Three, configuration errors are reported with a clear message, including the path of the circular dependency, instead of a cryptic StackOverflowError.
One design detail of this kind of Dependency Injection: the refresh(Class...) method in the code allows root beans to be created eagerly. Spring does the same: its documentation says the container validates the configuration, including missing beans and circular dependencies, as soon as it loads, and singletons are created eagerly by default so configuration errors blow up at startup instead of when the first user calls in.
The same scenario on Spring Framework 6.1
Now let us compare our Dependency Injection with a real container. The Maven project only needs spring-context 6.1.x (the demo uses 6.1.12, released in August 2024; no Spring Boot) and JDK 21. Configuration uses @Configuration with @Bean methods, where the method parameters are the injected dependencies:
@Configuration
static class AppConfig {
@Bean PaymentGateway gateway() { return new MomoGateway(); }
@Bean Notifier notifier() { return new EmailNotifier(); }
@Bean OrderService orderService(PaymentGateway g, Notifier n) { return new OrderService(g, n); }
}
try (var ctx = new AnnotationConfigApplicationContext(AppConfig.class)) {
ctx.getBean(OrderService.class).checkout("DH-1001", 1_250_000);
}The circular dependency scenario: bean a needs b through a parameter, and bean b needs a. Real output (with Spring's long log warning lines trimmed):
== 1. ApplicationContext with @Configuration + @Bean ==
OrderService created
[mail -> khach@example.com] Order DH-1001: MOMO-OK-1250000
singleton: true
beans in context: 8
== 2. Circular dependency through the constructor ==
UnsatisfiedDependencyException
root cause: BeanCurrentlyInCreationException
Error creating bean with name 'a': Requested bean is currently in creation: Is there an unresolvable circular reference?Two things stand out. First, getBeanDefinitionCount() returns 8 even though we declared only 3 beans: the rest are internal infrastructure beans such as annotation processors, evidence that the container carries many mechanisms our mini version lacks. Second, the cycle detection works the same way as in our hand-written version: Spring also tracks beans "currently in creation" and throws BeanCurrentlyInCreationException when it meets one again. The Spring documentation states that with constructor injection a circular dependency cannot be resolved and the container reports an error at runtime; the fix is to refactor, or to switch one side to setter injection, although the documentation does not recommend the latter.
What a real Dependency Injection container does on top
Compared with our 83 lines, Spring (and other containers such as Guice, Dagger, and Jakarta CDI) adds a lot, and understanding it helps you predict behavior when you hit a strange error:
- Component scanning: scans the classpath for classes annotated with
@Component,@Service,@Repositoryinstead of registering them by hand. - Scopes: singleton is the default; there are also prototype (a new instance on every lookup) and request and session in web applications.
- Qualifiers: when two beans share a type (two payment gateways), use
@Qualifier,@Primary, or the bean name to choose. Our mini container would fail with an ambiguity error, while Spring throwsNoUniqueBeanDefinitionException. - Lifecycle: calls
@PostConstruct,@PreDestroy,InitializingBean, and closes beans when the context closes (we sawtry-with-resourceson the context). - BeanPostProcessor and AOP: the container can replace a bean with a proxy before injecting it, the foundation for
@Transactionaland@Async. For details see Proxy pattern: JDK dynamic proxies, CGLIB and Spring AOP. - Dependency graph and destruction order: beans created later are destroyed first.
- Runtime optimization: reflection has a startup cost, so tools such as Spring AOT and GraalVM native image generate the bean wiring code at build time.
One standard worth remembering is Jakarta Dependency Injection (formerly JSR-330), which defines a small set of annotations such as @Inject, @Named, and @Singleton that many containers understand. Code that uses this standard is easier to move between Spring, Guice, and Quarkus than code that uses one framework's own annotations.
When you do not need a Dependency Injection container
Dependency Injection is a design technique, while the container is just a tool. A few trade-offs to weigh:
- Small applications, command-line tools, fast-starting Lambda functions: wiring by hand in
mainis usually enough, faster, and easier to read. The cost of classpath scanning and reflection in a container can become a startup bottleneck. - Hard-to-debug magic: when Dependency Injection is misconfigured at runtime, you have to read the container's stack trace. Fowler points this out as a weakness: in his article on Inversion of Control, in the comparison with Service Locator, he writes that inversion of control "tends to be hard to understand and leads to problems when you are trying to debug", so he uses it only when it is really needed.
- Abusing Service Locator through
ApplicationContext.getBean()in business code: this brings back exactly the hidden-dependency problem DI was created to remove. - Constructors that are too long: not a fault of DI but a signal that the class does too much. Split the class; do not switch to field injection to hide the symptom.
Common Dependency Injection mistakes
1. Field injection with @Autowired on private fields. The code looks tidy, but you cannot create the object in a unit test without a container or reflection, you cannot declare the field final, and it hides how many dependencies there are. Prefer constructor injection.
2. Circular dependencies "fixed" with a setter. This is the most common mistake when you are new to Dependency Injection. Moving one side to a setter makes the error disappear but keeps the bad design: two classes that know too much about each other. Extract the shared part into a third class, or use events (see Mediator pattern and Memento) so that one side does not need to know the other.
3. Injecting a prototype bean into a singleton. Dependency Injection runs only once, when the singleton is created, so the injected "prototype" bean effectively becomes a singleton. Use ObjectProvider or Provider to get a fresh instance each time.
4. Shared state in a singleton. A singleton is shared across threads, so a mutable List or SimpleDateFormat field causes data races. Keep beans stateless, or make their state thread-safe.
5. Two beans of the same type. Type-based Dependency Injection needs exactly one candidate. Adding a second implementation of PaymentGateway makes the whole application fail to start because it does not know which one to inject. Use @Primary or @Qualifier, and keep a test that starts the context.
6. Using new inside a managed bean. An object created with new lives outside the container: it is not injected and has no transactional proxy. If you need many instances, inject a factory.
Checklist and summary
- Apply Dependency Injection: business classes receive dependencies through the constructor and depend on interfaces, not concrete implementations.
- A single "composition root" is responsible for wiring objects, whether by hand or with a container.
- Use constructor injection for mandatory dependencies and setters for optional ones; avoid field injection.
- A long constructor is a sign that the class has too many responsibilities.
- A circular dependency is a design flaw: refactor instead of dodging it with a setter.
- Test that the whole context starts in CI so wiring errors surface early.
- Understand what a Dependency Injection container does (reflection, singleton cache, cycle detection, proxies) so you can read the stack trace when something fails.
The full source is in code/s6-20/: the mini/ folder (the hand-written container, run_output.txt) and spring-demo/ (Spring Framework 6.1, run_output_spring.txt). To read more about where the concept comes from, see Fowler's article and the IoC container chapter of the Spring Reference cited above.
Next article
With a container wiring objects together, the next step is to give it a clean data access layer. Up next: Repository pattern and Unit of Work with JPA, where we separate query logic from business logic and see how EntityManager plays the role of Unit of Work.



