In this article, we’ll walk through what DI (Dependency Injection) means, its benefits, and how to write it in Spring Boot — your first step toward testable, maintainable design.

What is Dependency Injection?

Dependency Injection (DI) is the practice of providing an object with the other objects it depends on, rather than having it create them itself.

Put simply: “instead of a class creating the objects it needs, those objects are handed to it from the outside.”

For example, imagine an OrderService that handles orders and needs a PaymentGateway to process payments. The PaymentGateway is the dependency, and passing it in from outside is the injection.

What Goes Wrong Without DI?

Without DI, classes tend to instantiate their dependencies directly with new.

public class OrderService {
    private final PaymentGateway paymentGateway = new StripePaymentGateway();

    public void checkout() {
        paymentGateway.pay();
    }
}

This looks simple at first glance, but it leads to several problems:

  • Switching payment providers (e.g., Stripe → PayPay) requires modifying OrderService directly
  • Tests risk invoking real payment processing
  • As dependencies grow, it becomes harder to see what a class actually depends on
  • The more places share a dependency, the more new calls scatter across the codebase, creating unnecessary instances

DI is a fundamental technique for avoiding code that is fragile to change and difficult to test.

The Benefits of Passing Dependencies from Outside

With DI, you pass dependencies in from the outside.

public class OrderService {
    private final PaymentGateway paymentGateway;

    public OrderService(PaymentGateway paymentGateway) {
        this.paymentGateway = paymentGateway;
    }

    public void checkout() {
        paymentGateway.pay();
    }
}

This simple change significantly improves the design:

  • OrderService no longer needs to know the concrete implementation of PaymentGateway
  • You can inject StripePaymentGateway in production and FakePaymentGateway in tests
  • Dependencies are visible as constructor arguments, making the structure easier to read

Getting comfortable with “depending on abstractions (interfaces) rather than concrete implementations” is a powerful habit to build alongside DI.

Instances Don’t Multiply Unnecessarily in Spring Boot

This is one of the major benefits of DI — or more precisely, of Spring’s container management.

Classes registered with @Component, @Service, @Repository, etc. in Spring Boot are Singleton-scoped by default. This means: “during the application’s lifetime, the same Bean is created once and reused.”

For example, even if OrderService is used by two different classes, Spring injects the same instance into both — no unnecessary duplication.

@Service
public class OrderService {
    public String status() {
        return "ok";
    }
}

@RestController
public class OrderController {
    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }
}

@RestController
public class AdminController {
    private final OrderService orderService;

    public AdminController(OrderService orderService) {
        this.orderService = orderService;
    }
}

Here, OrderService is created once, and the same instance is injected into both OrderController and AdminController.

By contrast, calling new OrderService() in each location creates a new instance every time. For heavyweight objects — database connections, external API clients, classes holding large amounts of configuration — this quietly adds up in startup time and memory usage.

Exceptions Exist

“Always exactly one instance” isn’t a hard rule — scopes can be changed as needed. For example, using prototype creates a new instance on every injection.

@Service
@Scope("prototype")
public class ReportBuilder {
}

So the accurate takeaway is: “Spring Boot defaults to Singleton, which prevents unnecessary instance proliferation for the same class.”

The Main DI Injection Methods

There are several ways to inject dependencies, but in Spring Boot you’ll typically encounter these three.

Constructor Injection

This is the recommended approach. It makes required dependencies explicit, works well with final, and is easy to test.

@Service
public class OrderService {
    private final PaymentGateway paymentGateway;

    public OrderService(PaymentGateway paymentGateway) {
        this.paymentGateway = paymentGateway;
    }
}

Field Injection

You’ll see this often in practice. It’s concise to write, but it obscures dependencies and makes testing harder — generally best to avoid it as a default.

@Service
public class OrderService {
    @Autowired
    private PaymentGateway paymentGateway;
}

Setter Injection

A valid option when a dependency is optional, but not appropriate for required dependencies.

@Service
public class OrderService {
    private PaymentGateway paymentGateway;

    @Autowired
    public void setPaymentGateway(PaymentGateway paymentGateway) {
        this.paymentGateway = paymentGateway;
    }
}

How DI Works in Spring Boot

In Spring Boot, the IoC container (Spring container) is responsible for creating objects and resolving their dependencies.

  • IoC (Inversion of Control) means the control over object creation shifts from your application code to the framework
  • DI is one concrete mechanism for achieving IoC

The most common approach in Spring Boot is registering classes with the container via annotations.

public interface PaymentGateway {
    void pay();
}

@Component
public class StripePaymentGateway implements PaymentGateway {
    @Override
    public void pay() {
        System.out.println("Pay with Stripe");
    }
}

@Service
public class OrderService {
    private final PaymentGateway paymentGateway;

    public OrderService(PaymentGateway paymentGateway) {
        this.paymentGateway = paymentGateway;
    }
}

When the application starts, Spring takes care of the following:

  • Creates StripePaymentGateway and registers it in the container
  • Sees that OrderService needs a PaymentGateway and automatically injects it
  • By default, creates one instance of each registered Bean and reuses it (Singleton)

Registering Dependencies with @Bean

For classes from external libraries or other cases where you can’t add annotations directly, use @Configuration and @Bean.

@Configuration
public class AppConfig {

    @Bean
    public PaymentGateway paymentGateway() {
        return new StripePaymentGateway();
    }
}

This brings PaymentGateway under Spring container management, making it injectable into other classes.

Testing Is Where DI Really Shines

The benefits of DI are most apparent in testing.

For example, in tests you don’t want to call real payment APIs, so you pass in a fake instead.

class FakePaymentGateway implements PaymentGateway {
    boolean called = false;

    @Override
    public void pay() {
        called = true;
    }
}

@Test
void checkout_calls_payment() {
    FakePaymentGateway fake = new FakePaymentGateway();
    OrderService service = new OrderService(fake);

    service.checkout();

    assertTrue(fake.called);
}

Simply being able to “pass it in from outside” makes tests safe, fast, and easy to write.

Common Pitfalls for Beginners

Ambiguity When Multiple Implementations Exist

If there are two or more implementations of PaymentGateway, Spring can’t determine which one to inject and will throw an error. Use @Qualifier to specify which one you want.

@Component("stripe")
public class StripePaymentGateway implements PaymentGateway { /* ... */ }

@Component("paypal")
public class PaypalPaymentGateway implements PaymentGateway { /* ... */ }

@Service
public class OrderService {
    private final PaymentGateway paymentGateway;

    public OrderService(@Qualifier("stripe") PaymentGateway paymentGateway) {
        this.paymentGateway = paymentGateway;
    }
}

Circular Dependencies Preventing Startup

When A depends on B and B depends on A, you have a circular dependency. This is a strong design smell — consider splitting responsibilities or revisiting the direction of dependencies to clean things up.

Storing State in Singleton Beans

Since Spring Beans are Singleton by default, holding state in fields (e.g., counters or temporary data) means that state is shared across multiple requests, leading to unexpected bugs.

A safe rule of thumb: “services should be stateless by default.” If you need state, confine it to local method variables, or reconsider the scope of your Bean.

Summary

  • DI is a design approach where an object’s dependencies are provided from outside
  • Stopping the use of new directly makes your code more resilient to change and easier to test
  • In Spring Boot, the IoC container handles object creation and dependency injection on your behalf
  • The default Singleton scope prevents unnecessary instance proliferation for the same class
  • In practice, building around constructor injection leads to the most stable designs

Once DI clicks, Spring Boot code becomes noticeably easier to read. Your next step is to think about “where should I introduce an interface so that implementations can be swapped out?” — that’s where your design instincts will really start to grow.