03-spring-boot-essentials

Spring Core and Dependency Injection: The IoC Container Explained

A staff-engineer guide to Spring's IoC container, bean lifecycle, scopes, and dependency injection patterns for production services.

August 14, 2026
backend-engineerspring-coreiocdibeanscontainer

Spring Core and Dependency Injection

Every Spring Boot application, no matter how much auto-configuration magic sits on top, is fundamentally an IoC container wiring together a graph of objects. This guide covers what actually happens when your application starts — how beans are defined, instantiated, wired, and destroyed — and the production patterns that separate maintainable services from tangled ones.


1. Inversion of Control: The Core Idea

In traditional procedural code, an object creates its own dependencies:

java
public class OrderService {
    private final OrderRepository repository = new JpaOrderRepository(); // tight coupling
}

Inversion of Control flips this: the object declares what it needs, and an external container supplies it. The object no longer controls how its dependencies are constructed — that control is inverted to the framework.

Dependency Injection (DI) is the specific technique Spring uses to implement IoC: dependencies are "injected" into an object rather than looked up or constructed by it.

💡

IoC is the principle; DI is the mechanism. Spring's container also does auto-wiring, lifecycle management, and AOP proxying — DI is the most visible part, but the container does much more.

Why this matters in production

  • Testability: swap JpaOrderRepository for a mock without touching OrderService.
  • Loose coupling: OrderService depends on the OrderRepository interface, not a concrete implementation — you can change persistence technology without touching business logic.
  • Centralized configuration: connection pools, thread pools, and clients are configured once and reused, not re-instantiated per class.

2. The ApplicationContext and BeanFactory

Spring's container comes in two flavors:

InterfaceRoleUse case
BeanFactoryBase container — lazy instantiation, basic DIRarely used directly; memory-constrained environments
ApplicationContextSuperset of BeanFactory — eager singleton instantiation, event publishing, AOP, i18n, environment abstractionEvery Spring Boot application

Spring Boot's SpringApplication.run() bootstraps a specific ApplicationContext implementation — typically AnnotationConfigServletWebServerApplicationContext for a web app.

@PostConstruct runs before BeanPostProcessor#postProcessAfterInitialization. This means AOP proxies (e.g., @Transactional interception) are applied after your init method runs — so calling a @Transactional method on this from within @PostConstruct will not go through the proxy. This is a classic source of "why isn't my transaction working" bugs.


3. Defining Beans

A bean is any object managed by the Spring container. There are two primary ways to declare one.

Stereotype annotations (component scanning)

java
@Service
public class OrderService {
 
    private final OrderRepository orderRepository;
    private final PaymentGateway paymentGateway;
 
    public OrderService(OrderRepository orderRepository, PaymentGateway paymentGateway) {
        this.orderRepository = orderRepository;
        this.paymentGateway = paymentGateway;
    }
 
    public Order placeOrder(OrderRequest request) {
        Order order = orderRepository.save(request.toOrder());
        paymentGateway.charge(order.getPaymentDetails());
        return order;
    }
}
StereotypeLayerNotes
@ComponentGenericBase annotation; all others are specializations
@ServiceBusiness logic layerSemantically signals a service — no extra behavior over @Component
@RepositoryData access layerAdds automatic persistence exception translation (DataAccessException)
@Controller / @RestControllerWeb layerRegisters request-mapping handler methods
@ConfigurationJava-based configClass containing @Bean methods
⚠️

@Repository's exception translation isn't just cosmetic — Spring wraps a PersistenceException or SQLException in a consistent DataAccessException hierarchy. If you build a data access class without @Repository, you lose this translation and leak vendor-specific exceptions into your service layer.

Explicit @Bean methods

Use @Bean when you don't own the class (third-party libraries) or when construction logic needs conditional branching:

java
@Configuration
public class ClientConfig {
 
    @Bean
    public RestClient paymentServiceClient(RestClient.Builder builder,
                                            @Value("${payment.service.base-url}") String baseUrl) {
        return builder
            .baseUrl(baseUrl)
            .requestInterceptor((request, body, execution) -> {
                request.getHeaders().add("X-Client-Id", "order-service");
                return execution.execute(request, body);
            })
            .build();
    }
 
    @Bean
    public ObjectMapper objectMapper() {
        return JsonMapper.builder()
            .addModule(new JavaTimeModule())
            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            .build();
    }
}
💡

