Skip to main content
Java · lesson 50 of 66
Open the Java page →
Java intermediate Lesson 50 of 66

Dependency Injection in Spring Boot

Run Spring's IoC container: constructor injection, two beans of one type, @Primary and @Qualifier, scopes, @ConfigurationProperties and lifecycle.

By SkillByExample · Updated

Dependency injection (DI) means a class receives its collaborators instead of creating them with new. In Spring the IoC container (the ApplicationContext) builds every object it manages — a bean — and passes each one what its constructor asks for. Each example below is a single DiApplication.java in a Spring Boot 3.5 project with only spring-boot-starter, so there is no web server: a CommandLineRunner prints the result and the app exits. application.properties turns off the banner and info logging so the output is just what the program prints:

spring.main.banner-mode=off
logging.level.root=WARN

Constructor injection

package com.example.di;

import java.util.Arrays;

import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;

interface Notifier { String send(String to, String text); }

@Component
class EmailNotifier implements Notifier {
    public String send(String to, String text) { return "email to " + to + ": " + text; }
}

@Service
class OrderService {
    private final Notifier notifier;                 // depends on the interface only

    OrderService(Notifier notifier) {                // single constructor: Spring injects it
        this.notifier = notifier;
    }

    String placeOrder(String customer) { return notifier.send(customer, "order placed"); }
}

@SpringBootApplication
public class DiApplication implements CommandLineRunner {
    private final OrderService orders;
    private final ApplicationContext ctx;
    DiApplication(OrderService orders, ApplicationContext ctx) { this.orders = orders; this.ctx = ctx; }

    public static void main(String[] args) { SpringApplication.run(DiApplication.class, args); }

    @Override
    public void run(String... args) {
        System.out.println(orders.placeOrder("ada@example.com"));
        System.out.println("Notifier beans: " + Arrays.toString(ctx.getBeanNamesForType(Notifier.class)));
        System.out.println("same instance: " + (ctx.getBean(OrderService.class) == orders));
    }
}
$ mvn -q -B package -DskipTests
$ java -jar target/di-0.0.1-SNAPSHOT.jar
email to ada@example.com: order placed
Notifier beans: [emailNotifier]
same instance: true

Nothing here calls new. Component scanning registered the three annotated classes as beans named after the class (emailNotifier, orderService, diApplication). To build DiApplication the container needed an OrderService, which needed a Notifier; the only bean implementing that interface was EmailNotifier, so it went in. A class with a single constructor needs no @Autowired. OrderService only knows the Notifier interface, so swapping email for SMS never touches it — and same instance: true shows beans are singletons by default: one shared object.

@Service, @Repository and @Controller are @Component with a role name; @Repository also translates database exceptions into Spring’s DataAccessException hierarchy.

Two beans of the same type

Add a second Notifier and leave OrderService asking for one.

@Component
class SmsNotifier implements Notifier {                // a second implementation
    public String send(String to, String text) { return "sms to " + to + ": " + text; }
}
$ java -jar target/di-0.0.1-SNAPSHOT.jar
...
***************************
APPLICATION FAILED TO START
***************************

Description:

Parameter 0 of constructor in com.example.di.OrderService required a single bean, but 2 were found:
	- emailNotifier: defined in URL [jar:nested:/tmp/sb/di/target/di-0.0.1-SNAPSHOT.jar/!BOOT-INF/classes/!/com/example/di/EmailNotifier.class]
	- smsNotifier: defined in URL [jar:nested:/tmp/sb/di/target/di-0.0.1-SNAPSHOT.jar/!BOOT-INF/classes/!/com/example/di/SmsNotifier.class]
...
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
...

The container refuses to guess, and it refuses at startup rather than at the first order. The Action line lists the three fixes, and the next example uses all three.

@Primary, @Qualifier and injecting every bean

