00:00

Spring Boot Dependency Injection and Spring Beans

Last updated:

Dependency injection in Spring Boot means your classes don't create the objects they need. They declare them, usually as constructor parameters, and Spring supplies them. The objects Spring creates and manages are called Spring beans. This one idea is behind almost everything in Spring Boot: it keeps classes loosely coupled, makes them easy to test, and lets the framework add features such as transactions around your code.

This tutorial explains how the Spring container builds beans, how to declare them with @Component and @Bean, how constructor injection works, and how to choose between several beans of the same type with @Primary, @Qualifier, @Fallback or a list. It then covers scopes and lifecycle callbacks, walks through a complete checkout example on Spring Boot 4.1, shows the real errors behind the most common pitfalls, and tests the code with and without Spring.

1. What Is Dependency Injection in Spring Boot?

A dependency is an object another object needs to do its job. A checkout service needs a payment gateway and a clock, for example. Without dependency injection, the service would create them itself with new, and be stuck with those exact classes. With dependency injection (DI), the service simply asks for them, and something else provides them.

In Spring, that "something else" is the IoC container, also called the application context. IoC stands for inversion of control: the container, not your code, controls when objects are created and how they are connected. Every object the container manages is a bean.

2. Why Dependency Injection Matters

  • Loose coupling. A service depends on the PaymentGateway interface, not on a specific card or UPI class, so implementations can change without touching it.
  • Easy testing. A test can pass a fixed clock or a fake gateway through the constructor. Section 11 does exactly that without starting Spring.
  • One place for configuration. How objects are created and shared is decided by the container, not repeated in every class.
  • Framework features. Because Spring creates your beans, it can wrap them in proxies that add transactions, caching, security or AOP aspects.

3. How the Spring Container Creates Beans

At startup, Spring Boot first scans the package of your @SpringBootApplication class and its sub-packages for components, and collects @Bean methods and auto-configuration. Each one becomes a bean definition, a recipe describing how to build the bean. The container then works out which beans depend on which, creates them in dependency order, injects each bean's dependencies, runs lifecycle callbacks, and only then starts accepting requests.

How the Spring container creates and manages beansAt startup Spring scans for @Component classes, @Bean methods and auto-configuration, and turns each into a bean definition holding its class, scope, name and dependencies. It then instantiates beans with their dependencies first, injects constructor arguments, setters and fields, and initialises them by calling @PostConstruct and wrapping them in AOP proxies where needed. The beans are then ready and the application serves requests. On shutdown Spring calls @PreDestroy, for singletons only. Prototype beans repeat the instantiate, inject and initialise steps every time they are requested, and Spring never calls their @PreDestroy. 1. Scan @Component classes, @Bean, auto-config 2. Bean definitions class, scope, name, dependencies 3. Instantiate dependencies first, via the constructor 4. Inject constructor args, setters, fields 5. Initialise @PostConstruct, AOP proxies 6. Ready beans in use, app serves requests 7. Shut down @PreDestroy, singletons only Prototype beans repeat steps 3 to 5 every time they are requested, and Spring never calls their @PreDestroy
Figure 1: the container turns scanned classes into bean definitions, then creates, injects and initialises each bean before the application starts.

By default every bean is a singleton: one instance, created at startup and shared by everyone who injects it.

4. Declaring Spring Beans: @Component vs @Bean

4.1 @Component and Its Stereotypes

Annotate your own classes, and component scanning registers them. The specialised annotations behave the same way, but say what the class is for:

AnnotationUse forExtra behaviour
@ComponentAny Spring-managed classNone
@ServiceBusiness logicNone (documents intent)
@RepositoryData accessTranslates persistence exceptions into Spring's DataAccessException
@Controller / @RestControllerWeb endpointsRegistered with Spring MVC
@ConfigurationClasses with @Bean methodsCalls between @Bean methods return the shared bean

The bean's default name is the class name with a lower-case first letter. UpiPaymentGateway becomes upiPaymentGateway. The Spring Boot annotations tutorial lists the other common annotations.

