Intermediate Reading time: ~13 min

IoC and Dependency Injection

IoC, DI, constructor injection, field injection, setter injection, Bean lifecycle, Bean scopes

IoC and Dependency Injection

IoC and Dependency Injection form the foundation of the Spring framework: the container manages object creation and wiring so application code can focus on business logic.


1. Definition

  • What is it? — Inversion of Control (IoC) is a design principle where the responsibility of creating and wiring objects is moved from application code to an external container. Dependency Injection (DI) is its most common implementation.
  • Why does it exist? — In the traditional approach, classes create their own dependencies using the new operator, leading to tight coupling. DI enables loosely coupled, testable, and swappable components.
  • Where does it fit? — The Spring IoC container is accessed via the ApplicationContext interface. It is the central element of the Spring ecosystem upon which all other modules (Boot, Web, Data, Security) are built.

2. Core Concepts

IoC vs DI

IoC is a principle, DI is a pattern. IoC says: "don't control the object graph yourself." DI specifies how: "receive your dependencies from the outside." The Spring IoC container implements both — it reads bean definitions, instantiates, and injects.

The Service Locator is also an IoC implementation, but Spring favors DI because it is more transparent and the code doesn't depend on the container API.

Injection types in detail

Type Mechanism When to use?
Constructor injection Constructor parameters ✅ Required dependencies (recommended default)
Setter injection Setter methods + @Autowired Optional or mutable dependencies
Field injection @Autowired field ⚠ Avoid — hidden dependencies

Constructor injection — why it's the default

@Service
public class OrderService {
    private final PaymentGateway paymentGateway;
    private final InventoryService inventoryService;

    // Spring auto-injects when there's a single constructor
    public OrderService(PaymentGateway paymentGateway, InventoryService inventoryService) {
        this.paymentGateway = paymentGateway;
        this.inventoryService = inventoryService;
    }
}

Benefits:

  • Dependencies can be final → immutable design
  • Cannot "forget" a dependency → compile-time safety
  • Easy to mock through constructor in tests
  • Circular dependencies detected immediately (fail-fast)
  • No @Autowired needed if there's a single constructor (Spring 4.3+)

@Autowired behavior in detail

@Autowired works in three places: on constructors, setters, and fields.

// With multiple constructors, you must mark which one Spring should use
@Service
public class NotificationService {
    private final EmailSender emailSender;
    private final SmsSender smsSender;

    @Autowired  // Required because there are two constructors
    public NotificationService(EmailSender emailSender, SmsSender smsSender) {
        this.emailSender = emailSender;
        this.smsSender = smsSender;
    }

    public NotificationService(EmailSender emailSender) {
        this(emailSender, null);
    }
}

Key behaviors:

  • @Autowired(required = false) — doesn't throw an exception if no matching bean exists
  • Resolves generic types: @Autowired List<EventListener> → collects all EventListener beans
  • @Autowired Map<String, PaymentGateway> → key is the bean name, value is the bean

Resolving multiple implementations: @Primary and @Qualifier

When an interface has multiple implementations, Spring must decide which to inject:

public interface PaymentGateway {
    String charge(double amount);
}

@Service
@Primary  // Default when no explicit @Qualifier is specified
public class StripeGateway implements PaymentGateway {
    public String charge(double amount) { return "Stripe: " + amount; }
}

@Service("paypal")
public class PaypalGateway implements PaymentGateway {
    public String charge(double amount) { return "PayPal: " + amount; }
}

@Service
public class OrderService {
    private final PaymentGateway defaultGateway;   // → StripeGateway (@Primary)
    private final PaymentGateway paypalGateway;

    public OrderService(
            PaymentGateway defaultGateway,
            @Qualifier("paypal") PaymentGateway paypalGateway) {
        this.defaultGateway = defaultGateway;
        this.paypalGateway = paypalGateway;
    }
}

Resolution order: @Qualifier name → @Primary → bean name match → exception.

Bean lifecycle

