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.

junior mid senior Spring Boot 3.x

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.

Before — tightly coupled
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.

After — constructor injection
@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);
}
📦 Real World

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.

💡 Pro Tip

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.

TermWhat it meansSpring 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).

🎯 Interview Tip

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.

CapabilityBeanFactoryApplicationContext
Instantiation Lazy by default — bean created on first getBean() Eager for singletons — created during context refresh (unless @Lazy)
Events No ApplicationEventPublisherContextRefreshedEvent, 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

ImplementationWhen 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
🔬 Under the Hood

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.

🔖 Version Note

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:

  1. Classpath scanning@Component and stereotype variants
  2. Java config@Configuration classes with @Bean methods
  3. XML<bean id="..." class="..."/> in legacy apps
  4. ProgrammaticBeanDefinitionRegistry.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.

AnnotationSemantic roleSpecial 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
Stereotypes in a layered app
@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> {}
⚠️ Pitfall

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.

Java configuration
@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.

Full vs lite — same @Bean call, different outcome
@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()); }
}
🔬 Under the Hood

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)

applicationContext.xml
<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.

Explicit scan configuration
@Configuration
@ComponentScan(
    basePackages = "com.acme.orders",
    excludeFilters = @ComponentScan.Filter(
        type = FilterType.ANNOTATION,
        classes = Experimental.class
    )
)
class ScanConfig {}
Filter typeUse case
FilterType.ANNOTATIONExclude/include by annotation (e.g. @Profile, custom markers)
FilterType.ASSIGNABLE_TYPEBy concrete class or interface
FilterType.REGEXPackage/name pattern
FilterType.CUSTOMImplement TypeFilter for complex rules
⚠️ Pitfall

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.

Constructor injection (Spring 4.3+ — @Autowired optional on single constructor)
@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).

Optional setter
@Component
public class AlertNotifier {
  private SmsClient smsClient;

  @Autowired(required = false)
  public void setSmsClient(SmsClient smsClient) {
    this.smsClient = smsClient;
  }
}

Field injection — anti-pattern

Avoid — field injection
@Service
public class BadOrderService {
  @Autowired
  private OrderRepository repository;  // hard to test without Spring / reflection
}
StyleProsCons
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

AnnotationSourceResolution
@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
🎯 Interview Tip

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

Multiple implementations
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

Optional<T> and required=false
@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().

ObjectProvider for lazy / prototype beans
@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();
  }
}
⚠️ Pitfall

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.

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

@Scope with proxyMode
@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; }
}
🔬 Under the Hood

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.

Register custom scope
@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 */ }
📦 Real World

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

  1. Instantiation — constructor called (dependencies may not all be injected yet in constructor body for some edge cases)
  2. Populate properties — dependency injection, @Value
  3. Aware interfacesBeanNameAware, BeanFactoryAware, ApplicationContextAware
  4. BeanPostProcessor.before — includes @PostConstruct processing setup
  5. Initialization callbacks@PostConstruct, InitializingBean.afterPropertiesSet(), custom init-method
  6. BeanPostProcessor.afterAOP proxy created here for @Transactional, @Async, etc.
  7. Bean in use
  8. Shutdown@PreDestroy, DisposableBean.destroy(), custom destroy-method
Lifecycle callbacks
@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.

Custom BeanFactoryPostProcessor
@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");
      }
    }
  }
}
⚠️ Pitfall

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

🔬 Under the Hood

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)

Unresolvable cycle
@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 levelMapContents
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

Break cycle with @Lazy
@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
⚠️ Pitfall

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.

🔖 Version Note

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

Profile-activated 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

AnnotationRegisters 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
Custom condition
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(); }
}
Boot auto-config pattern — user override
@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(/* ... */); }
}
💡 Pro Tip

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.

🎯 Interview Tip

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.

🔖 Version Note

Boot 2.x loaded auto-config from META-INF/spring.factories. Boot 3.x uses AutoConfiguration.imports — same mechanism, new file location.