Spring Core: IoC & DI

The container that builds your object graph โ€” bean lifecycle, scopes, and the problems they create

← Back to Index

What is Spring Framework?

Spring is a Java application framework built around one core idea: your business classes should not be responsible for finding, constructing, or wiring together the other objects they depend on. Spring does that job โ€” through a container that creates, configures, and connects your objects (called beans) based on configuration you provide via annotations or Java code.

Spring is not one library โ€” it's a modular ecosystem. Spring Core (this page) is the foundation everything else builds on: the IoC container and dependency injection mechanism. On top of that sit Spring MVC (web layer), Spring Data (persistence), Spring Security (auth), and dozens more โ€” each usable independently, all sharing the same underlying container and configuration model.

/*
 *  The Spring ecosystem, roughly:
 *
 *  Spring Core/Beans/Context  โ† THIS PAGE โ€” IoC container, DI, bean lifecycle
 *  Spring AOP                 โ† cross-cutting concerns (logging, transactions, security)
 *  Spring MVC                 โ† web layer: controllers, request mapping
 *  Spring Data                โ† repositories, JPA/MongoDB/Redis integration
 *  Spring Security             โ† authentication, authorisation
 *  Spring Boot                โ† auto-configuration + embedded server + starters
 *                                (NOT a different framework โ€” built ON TOP of all the above)
 */
Spring vs Spring Boot โ€” a common point of confusion

Spring Framework is the underlying technology: IoC container, DI, MVC, AOP, transaction management. It existed since 2003 and historically required substantial manual XML or Java configuration โ€” you had to wire together a DispatcherServlet, a datasource, a transaction manager, by hand. Spring Boot (since 2014) doesn't replace any of that โ€” it auto-configures it. spring-boot-starter-web on the classpath is enough for Boot to infer "this is a web app" and wire an embedded Tomcat, a DispatcherServlet, and sensible defaults automatically. Spring Boot is covered in its own dedicated page โ€” everything on this page (IoC, DI, beans, scopes) is the Spring Core layer Boot is built on top of, and applies identically whether you use Boot or not.

What is Inversion of Control, and Why Does It Exist?

Inversion of Control (IoC) means an object no longer creates or looks up its own dependencies โ€” a container creates them and hands them in. Dependency Injection (DI) is the specific technique Spring uses to achieve that: dependencies arrive via constructor, setter, or field, rather than the object instantiating them with new.

The problem this solves: when a class creates its own dependencies, it is permanently coupled to one concrete implementation. Swapping a real payment gateway for a test double means editing the class itself. Testing in isolation becomes impossible without a real database connection, a real HTTP client, a real everything. IoC inverts that: the class declares what it needs via its constructor signature, and remains ignorant of how that dependency gets built or which concrete implementation it actually receives.

// โŒ Without IoC โ€” UserService is permanently coupled to UserRepositoryImpl
public class UserService {
    private final UserRepository userRepository;
    public UserService() {
        this.userRepository = new UserRepositoryImpl();  // hardcoded, untestable in isolation
    }
}

// โœ… With IoC โ€” Spring builds and hands in whatever implements UserRepository
@Service
public class UserService {
    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {  // no @Autowired needed โ€” single constructor (Spring 4.3+)
        this.userRepository = userRepository;
    }
}
// In tests: new UserService(mockRepository) โ€” no Spring container required at all
@Autowired on a single constructor is unnecessary

Since Spring 4.3, if a class has exactly one constructor, Spring uses it for injection automatically โ€” @Autowired is redundant. You only need @Autowired when a class declares multiple constructors and you must tell Spring which one to use. Writing it on every constructor regardless is outdated style.

Injection Types โ€” and Why Constructor Wins

// โœ… Constructor injection โ€” the only one to use for required dependencies
@Service
public class OrderService {
    private final OrderRepository orderRepository;
    private final PaymentService paymentService;

    public OrderService(OrderRepository orderRepository, PaymentService paymentService) {
        this.orderRepository = orderRepository;
        this.paymentService = paymentService;
    }
}

