This is the first topic in Spring and the foundation for everything else. Let's start from scratch: why you need a container, how it creates and wires objects, and where the pitfalls are.
What IoC and DI are in plain terms
Normally an object creates what it needs by itself:
class OrderService {
private final PaymentClient payment = new PaymentClient(); // we create it ourselves
}
The problem: OrderService is hard-wired to a specific PaymentClient. You can't swap it out in a test, configure it from configuration, or reuse it.
Inversion of Control is the idea "don't create your dependencies yourself, let someone outside provide them." Control over object creation moves out of your code and into the container.
Dependency Injection is the concrete technique Spring uses to implement IoC: the container creates the PaymentClient itself and passes it into OrderService.
@Service
class OrderService {
private final PaymentClient payment;
OrderService(PaymentClient payment) { this.payment = payment; } // it was given to us
}
Short formula: IoC is the principle, DI is the pattern that implements it. The Spring container is what does all this.
ApplicationContext and BeanFactory
The Spring container comes in two flavors:
BeanFactory— the basic container. It can read object descriptions and create them on demand. You almost never use it directly.ApplicationContext— this is aBeanFactoryplus everything a real application needs: events, working with properties (Environment), messages for localization, resource loading. This is exactly what Spring Boot creates on startup.
@SpringBootApplication
public class App {
public static void main(String[] args) {
ApplicationContext ctx = SpringApplication.run(App.class, args);
}
}
The objects the container manages are called beans. On startup Spring scans packages, finds classes annotated with @Component (and @Service, @Repository, @Controller — which are special cases of it), creates beans from them, and wires them together.
Injection styles and why constructor injection is preferred
You can inject a dependency in three ways:
// 1. Via the constructor — recommended
@Service
class OrderService {
private final PaymentClient payment;
OrderService(PaymentClient payment) { this.payment = payment; }
}
// 2. Via a field — concise, but problematic
@Service
class OrderService {
@Autowired private PaymentClient payment;
}
// 3. Via a setter — for optional dependencies
@Service
class OrderService {
private PaymentClient payment;
@Autowired void setPayment(PaymentClient p) { this.payment = p; }
}
Why the constructor is usually the choice:
- the field can be made
final— the object can't be left "half-built"; - all dependencies are visible at once: if there are ten of them in the constructor, the class does too much (that's a useful signal);
- it's easy to test — the object is created with a plain
newand mocks, without starting Spring; - circular dependencies surface immediately instead of hiding (more on this below).
With a single constructor the @Autowired annotation isn't needed — Spring uses it anyway.
Scopes — how long a bean lives
Scope determines how many instances of a bean the container creates. There are six built-in ones; two matter in practice:
singleton(default) — one instance for the entire container. Almost all beans live this way:@Service,@Repository,@Controller. That's why beans are made without mutable state (stateless) — a single instance serves all requests in parallel.prototype— a new instance on every request for the bean. Spring creates it and "lets it go": the destruction method (@PreDestroy) is not called for a prototype.
There are also scopes for web applications: request (a new bean per HTTP request), session (per user session), application, and websocket.
How to inject a prototype into a singleton
A common trap. If you simply inject a prototype bean into a singleton, it will be injected once — when the singleton is created, and the "freshness" is lost. To get a fresh instance on every call, use ObjectProvider:
@Service
class OrderService {
private final ObjectProvider<RequestContext> provider; // RequestContext is a prototype
OrderService(ObjectProvider<RequestContext> p) { this.provider = p; }
void process() {
RequestContext ctx = provider.getObject(); // a new instance each time
}
}
Bean lifecycle
From creation to shutdown a bean goes through several phases:
- creation (constructor call);
- dependency injection;
- initialization callbacks —
@PostConstruct, thenafterPropertiesSet(); - wrapping in a proxy (if needed — for example, for
@Transactional); - the bean is ready to work;
- on application shutdown —
@PreDestroy(for singletons only).
The key practical takeaway: inside the constructor you cannot use dependencies that are injected into fields — they aren't set yet. Put "after-assembly" logic into @PostConstruct. This is one more reason to inject via the constructor: by the time it runs, all dependencies are already in place.
A detailed breakdown of each phase with demo code and pitfalls is in a separate article: Spring bean lifecycle with examples.
@Component and @Bean
Two ways to declare a bean, and the difference is often asked about:
@Component(and@Service/@Repository/@Controller) go on your own class — Spring will find it during scanning and create it itself.@Beangoes on a method in a@Configurationclass — for when you create the object yourself, for example when it's a class from a third-party library that you can't annotate.
@Configuration
class AppConfig {
@Bean
ObjectMapper objectMapper() { // third-party class — configure it by hand
return new ObjectMapper().findAndRegisterModules();
}
}
An important detail: @Configuration is wrapped in a proxy, so calling one @Bean method from another returns the same singleton, not a new object. If the @Bean methods live in a plain @Component, there's no such guarantee — dependencies are passed there through method parameters.
Circular dependencies
If A requires B and B requires A through the constructor, Spring won't be able to assemble them and will fail on startup (BeanCurrentlyInCreationException). Since Spring Boot 2.6+ this behavior is enabled by default. That's good: a cycle almost always means responsibilities are smeared across classes and they should be split — for example, by extracting shared logic into a third bean. Working around the cycle with @Lazy or field injection is masking, not a solution.
In short
- IoC is the principle (the container manages creation), DI is the implementation technique (dependencies are passed in from outside).
- There are three injection styles (constructor, setter, field); the constructor is the choice —
final, visible dependencies, testability. ApplicationContextis aBeanFactoryplus events, properties, resources, and localization.- Scopes: singleton (default), prototype, request, session, application, websocket.
- Singleton beans are made stateless — a single instance serves all threads.
- Inject a prototype into a singleton via
ObjectProvider, otherwise the instance gets fixed once. - Bean phases: creation → injection →
@PostConstruct→ (proxy) → work →@PreDestroy. - A circular dependency through the constructor → failure on startup; it's a signal to re-split the classes.
What to read next
- Spring bean lifecycle with examples — each phase with demo code.
- Auto-configuration, properties, profiles — how Spring Boot assembles the context automatically.
- Spring AOP — how
@Transactionaland the like turn a class into a proxy.