A common misconception among beginners is that dependency injection (DI) requires large, complex frameworks like Spring. While Spring and Guice offer powerful DI features, at their core, they rely on annotations and reflection to wire dependencies.
Many developers new to DI struggle with manual object creation—writing new Service()
everywhere instead of letting a container manage object lifecycles. This leads to tight coupling and hard-to-test code.
In this case study, we’ll build a mini dependency injection container from scratch using annotations and reflection. This hands-on approach will help you understand how enterprise frameworks like Spring Boot automate dependency management.
Think of dependency injection as hiring an assistant: instead of shopping for every item yourself, you give the assistant (the container) a list of what you need, and they fetch it for you.
Step 1: Define Custom Annotations
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.lang.annotation.ElementType;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Component {
}
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Inject {
}
@Component
marks classes as managed beans.@Inject
tells the container where dependencies should be injected.
Step 2: Create Example Components
@Component
class UserRepository {
public void save(String user) {
System.out.println("User saved: " + user);
}
}
@Component
class UserService {
@Inject
private UserRepository repository;
public void register(String username) {
repository.save(username);
}
}
UserRepository
is a dependency.UserService
depends onUserRepository
.
Step 3: Build the Mini DI Container
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;
public class MiniDIContainer {
private Map<Class<?>, Object> container = new HashMap<>();
public void register(Class<?>... classes) throws Exception {
// Step 1: Instantiate @Component classes
for (Class<?> clazz : classes) {
if (clazz.isAnnotationPresent(Component.class)) {
container.put(clazz, clazz.getDeclaredConstructor().newInstance());
}
}
// Step 2: Inject dependencies into @Inject fields
for (Object instance : container.values()) {
for (Field field : instance.getClass().getDeclaredFields()) {
if (field.isAnnotationPresent(Inject.class)) {
field.setAccessible(true);
Object dependency = container.get(field.getType());
if (dependency == null) {
throw new RuntimeException("No component found for " + field.getType());
}
field.set(instance, dependency);
}
}
}
}
public <T> T getBean(Class<T> clazz) {
return (T) container.get(clazz);
}
}
Step 4: Test the Container
public class Main {
public static void main(String[] args) throws Exception {
MiniDIContainer container = new MiniDIContainer();
container.register(UserRepository.class, UserService.class);
UserService service = container.getBean(UserService.class);
service.register("Alice");
}
}
Output:
User saved: Alice
Congratulations—you’ve built your own annotation-based DI container!
Real-World Applications
- Spring – Uses
@Component
,@Service
,@Repository
, and@Autowired
for DI. - Guice – Google’s DI framework with annotation-based binding.
- Micronaut – Lightweight DI container with compile-time processing.
📌 What's New in Java Versions?
- Java 5 – Introduced annotations and reflection improvements.
- Java 8 – Added repeatable annotations and enhanced reflection API.
- Java 9 – Stronger encapsulation under modules restricted deep reflection.
- Java 11 – No significant DI-related changes.
- Java 17 – Strong encapsulation, requiring explicit module exports.
- Java 21 – No significant updates for DI container building.
Pitfalls and Best Practices
Pitfalls
- Forgetting
@Retention(RUNTIME)
—annotations won’t be visible at runtime. - Not handling missing dependencies—leading to runtime errors.
- Using reflection excessively in performance-critical code.
Best Practices
- Always validate dependencies before injecting.
- Use caching for repeated lookups to improve performance.
- Keep the DI container modular and extensible.
- Document annotations and intended use for maintainability.
Summary + Key Takeaways
- Dependency Injection reduces coupling and improves testability.
- Annotations + Reflection can power a simple DI container.
- The container scans classes, instantiates them, and injects dependencies.
- Real frameworks like Spring are advanced versions of this simple idea.
FAQ
-
How does our DI container differ from Spring?
Ours is minimal; Spring adds scopes, AOP, lifecycle management, and more. -
Can I use constructor injection instead of field injection?
Yes, but it requires analyzing constructors with reflection. -
What happens if two components implement the same interface?
Our container doesn’t handle this; Spring uses qualifiers to resolve conflicts. -
Is reflection the only way to implement DI?
No, compile-time code generation (like Dagger) is faster and safer. -
Does DI affect performance?
Slightly at startup, but negligible compared to network/database operations. -
Can I inject private fields safely?
Yes, but requiressetAccessible(true)
. Restricted in Java 9+ without--add-opens
. -
How do I extend this to support packages instead of class lists?
Implement classpath scanning (usingReflections
library or custom logic). -
What’s the difference between
@Component
and@Service
in Spring?
They are semantically different stereotypes but technically behave the same. -
Can I combine annotations with XML configuration?
Yes, hybrid configurations are possible but less common today. -
Should I use this mini container in production?
No, it’s educational. Use mature frameworks like Spring or Micronaut.