// โš ๏ธ Setter injection โ€” only for genuinely OPTIONAL dependencies
@Service
public class NotificationService {
    private MetricsCollector metrics;  // app works fine if this is never set

    @Autowired(required = false)
    public void setMetrics(MetricsCollector metrics) { this.metrics = metrics; }
}

// โŒ Field injection โ€” avoid in application code
@Service
public class BadExample {
    @Autowired
    private EmailService emailService;  // can't be final, can't construct without reflection,
                                            // dependency is invisible at the constructor
}
Why constructor injection is the only correct default
  • Immutability โ€” fields can be final, the object can never be in a half-constructed state
  • Fail-fast โ€” missing dependencies are a compile error, not a runtime NPE discovered in production
  • True unit testing โ€” new OrderService(mockRepo, mockPayment) works with zero Spring context, zero reflection, zero @MockBean
  • Surfaces design smells โ€” a constructor with 8 parameters is an honest, visible signal the class is doing too much. Field injection hides that signal indefinitely.

Declaring Beans

Stereotype annotations โ€” component scanning

@Component   // generic โ€” anything not fitting a more specific stereotype
@Service     // business logic layer โ€” semantically the same as @Component, signals intent
@Repository  // data access layer โ€” ALSO enables automatic exception translation
             // (JDBC/JPA exceptions become Spring's DataAccessException hierarchy)
@Controller  // MVC presentation layer, returns view names
@RestController  // = @Controller + @ResponseBody โ€” returns response bodies directly (REST APIs)

Explicit @Bean declarations โ€” for third-party classes you can't annotate

@Configuration
public class AppConfig {

    @Bean
    public RestTemplate restTemplate(RestTemplateBuilder builder) {
        return builder.setConnectTimeout(Duration.ofSeconds(5)).build();
    }

    // Use @Bean specifically for classes you don't own โ€” can't add @Component to a
    // third-party library's class. For your own classes, prefer stereotype annotations.
}

Resolving multiple implementations: @Primary and @Qualifier

public interface PaymentGateway { void charge(BigDecimal amount); }

@Service @Primary  // default winner when multiple PaymentGateway beans exist and no qualifier is given
class StripeGateway implements PaymentGateway { ... }

@Service
class PaypalGateway implements PaymentGateway { ... }

@Service
public class CheckoutService {
    private final PaymentGateway gateway;

    public CheckoutService(@Qualifier("paypalGateway") PaymentGateway gateway) {  // overrides @Primary explicitly
        this.gateway = gateway;
    }
}

Bean Lifecycle

A bean isn't just constructed once and forgotten โ€” Spring runs it through distinct phases, and you can hook into the boundaries.

/*
 *  1. Constructor called (dependencies already resolved at this point)
 *  2. Dependencies injected (setter/field injection happens here, if used)
 *  3. @PostConstruct methods invoked โ€” bean is fully wired, ready for setup logic
 *  4. Bean is in active use
 *  5. @PreDestroy methods invoked โ€” container shutdown, last chance to clean up
 */

@Service
public class CacheWarmer {
    private final ProductRepository repo;
    private Map<Long, Product> cache;

    public CacheWarmer(ProductRepository repo) { this.repo = repo; }

    @PostConstruct
    void warmUp() {
        cache = repo.findAll().stream().collect(Collectors.toMap(Product::id, p -> p));
        // runs once, after construction AND dependency injection โ€” repo is guaranteed non-null here
    }

    @PreDestroy
    void shutdown() {
        cache.clear();  // release resources before the container tears down
    }
}

// Alternative for classes you can't annotate (third-party, via @Bean):
@Bean(initMethod = "start", destroyMethod = "stop")
public SomeThirdPartyClient client() { return new SomeThirdPartyClient(); }

Bean Scopes

Scope Lifetime Use case
singleton (default) One instance per container, shared everywhere Stateless services โ€” the overwhelming majority of beans
prototype New instance every time it's requested Stateful, non-thread-safe objects
request One instance per HTTP request Per-request context data (web apps only)
session One instance per HTTP session User session state (web apps only)

The scoped bean injection problem โ€” and how to actually fix it

