Why the container is the whole game
Almost every "how does Spring work?" question eventually bottoms out at the IoC container. If you understand how the container builds, wires, scopes, and destroys beans, then transactions, security, AOP, and auto-configuration all become variations on a theme. This article builds that foundation.
Inversion of Control in one example
Inversion of Control means your classes stop creating their own collaborators. A container creates them and passes the dependencies in.
// WITHOUT IoC — the class is welded to a concrete implementation:
class OrderService {
private final PaymentGateway gateway = new StripeGateway(); // hard-wired
}
// WITH IoC — the container supplies the dependency:
@Service
class OrderService {
private final PaymentGateway gateway;
OrderService(PaymentGateway gateway) { this.gateway = gateway; } // injected
}
The payoff is decoupling: OrderService depends on the PaymentGateway interface, so you can
swap Stripe for PayPal, or a mock in tests, without touching it. IoC is the principle;
dependency injection is how Spring realizes it.
What counts as a bean
A bean is any object the container instantiates, wires, and manages. If Spring created it, it's
a bean; if you new-ed it yourself, it isn't. Beans come from two places:
@Service // discovered by component scanning
class InvoiceService { }
@Configuration
class Config {
@Bean DataSource dataSource() { return ...; } // explicit factory method
}
Each bean has a name, a scope, a lifecycle, and a set of dependencies the container resolves for it.
ApplicationContext vs BeanFactory
Both are containers. BeanFactory is the minimal core (lazy instantiation + DI). ApplicationContext
extends it with everything real applications need — and it's what Spring Boot always gives you:
| Feature | BeanFactory | ApplicationContext |
|---|---|---|
| Dependency injection | ✅ | ✅ |
| Eager singleton creation (fail fast) | ❌ (lazy) | ✅ |
Events (ApplicationEventPublisher) | ❌ | ✅ |
i18n (MessageSource) | ❌ | ✅ |
Auto BeanPostProcessor registration | ❌ | ✅ |
The practical difference: ApplicationContext instantiates singletons eagerly at startup, so a
misconfiguration fails immediately rather than on first use.
The startup sequence
When the context refreshes, a fixed sequence runs inside AbstractApplicationContext.refresh():
// 1. Load bean DEFINITIONS (component scan + @Bean methods)
// 2. Run BeanFactoryPostProcessors → modify definitions
// (property placeholder resolution, @ConfigurationProperties binding)
// 3. Register BeanPostProcessors → will wrap beans later
// 4. Initialize MessageSource & event multicaster
// 5. Instantiate all non-lazy SINGLETONS (inject → init callbacks → proxies)
// 6. Publish ContextRefreshedEvent → ApplicationRunner / CommandLineRunner run
The crucial insight: definitions are registered and post-processed before any singleton is
built. That's why a BeanFactoryPostProcessor can alter definitions, and why eager singleton
creation in step 5 makes Spring "fail fast."
The bean lifecycle
Every bean travels through instantiation → injection → initialization → use → destruction:
@Component
class Connection {
@PostConstruct void open() { /* after injection, before use */ }
@PreDestroy void close() { /* before removal / context close */ }
}
In detail: constructor → dependency injection → BeanPostProcessor.postProcessBeforeInitialization
→ @PostConstruct / afterPropertiesSet / custom init → postProcessAfterInitialization
(where AOP proxies are applied) → in service → @PreDestroy on shutdown. Ninety-five percent
of the time you only need @PostConstruct and @PreDestroy.
Scopes: singleton, prototype, and the web scopes
The default scope is singleton — one shared instance per container:
@Service class CounterService { } // one instance, shared everywhere it's injected
Spring's singleton is one-per-context, not the JVM-wide GoF singleton. Because it's shared across threads, singleton beans must be stateless. A prototype bean is created fresh on every request:
@Component
@Scope("prototype")
class ShoppingCart { } // new instance each time it's requested
Two gotchas with prototypes: the container does not call @PreDestroy on them, and
field-injecting one into a singleton freezes a single instance at startup. Web apps add
@RequestScope, @SessionScope, and @ApplicationScope for per-request/per-user/per-context
state.
Scoped proxies: mixing lifetimes safely
A singleton is wired once, so injecting a request-scoped bean directly would freeze one instance forever. A scoped proxy fixes that:
@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
class RequestData { } // injected as a proxy
@Service
class Singleton {
private final RequestData data; // the proxy
Singleton(RequestData data) { this.data = data; }
// Every method call routes to the CURRENT request's instance.
}
The proxy is a thin stand-in injected at startup; each method call resolves the real, correctly- scoped target. Whenever a narrower scope is injected into a wider one, you need this.
Lazy initialization
Singletons are eager by default. @Lazy defers creation until first use:
@Service @Lazy
class ExpensiveReportEngine { } // built only when first needed
Use it for genuinely expensive, rarely-used beans, or to break a circular dependency (the
@Lazy side gets a proxy). The cost: configuration errors in a lazy bean surface only when it's
first touched — so eager-by-default's fail-fast behavior is usually worth keeping.
Why constructor injection beats getBean()
Calling applicationContext.getBean() is the Service Locator anti-pattern — your class actively
pulls from the container and couples itself to Spring:
// ANTI-PATTERN — hidden dependencies, hard to test:
class BadService {
void run(ApplicationContext ctx) {
PaymentGateway g = ctx.getBean(PaymentGateway.class);
}
}
// PREFERRED — dependencies visible, trivially mockable:
@Service
class GoodService {
private final PaymentGateway gateway;
GoodService(PaymentGateway gateway) { this.gateway = gateway; }
}
Constructor injection makes dependencies explicit, allows final fields, and lets unit tests just
pass mocks with no Spring at all. Reserve getBean() for genuinely dynamic, runtime-decided
lookups.
Circular dependencies
When A needs B and B needs A through constructors, neither can be built first, and Spring
throws BeanCurrentlyInCreationException:
@Service class A { A(B b) { } }
@Service class B { B(A a) { } } // ✗ unresolvable constructor cycle
Since Spring Boot 2.6 circular references are disallowed by default — a deliberate nudge to fix
the design. The clean fixes are to extract a third collaborator or mark one side @Lazy, not to
switch to field injection to paper over the smell.
Recap
The IoC container loads bean definitions, post-processes them, then eagerly instantiates
singletons — injecting dependencies, running lifecycle callbacks, and applying proxies along the
way. Beans are singletons by default (keep them stateless), with prototype and web scopes for other
lifetimes and scoped proxies to mix them safely. Favor constructor injection over getBean(), keep
startup eager to fail fast, and treat a constructor cycle as a design problem to refactor. Master
the container and the rest of Spring is just features built on top of it.