Component scanning vs @Bean: use stereotype annotations for classes you author (your services, controllers, repositories). Use @Bean methods for third-party types, framework objects, or beans that need constructor arguments you compute (base URLs, timeouts, credentials).


4. Dependency Injection Styles

Spring supports three injection mechanisms. Only one is recommended for production code.

java
// 1. Constructor injection (RECOMMENDED)
@Service
public class InventoryService {
    private final ProductRepository productRepository;
    private final EventPublisher eventPublisher;
 
    public InventoryService(ProductRepository productRepository, EventPublisher eventPublisher) {
        this.productRepository = productRepository;
        this.eventPublisher = eventPublisher;
    }
}
 
// 2. Setter injection (rare — for optional dependencies)
@Service
public class ReportingService {
    private MetricsCollector metricsCollector; // optional
 
    @Autowired(required = false)
    public void setMetricsCollector(MetricsCollector metricsCollector) {
        this.metricsCollector = metricsCollector;
    }
}
 
// 3. Field injection (AVOID in production code)
@Service
public class LegacyService {
    @Autowired
    private ProductRepository productRepository; // hard to test, hides dependencies
}

Why constructor injection wins

CriterionConstructor injectionField injection
ImmutabilityFields can be finalFields must be mutable
TestabilityPlain new OrderService(mockRepo, mockGateway) — no Spring/reflection neededRequires @ExtendWith(MockitoExtension.class) + reflection to set mocks
Fail-fastMissing dependency fails at context startup with a clear errorMay fail lazily, deep in a call stack, with an NPE
Circular dependency detectionFails loudly at startupSpring can silently resolve via early bean references, hiding a design smell
ExplicitnessAll dependencies visible in one place (the constructor signature)Dependencies scattered across the class body
🚨

Field injection with @Autowired is a code smell in new code. It works, but it hides the true complexity of a class — a constructor with eight parameters is an obvious signal to split the class; eight @Autowired fields hide that signal completely. Since Spring 4.3, if a class has exactly one constructor, @Autowired on it is not even required — Spring infers it implicitly.

java
// Spring 4.3+ — @Autowired is implicit with a single constructor
@Service
public class PricingService {
    private final DiscountEngine discountEngine;
 
    public PricingService(DiscountEngine discountEngine) { // no @Autowired needed
        this.discountEngine = discountEngine;
    }
}

5. Resolving Ambiguity: @Qualifier and @Primary

When multiple beans implement the same interface, Spring cannot decide which one to inject by type alone.

java
public interface NotificationChannel {
    void send(String to, String message);
}
 
@Component("emailChannel")
public class EmailChannel implements NotificationChannel { /* ... */ }
 
@Component("smsChannel")
public class SmsChannel implements NotificationChannel { /* ... */ }
java
// Option 1: @Qualifier — explicit, per-injection-point choice
@Service
public class AlertService {
    private final NotificationChannel channel;
 
    public AlertService(@Qualifier("smsChannel") NotificationChannel channel) {
        this.channel = channel;
    }
}
 
// Option 2: @Primary — sets a default when no qualifier is given
@Component
@Primary
public class EmailChannel implements NotificationChannel { /* ... */ }
 
// Option 3: inject all implementations
@Service
public class BroadcastService {
    private final List<NotificationChannel> channels;
 
    public BroadcastService(List<NotificationChannel> channels) {
        this.channels = channels; // Spring injects every matching bean, in declaration order
    }
 
    public void broadcast(String to, String message) {
        channels.forEach(c -> c.send(to, message));
    }
}

Injecting List<NotificationChannel> instead of a single bean is a powerful pattern for Strategy and Chain of Responsibility designs — Spring collects every bean of that type automatically. Use @Order on the implementations to control iteration order.


6. Bean Scopes

By default, every Spring bean is a singleton — one instance per container, shared by all injection points. This surprises engineers coming from frameworks where "new object per request" is the default.

ScopeLifecycleTypical use
singleton (default)One instance for the entire ApplicationContextStateless services, repositories, clients
prototypeNew instance every time the bean is requestedStateful, non-thread-safe helper objects
requestOne instance per HTTP request (web-aware contexts only)Request-scoped context data
sessionOne instance per HTTP sessionUser-session-scoped state
applicationOne instance per ServletContextRarely used; similar to singleton but web-scoped
java
@Service
@Scope("prototype")
public class ReportBuilder {
    private final List<String> sections = new ArrayList<>(); // mutable, per-use state
 
