Skip to content

IoC Container Interview Questions & Answers

16 questions Updated 2026-06-26 Share:

How the Spring IoC container works — inversion of control, the ApplicationContext, BeanFactory, bean lifecycle, singleton vs prototype scopes, lazy initialization, and how dependencies get wired.

Read the in-depth guideThe Spring IoC Container Explained: Beans, Scopes, and Lifecycle(opens in new tab)
16 of 16

Inversion of Control means your objects no longer create or look up their own collaborators — a container creates them and hands the dependencies in. Control over construction and wiring is "inverted" from your code to the framework.

// WITHOUT IoC — the class controls its own dependencies (tight coupling):
class OrderService {
    private final PaymentGateway gateway = new StripeGateway(); // hard-wired
}

// WITH IoC — the container supplies the dependency (loose coupling):
@Service
class OrderService {
    private final PaymentGateway gateway;
    OrderService(PaymentGateway gateway) { this.gateway = gateway; } // injected
}

Spring implements IoC through dependency injection: the container reads your bean definitions, instantiates them, and injects collaborators via constructors, setters, or fields. The benefit is that classes depend on interfaces, not concrete implementations, so you can swap or mock dependencies freely.

Rule of thumb: IoC = "don't call us, we'll call you" — let the container build and wire objects so your classes stay decoupled and testable.

IoC is the broad principle — a framework, not your code, controls object creation and flow. Dependency injection is one specific way to achieve IoC: supplying an object's dependencies from the outside rather than letting it create them.

// Dependency injection is the *technique*:
@Service
class ReportService {
    private final Clock clock;
    ReportService(Clock clock) { this.clock = clock; } // dependency injected in
}
// IoC is the *principle* the container realizes — DI is how Spring does it.

Other forms of IoC exist (the Service Locator pattern, template methods, callbacks), but Spring's primary mechanism is DI. Saying "Spring is an IoC container" and "Spring does dependency injection" describe the same system at two levels of abstraction.

Rule of thumb: IoC is the what (framework owns control); DI is the how (dependencies are passed in). Spring's IoC container is a dependency-injection engine.

A bean is simply an object that the Spring IoC container instantiates, configures, wires, and manages for its entire lifecycle. If the container created it, it's a bean; if you new-ed it yourself, it isn't.

@Service                    // ← tells the container "manage an instance of this"
class InvoiceService { }

// Elsewhere, the container holds a single managed instance you can inject:
@RestController
class InvoiceController {
    private final InvoiceService service;
    InvoiceController(InvoiceService service) { this.service = service; } // the bean
}

Beans are defined by @Component (and its stereotypes) discovered through scanning, or by @Bean methods inside @Configuration classes. Each bean has a name/id, a scope, a lifecycle, and a set of dependencies the container resolves.

Rule of thumb: "Bean" = a container-managed object. The container owns its creation, wiring, and destruction; you just declare it and ask for it.

Both are IoC containers. BeanFactory is the bare-bones core — lazy bean instantiation and DI. ApplicationContext extends it with the enterprise features real apps need, and is what Spring Boot always uses.

// ApplicationContext adds, on top of BeanFactory:
//  - eager singleton instantiation at startup (fail fast)
//  - event publishing (ApplicationEventPublisher)
//  - internationalization (MessageSource)
//  - resource loading (ResourceLoader)
//  - automatic BeanPostProcessor / BeanFactoryPostProcessor registration
ApplicationContext ctx = SpringApplication.run(App.class, args);
MyService s = ctx.getBean(MyService.class);

A key practical difference: ApplicationContext instantiates singletons eagerly at startup, so misconfiguration fails immediately instead of on first use. BeanFactory is lazy. You almost never use BeanFactory directly in application code.

Rule of thumb: Think "ApplicationContext = BeanFactory + events + i18n + resources + eager startup." In Spring Boot you're always working with an ApplicationContext.

The context builds its set of bean definitions from two sources: component scanning ( classes annotated with stereotypes) and @Bean methods in configuration classes. Both are gathered before any bean is instantiated.

@SpringBootApplication           // @ComponentScan starts at this package
class App { }

@Service class A { }             // found by scanning → a bean definition

@Configuration
class Config {
    @Bean B b() { return new B(); }   // explicit definition → a bean definition
}

First Spring registers all bean definitions (metadata: class, scope, dependencies), then it resolves dependencies and instantiates them in the right order. Component scanning starts at the package of the @SpringBootApplication class and descends into sub-packages, which is why your main class belongs in a root package.

Rule of thumb: Definitions first, instances second. The container collects every @Component and @Bean into a registry, then wires and builds them.

A bean goes through instantiation → dependency injection → initialization → use → destruction, with callback hooks at the init and destroy boundaries.

@Component
class Connection {
    @PostConstruct          // after dependencies injected, before bean is used
    void open() { /* acquire resources */ }