4.2 @Bean Methods

For classes you can't annotate, such as JDK or third-party types, or when creation needs logic, write a @Bean method in a @Configuration class. The method's return value becomes the bean, and its name becomes the bean name:

ClockConfig.java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class ClockConfig {

    // Clock is a JDK class, so it is registered with a @Bean method
    @Bean
    public Clock clock() {
        return Clock.systemUTC();
    }
}
Version note: Spring Framework 7 (Spring Boot 4) adds BeanRegistrar, an interface for registering beans in code, for example in a loop or depending on the environment, without writing one @Bean method per bean. You import an implementation with @Import. @Component and @Bean remain the normal way for everyday beans.

5. Constructor vs Setter vs Field Injection

Spring can inject a dependency in three ways. Constructor injection is the recommended one:

CheckoutService.java (excerpt)
@Service
public class CheckoutService {

    private final Map<String, PaymentGateway> gatewaysByMethod;
    private final ObjectProvider<ReceiptBuilder> receiptBuilders;
    private final Clock clock;
    private final AuditLog auditLog;

    // A single constructor needs no @Autowired
    public CheckoutService(List<PaymentGateway> gateways,
                           ObjectProvider<ReceiptBuilder> receiptBuilders,
                           Clock clock,
                           AuditLog auditLog) {
        // ...
    }
}

When a class has exactly one constructor, Spring uses it automatically, so no @Autowired is needed (this has been true since Spring 4.3).

ConstructorSetter (@Autowired on a setter)Field (@Autowired on a field)
Fields can be finalYesNoNo
Missing dependency failsAt startupAt startup if requiredAt startup if required
Usable in a plain unit testYes, with newYes, but easy to forget a setterOnly with reflection or Spring
Shows how many dependencies a class hasClearlyPartlyHidden
Use forAlmost everythingOptional dependencies that can changeTest classes only

For a dependency that may not exist, inject ObjectProvider<T> and call getIfAvailable(), or inject Optional<T>.

6. Choosing Between Multiple Beans of the Same Type

The example has two PaymentGateway beans: cardPaymentGateway and upiPaymentGateway. A constructor that asks for just one PaymentGateway stops the application from starting:

Console
***************************
APPLICATION FAILED TO START
***************************

Description:

Parameter 0 of constructor in com.javabytechie.shop.payment.RefundService required a single bean, but 2 were found:
	- cardPaymentGateway: defined in URL [.../com/javabytechie/shop/payment/CardPaymentGateway.class]
	- upiPaymentGateway: defined in URL [.../com/javabytechie/shop/payment/UpiPaymentGateway.class]

This may be due to missing parameter name information

Action:

Consider marking one of the beans as @Primary, updating the consumer to accept multiple beans, or using @Qualifier to identify the bean that should be consumed

Each fix below was run against those two beans. Here is which one a RefundService(PaymentGateway ...) received:

ApproachHowBean injected
@PrimaryOn CardPaymentGatewaycard
@FallbackOn CardPaymentGateway, so the other candidate winsupi
@Qualifier@Qualifier("upiPaymentGateway") PaymentGateway gatewayupi
Parameter namePaymentGateway upiPaymentGateway (matches the bean name)upi
Inject them allList<PaymentGateway> or Map<String, PaymentGateway>both
  • @Primary marks the default choice, and @Fallback (added in Spring Framework 6.2) marks the bean to use only when nothing else qualifies. They are two sides of the same idea.
  • @Qualifier picks a bean by name explicitly. It is the clearest choice when one consumer needs a specific implementation.
  • Parameter-name matching works because the Spring Boot build compiles with -parameters. It is convenient, but renaming the parameter silently changes which bean you get, so prefer @Qualifier.
  • Injecting all beans as a List (or a Map keyed by bean name) suits the strategy pattern, as in the checkout example, where the payment method picks the gateway at run time.

7. Spring Bean Scopes

