Skip to content

Spring Boot · Dependency Injection

The Spring IoC Container Explained: Beans, Scopes, and Lifecycle

6 min read Updated 2026-06-26 Share:

Practice IoC Container interview questions

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:

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

More ways to practice

The self-quiz is live. Join our channel for updates, new content & tech tips.

Join our WhatsApp Channel