Dependency Injection Deep Dive
Our checkout service had an OrderService that worked fine — until the payments team asked for a sandbox mode. The problem was one line, buried in the constructor:
public OrderService() {
this.payments = new PaymentClient(); // hardcoded, untestable, undebatable
}
PaymentClient built its own HTTP client with a 30-second timeout baked in, pointed at the production URL baked in. To test OrderService you needed the real payment provider. To point at sandbox you edited source code. The class owned its dependencies, which meant nobody else could change them. Dependency injection is the inversion of that sentence: the class declares what it needs, and the container supplies it. This post goes deep on how Spring does that — stereotypes, constructor injection, @Bean methods, scopes, and the circular-dependency trap — with every claim verified by running real code.
The stereotypes: naming your beans
Three annotations mark three roles. Technically they do the same thing (register the class as a bean); culturally they document intent, and intent is what the next reader needs:
package com.javamakeuse.checkout.payments;
import org.springframework.stereotype.Component;
import java.net.http.HttpClient;
@Component // a generic building block: talks to the payment provider
public class PaymentClient {
private final HttpClient http;
public PaymentClient(HttpClient http) {
this.http = http;
}
public String charge(String orderId, long amountCents) {
// TODO: real HTTPS call to the payment provider (later post in this track)
return "ch_" + orderId;
}
}
package com.javamakeuse.checkout.orders;
import org.springframework.stereotype.Repository;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
@Repository // the persistence boundary: owns how orders are stored
public class OrderRepository {
private final Map<String, String> statuses = new ConcurrentHashMap<>();
public void saveStatus(String orderId, String status) {
statuses.put(orderId, status);
}
public Optional<String> findStatus(String orderId) {
return Optional.ofNullable(statuses.get(orderId));
}
}
@Component— the generic stereotype: "this is a bean, managed by Spring." Infrastructure pieces likePaymentClientlive here.@Service— "this holds business logic" (shown below). No behavior difference from@Component; the name tells the reader where the rules live.@Repository— "this is the persistence boundary." One real behavior difference: Spring wraps repository beans with persistence-exception translation (a raw driver exception becomes Spring'sDataAccessException). With our in-memory map there's nothing to translate yet — the annotation is a promise about the role, kept when the database arrives in a later post.
All three are found by the @ComponentScan that @SpringBootApplication includes — anything annotated in your base package (or below) becomes a bean automatically. No XML, no registration list.
Constructor injection: the default for a reason
package com.javamakeuse.checkout.orders;
import com.javamakeuse.checkout.payments.PaymentClient;
import org.springframework.stereotype.Service;
@Service // the business logic: placing an order = charge + record
public class OrderService {
private final PaymentClient payments;
private final OrderRepository orders;
// One constructor: @Autowired is implied, dependencies are final.
public OrderService(PaymentClient payments, OrderRepository orders) {
this.payments = payments;
this.orders = orders;
}
public String placeOrder(String orderId, long amountCents) {
String chargeId = payments.charge(orderId, amountCents);
orders.saveStatus(orderId, "PAID");
return chargeId;
}
}
When a class has exactly one constructor, Spring treats it as the injection point — no @Autowired needed. (With multiple constructors you must mark one.) Three reasons this is the default, in order of how much pain each one saves:
finalfields — the object is complete or it doesn't exist. There is no moment when anOrderServiceexists without itsPaymentClient. Thenew PaymentClient()version from the opening story could at least be reasoned about; the field-injection version below can exist in a half-built state.- Testability without the container.
new OrderService(mockClient, mockRepo)is a plain Java call — the Mockito style from this track's JUnit 5 & Mockito post works with zero Spring on the classpath. If your class is hard to unit test, the constructor signature usually tells you why. - The dependencies are documentation. The constructor signature is the dependency list. No scanning the class for
@Autowiredfields to learn what it needs.
The anti-pattern, for recognition (you will inherit code that does this):
@Service
public class OrderService {
@Autowired
private PaymentClient payments; // don't do this
@Autowired
private OrderRepository orders; // don't do this
}
Field injection hides the dependencies, forbids final, and forces every test to boot Spring (or use reflection) just to set a field. It compiles, it runs, and it slowly taxes every future change. Constructor injection is the standing rule of this track.
@Bean: for types you don't own
Stereotypes only work on classes you can annotate. java.net.http.HttpClient is a JDK class — you can't put @Component on it. That's what @Configuration + @Bean is for: you write the construction code, Spring manages the result:
package com.javamakeuse.checkout;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.net.http.HttpClient;
import java.time.Duration;
// @Bean is for types you don't own: you can't put @Component
// on java.net.http.HttpClient, so you declare the bean by hand.
@Configuration
public class HttpConfig {
@Bean
public HttpClient httpClient() {
return HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3))
.build();
}
}
The method name (httpClient) becomes the bean name; the return type is the bean type. Spring calls it once, caches the result (singleton scope — see below), and injects it into PaymentClient's constructor. The 3-second timeout that was baked into source code in the opening story is now one line in one place.
Decision rule: stereotype annotations for your classes, @Bean methods for library types. If you find yourself writing a @Bean method that just calls new on your own class, delete the method and annotate the class.
Here is the whole wiring as the container sees it:
Scopes: singleton vs prototype, shown not told
Every bean has a scope: how many instances the container creates. The default is singleton — one instance per container, shared by every injection point. The alternative you'll actually meet is prototype — a new instance per request. Here is a stateful helper that must not be shared:
package com.javamakeuse.checkout;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;
import java.util.UUID;
@Component
@Scope("prototype")
public class IdGenerator {
private final UUID id = UUID.randomUUID();
public UUID id() {
return id;
}
}
And a small program that boots the real container and asks for beans twice. This ran verbatim during verification:
try (var ctx = new AnnotationConfigApplicationContext(CheckoutDiConfig.class)) {
var first = ctx.getBean(OrderService.class);
var second = ctx.getBean(OrderService.class);
System.out.println("singleton, same instance? " + (first == second));
System.out.println("charge id: " + first.placeOrder("ORD-7", 12999));
var genA = ctx.getBean(IdGenerator.class);
var genB = ctx.getBean(IdGenerator.class);
System.out.println("prototype, same instance? " + (genA == genB));
System.out.println("ids: " + genA.id() + " / " + genB.id());
}
singleton, same instance? true
charge id: ch_ORD-7
prototype, same instance? false
ids: 8b564fd5-e7f5-45fe-9dd4-6f14cd58cc11 / 250acd05-861a-4fcb-a039-1f2eaf0922bd
(Your UUIDs will differ — the point is that they do differ.) The wiring works end to end: placeOrder charged through the injected PaymentClient and recorded through the injected OrderRepository, no container magic visible at the call site.
Decision rules for scopes: stay with the singleton default unless the bean holds per-use mutable state (like our ID generator). And the classic trap: injecting a prototype bean into a singleton captures one instance at wiring time — the singleton keeps reusing it forever. If a singleton needs a fresh prototype per call, inject a ObjectProvider<IdGenerator> or javax… — rather, jakarta.inject.Provider<IdGenerator> — and call get() per use.
Circular dependencies: the knot you tie yourself
Sooner or later two services need each other. Billing wants to check the fraud hold before charging; refunds want to void the charge that billing created:
@Service
class BillingService {
private final RefundService refunds; // needs refunds
BillingService(RefundService refunds) { this.refunds = refunds; }
}
@Service
class RefundService {
private final BillingService billing; // needs billing
RefundService(BillingService billing) { this.billing = billing; }
}
Booting a container with these two produces a real, unambiguous failure — captured from the actual run:
org.springframework.beans.factory.UnsatisfiedDependencyException:
Error creating bean with name 'billingService': ...
Error creating bean with name 'refundService': ...
Error creating bean with name 'billingService':
Requested bean is currently in creation:
Is there an unresolvable circular reference
or an asynchronous initialization dependency?
Read the nesting inside-out: to build billingService Spring needs refundService, which needs billingService, which is currently in creation. With constructor injection there is no half-built object to hand over, so Spring refuses — correctly. (Field or setter injection would "work" by handing over the unfinished bean, which is exactly why constructor injection's refusal is a feature: it surfaces the design problem instead of hiding it.)
The fix is never a framework trick — it's a design change. The cycle exists because shared knowledge (which orders have settled money movement) lives in both services. Extract it into a third bean both depend on:
@Component
public class MoneyMovementLedger {
private final Set<String> settled = ConcurrentHashMap.newKeySet();
public boolean markSettled(String orderId) { return settled.add(orderId); }
public boolean isSettled(String orderId) { return settled.contains(orderId); }
}
@Service
public class BillingService {
private final MoneyMovementLedger ledger;
public BillingService(MoneyMovementLedger ledger) { this.ledger = ledger; }
public String bill(String orderId) {
ledger.markSettled(orderId);
return "billed:" + orderId;
}
}
@Service
public class RefundService {
private final MoneyMovementLedger ledger;
public RefundService(MoneyMovementLedger ledger) { this.ledger = ledger; }
public String refund(String orderId) {
if (!ledger.isSettled(orderId)) {
return "cannot-refund-unsettled:" + orderId;
}
return "refunded:" + orderId;
}
}
The graph is now a tree — both services point down at the ledger, nothing points back up. The same demo program, run against the fixed wiring:
billed:ORD-7
refunded:ORD-7
cannot-refund-unsettled:ORD-9
Decision rule: a circular dependency is a missing abstraction. When two beans need each other, find the knowledge they share, extract it into a third bean, and make both depend on that. If you ever reach for @Lazy to break a cycle, treat it as a code smell with a TODO — it postpones the failure instead of removing the knot.
What's next
You now own the core of Spring: the container finds your stereotypes, satisfies constructor arguments, builds @Bean methods for library types, and manages scopes — and you know what the failure modes look like when the graph has a knot. The next post in this track puts the wiring to work against a real database, where @Repository finally earns its name.
Field check before you move on: in the scope demo, change IdGenerator to singleton (remove the @Scope annotation) and re-run — confirm both IDs become identical. Then reintroduce the circular pair from this post in a scratch project, boot it, and read the full nested exception yourself. Recognizing that stack trace on sight will save you an hour someday.
Continue: Java Learning Roadmap 2026
Comments
Post a Comment