@Component
@Primary                                              // the default when a single Notifier is asked for
class EmailNotifier implements Notifier {
    public String send(String to, String text) { return "email to " + to + ": " + text; }
}

@Component
class SmsNotifier implements Notifier {
    public String send(String to, String text) { return "sms to " + to + ": " + text; }
}

@Service
class OrderService {
    private final Notifier notifier;
    OrderService(Notifier notifier) { this.notifier = notifier; }          // gets the @Primary one
    String placeOrder(String c) { return notifier.send(c, "order placed"); }
}

@Service
class AlertService {
    private final Notifier notifier;
    AlertService(@Qualifier("smsNotifier") Notifier notifier) { this.notifier = notifier; }
    String alert(String c) { return notifier.send(c, "card declined"); }
}

@Service
class BroadcastService {
    private final List<Notifier> all;                 // every Notifier bean
    BroadcastService(List<Notifier> all) { this.all = all; }
    void broadcast(String c) { all.forEach(n -> System.out.println("  " + n.send(c, "maintenance"))); }
}
$ java -jar target/di-0.0.1-SNAPSHOT.jar
email to ada: order placed
sms to ada: card declined
broadcast:
  email to ada: maintenance
  sms to ada: maintenance

(The runner calls placeOrder, alert and broadcast on the three services; imports and the @SpringBootApplication class follow the earlier pattern.) @Primary makes EmailNotifier the answer whenever a single Notifier is requested without further detail, so OrderService starts again unchanged. @Qualifier("smsNotifier") overrides that for one injection point by bean name. Asking for List<Notifier> injects every implementation — the basis of plugin-style designs such as a list of validators or event handlers.

Bean scopes

@Component                                    // default scope: singleton
class Counter {
    private final AtomicInteger n = new AtomicInteger();
    int next() { return n.incrementAndGet(); }
}

@Component
@Scope("prototype")                           // a new instance on every request to the container
class ShoppingCart {
    private static final AtomicInteger created = new AtomicInteger();
    final int id = created.incrementAndGet();
}

@SpringBootApplication
public class DiApplication implements CommandLineRunner {
    private final Counter a, b;
    private final ObjectProvider<ShoppingCart> carts;
    DiApplication(Counter a, Counter b, ObjectProvider<ShoppingCart> carts) { this.a = a; this.b = b; this.carts = carts; }

    public static void main(String[] args) { SpringApplication.run(DiApplication.class, args); }

    @Override
    public void run(String... args) {
        System.out.println("a == b: " + (a == b));
        System.out.println("a.next()=" + a.next() + ", b.next()=" + b.next());
        ShoppingCart c1 = carts.getObject(), c2 = carts.getObject();
        System.out.println("cart ids: " + c1.id + ", " + c2.id + "  same? " + (c1 == c2));
    }
}
$ java -jar target/di-0.0.1-SNAPSHOT.jar
a == b: true
a.next()=1, b.next()=2
cart ids: 1, 2  same? false

a and b are two constructor parameters but one object, so the second next() sees the first increment: a singleton’s state is shared by every class that injects it, and across every request thread in a web app, so keep singletons stateless or thread-safe. The prototype ShoppingCart is created anew on each getObject(). Injecting a prototype straight into a singleton’s constructor would create it only once — that is why ObjectProvider is used to ask for a fresh one each time. Web apps add request and session scopes on the same idea.

@Value and @ConfigurationProperties

app.name=shop
mail.host=smtp.example.com
mail.port=587
mail.timeout=5s
mail.admins=ops@example.com,cto@example.com
@ConfigurationProperties(prefix = "mail")
record MailProperties(String host, int port, Duration timeout, List<String> admins) {}

@SpringBootApplication
@ConfigurationPropertiesScan                   // finds @ConfigurationProperties records
public class DiApplication implements CommandLineRunner {
    private final MailProperties mail;
    private final String appName;

