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 like PaymentClient live 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's DataAccessException). 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:

  • final fields — the object is complete or it doesn't exist. There is no moment when an OrderService exists without its PaymentClient. The new 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 @Autowired fields 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:

Spring container builds the graph, then your code OrderService @Service PaymentClient @Component OrderRepository @Repository HttpClient @Bean in HttpConfig injects every arrow is a constructor argument the container satisfies

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.

singleton (default) getBean × 2 two requests one OrderService first == second prototype getBean × 2 two requests genA genB fresh instance every time genA != genB

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

Popular posts from this blog

JSP Servlet Interview Questions For Freshers Series 1

Java Banking Finance Services and Insurance (BFSI) domain interview questions

Java program to check even or odd number