The first article in this module made one claim and moved on: stop calling new on your collaborators, and let the container hand them in. That was the what. This article is the why — because a rule you don't understand is a rule you'll break the first time it's inconvenient.
Dependency injection is the practice of giving an object the things it needs from the outside, instead of letting it build them itself. You met the name in passing already. Here we look at what it actually buys you, what it costs, and why one way of doing it is plainly better than the rest.
You hit this decision every time you write a class that needs another class. Do you reach for new and build the collaborator inside? Or do you ask for it and let something else supply it? That one choice, repeated across a codebase, is the difference between code you can test and change and code that quietly fights you.
The thing DI actually fights
It is tempting to think the enemy here is the new keyword. It isn't. The enemy is coupling to construction — a class deciding, inside its own body, exactly how its collaborators are built.
Look at what a class signs up for the moment it builds its own collaborator:
class OrderService {
private final PaymentGateway gateway = new PaymentGateway(new StripeClient());
void placeOrder(Order order) {
gateway.charge(order.total());
}
}
That one line welds together two decisions that have nothing to do with each other:
-
What
OrderServiceneeds — something that can charge money. - Which exact thing it gets, and how that thing is built — this concrete class, wired to Stripe, constructed just so.
The first decision is OrderService's business. The second is not. OrderService should not hold an opinion on whether payments go through Stripe or Adyen, or what a gateway needs underneath. The moment it bakes that opinion into code, the two decisions can never move apart again.
Pulling the two decisions apart
So separate them. Make OrderService depend only on the role — what it needs — and let it state that need without naming a concrete class:
interface PaymentGateway {
void charge(Money amount);
}
@Service
class OrderService {
private final PaymentGateway gateway;
OrderService(PaymentGateway gateway) { // "give me something that charges"
this.gateway = gateway;
}
}
OrderService now names an interface and nothing else. It has no idea which class shows up at runtime. Something outside decides that and supplies it.
The principle you just applied has a name: depend on abstractions, not on concrete implementations — and let an outside party choose and supply the concrete one. Dependency injection is simply the delivery mechanism for that idea. The interface is the contract; injection is how the chosen implementation arrives.
That separation is the whole payoff, and it shows up in three concrete ways.
You can test it. In a test you hand OrderService a fake gateway that records calls instead of hitting a bank — possible only because nothing concrete is baked in.
You can swap implementations. Moving from Stripe to Adyen in production means changing one bean definition. Not a single line of OrderService changes.
The container can wrap what it supplies. Because the gateway arrives from outside, the container gets a chance to hand you something that isn't your plain object — it can wrap it first. That wrap is how @Transactional and the other annotations from the last article attach themselves, and it is only possible because you didn't build the thing yourself.
How Spring picks what to inject
So OrderService asks for a PaymentGateway. How does the container know what to give it?
By type. When Spring builds OrderService and sees a constructor parameter of type PaymentGateway, it looks through the beans it knows about for one whose type fits PaymentGateway, and injects that one.
@Component
class StripeGateway implements PaymentGateway {
public void charge(Money amount) { /* ... */ }
}
You never told Spring "use StripeGateway for OrderService." You declared a need by type, you have one bean of that type, and the container connected them.
What happens when two classes implement PaymentGateway — how you break the tie — is its own topic later in this module. For now the point is just this: resolution is by type.
Why the style of injection is not a matter of taste
There are three places Spring can put an injected dependency: the constructor, a setter, or straight into a field by reflection. They look interchangeable. They are not — and the difference is mechanical, not stylistic.
Here is the field version. It looks the cleanest and costs the most:
@Service
class OrderService {
@Autowired private PaymentGateway gateway; // looks tidy
}
Watch what this quietly allows. The object can be fully built while completely empty — new OrderService() succeeds and hands you an instance whose gateway is null. The field can't be final, so it stays mutable for the object's whole life. The only way to fill it is reflection, so you can't build a valid instance in a plain unit test without dragging Spring in. And the dependency is invisible from outside: nothing in the signature tells a caller this class even needs a gateway.
Now the constructor version:
@Service
class OrderService {
private final PaymentGateway gateway;
OrderService(PaymentGateway gateway) {
this.gateway = gateway;
}
}
Every one of those problems is gone — and not by convention, by language mechanics. The field is final, so it is set exactly once and the object is immutable after construction. There is no way to create the object without supplying its dependency; the compiler enforces it. And the dependency is right there in the signature for anyone to read.
The testing win becomes concrete the moment you write a test. No framework, no reflection — just a constructor call:
@Test
void chargesTheOrderTotal() {
var gateway = new RecordingGateway(); // a fake, records instead of charging
var service = new OrderService(gateway); // plain Java, no Spring in sight
service.placeOrder(new Order(Money.of(90)));
assertThat(gateway.lastCharge()).isEqualTo(Money.of(90));
}
That last point hides the most useful one. Because every dependency has to pass through the constructor, a class with too many of them grows an ugly seven-argument constructor you can't ignore. Field injection lets the same class quietly pile up fifteen @Autowired fields and still look tidy. The constructor turns "this class is doing too much" from a hidden fact into a visible smell. That isn't a style preference — it is design feedback you would otherwise never get.
The circular-dependency trap
There is a sharper version of the same benefit, and it is worth seeing in full. Suppose A needs B, and B needs A — a circular dependency.
With field injection, Spring can often paper over it. It creates both raw objects first, then sets the fields afterward, so the cycle "works" by accident and ships. With constructor injection, neither object can be built before the other exists, so Spring can't even start — it fails at boot with BeanCurrentlyInCreationException.
It is tempting to read that as constructor injection being stricter and therefore more annoying. It is the opposite. The cycle is a real design flaw. Constructor injection forces you to see it the second you boot the app, instead of meeting it as a StackOverflowError in production weeks later. Failing fast, at the safest possible moment, is the feature.
Setter injection is the third style. It has one honest, narrow use: a genuinely optional dependency that can be reconfigured after the object exists. That is rare. Everywhere else, reach for the constructor.
One thing resolution-by-type gives you for free
Because injection is by type, a trick falls out that turns DI into a plugin system. Ask for a List of an interface, and Spring injects every bean that implements it:
interface ShippingRule {
boolean applies(Order order);
Money surcharge(Order order);
}
@Service
class ShippingCalculator {
private final List<ShippingRule> rules;
ShippingCalculator(List<ShippingRule> rules) { // every ShippingRule bean, injected
this.rules = rules;
}
Money totalSurcharge(Order order) {
return rules.stream()
.filter(r -> r.applies(order))
.map(r -> r.surcharge(order))
.reduce(Money.ZERO, Money::add);
}
}
Adding a new rule is now just adding one @Component that implements ShippingRule. ShippingCalculator never changes — it doesn't even know how many rules exist. That is the plugin pattern, delivered entirely by DI.
One trap rides along with it: the order of the beans in that injected list is not guaranteed unless you make it so. If your rules must run in a set sequence — a discount rule before a tax rule — relying on whatever order Spring happened to hand them in is a bug waiting for the day a refactor reshuffles them. Pin it with @Order on each implementation (or have them implement Ordered), and the list arrives in that order. (Ask for Map<String, ShippingRule> instead, and you get each implementation keyed by its bean name — handy when you need to pick one by name at runtime.)
Putting it together
Dependency injection isn't about dodging a keyword. It is about refusing to let a class decide both what it needs and which implementation it gets — and pulling those apart so the implementation can be swapped, centralized, and wrapped by the container.
Spring resolves the need by type. Constructor injection is the way to receive it — not for neatness, but because the language then guarantees your object is immutable, impossible to build half-wired, trivial to test without the framework, and honest about how many dependencies it really carries. And once injection is by type, the List-of-interface plugin pattern comes for free — just remember to pin the order when it matters.
Top comments (0)