1330 words
7 minutes
Inversion of Control: From Java Objects to Spring Beans

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:

ResponsibilityDirect constructionDependency injection
Choose an implementationThe consuming serviceConfiguration or assembly code
Create dependenciesThe consuming serviceExternal code or a container
Supply dependenciesThe service constructs them internallyThey are passed into the service
Use dependenciesThe serviceThe 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;
@Configuration
public 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.com
Welcoming: alice@example.com

Keep 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.

StyleWhen dependencies arriveMain tradeoff
ConstructorDuring constructionExplicit requirements and stable references
SetterAfter constructionSupports defaults and later replacement
FieldAfter constructionConcise, 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;
@Component
public 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:

@Autowired
private 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:

ScopeInstance boundary
singletonOne instance per bean definition per container; the default
prototypeA new instance each time the bean is requested from the container
requestOne instance per HTTP request in a web-aware context
sessionOne 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.

Inversion of Control: From Java Objects to Spring Beans
https://astro-nyc.pages.dev/posts/inversion-of-control-spring-guide/
Author
Hari
Published at
2026-09-11
License
CC BY-NC-SA 4.0