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 OrderService without a real SMTP server and real Postgres
  • Configuration. prod-db.company.com is 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:

  1. Knows how to construct each class (scanning annotations, analyzing constructors)
  2. Knows which implementation to inject where (type-based resolution)
  3. 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:

  1. They create their own (new inside the class) — simple, inflexible, hard to test
  2. A service locator hands them out (Registry.get(EmailClient.class)) — more flexible, but hides dependencies
  3. 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.