A singleton is created once. If you inject a prototype or request-scoped bean directly into a singleton's constructor, you get exactly one instance of it, frozen forever โ€” defeating the entire purpose of the narrower scope.

// โŒ BROKEN: TaskProcessor is prototype, but injected once into a singleton
// โ€” every call gets the SAME instance, not a fresh one
@Service  // singleton by default
public class JobRunner {
    private final TaskProcessor processor;  // resolved ONCE, at JobRunner's own construction
    public JobRunner(TaskProcessor processor) { this.processor = processor; }
}

// โœ… FIX 1: ObjectProvider โ€” request a fresh instance on every call
@Service
public class JobRunner {
    private final ObjectProvider<TaskProcessor> processorProvider;
    public JobRunner(ObjectProvider<TaskProcessor> processorProvider) {
        this.processorProvider = processorProvider;
    }
    public void runJob() {
        TaskProcessor processor = processorProvider.getObject();  // fresh prototype instance, every call
        processor.execute();
    }
}

// โœ… FIX 2: scoped proxy โ€” Spring injects a proxy that resolves the real
// instance from the current scope on every method call, transparently
@Component @Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class TaskProcessor { ... }
// Now direct constructor injection of TaskProcessor works correctly โ€” the
// injected reference is a proxy, not the real bean, and delegates per-call.

ApplicationContext vs BeanFactory

BeanFactory is the root IoC container interface โ€” lazy, minimal, rarely used directly today. ApplicationContext extends it with everything a real application needs: eager singleton instantiation by default, internationalisation support, event publishing, and AOP integration. In practice, you always work with ApplicationContext; BeanFactory is mostly of historical/interview interest.

// Spring Boot โ€” ApplicationContext created and managed automatically
@SpringBootApplication  // = @Configuration + @EnableAutoConfiguration + @ComponentScan
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

// Without Spring Boot โ€” rare today, mostly for understanding what Boot does for you
ApplicationContext ctx = new AnnotationConfigApplicationContext(AppConfig.class);
UserService userService = ctx.getBean(UserService.class);

Publishing and listening to events

// Decouple "something happened" from "who reacts to it" โ€” beans don't
// need direct references to each other to communicate
public record OrderPlacedEvent(Long orderId, BigDecimal total) {}

@Service
public class OrderService {
    private final ApplicationEventPublisher publisher;
    public OrderService(ApplicationEventPublisher publisher) { this.publisher = publisher; }

    public void placeOrder(Order order) {
        // ... save order ...
        publisher.publishEvent(new OrderPlacedEvent(order.id(), order.total()));
    }
}

@Component
public class OrderNotifier {
    @EventListener
    void onOrderPlaced(OrderPlacedEvent event) {
        // runs synchronously by default; add @Async to run on a separate thread
    }
}

Circular Dependencies

@Service
public class A { public A(B b) { } }

@Service
public class B { public B(A a) { } }  // A needs B, B needs A โ€” circular
Spring Boot 2.6+ rejects this by default โ€” a real migration trap

Before Spring Boot 2.6, circular references between singletons were resolved silently using early-reference proxies. Since 2.6, this is disabled by default โ€” a circular dependency now throws BeanCurrentlyInCreationException at startup instead of being silently patched over. This breaks plenty of older codebases on upgrade, and the fix is almost never to re-enable the old behaviour (spring.main.allow-circular-references=true as a stopgap) โ€” it's to actually redesign the dependency: extract the shared logic into a third class both depend on, or use @Lazy on one side to defer resolution. A circular dependency is a design smell the framework is now correctly forcing you to confront rather than quietly papering over.

Conditional Beans and Profiles

// Only register this bean when the active profile matches
@Service @Profile("prod")
class SesEmailService implements EmailService { ... }

@Service @Profile("!prod")  // anything except prod โ€” dev, test, local
class ConsoleEmailService implements EmailService { ... }

// Register a bean only if a property has a specific value (or is absent + default given)
@Service
@ConditionalOnProperty(name = "feature.new-checkout", havingValue = "true")
class NewCheckoutFlow { ... }

Testing: Plain Unit Tests vs Spring Context Tests