A bean's scope decides how many instances exist and how long they live:

ScopeInstancesTypical use
singleton (default)One per application contextStateless services, repositories, controllers
prototypeA new one every time it is requestedStateful helpers such as builders
requestOne per HTTP requestPer-request data in web apps
sessionOne per HTTP sessionUser session data
applicationOne per ServletContextRarely needed

A singleton is shared by every request thread at once, so keep singletons stateless, or make their state thread-safe. To get a fresh prototype from inside a singleton, inject ObjectProvider<T> and call getObject() each time. Section 10.3 shows what goes wrong otherwise.

8. Bean Lifecycle Callbacks

After a bean is created and its dependencies are injected, Spring calls a method annotated with @PostConstruct. It is the place for setup that needs those dependencies. On shutdown, it calls @PreDestroy to release resources. Both annotations come from jakarta.annotation:

AuditLog.java (excerpt)
@Component
public class AuditLog {

    public AuditLog() {
        log.info("1. AuditLog constructor called");
    }

    @PostConstruct
    void open() {
        log.info("2. @PostConstruct: audit log opened");
    }

    @PreDestroy
    void close() {
        log.info("3. @PreDestroy: audit log closed after {} entries", entries.get());
    }
}
Console (startup, then shutdown)
INFO  c.javabytechie.shop.payment.AuditLog : 1. AuditLog constructor called
INFO  c.javabytechie.shop.payment.AuditLog : 2. @PostConstruct: audit log opened
...
INFO  c.javabytechie.shop.payment.AuditLog : 3. @PreDestroy: audit log closed after 2 entries

For singletons, Spring calls @PreDestroy when the application context closes. For prototype beans, it does not: Spring hands the object over and stops tracking it, so the caller must clean up.

9. A Complete Example: Checkout with Payment Gateways

The checkout API charges an order through the gateway for the requested payment method. It brings together a list of beans, a prototype obtained through ObjectProvider, a @Bean for Clock, and lifecycle callbacks. It runs on Spring Boot 4.1 with Java 21.

Beans wired into CheckoutServiceCheckoutController depends on CheckoutService, a singleton @Service. CheckoutService receives both CardPaymentGateway and UpiPaymentGateway together as a List of PaymentGateway, the Clock bean declared with @Bean in ClockConfig, and the AuditLog bean with its @PostConstruct and @PreDestroy callbacks. It gets ReceiptBuilder, a prototype bean, through ObjectProvider, which supplies a new builder for each checkout. CheckoutController @RestController CheckoutService @Service, singleton CardPaymentGateway method() = "card" UpiPaymentGateway method() = "upi" List<PaymentGateway> ReceiptBuilder @Scope("prototype") ObjectProvider: a new builder per checkout Clock @Bean in ClockConfig AuditLog @PostConstruct, @PreDestroy
Figure 2: every dependency reaches CheckoutService through its constructor; the prototype ReceiptBuilder comes through ObjectProvider.

The only dependency is the web starter, plus the test starter for section 11:

pom.xml
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

9.1 Payment Gateways

package com.javabytechie.shop.payment;

import java.math.BigDecimal;

public interface PaymentGateway {

    // The payment method this gateway handles, such as "card" or "upi"
    String method();

    String charge(String orderId, BigDecimal amount);
}

9.2 The Checkout Service and Its Collaborators

package com.javabytechie.shop.payment;

import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;

@Service
public class CheckoutService {

    private static final Logger log = LoggerFactory.getLogger(CheckoutService.class);

    private final Map<String, PaymentGateway> gatewaysByMethod;
    private final ObjectProvider<ReceiptBuilder> receiptBuilders;
    private final Clock clock;
    private final AuditLog auditLog;