    public ReportBuilder addSection(String section) {
        sections.add(section);
        return this;
    }
}
🚨

The classic bug: injecting a prototype-scoped bean into a singleton-scoped bean. Because the singleton is created once, the prototype dependency is also resolved only once at that moment — you get the same "prototype" instance forever, defeating the purpose. Fix this with a ObjectProvider<ReportBuilder> or ObjectFactory<ReportBuilder> injected into the singleton, so a fresh instance is fetched on every call.

java
@Service // singleton
public class ReportService {
    private final ObjectProvider<ReportBuilder> reportBuilderProvider;
 
    public ReportService(ObjectProvider<ReportBuilder> reportBuilderProvider) {
        this.reportBuilderProvider = reportBuilderProvider;
    }
 
    public Report generate() {
        ReportBuilder builder = reportBuilderProvider.getObject(); // fresh instance every call
        return builder.addSection("summary").addSection("details").build();
    }
}

Thread safety and singletons

Because singleton beans are shared across every concurrent request, they must be stateless or thread-safe. This is why constructor-injected final fields referencing other singletons (repositories, clients) are safe — the shared state itself (the database, the HTTP client) is designed for concurrent access — but adding a mutable instance field to a @Service is almost always a bug waiting to happen.

java
// BAD: mutable instance state on a singleton — race condition under load
@Service
public class CounterService {
    private int requestCount = 0; // shared across all threads!
 
    public void increment() {
        requestCount++; // not atomic, lost updates under concurrency
    }
}
 
// GOOD: use a thread-safe primitive, or better, push state to an external store
@Service
public class CounterService {
    private final AtomicLong requestCount = new AtomicLong();
 
    public void increment() {
        requestCount.incrementAndGet();
    }
}

7. Bean Lifecycle Callbacks

Spring gives you hooks at both ends of a bean's life.

java
@Component
public class ConnectionPoolManager implements InitializingBean, DisposableBean {
 
    private HikariDataSource dataSource;
 
    @PostConstruct
    public void warmUp() {
        // Runs first among lifecycle hooks — good for cheap validation
    }
 
    @Override
    public void afterPropertiesSet() {
        // Interface-based equivalent of @PostConstruct — runs after it
        this.dataSource = buildDataSource();
    }
 
    @PreDestroy
    public void drainConnections() {
        // Graceful shutdown — runs before destroy()
        dataSource.getHikariPoolMXBean().softEvictConnections();
    }
 
    @Override
    public void destroy() {
        dataSource.close();
    }
}

Prefer @PostConstruct / @PreDestroy over implementing InitializingBean / DisposableBean. The annotation-based approach has no Spring-interface coupling in your class, which matters if you ever need to reuse the class outside a Spring context (unit tests, another framework).

Lazy initialization

java
@Component
@Lazy // instance created only on first use, not at context startup
public class ExpensiveReportGenerator {
    public ExpensiveReportGenerator() {
        // heavy setup — PDF template engine, font loading, etc.
    }
}
yaml
# application.yml — make ALL beans lazy by default (Spring Boot 2.2+)
spring:
  main:
    lazy-initialization: true
⚠️

Global lazy initialization speeds up startup but defers configuration errors from "fails fast at boot" to "fails on first request" — a much worse production experience. Use it selectively for genuinely expensive, rarely-used beans, not as a blanket setting for a production service.


8. Circular Dependencies

A circular dependency occurs when bean A needs bean B, and B needs A.

java
@Service
public class OrderService {
    private final ShippingService shippingService;
    public OrderService(ShippingService shippingService) {
        this.shippingService = shippingService;
    }
}
 
@Service
public class ShippingService {
    private final OrderService orderService;
    public ShippingService(OrderService orderService) { // circular!
        this.orderService = orderService;
    }
}

With constructor injection, this fails at startup:

text
BeanCurrentlyInCreationException: Error creating bean with name 'orderService':
Requested bean is currently in creation: Is there an unresolvable circular reference?
🚨

Spring can resolve circular dependencies with field/setter injection (via a cache of "early bean references"), but this is a workaround, not a solution. A circular dependency between two services is almost always a sign the responsibilities are split wrong. The fix is to extract the shared logic into a third class that both depend on, or use events (ApplicationEventPublisher) to decouple the interaction.

