Dependency injection is the mental model every Java developer eventually absorbs, often without understanding why. “Spring does DI, so we use Spring, so we have beans everywhere.” This article steps back: what is DI actually solving, and when is it worth the machinery?
The problem DI solves
Consider a class that sends order confirmations:
public class OrderService {
private final EmailClient email = new SmtpEmailClient("mail.company.com");
private final Database db = new PostgresDatabase("prod-db.company.com");
public void placeOrder(Order o) {
db.save(o);
email.send(o.customer(), "Order confirmed");
}
}Looks fine for a demo. In a real system:
- Testing. You can’t unit-test
OrderServicewithout a real SMTP server and real Postgres - Configuration.
prod-db.company.comis hardcoded — different environments? - Flexibility. Want to switch to SendGrid? Edit every class
- Lifecycle. Who closes
PostgresDatabase? Who creates it before the first request?
The root issue: OrderService creates its own dependencies. It’s tightly coupled to SmtpEmailClient and PostgresDatabase — their constructors, their configuration, their lifecycle.
The fix: hand them in
public class OrderService {
private final EmailClient email;
private final Database db;
public OrderService(EmailClient email, Database db) {
this.email = email;
this.db = db;
}
public void placeOrder(Order o) {
db.save(o);
email.send(o.customer(), "Order confirmed");
}
}Now OrderService doesn’t know or care where its dependencies come from. In tests, pass mocks. In prod, pass real ones. Configuration lives outside the class. Lifecycle is somebody else’s problem.
That’s it. That’s dependency injection. Everything else is plumbing.
Three forms of DI
Constructor injection — dependencies passed to the constructor. The Java default. Works without any framework.
new OrderService(email, db);Setter injection — dependencies set via setters after construction. Useful for optional dependencies. Discouraged for required ones (mutable state).
Field injection — dependencies set on fields directly, usually via reflection. The @Autowired on a field. Terse but fragile (no compile-time guarantee fields are set).
Use constructor injection by default. It’s the most type-safe, easiest to test, and works without any framework.
Where frameworks come in
At two classes, you wire manually. At two hundred, you don’t. That’s where DI containers earn their keep.
A DI container (Spring, Guice, Dagger, Micronaut) does three things:
- Knows how to construct each class (scanning annotations, analyzing constructors)
- Knows which implementation to inject where (type-based resolution)
- Controls lifecycle (singletons, request-scoped, etc.)
With Spring:
@Service
public class OrderService {
private final EmailClient email;
private final Database db;
public OrderService(EmailClient email, Database db) {
this.email = email;
this.db = db;
}
}Spring scans the classpath, finds @Service, identifies constructor dependencies, finds their implementations (also annotated), and wires them up. Your code looks identical to the manual version — the framework just automates the construction.
What DI is not
DI is not Spring. Spring uses DI; they’re not the same thing. You can do DI with zero frameworks, just by passing constructor arguments. You can use Spring for a lot more than DI.
DI is not inversion of control. IoC is the broader principle (the framework calls your code, not the other way around). DI is one specific way to achieve it.
DI is not a performance feature. It adds a tiny bit of startup overhead (reflection scanning). The benefits are testability and flexibility, not speed.
Common pitfalls
Constructor with 15 parameters. The DI makes it obvious that the class has too many dependencies. Don’t inject more; split the class.
Circular dependencies. A needs B, B needs A. Usually a design smell. The fix is almost never @Lazy — it’s refactoring to remove the circle.
Field injection everywhere. Tempting because it’s less typing. Painful because tests now need Spring to run, and you can’t tell from the constructor what the class needs.
Abusing scopes. Most beans should be singletons. Request-scoped beans inside singletons require proxies and cause subtle bugs.
new-ing beans inside beans. If your @Service constructs another @Service with new, you’ve bypassed the container. That collaborator won’t have its own dependencies wired.
Manual wiring vs container — when to choose what
Use a DI container when:
- 20+ components to wire
- Lifecycle management matters (singletons, scopes, close-on-shutdown)
- You’re already using a framework that provides one
Wire manually when:
- Small project or library (DI frameworks add startup time and complexity)
- You want zero runtime dependencies on reflection
- You want explicit wiring (some teams prefer it for clarity)
DI and testing
The biggest payoff. A DI’d class is trivially unit-testable:
@Test
void sendsConfirmationEmail() {
var mockEmail = mock(EmailClient.class);
var mockDb = mock(Database.class);
var service = new OrderService(mockEmail, mockDb);
service.placeOrder(new Order("c-1", ...));
verify(mockEmail).send("c-1", "Order confirmed");
}No Spring, no test containers, no integration setup. The constructor tells you exactly what to mock.
Compare to field-injected code, where you need either @SpringBootTest (slow) or reflection tricks to populate fields.
The philosophy
DI is an answer to one question: where do objects’ dependencies come from?
Three possible answers:
- They create their own (
newinside the class) — simple, inflexible, hard to test - A service locator hands them out (
Registry.get(EmailClient.class)) — more flexible, but hides dependencies - They’re passed in (DI) — most flexible, dependencies visible, testable
Modern Java has settled on #3. Understand why, and the rest of the ecosystem (Spring, Quarkus, Micronaut) makes sense as implementations of the same idea.
Closing thought
DI looks like bureaucracy when you’re writing your first Spring Boot app. It reveals its value the first time you need to swap a real database for an in-memory one in tests, or when a constructor has grown so large it tells you your class is doing too much. The machinery is just plumbing — the insight is that classes shouldn’t know how to build the things they depend on.