    // A single constructor needs no @Autowired; Spring passes every PaymentGateway bean in the list
    public CheckoutService(List<PaymentGateway> gateways,
                           ObjectProvider<ReceiptBuilder> receiptBuilders,
                           Clock clock,
                           AuditLog auditLog) {
        this.gatewaysByMethod = gateways.stream()
                .collect(Collectors.toMap(PaymentGateway::method, Function.identity()));
        this.receiptBuilders = receiptBuilders;
        this.clock = clock;
        this.auditLog = auditLog;
        log.info("CheckoutService created with gateways {}", gatewaysByMethod.keySet());
    }

    public Receipt checkout(CheckoutRequest request) {
        PaymentGateway gateway = gatewaysByMethod.get(request.method());
        if (gateway == null) {
            throw new UnsupportedPaymentMethodException(request.method());
        }

        String transactionId = gateway.charge(request.orderId(), request.amount());
        Instant paidAt = clock.instant();
        auditLog.record("order " + request.orderId() + " paid by " + request.method());

        // getObject() returns a new ReceiptBuilder for every checkout
        return receiptBuilders.getObject()
                .line("Order " + request.orderId())
                .line("Paid by " + request.method())
                .build(request.orderId(), request.amount(), transactionId, paidAt);
    }
}

9.3 Controller, DTOs and Application

An unknown payment method throws UnsupportedPaymentMethodException, which the controller turns into 400 Bad Request:

package com.javabytechie.shop.payment;

import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/checkout")
public class CheckoutController {

    private final CheckoutService checkoutService;

    public CheckoutController(CheckoutService checkoutService) {
        this.checkoutService = checkoutService;
    }

    @PostMapping
    public Receipt checkout(@RequestBody CheckoutRequest request) {
        return checkoutService.checkout(request);
    }

    @ExceptionHandler(UnsupportedPaymentMethodException.class)
    public ProblemDetail unsupportedMethod(UnsupportedPaymentMethodException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage());
    }
}

9.4 Run It

Start the app with ./mvnw spring-boot:run, then pay two orders and try an unsupported method:

Terminal
$ curl -s -X POST localhost:8080/api/checkout -H 'Content-Type: application/json' \
    -d '{"orderId":"ORD-1001","amount":1499.00,"method":"upi"}'
{"orderId":"ORD-1001","amount":1499.00,"transactionId":"UPI-ORD-1001","paidAt":"2026-10-25T04:43:53.502157Z","lines":["Order ORD-1001","Paid by upi"]}

$ curl -s -X POST localhost:8080/api/checkout -H 'Content-Type: application/json' \
    -d '{"orderId":"ORD-1002","amount":89.00,"method":"card"}'
{"orderId":"ORD-1002","amount":89.00,"transactionId":"CARD-ORD-1002","paidAt":"2026-10-25T04:43:53.521715Z","lines":["Order ORD-1002","Paid by card"]}

$ curl -s -i -X POST localhost:8080/api/checkout -H 'Content-Type: application/json' \
    -d '{"orderId":"ORD-1003","amount":10.00,"method":"cash"}'
HTTP/1.1 400
Content-Type: application/problem+json

{"detail":"Unsupported payment method: cash","instance":"/api/checkout","status":400,"title":"Bad Request"}

Each receipt contains only its own lines, because each checkout got a new ReceiptBuilder. The log shows the container at work: the AuditLog bean is created and initialised before CheckoutService, which depends on it, and closed on shutdown:

Console
[main] c.javabytechie.shop.payment.AuditLog : 1. AuditLog constructor called
[main] c.javabytechie.shop.payment.AuditLog : 2. @PostConstruct: audit log opened
[main] c.j.shop.payment.CheckoutService     : CheckoutService created with gateways [card, upi]
[main] c.javabytechie.shop.ShopApplication  : Started ShopApplication in 0.599 seconds
[nio-8080-exec-1] c.j.shop.payment.UpiPaymentGateway  : Collecting 1499.00 via UPI for order ORD-1001
[nio-8080-exec-1] c.javabytechie.shop.payment.AuditLog : AUDIT order ORD-1001 paid by upi
[nio-8080-exec-3] c.j.shop.payment.CardPaymentGateway : Charging card 89.00 for order ORD-1002
[nio-8080-exec-3] c.javabytechie.shop.payment.AuditLog : AUDIT order ORD-1002 paid by card
[ionShutdownHook] c.javabytechie.shop.payment.AuditLog : 3. @PreDestroy: audit log closed after 2 entries