    DiApplication(MailProperties mail, @Value("${app.name:unnamed}") String appName) {
        this.mail = mail;
        this.appName = appName;
    }

    public static void main(String[] args) { SpringApplication.run(DiApplication.class, args); }

    @Override
    public void run(String... args) {
        System.out.println("app: " + appName);
        System.out.println(mail);
        System.out.println("timeout in ms: " + mail.timeout().toMillis());
    }
}
$ java -jar target/di-0.0.1-SNAPSHOT.jar
app: shop
MailProperties[host=smtp.example.com, port=587, timeout=PT5S, admins=[ops@example.com, cto@example.com]]
timeout in ms: 5000

Every key under mail. was bound into one immutable record and converted on the way: "587" to an int, "5s" to a Duration (printed in ISO-8601 as PT5S), and the comma-separated string to a List. The record is an ordinary bean, injected like any other. @Value("${app.name:unnamed}") pulls a single key with a fallback after the colon. For a handful of related settings, prefer the record: one typed object is easier to test and to find than @Value strings scattered over classes.

The bean lifecycle

@Bean methods register objects you cannot annotate — library classes, or ones that need constructor arguments.

class ConnectionPool {                        // a class you don't own: no annotations
    private final int size;
    ConnectionPool(int size) { this.size = size; System.out.println("ConnectionPool constructor, size=" + size); }
    void open()  { System.out.println("ConnectionPool.open() (initMethod): " + size + " connections"); }
    void close() { System.out.println("ConnectionPool.close() (destroyMethod)"); }
}

@Configuration
class PoolConfig {
    @Bean(initMethod = "open", destroyMethod = "close")
    ConnectionPool pool() { return new ConnectionPool(4); }

    @PostConstruct void ready()  { System.out.println("PoolConfig @PostConstruct"); }
    @PreDestroy   void gone()   { System.out.println("PoolConfig @PreDestroy"); }
}

@SpringBootApplication
public class DiApplication implements CommandLineRunner {
    DiApplication(ConnectionPool pool) { System.out.println("DiApplication constructor got the pool"); }

    public static void main(String[] args) {
        SpringApplication.run(DiApplication.class, args).close();   // close() triggers destruction
        System.out.println("main() returns");
    }

    @Override
    public void run(String... args) { System.out.println("CommandLineRunner.run()"); }
}
$ java -jar target/di-0.0.1-SNAPSHOT.jar
PoolConfig @PostConstruct
ConnectionPool constructor, size=4
ConnectionPool.open() (initMethod): 4 connections
DiApplication constructor got the pool
CommandLineRunner.run()
ConnectionPool.close() (destroyMethod)
PoolConfig @PreDestroy
main() returns

The @Configuration class is itself a bean, built and initialised before its @Bean methods run. Each bean is fully constructed and initialised before anything that depends on it receives it, so DiApplication never sees an unopened pool. Runners fire once the context is ready. On close() — which also runs on a normal JVM shutdown — beans are destroyed in reverse dependency order: the pool closes before the configuration that created it. @PostConstruct/@PreDestroy (from jakarta.annotation) do the same job for your own classes.

Testing without Spring

Because OrderService from the first example takes its Notifier in the constructor, a unit test needs no container at all.

package com.example.di;

import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.Test;

class OrderServiceTest {

    @Test
    void placeOrderNotifiesTheCustomer() {
        Notifier fake = (to, text) -> "FAKE " + to + " " + text;   // no Spring, no email server
        OrderService service = new OrderService(fake);

        assertEquals("FAKE bob order placed", service.placeOrder("bob"));
    }
}
$ mvn -B test 2>&1 | grep -E 'Tests run|BUILD'
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.046 s -- in com.example.di.OrderServiceTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

Notifier has one abstract method, so a lambda is a complete fake. Field injection (@Autowired private Notifier notifier;) would make this impossible without reflection or a Spring test context — the main reason constructor injection is the recommended style.

