Definition
- Inversion of Control (IoC) -- a design principle where the framework manages object creation and dependency wiring instead of the application code doing it manually with the
newkeyword. - Dependency Injection (DI) -- the mechanism Spring uses to implement IoC by pushing dependencies into beans via constructors, setters, or fields.
- BeanFactory -- the basic IoC container interface that uses lazy initialization, creating beans only when first requested.
- ApplicationContext -- the full-featured container that extends
BeanFactorywith eager initialization, event publishing, AOP proxy support, annotation scanning,@Profile, and i18n. - Dependency Lookup -- a discouraged pattern where the bean pulls dependencies from the container via
applicationContext.getBean(), coupling code to the Spring API.
Without IoC, every teller at National Bank of India builds their own desk, finds their own printer, and connects their own terminal. With IoC, the branch manager (Spring container) sets up everything before the teller arrives. The teller declares "I need a printer and a terminal" and the manager provides them. BeanFactory is a basic manager who fetches items only when asked (lazy). ApplicationContext is a senior manager who prepares everything before the bank opens (eager) and also handles announcements, security audits, and multilingual forms.
Understanding the Concepts
What is Inversion of Control?
Inversion of Control is a design principle where the framework creates and manages objects instead of your application code doing it with the new keyword. In traditional Java, the caller creates its own dependencies. With IoC, the Spring container takes that responsibility.
In Rajesh's banking application at National Bank of India, the TransferService needs an AccountRepository to move money between accounts. Without IoC, Rajesh writes new JdbcAccountRepository() inside the service, coupling it to one specific implementation.
package com.bank.service;
import com.bank.repository.JdbcAccountRepository;
import java.math.BigDecimal;
// WITHOUT IoC -- tight coupling
public class TransferService {
private final JdbcAccountRepository repo = new JdbcAccountRepository(); // hardcoded
public void transfer(String from, String to, BigDecimal amount) {
var sender = repo.findById(from);
sender.debit(amount);
repo.save(sender);
}
}
With IoC, the Spring container creates the repository and injects it:
package com.bank.service;
import com.bank.repository.AccountRepository;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
// WITH IoC -- Spring injects the dependency
@Service
public class TransferService {
private final AccountRepository repo; // interface only
public TransferService(AccountRepository repo) {
this.repo = repo; // Spring decides the implementation
}
public void transfer(String from, String to, BigDecimal amount) {
var sender = repo.findById(from);
sender.debit(amount);
repo.save(sender);
}
}
The "control" of object creation is "inverted" from the application code to the framework. The service depends on the AccountRepository interface and never knows whether it receives a JPA, JDBC, or mock implementation.
Why does IoC exist?
IoC exists to solve three problems: tight coupling, poor testability, and rigid configuration.
Without IoC, changing from JDBC to JPA means modifying every class that creates a repository with new. With IoC, you swap the implementation in one place and every consumer gets the new version automatically. Testability improves dramatically because Rajesh can test the TransferService with a mock repository and no Spring context:
package com.bank.service;
import com.bank.model.Account;
import com.bank.repository.AccountRepository;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.mockito.Mockito.*;
class TransferServiceTest {
@Test
void shouldDebitSenderAccount() {
AccountRepository mockRepo = mock(AccountRepository.class);
Account sender = new Account("SAV2001", new BigDecimal("50000"));
when(mockRepo.findById("SAV2001")).thenReturn(sender);
TransferService service = new TransferService(mockRepo);
service.transfer("SAV2001", "SAV2002", new BigDecimal("10000"));
verify(mockRepo).save(sender);
}
}
Test passed in 45ms -- no database, no Spring context.
IoC also enforces the Dependency Inversion Principle: high-level modules depend on abstractions, not implementations. The TransferService depends on the AccountRepository interface. In production, Spring injects the JPA implementation connected to PostgreSQL. In integration tests, it injects a Testcontainers-backed repository. In unit tests, Mockito provides a mock.
How does the IoC container work internally?
The Spring IoC container reads bean definitions from annotations (@Component, @Service, @Bean) or XML, builds a dependency graph, and creates beans in the correct order. The container stores singleton beans in an internal ConcurrentHashMap called the singleton registry.
The core steps are:
- Bean definition loading -- the container scans packages specified by
@ComponentScanand reads@Beanmethods in@Configurationclasses. - BeanFactoryPostProcessors -- these modify bean definitions before any bean is created.
PropertySourcesPlaceholderConfigurerresolves${...}placeholders. - Bean instantiation -- the container calls constructors, resolving constructor arguments from the dependency graph.
- Dependency injection -- field and setter dependencies are populated.
- BeanPostProcessors -- these modify bean instances after creation.
AutowiredAnnotationBeanPostProcessorhandles@Autowired.AbstractAutoProxyCreatorwraps beans with AOP proxies for@Transactional.
package com.bank;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
@SpringBootApplication
public class BankApplication {
public static void main(String[] args) {
ConfigurableApplicationContext ctx = SpringApplication.run(BankApplication.class, args);
// Container holds all singleton beans
System.out.println("Bean count: " + ctx.getBeanDefinitionCount());
System.out.println("TransferService: " + ctx.getBean("transferService").getClass());
}
}
Bean count: 142
TransferService: class com.bank.service.TransferService$$SpringCGLIB$$0
Notice the $$SpringCGLIB$$0 suffix. The container returned a CGLIB proxy, not the raw service. This proxy is what enables @Transactional and @Cacheable interception.
How is BeanFactory different from ApplicationContext?
BeanFactory is the basic IoC container interface. ApplicationContext extends it with enterprise features. In practice, every Spring Boot application uses ApplicationContext.
| Feature | BeanFactory | ApplicationContext |
|---|---|---|
| Bean creation | Lazy -- created on first getBean() | Eager -- singletons created at startup |
| BeanPostProcessor | Manual registration required | Auto-registered |
| Event publishing | Not supported | ApplicationEventPublisher built in |
| i18n (MessageSource) | Not supported | Built in |
| @Profile / @PropertySource | Not supported | Built in |
| AOP proxy creation | Manual | Automatic via AbstractAutoProxyCreator |
| Annotation scanning | Not supported | @ComponentScan built in |
| Resource loading | Basic | Classpath, URL, file system resources |
The eager initialization of ApplicationContext is critical in production. A missing dependency or invalid configuration fails the application at startup with a clear error, not at 3 AM when the first customer request triggers the misconfigured code path.
package com.bank.config;
import com.bank.repository.AccountRepository;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.beans.factory.xml.XmlBeanFactory;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.core.io.ClassPathResource;
public class ContainerComparison {
public static void main(String[] args) {
// BeanFactory -- lazy, basic
BeanFactory factory = new XmlBeanFactory(
new ClassPathResource("beans.xml")
);
// Bean NOT created yet
AccountRepository repo = factory.getBean(AccountRepository.class);
// Bean created NOW on first access
// ApplicationContext -- eager, full-featured
ApplicationContext ctx = new AnnotationConfigApplicationContext(
"com.bank"
);
// ALL singletons already created
// Errors caught at startup, not at runtime
}
}
What is Dependency Injection vs Dependency Lookup?
Dependency Injection means the container pushes dependencies into the bean automatically. Dependency Lookup means the bean pulls dependencies from the container by calling getBean(). DI is the preferred approach because it keeps the bean decoupled from the Spring API.
| Aspect | Dependency Injection | Dependency Lookup |
|---|---|---|
| Direction | Container pushes to bean | Bean pulls from container |
| Coupling | Bean knows only the interface | Bean knows ApplicationContext |
| Testability | Pass mock in constructor | Must mock the entire container |
| Usage | Standard -- constructor injection | Rare -- dynamic bean selection |
package com.bank.service;
import com.bank.repository.AccountRepository;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
// DEPENDENCY INJECTION -- preferred
@Service
public class TransferServiceDI {
private final AccountRepository repo;
public TransferServiceDI(AccountRepository repo) {
this.repo = repo; // container pushes the dependency
}
}
// DEPENDENCY LOOKUP -- discouraged
@Service
public class TransferServiceLookup {
private final ApplicationContext ctx;
public TransferServiceLookup(ApplicationContext ctx) {
this.ctx = ctx;
}
public void transfer(String from, String to, BigDecimal amount) {
// bean pulls the dependency -- coupled to Spring API
AccountRepository repo = ctx.getBean(AccountRepository.class);
var sender = repo.findById(from);
sender.debit(amount);
repo.save(sender);
}
}
Dependency Lookup is acceptable in rare cases where the bean type is determined at runtime, such as selecting a payment processor based on the payment method. For all other cases, use constructor injection.
What is the refresh() lifecycle?
When SpringApplication.run() executes, it calls refresh() on the ApplicationContext. This method orchestrates the entire container startup sequence:
- prepareRefresh() -- set startup timestamp, validate required properties.
- obtainFreshBeanFactory() -- load all bean definitions from
@ComponentScanand@Beanmethods. - prepareBeanFactory() -- register standard environment beans and configure the classloader.
- invokeBeanFactoryPostProcessors() -- run
BeanFactoryPostProcessorimplementations.ConfigurationClassPostProcessorprocesses@Configuration.PropertySourcesPlaceholderConfigurerresolves${...}. - registerBeanPostProcessors() -- register
BeanPostProcessorimplementations for later use. - initMessageSource() -- set up i18n
MessageSource. - initApplicationEventMulticaster() -- set up the event dispatcher.
- onRefresh() -- create the embedded Tomcat server (Spring Boot).
- registerListeners() -- register
@EventListenerbeans. - finishBeanFactoryInitialization() -- create ALL singleton beans (eager init).
- finishRefresh() -- publish
ContextRefreshedEvent, start the web server.
package com.bank.listener;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
public class StartupListener {
@EventListener
public void onContextRefreshed(ContextRefreshedEvent event) {
long beanCount = event.getApplicationContext().getBeanDefinitionCount();
System.out.println("Container ready with " + beanCount + " beans");
}
}
Container ready with 142 beans
What are common mistakes with IoC?
The most common IoC mistakes involve creating Spring beans with new instead of letting the container manage them.
WRONG -- creating a Spring bean with new:
package com.bank.controller;
import com.bank.service.TransferService;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
@RestController
public class TransferController {
@PostMapping("/transfers")
public String transfer() {
// WRONG -- bypasses IoC container
TransferService service = new TransferService();
// @Transactional does nothing
// @Autowired fields are null
// @PostConstruct never ran
service.transfer("SAV2001", "SAV2002", new BigDecimal("10000"));
return "done";
}
}
CORRECT -- let the container inject the bean:
package com.bank.controller;
import com.bank.service.TransferService;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
@RestController
public class TransferController {
private final TransferService transferService;
public TransferController(TransferService transferService) {
this.transferService = transferService; // container provides it
}
@PostMapping("/transfers")
public String transfer() {
transferService.transfer("SAV2001", "SAV2002", new BigDecimal("10000"));
return "done";
}
}
Another common mistake is using getBean() everywhere instead of constructor injection, turning the IoC container into a Service Locator and losing the benefits of compile-time safety and testability.
What about testing with IoC?
IoC makes testing straightforward because dependencies are injected through the constructor. You can test at three levels without changing the service code:
package com.bank.service;
import com.bank.model.Account;
import com.bank.repository.AccountRepository;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.mockito.Mockito.*;
import static org.assertj.core.api.Assertions.*;
// UNIT TEST -- no Spring context, runs in milliseconds
class TransferServiceUnitTest {
@Test
void shouldRejectInsufficientBalance() {
AccountRepository repo = mock(AccountRepository.class);
Account sender = new Account("SAV2001", new BigDecimal("500"));
when(repo.findById("SAV2001")).thenReturn(sender);
TransferService service = new TransferService(repo);
assertThatThrownBy(() ->
service.transfer("SAV2001", "SAV2002", new BigDecimal("10000"))
).isInstanceOf(InsufficientBalanceException.class);
}
}
TransferServiceUnitTest > shouldRejectInsufficientBalance PASSED (48ms)
The same TransferService works in integration tests with a real database (Testcontainers) and in production with PostgreSQL. The service code is identical in all three scenarios because IoC decouples the service from the repository implementation.
What should a senior engineer know?
- ApplicationContext hierarchy. Spring MVC creates a parent-child context: the root context holds services and repositories, the servlet context holds controllers. A child can see parent beans but not vice versa. Spring Boot with a single
DispatcherServletuses a flat context. - Customizing the container.
BeanFactoryPostProcessormodifies bean definitions before instantiation (example:PropertySourcesPlaceholderConfigurer).BeanPostProcessormodifies bean instances after creation (example:AutowiredAnnotationBeanPostProcessor). - Startup performance. Large applications with 500+ beans can take 10+ seconds to start. Use
spring.main.lazy-initialization=truecautiously in development. In production, eager init is preferred to catch errors at startup. - GraalVM native images. Spring Boot 3 supports AOT (ahead-of-time) compilation. The container processes bean definitions at build time, eliminating reflection-based scanning at runtime. This reduces startup from seconds to milliseconds.
- Conditional beans.
@ConditionalOnProperty,@ConditionalOnClass,@ConditionalOnMissingBeancontrol bean registration. Spring Boot auto-configuration uses these extensively to configure defaults that can be overridden.
What are common interview follow-up questions?
- What is the difference between
@Beanand@Component? - How does Spring handle circular dependencies?
- What are the bean scopes in Spring?
- What is the difference between
BeanFactoryPostProcessorandBeanPostProcessor? - How does Spring Boot auto-configuration work?
- What is the difference between
@Configurationand@Component? (Full vs lite mode for@Beanmethods.) - Can you have multiple
ApplicationContextinstances in one JVM? - What happens if you call
getBean()for a prototype-scoped bean?
Visual Overview
┌──────────────────────────────────────────────────────┐
│ ApplicationContext │
│ (eager init, events, AOP, i18n, profiles) │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ BeanFactory │ │
│ │ (lazy init, basic DI, singleton registry) │ │
│ └──────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────┘
SpringApplication.run()
│
▼
refresh()
│
├─── Load bean definitions (@ComponentScan, @Bean)
├─── BeanFactoryPostProcessors (resolve ${...})
├─── Register BeanPostProcessors
├─── Create ALL singletons (eager)
│ ├── Instantiate (constructor)
│ ├── Inject (@Autowired fields/setters)
│ ├── BeanPostProcessor.before (@PostConstruct)
│ └── BeanPostProcessor.after (AOP proxy)
├─── Publish ContextRefreshedEvent
└─── Start embedded Tomcat
Key Takeaway
Inversion of Control is a design principle where the framework takes responsibility for creating objects and wiring their dependencies, instead of the application code doing it with the new keyword. In Rajesh's banking transfer service at National Bank of India, the TransferService declares its dependency on AccountRepository as a final field in the constructor. Spring's IoC container scans for the JPA repository implementation, creates it, and injects it automatically. The TransferService never knows or cares whether it received a JPA repository, a JDBC repository, or a mock. This gives us testability: in unit tests, Rajesh creates the service with a mock repository using Mockito, with no Spring context and no database, making tests run in milliseconds.
Spring provides two IoC container implementations. BeanFactory is the basic interface with lazy bean initialization that creates beans only when first requested. ApplicationContext extends it with eager initialization that catches configuration errors at startup, automatic registration of BeanPostProcessors for annotation processing, event publishing, internationalization, and environment-aware profiles. Every Spring Boot application uses ApplicationContext because SpringApplication.run() creates one automatically. The eager initialization is particularly valuable in production because a missing dependency or invalid configuration fails the application at startup with a clear error, not at 3 AM when Priya's first loan application triggers the misconfigured code path.
The key architectural benefit of IoC is that it enforces the Dependency Inversion Principle: high-level modules depend on abstractions, not implementations. When the National Bank of India migrated from JDBC to JPA, the team changed the repository implementation without modifying a single line of the TransferService or the TransferController. That is the real power of Inversion of Control.
Remember one sentence: IoC means the container creates and wires your objects so your code depends only on interfaces, never on implementations.
---