10. Common Pitfalls and How to Fix Them

10.1 "Required a Single Bean, but 2 Were Found"

Two or more beans match an injection point. Fix it with @Primary, @Qualifier, @Fallback or a List, as section 6 shows.

10.2 Circular Dependencies

If OrderService needs InvoiceService and InvoiceService needs OrderService, the application refuses to start:

Console
Description:

The dependencies of some of the beans in the application context form a cycle:

┌─────┐
|  invoiceService defined in URL [.../com/javabytechie/shop/payment/InvoiceService.class]
↑     ↓
|  orderService defined in URL [.../com/javabytechie/shop/payment/OrderService.class]
└─────┘

Action:

Relying upon circular references is discouraged and they are prohibited by default. Update your application to remove the dependency cycle between beans. As a last resort, it may be possible to break the cycle automatically by setting spring.main.allow-circular-references to true.

How to fix it: a cycle usually means responsibilities are tangled. Move the shared logic into a third bean that both use, or let one side publish an event instead of calling the other. Turning on spring.main.allow-circular-references hides the design problem and only works with setter or field injection.

10.3 Injecting a Prototype Directly into a Singleton

A prototype injected through a singleton's constructor is created only once, together with that singleton, and then reused forever. A test service that injected ReceiptBuilder directly produced this:

Console
first=[Order ORD-1]
second=[Order ORD-1, Order ORD-2]

The second receipt contains the first order's line. How to fix it: inject ObjectProvider<ReceiptBuilder> and call getObject() per use, as CheckoutService does.

10.4 Creating Beans with new

An object you create with new CheckoutService(...) inside other code is not a bean. Spring doesn't inject into it, and features such as @Transactional or @Async on it do nothing. How to fix it: inject the bean instead. Use new only in unit tests, where passing dependencies by hand is the point.

10.5 A Component That Is Never Found

Component scanning covers only the main class's package and its sub-packages. A class in com.javabytechie.payments is invisible to an application in com.javabytechie.shop, and you get "required a bean of type ... that could not be found". How to fix it: keep the main class in the root package, or add the package with @SpringBootApplication(scanBasePackages = ...).

11. Testing Code That Uses Dependency Injection

Constructor injection pays off in tests. This unit test builds CheckoutService with new, a fixed clock and a simple ObjectProvider, so it runs in milliseconds and its result never depends on the time of day:

CheckoutServiceTest.java
package com.javabytechie.shop.payment;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.math.BigDecimal;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;

// No Spring here: constructor injection lets the test pass every dependency by hand
class CheckoutServiceTest {

    private final Clock fixedClock = Clock.fixed(Instant.parse("2026-10-25T09:30:00Z"), ZoneOffset.UTC);
    private final AuditLog auditLog = new AuditLog();

    // Stands in for Spring's provider: a new ReceiptBuilder on every call
    private final ObjectProvider<ReceiptBuilder> receiptBuilders = new ObjectProvider<>() {
        @Override
        public ReceiptBuilder getObject() {
            return new ReceiptBuilder();
        }
    };

    private final CheckoutService checkoutService = new CheckoutService(
            List.of(new CardPaymentGateway(), new UpiPaymentGateway()),
            receiptBuilders,
            fixedClock,
            auditLog);

    @Test
    void chargesTheGatewayForTheRequestedMethod() {
        assertThat(auditLog.size()).isZero();

        Receipt receipt = checkoutService.checkout(
                new CheckoutRequest("ORD-1001", new BigDecimal("1499.00"), "upi"));

        assertThat(receipt.transactionId()).isEqualTo("UPI-ORD-1001");
        assertThat(receipt.paidAt()).isEqualTo(Instant.parse("2026-10-25T09:30:00Z"));
        assertThat(receipt.lines()).containsExactly("Order ORD-1001", "Paid by upi");
        assertThat(auditLog.size()).isEqualTo(1);
    }