    @PreDestroy             // before the bean is removed / context closes
    void close() { /* release resources */ }
}

In order: the container (1) instantiates the bean (constructor), (2) injects dependencies, (3) runs BeanPostProcessor.postProcessBeforeInitialization, (4) calls @PostConstruct / InitializingBean.afterPropertiesSet / a custom init method, (5) runs postProcessAfterInitialization (where AOP proxies are applied), then the bean is in service. On shutdown it calls @PreDestroy / DisposableBean.destroy / custom destroy.

Rule of thumb: Use @PostConstruct to finish setup after injection and @PreDestroy to clean up — they're the two hooks you'll reach for 95% of the time.

The default scope is singleton: the container creates exactly one instance per ApplicationContext and returns that same shared instance everywhere it's injected.

@Service
class CounterService { }          // singleton by default

// Both controllers receive the SAME CounterService instance:
@RestController class A { A(CounterService c) { } }
@RestController class B { B(CounterService c) { } }

Spring's "singleton" is one-per-container, not the JVM-wide GoF singleton. Because the single instance is shared across threads, singleton beans must be stateless (or use only thread-safe state) — storing per-request mutable fields in a singleton is a classic bug.

Rule of thumb: Default = one shared, stateless instance per context. Keep singleton beans thread-safe; never stash request- or user-specific mutable state in them.

A singleton bean is created once and shared. A prototype bean is created anew on every injection or getBean() call — the container builds it, wires it, hands it over, and then forgets about it.

@Component
@Scope("prototype")           // a fresh instance every time it's requested
class ShoppingCart { }

@Service
class CartManager {
    @Autowired ObjectProvider<ShoppingCart> cartProvider;
    ShoppingCart newCart() { return cartProvider.getObject(); } // new each call
}

Crucial gotcha: the container does not manage a prototype's full lifecycle — it does not call @PreDestroy on prototypes, and you are responsible for cleanup. Also, injecting a prototype into a singleton with a plain field gives you one instance frozen at startup; use ObjectProvider, @Lookup, or a scoped proxy to get a fresh one each time.

Rule of thumb: Singleton = shared and fully managed; prototype = new every request and only half-managed (no destroy callback). Don't field-inject a prototype into a singleton.

In web applications Spring adds request, session, and application scopes that tie a bean's lifetime to an HTTP request, an HTTP session, or the servlet context.

@Component
@RequestScope                 // one instance per HTTP request
class RequestContext {
    private String correlationId;
}

@Component
@SessionScope                 // one instance per user session
class UserPreferences { }

These let you hold per-request or per-user state safely without threading it through every method call. Because such beans are injected into singletons, Spring wires in a scoped proxy that, on each method call, resolves the real instance for the current request or session.

Rule of thumb: Reach for @RequestScope / @SessionScope when you need per-request or per-user state; Spring's scoped proxy makes them safe to inject into singletons.

A singleton is created once at startup, so any dependency injected into it is also resolved once. If that dependency is request- or prototype-scoped, you'd be frozen with a single instance forever. A scoped proxy breaks that by deferring resolution to call time.

@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
class RequestData { }         // injected as a CGLIB proxy, not the real bean

@Service
class Singleton {
    private final RequestData data;          // actually the proxy
    Singleton(RequestData data) { this.data = data; }
    // Each method call on 'data' is routed to the CURRENT request's instance.
}

The proxy is a thin stand-in injected at startup; on every method invocation it looks up the real, correctly-scoped target for the current context and delegates. Without it, the "shorter-lived" bean would silently behave like a singleton.

Rule of thumb: Whenever a narrower scope is injected into a wider one, use a scoped proxy (proxyMode) so each call resolves the right instance instead of a stale one.

By default singletons are created eagerly at startup. @Lazy defers a bean's creation until it is first actually used, trading startup speed for first-access latency.

@Service
@Lazy                         // not built until something asks for it
class ExpensiveReportEngine {
    ExpensiveReportEngine() { /* loads big models, slow */ }
}

// Or break a circular dependency by making one side lazy:
@Service class A { A(@Lazy B b) { } }   // a proxy is injected, B built on first use

Common uses: speeding up startup when a heavy bean is rarely used, and breaking circular dependencies (the @Lazy side gets a proxy, deferring the real construction). The downside is that configuration errors in a lazy bean surface only when it's first touched, not at startup — so the eager default's "fail fast" is usually preferable.

Rule of thumb: Keep eager-by-default (fail fast); add @Lazy selectively for genuinely expensive, rarely-used beans or to untangle a circular reference.

Calling applicationContext.getBean() is a Service Locator — your class actively pulls dependencies from the container, coupling it to Spring and hiding what it needs. Constructor injection is push: dependencies arrive from outside, declared openly.