A Spring bean's lifecycle:

  1. Bean definition loading — @Component scan or @Bean method
  2. Instantiation — constructor invocation
  3. Dependency injection — setter/field injection (constructor already done)
  4. BeanPostProcessor.postProcessBeforeInitialization()
  5. Initialization — @PostConstruct or InitializingBean.afterPropertiesSet()
  6. BeanPostProcessor.postProcessAfterInitialization() — proxies created here (AOP, @Transactional)
  7. In use — the bean is available in the application
  8. Destruction — @PreDestroy or DisposableBean.destroy() (singleton only)

Bean scopes

Scope Lifetime Typical use
singleton One instance per ApplicationContext ✅ Default, stateless services
prototype New instance per request Stateful or short-lived objects
request One HTTP request Web-specific, e.g., request context
session One HTTP session Web-specific, e.g., user session data
application One ServletContext Rarely used, global web scope

3. Practical Usage

When to use

  • Constructor injection: all required dependencies — this is the Spring team's recommendation
  • @Autowired setter: optional dependency that the class can function without
  • ObjectProvider<T>: lazy or optional resolution, conditional logic
  • @PostConstruct: initialization logic needed after dependencies are injected
  • @Qualifier: when multiple implementations exist and explicit selection is needed
  • @Primary: when there's a "default" implementation used in most places

When NOT to use

  • Field injection: creates hidden dependencies, hinders testing, doesn't support immutable design
  • @Lazy circular dependency workaround: fix the design, don't treat the symptom
  • Prototype bean injected into singleton without proxy: the singleton will always see the same prototype instance
  • Many dependencies in one constructor (>5): signals the class does too much — refactoring needed

ObjectProvider and Provider

@Service
public class ReportService {
    private final ObjectProvider<ExpensiveClient> clientProvider;

    public ReportService(ObjectProvider<ExpensiveClient> clientProvider) {
        this.clientProvider = clientProvider;
    }

    public void generateReport() {
        // Lazy: only creates the instance when actually needed
        ExpensiveClient client = clientProvider.getIfAvailable();
        if (client != null) {
            client.fetchData();
        }
    }
}

ObjectProvider is Spring-specific, while Provider<T> (JSR-330) is standard. Both provide lazy resolution, but ObjectProvider offers a richer API (getIfAvailable, getIfUnique, stream()).


4. Code Examples

Basic — Constructor injection and lifecycle

package com.example.billing;

import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.springframework.stereotype.Service;

public interface PaymentGateway {
    String charge(double amount);
}

@Service
public class StripePaymentGateway implements PaymentGateway {
    @Override
    public String charge(double amount) {
        return "Stripe charged: " + amount;
    }
}

@Service
public class BillingService {
    private final PaymentGateway paymentGateway;

    // Single constructor → @Autowired not required
    public BillingService(PaymentGateway paymentGateway) {
        this.paymentGateway = paymentGateway;
    }

    @PostConstruct
    void init() {
        System.out.println("BillingService ready");
    }

    @PreDestroy
    void shutdown() {
        System.out.println("BillingService shutting down");
    }

    public String processBilling(double amount) {
        return paymentGateway.charge(amount);
    }
}

Advanced — Scopes and ObjectProvider

package com.example.audit;

import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Service;

@Configuration
public class AuditConfig {
    @Bean
    @Scope("prototype")
    public AuditContext auditContext() {
        return new AuditContext();
    }
}

public class AuditContext {
    private final long createdAt = System.nanoTime();
    public long getCreatedAt() { return createdAt; }
}

@Service
public class AuditService {
    private final ObjectProvider<AuditContext> auditContextProvider;

    public AuditService(ObjectProvider<AuditContext> auditContextProvider) {
        this.auditContextProvider = auditContextProvider;
    }

    public void audit(String action) {
        // Gets a fresh prototype instance on every call
        AuditContext ctx = auditContextProvider.getObject();
        System.out.println("Audit [" + ctx.getCreatedAt() + "]: " + action);
    }
}

Multiple implementations — @Qualifier with custom annotation

@Qualifier
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.TYPE})
public @interface Premium {}

@Service
@Premium
public class PremiumNotificationService implements NotificationService {
    // premium channel: SMS + email + push
}

@Service
public class BasicNotificationService implements NotificationService {
    // basic channel: email only
}

@Service
public class AccountService {
    public AccountService(@Premium NotificationService premiumNotifier,
                          NotificationService basicNotifier) {
        // @Premium explicitly marks which implementation is requested
    }
}

