IoC & Dependency Injection
Everything else in Spring—MVC, Security, Data, AOP—sits on the container. This chapter explains how Spring decides what to create, when, and with which dependencies: from stereotype annotations and CGLIB-enhanced @Configuration classes to the three-level singleton cache that resolves circular references.
The problem DI solves
Without a container, application code typically constructs its own collaborators with new. That works in demos; in production it creates tight coupling, hides dependencies, and makes unit tests painful.
Tight coupling — before DI
OrderService hard-codes new JdbcOrderRepository(). You cannot swap in an in-memory repository for tests, cannot configure a connection pool centrally, and every change to repository construction ripples through callers.
public class OrderService {
private final OrderRepository repository = new JdbcOrderRepository();
public Order findById(long id) {
return repository.findById(id).orElseThrow();
}
}
After DI — depend on abstractions
The service declares what it needs; the container supplies a concrete implementation at runtime. Tests inject a stub; production injects JDBC; staging might inject a fake with latency simulation.
@Service
public class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository;
}
public Order findById(long id) {
return repository.findById(id).orElseThrow();
}
}
// Unit test — no Spring context required
@Test
void findById_returnsOrder() {
var repo = mock(OrderRepository.class);
when(repo.findById(1L)).thenReturn(Optional.of(new Order(1L)));
var service = new OrderService(repo);
assertThat(service.findById(1L).id()).isEqualTo(1L);
}
Legacy monoliths often mix new with Spring beans. Refactoring hotspots: services that construct DAOs, HTTP clients created inside controllers, and static singletons holding DB connections. The first win is constructor injection + interface extraction—then register implementations as beans.
If a class has more than 5–7 constructor parameters, the design smell is usually too many responsibilities—not "Spring is verbose." Split the service or introduce a façade bean before reaching for field injection.
Inversion of Control vs Dependency Injection
Interviewers conflate these constantly. They are related but not the same thing.
| Term | What it means | Spring example |
|---|---|---|
| IoC (principle) | Framework controls object creation and lifecycle—you don't call new for managed objects | ApplicationContext creates and wires singleton beans during refresh() |
| DI (pattern) | Dependencies are supplied from outside rather than created internally | Constructor parameters filled by AutowiredAnnotationBeanPostProcessor |
IoC is the goal (invert who is in charge). DI is the mechanism Spring most often uses to achieve it. Other IoC techniques include the Service Locator pattern (context.getBean()) and factory methods (@Bean).
Answer: "IoC means the container owns creation and lifecycle. DI is how dependencies arrive—typically constructor injection. You can have IoC without annotation-based DI (XML config, programmatic registration), but Spring Boot apps are DI-first."
BeanFactory vs ApplicationContext
BeanFactory is the minimal IoC container API. ApplicationContext extends it with enterprise features you rely on daily—even if you never type the interface name.
| Capability | BeanFactory | ApplicationContext |
|---|---|---|
| Instantiation | Lazy by default — bean created on first getBean() | Eager for singletons — created during context refresh (unless @Lazy) |
| Events | No | ApplicationEventPublisher — ContextRefreshedEvent, custom events |
| i18n | No | MessageSource for localized messages |
| AOP auto-proxy | Manual setup | Automatic — @Transactional, @Cacheable work out of the box |
| Environment | Limited | Environment, PropertySource, profiles, ${...} placeholders |
Common ApplicationContext implementations
| Implementation | When used |
|---|---|
| AnnotationConfigApplicationContext | Standalone apps, tests — pure Java config + component scan |
| ClassPathXmlApplicationContext | Legacy XML bean definitions — still in older codebases |
| AnnotationConfigServletWebServerApplicationContext | Spring Boot servlet apps — this is your production context type |
| ReactiveWebServerApplicationContext | Spring Boot + WebFlux |
When you call SpringApplication.run(), Boot selects the context class from classpath signals (servlet web vs reactive vs none). The returned context is an ApplicationContext; internally it still delegates bean creation to a DefaultListableBeanFactory — the concrete BeanFactory implementation.
Spring Framework 6 / Boot 3 use Jakarta EE 9+ namespaces (jakarta.*). Context types and behavior are unchanged; package imports moved from javax.*.
Bean definition & registration
A bean definition is metadata: class name, scope, constructor args, property values, init/destroy methods. Spring parses annotations, XML, or @Bean methods into definitions, then instantiates beans from them.
Registration paths you'll see in production:
- Classpath scanning — @Component and stereotype variants
- Java config — @Configuration classes with @Bean methods
- XML — <bean id="..." class="..."/> in legacy apps
- Programmatic — BeanDefinitionRegistry.registerBeanDefinition() in frameworks/libraries
Stereotype annotations
All stereotypes are meta-annotated with @Component. Spring treats them identically for registration; the names communicate intent to humans and some modules add behavior.
| Annotation | Semantic role | Special behavior |
|---|---|---|
| @Component | Generic Spring-managed class | None — base stereotype |
| @Service | Business logic layer | None (convention only); often where @Transactional lives |
| @Repository | Persistence / DAO layer | Exception translation — JDBC/JPA exceptions → Spring DataAccessException hierarchy |
| @Controller | Web MVC controller (returns views) | Detected by MVC for handler mapping |
| @RestController | REST API controller | @Controller + @ResponseBody on class |
@RestController
@RequestMapping("/api/orders")
class OrderController {
private final OrderService orderService;
OrderController(OrderService orderService) { this.orderService = orderService; }
@GetMapping("/{id}")
OrderDto get(@PathVariable long id) { return orderService.findDto(id); }
}
@Service
class OrderService {
private final OrderRepository repository;
OrderService(OrderRepository repository) { this.repository = repository; }
// @Transactional here — not on @Repository
}
@Repository
interface OrderRepository extends JpaRepository<Order, Long> {}
Putting @Transactional on a @Repository interface works only if the repository is a Spring proxy (Spring Data: yes). Custom DAO classes without interfaces need transactions on the @Service layer where the proxy wraps concrete classes.
@Configuration & @Bean methods
Use @Bean when you don't control the class (third-party library) or need explicit factory logic. The @Configuration vs @Component distinction is one of the most misunderstood topics in Spring.
@Configuration
public class AppConfig {
@Bean
public ObjectMapper objectMapper() {
return JsonMapper.builder()
.serializationInclusion(JsonInclude.Include.NON_NULL)
.build();
}
@Bean
public RestTemplate restTemplate(RestTemplateBuilder builder) {
return builder
.setConnectTimeout(Duration.ofSeconds(3))
.setReadTimeout(Duration.ofSeconds(10))
.build();
}
}
@Configuration (full) vs @Component (lite) — the CGLIB trap
In a full @Configuration class, Spring CGLIB-enhances the class so @Bean method calls route through the proxy — singleton beans stay singleton even when one @Bean method calls another.
In lite mode (@Component class with @Bean methods, or @Configuration(proxyBeanMethods = false)), method calls are plain Java — each call creates a new instance.
@Configuration // FULL — serviceA() and serviceB() share one DataSource
class FullConfig {
@Bean DataSource dataSource() { return new HikariDataSource(); }
@Bean ServiceA serviceA() { return new ServiceA(dataSource()); }
@Bean ServiceB serviceB() { return new ServiceB(dataSource()); }
}
@Configuration(proxyBeanMethods = false) // LITE — two DataSource instances!
class LiteConfig {
@Bean DataSource dataSource() { return new HikariDataSource(); }
@Bean ServiceA serviceA() { return new ServiceA(dataSource()); }
@Bean ServiceB serviceB() { return new ServiceB(dataSource()); }
}
Spring Boot 2.2+ defaults to proxyBeanMethods = false on @SpringBootApplication for faster startup. That's safe when @Bean methods don't call each other. If they do, enable full mode: @Configuration(proxyBeanMethods = true) on that config class.
Legacy XML (still in older codebases)
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:context="http://www.springframework.org/schema/context"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="...">
<context:component-scan base-package="com.acme"/>
<bean id="dataSource" class="com.zaxxer.hikari.HikariDataSource">
<property name="jdbcUrl" value="${db.url}"/>
</bean>
</beans>
Component scanning
@ComponentScan tells Spring where to look for stereotype-annotated classes. Boot's @SpringBootApplication scans the package of the main class and sub-packages.
@Configuration
@ComponentScan(
basePackages = "com.acme.orders",
excludeFilters = @ComponentScan.Filter(
type = FilterType.ANNOTATION,
classes = Experimental.class
)
)
class ScanConfig {}
| Filter type | Use case |
|---|---|
| FilterType.ANNOTATION | Exclude/include by annotation (e.g. @Profile, custom markers) |
| FilterType.ASSIGNABLE_TYPE | By concrete class or interface |
| FilterType.REGEX | Package/name pattern |
| FilterType.CUSTOM | Implement TypeFilter for complex rules |
Beans outside the scanned package tree are invisible. Classic bug: main class in com.acme but shared library beans in com.shared — add @ComponentScan("com.shared") or move the main class up the package hierarchy.
Dependency injection types
Spring can inject via constructor, setter, or field. Only one style should dominate your codebase.
Constructor injection — preferred
Dependencies are required, immutable (final fields), and the object is fully initialized after construction. Missing beans fail at context startup—not at 3 AM on the first code path that touches the field.
@Service
public class PaymentService {
private final PaymentGateway gateway;
private final MeterRegistry metrics;
public PaymentService(PaymentGateway gateway, MeterRegistry metrics) {
this.gateway = gateway;
this.metrics = metrics;
}
}
Setter injection — optional dependencies
Use when a dependency is genuinely optional or must be swapped after construction (rare in modern Spring).
@Component
public class AlertNotifier {
private SmsClient smsClient;
@Autowired(required = false)
public void setSmsClient(SmsClient smsClient) {
this.smsClient = smsClient;
}
}
Field injection — anti-pattern
@Service
public class BadOrderService {
@Autowired
private OrderRepository repository; // hard to test without Spring / reflection
}
| Style | Pros | Cons |
|---|---|---|
| Constructor | Immutable, testable, fail-fast, clear required deps | Many deps → design smell (not injection's fault) |
| Setter | Optional deps, reconfiguration | Mutable state; easy to forget to call setter |
| Field | Less boilerplate in tutorials | Untestable without framework; hides dependencies; cannot be final |
@Autowired vs @Inject vs @Resource
| Annotation | Source | Resolution |
|---|---|---|
| @Autowired | Spring | By type first; falls back to qualifier/name; required=false supported |
| @Inject | Jakarta CDI (jakarta.inject) | By type; equivalent to required=true @Autowired |
| @Resource | Jakarta (jakarta.annotation) | By name first (field/method name or explicit name), then type |
Spring team recommendation since 2016: constructor injection for required dependencies. Lombok @RequiredArgsConstructor + final fields is idiomatic in Boot codebases—still constructor injection under the hood.
Bean resolution
When multiple beans implement the same type, Spring needs disambiguation—or it throws NoUniqueBeanDefinitionException at startup.
Autowiring by type vs by name
Default autowiring is by type: Spring finds all beans assignable to the parameter type. If exactly one candidate exists, it wins. If multiple exist, it falls back to parameter name matching bean name (requires debug symbols / -parameters compiler flag for constructor arg names). Still ambiguous → use @Qualifier or @Primary.
@Qualifier and @Primary
public interface NotificationSender { void send(String message); }
@Component("emailSender")
class EmailNotificationSender implements NotificationSender { /* ... */ }
@Component("smsSender")
class SmsNotificationSender implements NotificationSender { /* ... */ }
@Component
@Primary // default when type alone is injected
class SlackNotificationSender implements NotificationSender { /* ... */ }
@Service
class AlertService {
private final NotificationSender defaultSender;
private final NotificationSender smsSender;
AlertService(
NotificationSender defaultSender, // Slack — @Primary wins
@Qualifier("smsSender") NotificationSender smsSender
) {
this.defaultSender = defaultSender;
this.smsSender = smsSender;
}
}
Optional dependencies
@Service
class ReportService {
private final Optional<MetricsExporter> exporter;
ReportService(Optional<MetricsExporter> exporter) {
this.exporter = exporter;
}
void publish() {
exporter.ifPresent(MetricsExporter::flush);
}
}
Programmatic lookup — escape hatch
Prefer injection. Use programmatic lookup for framework code, dynamic strategies, or legacy integration. ObjectProvider<T> is the modern lazy/safe alternative to raw getBean().
@Service
class JobRunner {
private final ObjectProvider<WorkerTask> taskProvider;
JobRunner(ObjectProvider<WorkerTask> taskProvider) {
this.taskProvider = taskProvider;
}
void run() {
WorkerTask task = taskProvider.getObject(); // new prototype instance per call
task.execute();
}
}
Calling applicationContext.getBean(MyService.class) from MyService creates hidden coupling and makes testing harder. If you need multiple strategy implementations, inject List<Strategy> or a dedicated registry bean.
Bean scopes
Scope controls instance cardinality and lifecycle. Default is singleton—one instance per container.
| Scope | Instances | Notes |
|---|---|---|
| singleton (default) | One per IoC container | Must be thread-safe if mutable shared state; stateless services are ideal |
| prototype | New instance per getBean() / injection point | Spring does not manage full destroy lifecycle for prototypes |
| request | One per HTTP request | Requires web-aware context |
| session | One per HTTP session | Shopping cart, user wizard state |
| application | One per ServletContext | Global web app singleton |
Prototype into singleton — scoped proxy
Injecting a prototype bean into a singleton field captures one prototype instance at singleton creation time. Fix: scoped proxy creates a new prototype on each method call.
@Component
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE,
proxyMode = ScopedProxyMode.TARGET_CLASS)
class AuditContext {
private final Instant created = Instant.now();
Instant getCreated() { return created; }
}
@Service
class AuditService {
private final AuditContext context; // proxy — each method call hits new instance
AuditService(AuditContext context) { this.context = context; }
}
ScopedProxyMode.TARGET_CLASS uses CGLIB; INTERFACES uses JDK dynamic proxy. The proxy delegates to ObjectFactory.getObject() which fetches the correct scoped instance from the container.
Custom scopes
Implement Scope and register via beanFactory.registerScope("tenant", new TenantScope()). Common in multi-tenant SaaS: one bean instance per tenant ID stored in a custom scope context. Spring Cloud uses custom scopes for refresh and request headers in some modules.
@Configuration
class ScopeConfig implements BeanFactoryAware {
@Override
public void setBeanFactory(BeanFactory beanFactory) {
((ConfigurableBeanFactory) beanFactory)
.registerScope("tenant", new TenantScope());
}
}
@Component
@Scope(value = "tenant", proxyMode = ScopedProxyMode.TARGET_CLASS)
class TenantPreferences { /* resolved per TenantContext */ }
Most production services are singletons with no mutable fields. Prototype scope appears for per-operation command objects, per-tenant strategy instances, or batch step handlers. Request/session scope is less common in stateless REST APIs—prefer explicit request parameters or JWT claims.
Bean lifecycle
Spring doesn't just call new—it runs a well-defined pipeline. Understanding this pipeline explains when @PostConstruct runs, when AOP proxies appear, and why some properties are still null in constructors.
sequenceDiagram participant BF as BeanFactory participant BPP as BeanPostProcessor participant Bean as Bean instance BF->>Bean: instantiate via constructor BF->>Bean: populate properties and inject deps BF->>Bean: BeanNameAware setBeanName BF->>Bean: BeanFactoryAware setBeanFactory BF->>Bean: ApplicationContextAware setContext BPP->>Bean: postProcessBeforeInitialization BF->>Bean: PostConstruct or afterPropertiesSet BF->>Bean: custom init-method BPP->>Bean: postProcessAfterInitialization Note over Bean: Bean ready for use BF->>Bean: PreDestroy or destroy-method on shutdown
Full initialization sequence
- Instantiation — constructor called (dependencies may not all be injected yet in constructor body for some edge cases)
- Populate properties — dependency injection, @Value
- Aware interfaces — BeanNameAware, BeanFactoryAware, ApplicationContextAware
- BeanPostProcessor.before — includes @PostConstruct processing setup
- Initialization callbacks — @PostConstruct, InitializingBean.afterPropertiesSet(), custom init-method
- BeanPostProcessor.after — AOP proxy created here for @Transactional, @Async, etc.
- Bean in use
- Shutdown — @PreDestroy, DisposableBean.destroy(), custom destroy-method
@Component
class CacheWarmer {
private final RedisTemplate<String, String> redis;
CacheWarmer(RedisTemplate<String, String> redis) {
this.redis = redis;
}
@PostConstruct
void warm() {
redis.opsForValue().set("status", "ready");
}
@PreDestroy
void shutdown() {
// flush connections, deregister listeners
}
}
BeanPostProcessor — extension point
BeanPostProcessor can modify or replace beans before and after initialization. Spring's AOP infrastructure registers processors that wrap beans in proxies when needed. Every @Autowired is handled by AutowiredAnnotationBeanPostProcessor—also a BPP.
BeanFactoryPostProcessor — mutate definitions early
Runs before any bean instantiation. Can alter bean definitions—property values, bean class, whether a bean exists. PropertySourcesPlaceholderConfigurer resolves ${property} placeholders in bean definitions.
@Component
class FeatureFlagPostProcessor implements BeanFactoryPostProcessor {
@Override
public void postProcessBeanFactory(ConfigurableListableBeanFactory factory) {
if (!Boolean.parseBoolean(System.getenv("FEATURE_X_ENABLED"))) {
if (factory.containsBeanDefinition("featureXService")) {
factory.removeBeanDefinition("featureXService");
}
}
}
}
Calling injected dependencies inside the constructor—they may not be fully initialized yet. Use @PostConstruct for logic that needs fully-wired dependencies. Also: the bean you receive via injection is the proxy; internal this.foo() calls skip advice (self-invocation).
AbstractAutowireCapableBeanFactory.createBean() orchestrates the lifecycle. The returned object from getBean() is often the post-processed proxy, not the raw instance. That's why @Transactional on public methods works on injected references but not on private self-calls.
Circular dependencies
Bean A needs B; B needs A. Spring can sometimes resolve this for singletons—but constructor cycles always fail. Circular dependencies are a design smell; Spring's workaround is a safety net, not approval.
Constructor injection — fail fast (good)
@Service
class ServiceA {
ServiceA(ServiceB b) {}
}
@Service
class ServiceB {
ServiceB(ServiceA a) {} // BeanCurrentlyInCreationException at startup
}
Field/setter cycles — three-level singleton cache
For singleton beans using field or setter injection, Spring breaks the cycle with an early reference exposed before the bean is fully initialized.
| Cache level | Map | Contents |
|---|---|---|
| 1 — complete | singletonObjects | Fully initialized singletons |
| 2 — early | earlySingletonObjects | Early exposed instances (may not be fully initialized) |
| 3 — factories | singletonFactories | ObjectFactories that can produce early references |
sequenceDiagram participant Ctx as Container participant A as Bean A participant B as Bean B Ctx->>A: start creating A Ctx->>A: add A factory to level-3 cache Ctx->>B: create B needs A Ctx->>A: get early reference from factory Ctx->>B: inject early A into B Ctx->>B: finish B Ctx->>A: inject finished B into A Ctx->>A: finish A move to level-1 cache
@Lazy as escape hatch
@Service
class ServiceA {
private final ServiceB b;
ServiceA(@Lazy ServiceB b) { this.b = b; } // proxy injected — resolved on first use
}
How to refactor (preferred)
- Extract shared logic into a third bean both depend on
- Event-driven decoupling — publish event instead of direct call
- Method object / parameter object — pass context instead of circular service reference
- Interface segregation — split fat services so dependency graph is DAG
Spring Boot 2.6+ changed default behavior: circular references are disallowed by default (spring.main.allow-circular-references=true to re-enable). Don't flip the flag in new code—fix the design. Prototype-in-singleton cycles and constructor cycles were never reliably supported.
Boot 2.6 (2021): allow-circular-references default changed to false. Many legacy apps broke on upgrade—usually a signal to refactor, not revert the default.
Profiles & conditional beans
Not every bean belongs in every environment. Profiles select bean sets; @Conditional* annotations are the backbone of Spring Boot auto-configuration.
@Profile — environment-specific beans
public interface PaymentGateway { PaymentResult charge(Money amount); }
@Component
@Profile("prod")
class StripePaymentGateway implements PaymentGateway { /* real API */ }
@Component
@Profile("dev | local")
class FakePaymentGateway implements PaymentGateway {
public PaymentResult charge(Money amount) {
return PaymentResult.approved("fake-" + UUID.randomUUID());
}
}
Activate via spring.profiles.active=dev, env var SPRING_PROFILES_ACTIVE, or @ActiveProfiles in tests.
@Conditional family
| Annotation | Registers bean when… |
|---|---|
| @ConditionalOnClass | Named class is on classpath (e.g. DataSource) |
| @ConditionalOnMissingBean | No bean of type already defined — user bean wins |
| @ConditionalOnProperty | Property matches (havingValue, matchIfMissing) |
| @ConditionalOnWebApplication | Servlet or reactive web app detected |
| @Conditional | Custom Condition implementation matches |
class OnKubernetesCondition implements Condition {
@Override
public boolean matches(ConditionContext ctx, AnnotatedTypeMetadata meta) {
return ctx.getEnvironment().containsProperty("KUBERNETES_SERVICE_HOST");
}
}
@Configuration
@Conditional(OnKubernetesCondition.class)
class KubernetesConfig {
@Bean K8sLeaderElection leaderElection() { return new K8sLeaderElection(); }
}
@Configuration
@ConditionalOnClass(DataSource.class)
@ConditionalOnMissingBean(DataSource.class)
class DataSourceAutoConfiguration {
@Bean
@ConfigurationProperties("spring.datasource")
DataSource dataSource() {
return DataSourceBuilder.create().build();
}
}
// Your app — this wins, auto-config backs off
@Configuration
class MyDataSourceConfig {
@Bean DataSource dataSource() { return new HikariDataSource(/* ... */); }
}
Debug why a bean is missing: hit /actuator/conditions (Boot Actuator) to see which @Conditional evaluations passed or failed. In tests, use @MockBean or define your own bean to trigger @ConditionalOnMissingBean backoff.
Explain how Boot auto-configuration respects your beans: classpath detection → load candidate configs from META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports (Boot 3) → filter with @ConditionalOn* → skip if @ConditionalOnMissingBean finds yours.
Boot 2.x loaded auto-config from META-INF/spring.factories. Boot 3.x uses AutoConfiguration.imports — same mechanism, new file location.