This is the first topic in Spring and the foundation for everything else: why you need a container, how it creates and wires objects, and where the pitfalls are.
The assembly order is fixed: first the dependency, then the constructor it is passed into, then @PostConstruct — by that moment every field is filled in. The finished bean is one: thread 1 and thread 2 get the same instance.
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 in a test or configure it from outside.
Inversion of Control is the idea "don't create your dependencies yourself, let someone outside provide them": object creation moves out of your code 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.
ApplicationContext and BeanFactory
The Spring container comes in two flavors:
BeanFactory— the basic container: reads object descriptions and creates them on demand. You almost never use it directly.ApplicationContext— aBeanFactoryplus everything a real application needs: events, properties (Environment), localization messages, resource loading. It is whatSpringApplication.run(App.class, args)returns on startup.
The objects the container manages are called beans. On startup Spring scans packages, finds classes annotated with @Component (and its special cases @Service, @Repository, @Controller), 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: ten parameters in the constructor is a signal the class does too much;
- 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 @Autowired 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":@PreDestroyis not called for a prototype.
Web applications add 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: injected plainly, a prototype bean lands in the singleton once, at its creation, and the "freshness" is lost. A fresh instance on every call is what ObjectProvider gives you:
@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
}
}
The same mechanics in plain Java:
live example
import java.util.function.Supplier;
public class ScopeDemo {
static int created = 0;
record RequestContext(int id) { }
static class OrderService {
private final RequestContext fixed; // injected once
private final Supplier<RequestContext> provider; // the role of ObjectProvider
OrderService(RequestContext fixed, Supplier<RequestContext> provider) {
this.fixed = fixed;
this.provider = provider;
}
void handle() {
System.out.println("field: #" + fixed.id() + " provider: #" + provider.get().id());
}
}
public static void main(String[] args) {
Supplier<RequestContext> factory = () -> new RequestContext(++created);
OrderService service = new OrderService(factory.get(), factory);
service.handle();
service.handle();
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
The field prints #1 both times, the provider prints #2 and #3: ObjectProvider is not an object but a way to ask for one at call 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 same order shows up without Spring:
live example
public class LifecycleDemo {
static class PaymentClient {
String pay(String order) { return "paid " + order; }
}
static class OrderService {
private final PaymentClient payment; // came in through the constructor
private String mode; // "field injection" — arrives later
OrderService(PaymentClient payment) {
this.payment = payment;
System.out.println("2. constructor: did payment arrive? " + (payment != null));
System.out.println(" and field mode is still " + mode);
}
void afterAssembly() { // the role of @PostConstruct
System.out.println("4. @PostConstruct: mode = " + mode + ", " + payment.pay("A-17"));
}
}
public static void main(String[] args) {
System.out.println("1. container created the dependency");
OrderService service = new OrderService(new PaymentClient());
service.mode = "production";
System.out.println("3. field injected");
service.afterAssembly();
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Inside the constructor mode is still null — the field is filled in afterwards. So "after-assembly" logic goes into @PostConstruct, while required dependencies come through the constructor: by the time it runs they are in place.
@Component and @Bean
Two ways to declare a bean, and they are not interchangeable:
@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 can't assemble them and fails on startup (BeanCurrentlyInCreationException): a cycle can't be closed through constructors in principle. A cycle built with field injection used to be resolved by Spring itself, but since Boot 2.6 that is forbidden by default — such an application no longer starts either (the spring.main.allow-circular-references flag brings the old behavior back, but better leave it alone). 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. @Lazy and field injection don't cure a cycle, they hide it.
In short
- IoC is the principle: the container manages creation; DI is the technique: dependencies are passed in from outside.
- Three injection styles (constructor, setter, field); the constructor wins —
final, visible dependencies, testability. ApplicationContextis aBeanFactoryplus events, properties, resources, and localization.- The default scope is singleton, so beans are made stateless; a prototype inside a singleton is taken through
ObjectProvider, otherwise the instance gets fixed once. - Bean phases: creation → injection →
@PostConstruct→ (proxy) → work →@PreDestroy(singletons only). - A circular dependency through the constructor breaks startup — a signal to re-split the classes, not a reason to switch on
@Lazy.
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.