Common mistake

// ❌ BAD — field injection, hidden dependencies
@Service
public class BadService {
    @Autowired
    private UserRepository userRepository;
    @Autowired
    private EmailService emailService;
    // Requires reflection in tests for mocking
}

// ✅ GOOD — constructor injection, explicit dependencies
@Service
public class GoodService {
    private final UserRepository userRepository;
    private final EmailService emailService;

    public GoodService(UserRepository userRepository, EmailService emailService) {
        this.userRepository = userRepository;
        this.emailService = emailService;
    }
}

5. Trade-offs

Aspect Details
⚡ Performance Singleton scope is fast since it's created once. Prototype adds overhead on each retrieval. Web scopes are slowest due to scope proxy.
đŸ’Ÿ Memory Singleton keeps a single instance in memory. Prototype instances are managed by GC, but the container doesn't call @PreDestroy.
🔧 Maintainability Constructor injection is explicit and transparent. Field injection is brief but hidden and harder to test.
🔄 Flexibility ObjectProvider and @Lazy provide deferred resolution. Scope proxies transparently handle different lifecycles.
đŸ§Ș Testability Constructor injection → new Service(mockA, mockB). Field injection → reflection or @InjectMocks.

6. Common Mistakes

  1. Using field injection — Short but creates hidden dependencies. The Spring team itself recommends constructor injection. Requires reflection for mocking in tests.

  2. Hiding circular dependencies with @Lazy — If A depends on B and B depends on A, @Lazy only postpones the problem. The solution: introduce an intermediary service or use the events pattern.

  3. Expecting prototype behavior from prototype bean in singleton — If you inject a prototype bean into a singleton service, the singleton will always use the same instance. Solution: ObjectProvider or @Lookup.

  4. @PreDestroy on prototype beans — The container doesn't manage the end of a prototype bean's lifecycle. The @PreDestroy callback won't be called automatically.

  5. Too many dependencies in one constructor — If there are 6-7+ parameters, the class likely violates the Single Responsibility Principle.

  6. Creating Spring-managed objects with new operator — Instances created this way are not Spring beans, receive no injection, and don't go through the lifecycle.

  7. Misunderstanding @Autowired on collections — @Autowired List<Validator> doesn't look for a List<Validator> bean; it collects ALL beans of type Validator. This can be surprising if unintentional.

  8. Forgetting @Qualifier — If two beans of the same type exist without @Primary or @Qualifier, a NoUniqueBeanDefinitionException occurs at startup.


7. Deep Dive

BeanPostProcessor and the proxy mechanism

The Spring IoC container doesn't just create beans — it runs them through a pipeline. The BeanPostProcessor interface provides two hooks: postProcessBeforeInitialization and postProcessAfterInitialization. AOP proxies, @Transactional wrappers, and security interceptors are created in the latter.

This means the bean that other services receive may not be the original object — it could be a CGLIB proxy or JDK dynamic proxy. Consequence: CGLIB proxy doesn't work on final classes, and @Transactional has no effect on private methods.

Singleton vs Thread-safety

Singleton scope does not automatically mean thread-safety. A singleton bean can be used by multiple threads simultaneously. If you maintain mutable state (e.g., a List field), it leads to race conditions. Solutions: stateless design, ThreadLocal, or synchronized blocks.

// ❌ Not thread-safe singleton
@Service
public class CounterService {
    private int count = 0;  // mutable state!
    public void increment() { count++; }  // race condition
}

// ✅ Thread-safe solutions
@Service
public class SafeCounterService {
    private final AtomicInteger count = new AtomicInteger(0);
    public void increment() { count.incrementAndGet(); }
}

Scope proxy internals

When you inject a request-scoped bean into a singleton, Spring creates a CGLIB proxy. On each method invocation, the proxy looks up the bean instance associated with the current HTTP request. Because of this, getClass() returns the proxy class, and equals()/hashCode() behavior may change.

@Bean
@Scope(value = WebApplicationContext.SCOPE_REQUEST,
       proxyMode = ScopedProxyMode.TARGET_CLASS)
public RequestContext requestContext() {
    return new RequestContext();
}

Circular dependency resolution internals

