A service needs a way to store users and send welcome emails. Should it create those collaborators itself, or receive them from somewhere else?
That decision is a practical starting point for understanding Inversion of Control (IoC). In Spring, the container assembles application objects and supplies their dependencies, leaving each service to focus on its own work.
This guide follows a registration service from direct object creation to dependency injection and Spring configuration.
1. The Problem: A Service That Builds Its Own Dependencies
Consider this service. The persistence and email implementations are placeholders for application code:
public class UserService { private final UserDao userDao = new JdbcUserDao(); private final EmailService emailService = new SmtpEmailService();
public void register(User user) { userDao.save(user); emailService.sendWelcomeEmail(user.email()); }}UserService handles registration, but it also chooses and constructs the infrastructure used for that task. Replacing JDBC storage with another implementation requires editing the service. Testing registration in isolation is awkward because constructing the service also constructs its real collaborators.
The problem is the placement of those construction decisions. A service becomes easier to reuse when its caller can supply the dependencies.
2. What Gets Inverted?
IoC is a design principle that transfers control from application code to an external mechanism, such as a framework. It is broader than object creation: framework callbacks and event handlers also invert control over when application code runs.
For dependency management, the change looks like this:
| Responsibility | Direct construction | Dependency injection |
|---|---|---|
| Choose an implementation | The consuming service | Configuration or assembly code |
| Create dependencies | The consuming service | External code or a container |
| Supply dependencies | The service constructs them internally | They are passed into the service |
| Use dependencies | The service | The service |
Dependency injection (DI) is the pattern of supplying an object’s collaborators from outside. Spring uses DI to implement IoC for application components, but DI also works in plain Java.
3. Start with Constructor Injection
Define the contracts that registration needs. These examples use Java 17 or later; place each public type in its own file in the same package.
public record User(String email) {}public interface UserDao { void save(User user);}public interface EmailService { void sendWelcomeEmail(String email);}Now pass both dependencies into the service:
import java.util.Objects;
public class UserService { private final UserDao userDao; private final EmailService emailService;
public UserService(UserDao userDao, EmailService emailService) { this.userDao = Objects.requireNonNull(userDao); this.emailService = Objects.requireNonNull(emailService); }
public void register(User user) { userDao.save(user); emailService.sendWelcomeEmail(user.email()); }}The constructor makes the required collaborators visible. The null checks reject invalid arguments even when the service is constructed outside Spring, and final prevents reassignment of the dependency references. It does not make the collaborators themselves immutable.
Interfaces provide a convenient boundary for substituting implementations. DI does not require them: a constructor can accept a concrete class too.
Test the Behavior Without a Container
Because both interfaces have one abstract method, a small JUnit 5 test can use method references as test doubles:
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.util.ArrayList;import java.util.List;import org.junit.jupiter.api.Test;
class UserServiceTest { @Test void registrationSavesUserAndSendsWelcomeEmail() { List<User> savedUsers = new ArrayList<>(); List<String> recipients = new ArrayList<>(); UserService service = new UserService( savedUsers::add, recipients::add ); User user = new User("alice@example.com");
service.register(user);
assertEquals(List.of(user), savedUsers); assertEquals(List.of(user.email()), recipients); }}The test exercises registration without a database, mail server, or Spring application context. The two operations are deliberately simple; production code must also decide how to handle failures between saving a user and sending email.
4. Let Spring Assemble the Objects
A bean is an object managed by Spring’s container. Java configuration can register the service above without adding Spring annotations to it.
The following configuration uses demonstration implementations that print their actions. With spring-context available, it shows the complete wiring without requiring external services:
import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;
@Configurationpublic class AppConfig { @Bean public UserDao userDao() { return user -> System.out.println("Saving: " + user.email()); }
@Bean public EmailService emailService() { return email -> System.out.println("Welcoming: " + email); }
@Bean public UserService userService( UserDao userDao, EmailService emailService) { return new UserService(userDao, emailService); }}Spring supplies the arguments to the userService() factory method using registered beans. The service still uses the same ordinary Java constructor.
Create and close the context at the application boundary:
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class Main { public static void main(String[] args) { try (var context = new AnnotationConfigApplicationContext(AppConfig.class)) { UserService service = context.getBean(UserService.class); service.register(new User("alice@example.com")); } }}Expected output:
Saving: alice@example.comWelcoming: alice@example.comKeep container lookups such as getBean() near startup code. Passing collaborators into business classes preserves explicit dependencies and keeps those classes easy to test.
Component Scanning as an Alternative
Spring can also discover classes annotated with @Component or specializations such as @Service and @Repository, provided their packages are included in component scanning.
For example, you could annotate UserService with @Service and remove its explicit @Bean method. Keep registrations for its dependencies. Choose one registration approach for that service to avoid duplicate beans.
A class with a single constructor does not need @Autowired on that constructor. See Spring’s autowiring documentation.
5. Choosing an Injection Style
Constructor injection is a useful default for required dependencies. Spring’s dependency injection guide recommends constructors for mandatory collaborators and setters for optional ones with reasonable defaults.
| Style | When dependencies arrive | Main tradeoff |
|---|---|---|
| Constructor | During construction | Explicit requirements and stable references |
| Setter | After construction | Supports defaults and later replacement |
| Field | After construction | Concise, but dependencies are hidden from callers |
Setter Injection
An optional notification dependency can have a no-op default:
import org.springframework.beans.factory.annotation.Autowired;import org.springframework.stereotype.Component;
@Componentpublic class RegistrationNotifier { private EmailService emailService = email -> {};
@Autowired(required = false) public void setEmailService(EmailService emailService) { this.emailService = emailService; }
public void notify(User user) { emailService.sendWelcomeEmail(user.email()); }}This default is appropriate only if skipping notifications is acceptable. A plain @Autowired setter is required by default; using a setter does not automatically make its dependency optional.
Field Injection
You will also encounter fields such as:
@Autowiredprivate UserDao userDao;Spring can populate the field after constructing a managed object. However, callers cannot see the requirement in the constructor, and plain Java tests need another way to set it. Prefer constructor injection for services like the registration example.
6. What the Container Manages
ApplicationContext coordinates bean definitions, dependency resolution, and lifecycle callbacks. In a typical configuration, it creates non-lazy singleton beans during startup; other beans may be created later.
Creation can use constructors or factory methods, including the @Bean methods above. Describing it only as reflection misses the role of explicit configuration.
Scope determines when instances are created and shared:
| Scope | Instance boundary |
|---|---|
singleton | One instance per bean definition per container; the default |
prototype | A new instance each time the bean is requested from the container |
request | One instance per HTTP request in a web-aware context |
session | One instance per HTTP session in a web-aware context |
A singleton is not automatically thread-safe. Also, injecting a prototype into a singleton creates that dependency when the singleton is assembled; it does not produce a fresh object on each method call.
Lifecycle management has limits: Spring initializes prototype beans but does not automatically run their destruction callbacks. See the bean scope documentation for details.
7. Common Wiring Mistakes
No Matching Bean
Declaring a constructor parameter does not register its implementation. Ensure the dependency is declared through configuration or discovered by component scanning.
Multiple Candidates
If multiple beans satisfy a dependency, make the selection explicit. Use @Primary for a preferred default or @Qualifier to narrow the candidates at an injection point.
Circular Dependencies
If service A requires service B and B requires A through their constructors, neither can be constructed first. Revisit the responsibilities: extracting shared behavior or introducing a coordinating service often makes the dependency direction clearer.
Treating Every Use of new as a Problem
DI does not eliminate object construction. The configuration above uses new UserService(...), and business code still creates values such as User. The useful distinction is whether a component should own the construction of a collaborator whose implementation or lifecycle is configured elsewhere.
Confusing Spring with Spring Boot
Spring Framework provides the IoC container. Spring Boot builds on it with application setup and auto-configuration. Annotations such as @ConditionalOnClass and @ConditionalOnMissingBean belong to Spring Boot; they are not prerequisites for DI.
8. Applying IoC in Everyday Code
Begin with the responsibilities of the class. Declare its required collaborators in a constructor, and assemble them in configuration or startup code. Use interfaces where an interchangeable contract helps, and verify business behavior with small tests that supply their own dependencies.
As the application grows, Spring can take over assembly, scopes, and lifecycle coordination. The service’s job remains straightforward: use the collaborators it receives to perform its work.