// โœ… FASTEST: plain unit test โ€” no Spring involved at all, just Java + Mockito
// This is exactly what constructor injection enables and field injection prevents
@Test
void createUser_sendsWelcomeEmail() {
    UserRepository mockRepo  = mock(UserRepository.class);
    EmailService    mockEmail = mock(EmailService.class);
    UserService service = new UserService(mockRepo, mockEmail);  // no Spring container, milliseconds to run

    service.createUser(new User("a@b.com"));
    verify(mockEmail).sendWelcomeEmail("a@b.com");
}

// โš ๏ธ SLOWER: @SpringBootTest loads the full ApplicationContext โ€” only for
// genuine integration tests that need real wiring, real config, real beans
@SpringBootTest
class UserServiceIntegrationTest {
    @Autowired private UserService userService;  // field injection is fine HERE โ€” test code, not production code

    @MockitoBean  // replaces the real bean in the context with a mock (Spring Boot 3.4+ name; @MockBean on older versions)
    private EmailService emailService;
}
Default to plain unit tests

A @SpringBootTest loads the entire context โ€” every bean, every auto-configuration, every property source. On a real application this can take seconds per test class, multiplied across hundreds of classes in CI. Reach for it only when you genuinely need Spring's wiring under test (a controller's request mapping, a repository against a real database via Testcontainers). For pure business logic, constructor injection means you never need Spring running at all.

Interview Questions

๐ŸŽ“ Junior level

Q: What is the difference between IoC and DI?
IoC is the principle โ€” control over object creation is inverted from the application to a container. DI is the technique Spring uses to implement it โ€” dependencies are injected via constructor, setter, or field rather than created internally with new.

Q: What is a Spring bean?
Any object whose lifecycle (creation, configuration, dependency wiring, destruction) is managed by the Spring IoC container, rather than by application code calling new directly.

Q: Why is constructor injection preferred over field injection?
Constructor injection allows final fields (true immutability), makes missing dependencies a compile-time error instead of a runtime NPE, and allows the class to be instantiated and unit-tested with plain new, with no Spring container required at all.

๐Ÿ”ฅ Senior level

Q: What is the scoped bean injection problem, and how do you fix it correctly?
Injecting a narrower-scoped bean (prototype, request) directly into a singleton resolves it exactly once, at the singleton's own construction โ€” every subsequent use sees the same frozen instance, defeating the scope entirely. Fix with ObjectProvider<T> (explicitly request a fresh instance per call) or a scoped proxy (proxyMode = ScopedProxyMode.TARGET_CLASS, which makes the injected reference a proxy that re-resolves the real bean from the active scope on every method invocation, transparently).

Q: Why did Spring Boot 2.6 change how it handles circular dependencies, and what's the right fix?
Before 2.6, circular references between singletons were silently resolved using early-reference proxies during bean creation. Since 2.6, this is disabled by default โ€” Spring now throws BeanCurrentlyInCreationException at startup, treating a circular dependency as the design problem it actually is rather than quietly working around it. The correct fix is virtually never re-enabling the legacy flag โ€” it's extracting the shared responsibility into a third class both sides depend on, breaking the cycle structurally.

Q: Why does @Repository do more than just mark a bean for component scanning?
@Repository additionally enables Spring's PersistenceExceptionTranslationPostProcessor, which wraps the bean's methods to translate platform-specific exceptions (JDBC SQLException, JPA's PersistenceException) into Spring's unified, unchecked DataAccessException hierarchy. This means calling code can catch a single consistent exception type regardless of which persistence technology sits underneath โ€” a detail @Component alone would not provide.

Q: When would you deliberately use @SpringBootTest over a plain unit test?
Only when the test's purpose is verifying Spring's own wiring behaviour โ€” that a controller correctly maps a request, that auto-configuration produces the expected beans, that a repository works against a real database via Testcontainers. For testing business logic itself, a plain unit test with manually constructed mocks is faster, simpler, and doesn't conflate "is my logic correct" with "is my Spring configuration correct" โ€” two genuinely different concerns that a full context load tests simultaneously and slowly.