// ANTI-PATTERN — service locator, hidden dependencies, hard to test:
@Service
class BadService {
    void run(ApplicationContext ctx) {
        PaymentGateway g = ctx.getBean(PaymentGateway.class); // coupled to Spring
    }
}

// PREFERRED — dependencies visible in the constructor, trivially mockable:
@Service
class GoodService {
    private final PaymentGateway gateway;
    GoodService(PaymentGateway gateway) { this.gateway = gateway; }
}

With injection the class has no compile-time dependency on Spring, its requirements are obvious from the constructor signature, and unit tests just pass mocks. getBean() is reserved for rare cases like dynamically selecting among many beans by name at runtime.

Rule of thumb: Declare dependencies in the constructor and let the container push them in; reach for getBean() only for genuinely dynamic, runtime-decided lookups.

A BeanPostProcessor is an extension point that lets you intercept every bean right after instantiation and dependency injection — once before initialization callbacks and once after. It's how Spring itself implements much of its "magic."

@Component
class TimingPostProcessor implements BeanPostProcessor {
    @Override
    public Object postProcessAfterInitialization(Object bean, String name) {
        // Return a proxy or the bean itself; here we just inspect it.
        if (bean instanceof Auditable) { /* wrap, log, register, etc. */ }
        return bean;
    }
}

postProcessAfterInitialization is exactly where Spring wraps beans in AOP proxies (for @Transactional, @Async, @Cacheable). @Autowired field injection and @PostConstruct are themselves handled by built-in post-processors. Note they apply to ordinary beans only — not to BeanFactoryPostProcessors, which run earlier on bean definitions.

Rule of thumb: BeanPostProcessor hooks every bean's initialization (and is where proxies get applied); BeanFactoryPostProcessor edits bean definitions before any bean exists.

A circular dependency is when bean A needs B and B needs A. With constructor injection this is unresolvable — neither can be built first — and Spring throws BeanCurrentlyInCreationException at startup.

// FAILS: each constructor needs the other fully built first.
@Service class A { A(B b) { } }
@Service class B { B(A a) { } }

// Works (setter/field injection): Spring creates A, exposes an early
// reference, then injects each into the other after construction.
@Service class A { @Autowired B b; }
@Service class B { @Autowired A a; }

Field/setter injection can break the cycle because Spring instantiates the raw object first and injects afterwards, using an "early reference" from its singleton cache. The cleaner fixes: refactor to remove the cycle (extract a third collaborator), or mark one side @Lazy so it gets a proxy. Since Spring Boot 2.6, circular references are disallowed by default and must be explicitly enabled — a nudge toward fixing the design.

Rule of thumb: A constructor cycle is a design smell — break it by extracting a shared collaborator or applying @Lazy, rather than switching to field injection to paper over it.

By default a scanned bean's name is its class name with the first letter lowercased; a @Bean method's name is the method name. You can override either explicitly.

@Service                          // bean name: "invoiceService"
class InvoiceService { }

@Service("invoices")              // bean name: "invoices"
class InvoiceService2 { }

@Configuration
class Config {
    @Bean                         // bean name: "dataSource" (the method name)
    DataSource dataSource() { return ...; }

    @Bean(name = "readOnlyDs")    // explicit name
    DataSource other() { return ...; }
}

Names matter when you have multiple beans of the same type and need @Qualifier("...") to pick one, or when referencing a bean by name with @Resource/getBean("name"). Each bean name must be unique within the container.

Rule of thumb: Let Spring derive names by convention; supply an explicit name only when you have several beans of one type and need a stable handle to qualify between them.

Container startup ("context refresh") follows a fixed sequence inside AbstractApplicationContext.refresh() — knowing it explains when your hooks fire.

// refresh() roughly does, in order:
// 1. Create the BeanFactory and load bean DEFINITIONS (scan + @Bean methods)
// 2. Run BeanFactoryPostProcessors  → can modify definitions
//    (e.g. @ConfigurationProperties binding, @Value placeholder resolution)
// 3. Register BeanPostProcessors    → will wrap beans later
// 4. Initialize MessageSource, ApplicationEventMulticaster
// 5. Instantiate all non-lazy SINGLETONS (DI → init callbacks → proxies)
// 6. Publish ContextRefreshedEvent  → ApplicationRunner / CommandLineRunner run

The key ordering insight: definitions are registered and post-processed before any singleton is instantiated, which is why a BeanFactoryPostProcessor can alter definitions and why @ConditionalOnBean is order-sensitive. Eager singleton creation in step 5 is what makes startup "fail fast" on misconfiguration.

Rule of thumb: Definitions and definition-post-processors first, then eager singletons, then the ready event — that order is why config changes happen before construction and why Spring catches wiring errors at boot.

More ways to practice

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

Join our WhatsApp Channel