    @Test
    void rejectsUnknownPaymentMethod() {
        assertThatThrownBy(() -> checkoutService.checkout(
                new CheckoutRequest("ORD-1003", BigDecimal.TEN, "cash")))
                .isInstanceOf(UnsupportedPaymentMethodException.class)
                .hasMessage("Unsupported payment method: cash");
        assertThat(auditLog.size()).isZero();
    }
}

A separate @SpringBootTest checks the wiring itself: that both gateways are registered, that ReceiptBuilder really is a prototype, and that real checkouts don't share receipt lines:

CheckoutWiringTest.java
package com.javabytechie.shop.payment;

import static org.assertj.core.api.Assertions.assertThat;

import java.math.BigDecimal;
import java.util.List;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.ApplicationContext;

// With Spring: checks that the context wires the beans the way the code expects
@SpringBootTest
class CheckoutWiringTest {

    @Autowired
    private ApplicationContext context;

    @Autowired
    private List<PaymentGateway> gateways;

    @Autowired
    private CheckoutService checkoutService;

    @Test
    void registersBothGateways() {
        assertThat(gateways).extracting(PaymentGateway::method).containsExactlyInAnyOrder("card", "upi");
    }

    @Test
    void receiptBuilderIsAPrototype() {
        ReceiptBuilder first = context.getBean(ReceiptBuilder.class);
        ReceiptBuilder second = context.getBean(ReceiptBuilder.class);

        assertThat(first).isNotSameAs(second);
    }

    @Test
    void consecutiveCheckoutsDoNotShareReceiptLines() {
        Receipt one = checkoutService.checkout(new CheckoutRequest("ORD-1", new BigDecimal("10.00"), "card"));
        Receipt two = checkoutService.checkout(new CheckoutRequest("ORD-2", new BigDecimal("20.00"), "card"));

        assertThat(one.lines()).containsExactly("Order ORD-1", "Paid by card");
        assertThat(two.lines()).containsExactly("Order ORD-2", "Paid by card");
    }
}

@Autowired on fields is fine in test classes, which JUnit creates, not your application code. To replace a bean with a Mockito mock in a Spring test, use @MockitoBean. @MockBean was removed in Spring Boot 4. The Spring Boot testing tutorial covers test slices.

12. Best Practices

  • Use constructor injection with final fields, and leave out @Autowired on single constructors.
  • Depend on interfaces, such as PaymentGateway, so implementations can change and tests can substitute them.
  • Keep singletons stateless, because one instance serves every thread.
  • Get prototypes through ObjectProvider instead of injecting them directly into singletons.
  • Resolve ambiguity explicitly with @Qualifier or @Primary, rather than relying on parameter names.
  • Inject a List or Map for strategies, and choose the implementation at run time.
  • Use @Bean for classes you don't own, such as Clock, and @Component for your own.
  • Break circular dependencies by redesigning, not by enabling allow-circular-references.
  • Put setup in @PostConstruct, not the constructor, when it needs injected dependencies, and release resources in @PreDestroy.
  • Inject time as a Clock bean, so tests can use a fixed clock.

13. Summary

Dependency injection in Spring Boot lets the container create your objects as Spring beans and hand them their dependencies, so your classes stay small, loosely coupled and easy to test. Declare beans with @Component (and its stereotypes) or @Bean methods, inject them through constructors, and resolve multiple candidates with @Primary, @Qualifier, @Fallback or a List. Keep singletons stateless, fetch prototypes through ObjectProvider, use @PostConstruct and @PreDestroy for setup and clean-up, and remove circular dependencies by redesigning. The main recommendation: use constructor injection everywhere in application code, and you get both cleaner design and tests that need no Spring at all.