Dependency Injection & Bean Lifecycle

Dependency Injection Explained

18 min Lesson 1 of 13

Dependency Injection Explained

Dependency Injection (DI) is one of those ideas that sounds abstract the first time you encounter it, yet every professional Java codebase relies on it. At its core, DI is simple: a class does not create the objects it depends on — something outside the class provides them. That shift in responsibility is the whole idea, and its consequences for design, testability, and maintainability are profound.

The Problem DI Solves

Consider a realistic starting point: an OrderService that sends confirmation emails.

// Without DI — the class controls its own dependency public class OrderService { private final EmailClient emailClient = new SmtpEmailClient("smtp.example.com", 587); public void placeOrder(Order order) { // ... business logic ... emailClient.send(order.getCustomerEmail(), "Order confirmed", buildBody(order)); } }

This looks harmless, but it creates several hidden problems:

  • Hard-coded implementation. OrderService is permanently bound to SmtpEmailClient. Switching to SendGrid or SES means editing this class, not just changing configuration.
  • Impossible to unit-test in isolation. Every test that calls placeOrder will attempt to open a real SMTP socket. Tests become slow, flaky, and infrastructure-dependent.
  • Hidden configuration. The SMTP host and port are buried inside OrderService. Changing them for staging vs production requires modifying business-logic code.
  • Tight coupling. If SmtpEmailClient's constructor changes, OrderService must change too, even if the higher-level contract (send an email) has not changed.

DI as the Solution

The fix is straightforward: instead of instantiating SmtpEmailClient internally, accept an EmailClient interface from the outside.

// Define the contract public interface EmailClient { void send(String to, String subject, String body); } // Production implementation public class SmtpEmailClient implements EmailClient { public SmtpEmailClient(String host, int port) { /* ... */ } @Override public void send(String to, String subject, String body) { /* SMTP logic */ } } // OrderService no longer knows HOW email is sent public class OrderService { private final EmailClient emailClient; // depends on the interface // The dependency is INJECTED via the constructor public OrderService(EmailClient emailClient) { this.emailClient = emailClient; } public void placeOrder(Order order) { // ... business logic ... emailClient.send(order.getCustomerEmail(), "Order confirmed", buildBody(order)); } }

Now a test can inject a lightweight fake without touching SMTP at all:

class OrderServiceTest { @Test void placeOrder_sendsConfirmationEmail() { // Arrange — a simple in-memory fake, not a real SMTP connection List<String> sentTo = new ArrayList<>(); EmailClient fake = (to, subject, body) -> sentTo.add(to); OrderService service = new OrderService(fake); Order order = new Order("customer@example.com"); // Act service.placeOrder(order); // Assert assertEquals(1, sentTo.size()); assertEquals("customer@example.com", sentTo.get(0)); } }
The key insight: DI is not a framework feature — it is a design principle. The code above uses zero Spring annotations and still benefits fully from DI. Spring, Jakarta CDI, and Guice automate the wiring, but the principle is independent of any framework.

The Three Kinds of Injection

There are three standard mechanisms for delivering a dependency into a class. Each has a different syntax and different trade-offs — later lessons cover each in depth, but you need to recognise them now.

1. Constructor Injection

The dependency is passed as a constructor parameter. This is the preferred style in modern Spring applications.

@Service public class OrderService { private final EmailClient emailClient; // immutable after construction @Autowired // optional in Spring 6 when there is exactly one constructor public OrderService(EmailClient emailClient) { this.emailClient = emailClient; } }

Advantages: the field can be final (guaranteeing it is never null after construction); the class is impossible to instantiate without its dependencies; and the dependency graph is explicit in the constructor signature.

2. Setter Injection

The dependency is provided through a setter method after the object is constructed.

@Service public class ReportService { private NotificationClient notificationClient; @Autowired public void setNotificationClient(NotificationClient client) { this.notificationClient = client; } }

Useful when a dependency is genuinely optional, or when you need to support circular references (though circular references usually signal a design problem). The downside: the field cannot be final, so the dependency could theoretically be replaced or left null.

3. Field Injection

The framework injects directly into the field using reflection, bypassing constructors and setters entirely.

@Service public class LegacyService { @Autowired private PaymentGateway gateway; // Spring writes directly to this field }
Field injection is convenient but problematic. You cannot make the field final, the dependency is invisible in the public API, and instantiating the class in a unit test without a Spring context requires reflection hacks. Prefer constructor injection for all mandatory dependencies. Reserve field injection only for very short-lived prototype code or test classes (@MockBean in Spring Boot tests is a legitimate use case).

Why DI Improves Design

DI enforces the Dependency Inversion Principle (the D in SOLID): high-level modules (OrderService) should depend on abstractions (EmailClient), not on concrete implementations (SmtpEmailClient). This has direct, practical consequences:

  • Swap implementations without changing business logic. Route emails through a queue for performance, or silence them in a staging environment, by providing a different EmailClient bean — OrderService is untouched.
  • Test each class independently. A unit test provides a fake or mock dependency; no container, no database, no SMTP needed. Tests run in milliseconds.
  • Configuration lives at the composition root. The place where objects are wired together (a Spring @Configuration class or the application context) is the only place that knows about concrete types and external configuration. Business-logic classes stay ignorant of infrastructure details.
  • Parallel development. Teams can work on OrderService and SmtpEmailClient simultaneously, agreeing only on the EmailClient interface contract.

The Spring Container as the Injector

In a plain Java application you would wire dependencies yourself in a main method — this is called the composition root:

public class Main { public static void main(String[] args) { EmailClient emailClient = new SmtpEmailClient("smtp.example.com", 587); OrderService orderService = new OrderService(emailClient); // use orderService ... } }

In a Spring application the ApplicationContext is the composition root. You annotate classes with stereotypes like @Service, @Repository, and @Component; Spring scans them, instantiates them in the right order, and injects their dependencies. You rarely write wiring code manually.

Think of Spring as a smart factory. It reads your annotations, figures out what each class needs, builds everything in the correct order, and hands you a fully assembled application context. DI is the principle; Spring is the automation.

Summary

Dependency Injection means providing a class with the objects it needs rather than letting the class construct them. The three mechanisms — constructor, setter, and field injection — all achieve this, but constructor injection is strongly preferred because it produces immutable, testable, and self-documenting classes. DI enforces the Dependency Inversion Principle, decouples business logic from infrastructure, and is the foundation on which the entire Spring ecosystem is built. In the next lesson you will implement constructor injection in depth, including how Spring resolves and satisfies constructor parameters automatically.

ES
Edrees Salih
1 hour ago

We are still cooking the magic in the way!