Practice

  1. Add a third implementation, PushNotifier, point AlertService at it, and change BroadcastService to inject Map<String, Notifier> so it prints each bean’s name.
Solution
@Component
class PushNotifier implements Notifier {
    public String send(String to, String text) { return "push to " + to + ": " + text; }
}

// in AlertService:
AlertService(@Qualifier("pushNotifier") Notifier notifier) { this.notifier = notifier; }

@Service
class BroadcastService {
    private final Map<String, Notifier> byName;       // bean name -> bean
    BroadcastService(Map<String, Notifier> byName) { this.byName = byName; }
    void broadcast(String c) { byName.forEach((name, n) -> System.out.println("  " + name + " -> " + n.send(c, "maintenance"))); }
}
$ java -jar target/di-0.0.1-SNAPSHOT.jar
email to ada: order placed
push to ada: card declined
broadcast:
  emailNotifier -> email to ada: maintenance
  pushNotifier -> push to ada: maintenance
  smsNotifier -> sms to ada: maintenance

@Primary still makes OrderService pick email even though there are now three candidates.

  1. Put @Scope("prototype") on Counter as well. Predict all three lines before running.
Solution
@Component
@Scope("prototype")
class Counter {
    private final AtomicInteger n = new AtomicInteger();
    int next() { return n.incrementAndGet(); }
}
$ java -jar target/di-0.0.1-SNAPSHOT.jar
a == b: false
a.next()=1, b.next()=1
cart ids: 1, 2  same? false

Each constructor parameter now gets its own Counter, so each counts from 1 independently.

  1. Add a from component to MailProperties that defaults to noreply@example.com when no mail.from key is set, then override it from the command line.
Solution
// import org.springframework.boot.context.properties.bind.DefaultValue;
@ConfigurationProperties(prefix = "mail")
record MailProperties(String host, int port, Duration timeout, List<String> admins,
                      @DefaultValue("noreply@example.com") String from) {}
$ java -jar target/di-0.0.1-SNAPSHOT.jar
app: shop
MailProperties[host=smtp.example.com, port=587, timeout=PT5S, admins=[ops@example.com, cto@example.com], from=noreply@example.com]
timeout in ms: 5000
$ java -jar target/di-0.0.1-SNAPSHOT.jar --mail.from=shop@example.com | grep from
MailProperties[host=smtp.example.com, port=587, timeout=PT5S, admins=[ops@example.com, cto@example.com], from=shop@example.com]

Frequently Asked Questions

What is Inversion of Control (IoC) in Spring?
Inversion of Control means the framework, not your code, creates objects and connects them. A class declares what it needs, usually as constructor parameters, and the Spring container builds every bean, works out the order to create them in, and passes each one its dependencies. Your code never calls new on its collaborators.
Which injection type should I use — constructor, setter, or field?
Prefer constructor injection. It makes dependencies explicit, lets fields be final, fails at startup if something is missing, and lets you unit test the class by calling the constructor with fakes and no Spring at all. When a class has a single constructor Spring uses it automatically, so no @Autowired annotation is needed.
What happens when Spring finds two beans of the same type?
If a constructor asks for one bean of a type and two exist, the application fails to start with an error saying it required a single bean but 2 were found. Fix it by marking one implementation @Primary as the default, by naming the one you want with @Qualifier, or by injecting a List or Map of all of them.
What is the difference between singleton and prototype scope?
A singleton bean, the default, is created once per application context, and every injection point receives the same instance, so it must be safe to share. A prototype bean is created fresh each time it is requested from the container, for example through ObjectProvider.getObject(), and Spring does not call its destroy callbacks.
Should I use @Value or @ConfigurationProperties?
@Value is fine for one or two standalone properties. For a group of related settings, bind them to a record with @ConfigurationProperties: you get one typed object, conversion of values such as 5s to a Duration and comma lists to a List, default values through @DefaultValue, and a single place to see all keys.