Master Core Spring Boot Concepts: Inversion of Control, Dependency Injection, and Your First Application
Published · Updated
Inversion of Control (IoC) means your application delegates object creation and wiring to a container. Dependency Injection (DI) is the specific process by which an object declares what it needs and the container supplies those collaborators. In Spring, container-managed objects are called beans.
Version assumptions (updated 7 August 2026): the example targets Spring Boot 4.1.x and Java 17+. It uses constructor injection, which the Spring Framework documentation recommends for required dependencies.
Why invert control?
Consider a service that constructs its own collaborator:
public class GreetingService {
private final TimeProvider timeProvider = new SystemTimeProvider();
}
GreetingService now chooses a concrete clock, controls its creation, and is
harder to test at a known time. With constructor injection, it declares the
requirement instead:
public class GreetingService {
private final TimeProvider timeProvider;
public GreetingService(TimeProvider timeProvider) {
this.timeProvider = timeProvider;
}
}
Plain Java code can supply that argument manually. In a Spring application, the
ApplicationContext finds the relevant bean and passes it to the constructor.
The service no longer locates or constructs the dependency.
Build a small injected service
Generate a Maven project with Java 17+ and the Spring Web dependency, as shown in the first Spring application guide. Keep these classes under the generated application’s base package.
Create GreetingService.java:
package com.example.demo;
import org.springframework.stereotype.Service;
@Service
public class GreetingService {
public String greet(String name) {
String safeName = name == null || name.isBlank() ? "Spring" : name;
return "Hello, " + safeName + "!";
}
}
@Service is a specialization of @Component. Component scanning finds this
class and registers an instance as a bean.
Now create GreetingController.java:
package com.example.demo;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class GreetingController {
private final GreetingService greetingService;
public GreetingController(GreetingService greetingService) {
this.greetingService = greetingService;
}
@GetMapping("/greeting")
public Greeting greeting(@RequestParam(required = false) String name) {
return new Greeting(greetingService.greet(name));
}
}
And add the response record:
package com.example.demo;
public record Greeting(String message) {}
There is no @Autowired on the controller constructor. When a bean has one
constructor, Spring can use it without that annotation. The explicit constructor
keeps the dependency visible and lets the field remain final.
What happens during startup?
SpringApplication.run(...)creates and refreshes the application context.- Component scanning finds
GreetingServiceandGreetingController. - The container creates the
GreetingServicebean. - It sees that
GreetingControllerrequires aGreetingServiceand passes the bean into the controller constructor. - Spring MVC registers the controller’s
/greetingmapping.
The default scope for these beans is singleton: the container normally keeps one instance per application context. “Singleton” here describes a Spring scope; it does not require implementing the classic singleton design pattern.
Constructor injection makes the controller easy to isolate
Because the controller declares its service through the constructor, a focused test can assemble both plain Java objects without starting Spring:
GreetingService service = new GreetingService();
GreetingController controller = new GreetingController(service);
assertEquals("Hello, Asha!", controller.greeting("Asha").message());
This is a practical value of DI: dependencies are visible at the boundary and can be supplied directly in focused tests. An interface is useful when there are genuinely different implementations or when it improves a boundary; it is not mandatory for every bean.
Common DI mistakes
- Field injection: it hides required dependencies and prevents
finalfields. Prefer a constructor for mandatory collaborators. - Calling
newfor a service inside a controller: the new instance is outside container management and bypasses configured collaborators or proxies. - Multiple matching beans: inject a more specific type or use an explicit qualifier; do not rely on an accidental choice.
- Circular constructors: if A requires B and B requires A, Spring cannot construct either. Treat the cycle as a design signal and separate the responsibilities rather than hiding it with field injection.
- Package placement: a component outside the default component-scan tree is not discovered.
Next, see how these beans cooperate in a layered MVC/REST application, then improve its HTTP boundary in the Spring Boot REST API guide.
Primary references
- Spring Framework: IoC container and beans
- Spring Framework: dependency injection
- Spring guide: Building an Application with Spring Boot
These dependency-boundary skills are part of the backend foundation for the Forward-Deployed Engineer program.