In Spring 5/6, circular dependency resolution is disabled by default for constructor injection. For setter injection, Spring uses "early references" — the bean is partially initialized (constructor runs, but setters haven't). This is the "three-level cache" mechanism:

  1. singletonObjects — fully initialized beans
  2. earlySingletonObjects — partial beans (pulled early from factory)
  3. singletonFactories — ObjectFactory instances that return beans mid-initialization

This is a fragile mechanism — if the early reference is proxied (e.g., @Transactional), problems may occur. Therefore, best practice: don't rely on circular dependency resolution.


8. Interview Questions

Q: What's the difference between IoC and DI? A: IoC is a design principle — it inverts the control of object creation (the container manages, not the application code). DI is the concrete implementation of this principle — the object receives its dependencies externally via constructor, setter, or field injection.

Q: Why is constructor injection better than field injection? A: Constructor injection is explicit (the compiler enforces it), supports immutable design (final fields), detects circular dependencies immediately, and is easily mockable in tests via new Service(mockDep) without reflection.

Q: What happens when you inject a prototype bean into a singleton? A: The singleton is initialized only once and will always use the same prototype instance. If a fresh prototype is needed on every call, use ObjectProvider<T>, Provider<T> (JSR-330), or a @Lookup method.

Q: How does Spring handle circular dependencies? A: With constructor injection, the context fails to start (fail-fast, good signal). With setter/field injection, Spring uses early references (partial instantiation of singleton beans), but this is fragile and disabled by default in Spring 6. The correct solution: refactoring, events, or introducing an intermediary dependency.

Q: What's the difference between @Bean and @Component? A: @Component (and @Service, @Repository) is a class-level annotation — component scanning registers it automatically. @Bean is a method-level annotation in a @Configuration class — it gives manual control over bean creation and can be used for external library classes.

Q: What's the difference between @Primary and @Qualifier? A: @Primary marks the default candidate when multiple beans of the same type exist. @Qualifier specifies at the injection point which bean is requested by name. @Qualifier is stronger — it overrides @Primary.

Q: What does @Autowired List<T> do? A: It collects ALL beans of type T from the ApplicationContext into a list. The Map<String, T> variant also provides the bean name as the key. Useful for strategy pattern and plugin architecture.


9. Glossary

Term Meaning
IoC Inversion of Control — the container manages object creation
DI Dependency Injection — passing dependencies from outside
Bean An object managed by the Spring container
Scope A rule determining the bean's lifetime
Lifecycle hook Callback at specific points in a bean's lifecycle (@PostConstruct, @PreDestroy)
Circular dependency A mutual dependency between two or more beans
ObjectProvider Spring interface for lazy/optional dependency resolution
Scope proxy CGLIB proxy enabling injection between different scopes
@Primary Default bean marker when multiple beans of the same type exist
@Qualifier Explicit bean selector by name or custom annotation
BeanPostProcessor Pipeline hook for customizing beans (proxy creation, etc.)
Early reference Partially initialized bean for circular dependency resolution

10. Cheatsheet

INJECTION TYPES:
  Constructor  → ✅ default, final fields, fail-fast circular dep
  Setter       → optional dependencies, @Autowired required
  Field        → ❌ anti-pattern, hidden dependencies

@AUTOWIRED BEHAVIOR:
  1 constructor          → automatic, no @Autowired needed
  2+ constructors        → @Autowired marks which one to use
  @Autowired List<T>     → collects all beans of type T
  @Autowired Map<S,T>    → bean name → bean instance

RESOLVING MULTIPLE IMPLEMENTATIONS:
  @Primary               → default candidate
  @Qualifier("name")     → explicit bean name
  Custom @Qualifier      → resolution by custom annotation
  Resolution order       → @Qualifier > @Primary > bean name

SCOPES:
  singleton   one instance (default)
  prototype   new per request
  request     per HTTP request
  session     per HTTP session

LIFECYCLE:
  Constructor → setter DI → @PostConstruct → in use → @PreDestroy
  BeanPostProcessor: proxies created here (AOP, @Transactional)

TIPS:
  5+ dependencies → SRP violation, refactor
  Prototype into singleton → use ObjectProvider<T>
  Circular dependency → design smell, don't @Lazy workaround
  new ClassName() → NOT a Spring bean, no injection

🎼 Games

10 questions