java
// Fix: extract shared coordination into a third component
@Service
public class OrderShippingCoordinator {
    private final OrderService orderService;
    private final ShippingService shippingService;
 
    public OrderShippingCoordinator(OrderService orderService, ShippingService shippingService) {
        this.orderService = orderService;
        this.shippingService = shippingService;
    }
 
    public void processShipment(String orderId) {
        Order order = orderService.get(orderId);
        shippingService.ship(order);
        orderService.markShipped(order.getId());
    }
}

9. Conditional Bean Registration

Spring Boot's auto-configuration is built entirely on conditional bean creation — the same tool is available for your own beans.

java
@Configuration
public class CacheConfig {
 
    @Bean
    @ConditionalOnProperty(name = "cache.provider", havingValue = "redis")
    public CacheManager redisCacheManager(RedisConnectionFactory connectionFactory) {
        return RedisCacheManager.create(connectionFactory);
    }
 
    @Bean
    @ConditionalOnProperty(name = "cache.provider", havingValue = "in-memory", matchIfMissing = true)
    public CacheManager inMemoryCacheManager() {
        return new ConcurrentMapCacheManager();
    }
 
    @Bean
    @ConditionalOnMissingBean(MeterRegistry.class)
    public MeterRegistry simpleMeterRegistry() {
        return new SimpleMeterRegistry(); // fallback if Micrometer registry isn't on the classpath
    }
}
AnnotationRegisters bean when...
@ConditionalOnPropertyA property matches an expected value
@ConditionalOnMissingBeanNo bean of that type already exists
@ConditionalOnBeanAnother specific bean is already registered
@ConditionalOnClassA class is present on the classpath (used heavily by starters)
@ProfileA given Spring profile is active

10. How Component Scanning Actually Works

@SpringBootApplication bundles @ComponentScan, which by default scans the package of the annotated class and all sub-packages.

java
@SpringBootApplication // = @Configuration + @EnableAutoConfiguration + @ComponentScan
public class OrderServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderServiceApplication.class, args);
    }
}
⚠️

Package structure matters. If your main class lives in com.acme.orders, a @Service in com.acme.shared.utils will not be picked up unless it's a sub-package of com.acme.orders, or you explicitly add @ComponentScan(basePackages = "com.acme.shared"). This is a common cause of "bean not found" errors after a package refactor.


Key takeaways

  • Spring's container inverts control: your classes declare dependencies via constructor parameters; the container supplies and wires them.
  • Always use constructor injection. It enables final fields, makes testing trivial without Spring, and fails fast at startup instead of at runtime.
  • Beans are singletons by default — never store per-request mutable state in a @Service or @Repository field.
  • Injecting a prototype-scoped bean into a singleton silently freezes it to one instance; use ObjectProvider<T> to get a fresh instance per call.
  • @PostConstruct runs before AOP proxies (like @Transactional) are applied — calling a proxied method on this inside it bypasses the proxy.
  • Circular dependencies between services are a design smell, not a Spring quirk to work around — extract a coordinating class or use events instead.
  • @Repository's exception translation is a real, functional benefit, not decoration — use it on every data access class.
  • Component scanning is package-hierarchy-based; beans outside the root package's tree require an explicit @ComponentScan base package.

Interview Questions

  • What is the difference between Inversion of Control and Dependency Injection?
  • Why is constructor injection preferred over field injection in production Spring code?
  • What is the default bean scope in Spring, and why does that matter for thread safety?
  • Walk through the Spring bean lifecycle from instantiation to destruction. Where do @PostConstruct and @PreDestroy fit in?
  • What happens if you inject a prototype-scoped bean into a singleton-scoped bean? How do you fix it?
  • How does Spring resolve ambiguity when multiple beans implement the same interface?
  • What causes a circular dependency exception, and how would you refactor to eliminate it?
  • What is the difference between @Component, @Service, and @Repository? Is there functional behavior behind @Repository?
  • How does @ConditionalOnMissingBean power Spring Boot auto-configuration?
  • Why might a @Service class not be picked up by component scanning after a package refactor?
  • What is the purpose of ObjectProvider<T>, and when would you use it over direct injection?
  • How would you inject all implementations of an interface into a single collaborator, and why is that useful?
  • What is the risk of setting spring.main.lazy-initialization: true globally in production?