Dependency Injection in Spring Boot: Why Constructor Injection Won


This is the run-time companion to Dependency Management in Spring Boot Microservices — that post covered how you declare and version libraries at build time; this one covers how Spring wires your objects together at run time. We’ll start with a class that builds its own collaborators, feel why that doesn’t scale, and let constructor injection earn its place — including over the field injection most tutorials reach for first.

The problem: a class that builds its own dependencies

Here’s an OrderService that does the obvious thing — it creates what it needs:

public class OrderService {
    private final PaymentClient paymentClient;
    private final InventoryRepository inventory;

    public OrderService() {
        // hard-wired: this class decides how its collaborators are built
        this.paymentClient = new PaymentClient("https://payments.prod/api", 3000);
        this.inventory = new InventoryRepository(new PostgresConnection("prod-db"));
    }

    public void placeOrder(Order order) { /* ... */ }
}

It compiles, it runs, and it looks harmless. The rot is structural, and it shows up the moment the class has to live in more than one context.

It can’t be tested in isolation. To unit-test placeOrder, you’d want a fake PaymentClient that doesn’t hit the network. You can’t hand one in — the constructor hard-codes a real one pointed at production. Your “unit” test now needs a live payments endpoint and a Postgres box.

It hard-codes environment. The prod URL and DB host are baked into the class. Staging, local, and test all need different values, so you end up threading if (env == ...) branches through code that should not know what an environment is.

It doesn’t scale past a toy graph. PaymentClient needs config; the repository needs a connection, which needs a pool, which needs credentials. Every object constructing its own dependencies means that whole tree gets rebuilt, by hand, at every call site — and rebuilt differently each time someone forgets a setting.

graph TD
  O[OrderService] -->|new| P[PaymentClient]
  O -->|new| R[InventoryRepository]
  R -->|new| C[PostgresConnection]
  P -->|new| CFG[hard-coded URL + timeout]
  C -->|new| CRED[hard-coded host + creds]
  style O fill:#ffd9d9,stroke:#c00

The root cause has a name: the class controls both its business logic and the construction of everything it depends on. Those are two jobs, and the second one shouldn’t be its.

Inverting control

The fix is to stop having the object build its collaborators and instead have them handed in from outside. That inversion — the object no longer controls how its dependencies come to exist — is Inversion of Control (IoC), and dependency injection is how you implement it.

In Spring, a container (the application context) owns that job. You declare what each collaborator is once, and the container constructs the graph and passes each object what it needs.

graph TD
  CTX[Spring ApplicationContext] -->|constructs & injects| O[OrderService]
  CTX -->|constructs| P[PaymentClient]
  CTX -->|constructs| R[InventoryRepository]
  P -.->|injected into| O
  R -.->|injected into| O
  style CTX fill:#d9e8ff,stroke:#369,stroke-width:2px
  style O fill:#d9ffe0,stroke:#3a3

But how the container injects matters. There are three ways, and they are not equivalent.

The tempting-but-flawed version: field injection

This is the one most examples show, because it’s the shortest:

@Service
public class OrderService {
    @Autowired private PaymentClient paymentClient;
    @Autowired private InventoryRepository inventory;

    public void placeOrder(Order order) { /* ... */ }
}

No constructor, no boilerplate — Spring reflectively sets the fields after constructing the object. It looks like a clean win. It isn’t, for reasons that only surface later:

  • You still can’t build it without Spring. In a plain unit test, new OrderService() gives you an object with null fields — the @Autowired only works inside the container. So you’re forced into reflection or a full Spring context just to test one method.
  • The fields can’t be final. They’re set after construction, so the object is mutable and its dependencies can be swapped or left null. You lose the compiler’s guarantee that a fully-built OrderService has its collaborators.
  • Dependencies are hidden. The only way to know what this class needs is to grep for @Autowired. A constructor puts that list in the class’s public signature, where callers and readers can see it.
  • It hides “too many dependencies.” Because adding one is just one more annotated field, a class can quietly grow ten of them. A constructor with ten parameters looks wrong — and that visible discomfort is a useful design signal.

The solution: constructor injection

Ask for the dependencies in the constructor. Spring sees the parameters and supplies the matching beans:

@Service
public class OrderService {
    private final PaymentClient paymentClient;
    private final InventoryRepository inventory;

    // No @Autowired needed — a single constructor is autowired by default
    public OrderService(PaymentClient paymentClient, InventoryRepository inventory) {
        this.paymentClient = paymentClient;
        this.inventory = inventory;
    }

    public void placeOrder(Order order) { /* ... */ }
}

Every problem above dissolves — not by convention, but by construction:

  1. Testable with plain Java. new OrderService(fakePayment, fakeInventory) — no container, no reflection. The class is trivially unit-testable because you control exactly what goes in.
  2. Genuinely immutable. The fields are final and set once at construction. A constructed OrderService is guaranteed to have its collaborators, and no code can swap them out later.
  3. Dependencies are explicit. The constructor is the list of what this class needs. It can’t hide a dependency, and a bloated constructor honestly signals a class doing too much.
  4. Fail-fast, at startup. If a required bean is missing, the context fails to start — you find out at boot, not on the first production request. And an unbreakable circular dependency (A needs B needs A) surfaces immediately instead of being silently tolerated the way field injection allows.

Takeaways

Concern Field / setter injection Constructor injection
Unit test without Spring Needs reflection or a context new it with fakes
Immutable (final) fields No Yes
Dependencies visible Hidden in annotations In the constructor signature
Missing bean detected Sometimes at runtime At startup
“Too many deps” signal Hidden A fat constructor is obvious

The deeper point isn’t “annotate differently.” It’s that a class shouldn’t be in charge of building the things it depends on — that’s the one job you hand to the container. Once construction moves out, constructor injection is simply the option that makes the result immutable, explicit, and testable with nothing but new. Reach for it by default; drop to setter injection only for the rare dependency that’s truly optional.