Spring Core: IoC, DI, Bean Lifecycle, ApplicationContext
Master Spring Framework core concepts: Inversion of Control, Dependency Injection patterns, Bean lifecycle phases, and ApplicationContext variants.
Master Spring Framework core concepts: Inversion of Control, Dependency Injection patterns, Bean lifecycle phases, and ApplicationContext variants. The guide uses practical examples to explain introduction to spring framework core, inversion of control (ioc) and shows how to apply the ideas in a Spring Boot project. It closes with common pitfalls and production checks so you can apply the pattern with fewer surprises.
Spring Core: IoC, DI, Bean Lifecycle, ApplicationContext
Spring Framework sits at the heart of modern Java enterprise development. Whether you are building a microservices architecture or a monolith application, Spring handles the heavy lifting of object creation, dependency management, and lifecycle orchestration. This guide covers the four foundational concepts that make it all work: Inversion of Control, Dependency Injection, Bean Lifecycle, and ApplicationContext.
By the end of this post, you will understand how Spring manages your objects, why the framework is structured the way it is, and how to avoid the traps that catch most developers learning Spring for the first time.
Introduction to Spring Framework Core
The Spring Framework is built around a single powerful idea: let the framework manage the objects your application needs rather than having your code create and wire them together manually. This shift in responsibility is not just a convenience; it fundamentally changes how you design applications.
When you annotate a class with @Component, mark a constructor with @Autowired, or declare a @Bean method in a configuration class, you are participating in Spring’s dependency injection container. Understanding how this container operates helps you debug issues, optimize startup time, and make architectural decisions with confidence.
The core Spring Framework consists of several modules, but the container itself lives in the spring-core and spring-beans packages. Everything else — web, data, security — builds on top of this foundation.
Before Spring, wiring dependencies manually created brittle code:
// Manual wiring — OrderService owns its dependencies' lifecycles
public class OrderService {
private final PaymentProcessor processor = new PayPalProcessor();
private final InventoryRepository repo = new JdbcInventoryRepository();
public Order processOrder(String orderId) {
if (!repo.exists(orderId)) throw new IllegalArgumentException("Not found");
return new Order(orderId, processor.charge(orderId));
}
}
With Spring, the container handles construction and wiring — your code just declares what it needs:
// Spring injects these at construction time
@Service
public class OrderService {
private final PaymentProcessor processor;
private final InventoryRepository repo;
public OrderService(PaymentProcessor processor, InventoryRepository repo) {
this.processor = processor;
this.repo = repo;
}
public Order processOrder(String orderId) {
if (!repo.exists(orderId)) throw new IllegalArgumentException("Not found");
return new Order(orderId, processor.charge(orderId));
}
}
The second version has no idea how PaymentProcessor or InventoryRepository are created — Spring supplies them, and you can swap implementations by changing configuration, not code.
Inversion of Control (IoC)
Inversion of Control is a design principle where the control over program flow shifts from your code to an external framework. In traditional programming, your main method creates objects, calls methods, and manages the call stack. With IoC, the framework handles instantiation and method invocation, and your code provides callback hooks.
Picture a theme park. In a traditional approach, you plan the route, when to eat, and where to go next. With IoC, you buy a ticket and let the park’s schedule determine your experience — you just show up for each ride.
Hollywood Principle
IoC is frequently described with the phrase “Don’t call us, we’ll call you.” This comes from Hollywood studios who told aspiring actors not to call the studio for work; the studio would contact them when needed. In Spring, this means your code defines behavior through callbacks (methods annotated with @PostConstruct, for example), and Spring invokes them at the appropriate time.
What IoC Gives You
- Decoupling — Your business logic does not depend on concrete implementations of collaborator classes
- Testability — You can inject mock dependencies without involving the full Spring container
- Flexibility — You can swap implementations by changing configuration, not code
- Lifecycle management — The framework handles setup and teardown consistently
The IoC Container
In Spring, the IoC container is the ApplicationContext. It holds your bean definitions, resolves dependencies, and manages the complete lifecycle of every managed object. When the application starts, Spring reads configuration metadata (XML, annotations, or Java configuration) and builds a graph of objects and their dependencies.
// Traditional approach: your code creates dependencies
public class OrderService {
private final PaymentProcessor processor = new PayPalProcessor();
private final InventoryRepository repo = new JdbcInventoryRepository();
}
// IoC approach: dependencies are injected by the container
public class OrderService {
private final PaymentProcessor processor;
private final InventoryRepository repo;
public OrderService(PaymentProcessor processor, InventoryRepository repo) {
this.processor = processor;
this.repo = repo;
}
}
The second version has no knowledge of how PaymentProcessor or InventoryRepository are created. The container supplies them. This is the essence of Inversion of Control.
Dependency Injection (DI)
Dependency Injection is the mechanism through which IoC is implemented in Spring. Rather than your code instantiating its own dependencies, the container provides them. Spring supports three primary injection patterns.
Constructor Injection
Dependencies are supplied through a class constructor. Spring calls the constructor with the required beans.
@Service
public class UserService {
private final UserRepository userRepository;
private final EmailService emailService;
@Autowired
public UserService(UserRepository userRepository, EmailService emailService) {
this.userRepository = userRepository;
this.emailService = emailService;
}
}
Constructor injection is the Spring-recommended approach because it makes dependencies explicit, enables immutability (final fields), and works well with testing frameworks. When all dependencies are constructor-injected, the object is fully constructed by the time you receive it.
Since Spring 4.3, the @Autowired annotation is optional on constructors with a single constructor, but you should include it for clarity.
Setter Injection
Dependencies are provided through setter methods that the container calls after constructing the bean.
@Service
public class NotificationService {
private MessageSender messageSender;
@Autowired
public void setMessageSender(MessageSender messageSender) {
this.messageSender = messageSender;
}
}
Setter injection is useful when a dependency is optional or when you need to reconfigure the bean at runtime. It creates mutable objects, which can make reasoning about state more difficult. Use it when constructor injection is not practical.
Field Injection
Dependencies are injected directly into fields, even private ones, using reflection.
@Service
public class InventoryService {
@Autowired
private WarehouseClient warehouseClient;
@Autowired
private CacheService cacheService;
}
Field injection is concise but has serious drawbacks. It makes dependencies invisible in the class’s public API, prevents immutability, and makes unit testing difficult without a Spring context. The Spring team explicitly recommends avoiding field injection in favor of constructor or setter injection.
// Field injection is difficult to test without Spring
// This will fail with NullPointerException in a unit test
@Test
void testInventoryService() {
InventoryService service = new InventoryService();
service.processReorder(); // warehouseClient is null
}
Which Injection Pattern to Use
| Pattern | Immutable | Testable | Optional Dependencies | Readability |
|---|---|---|---|---|
| Constructor | Yes | Excellent | Explicit via overloaded constructors | High |
| Setter | No | Good | Yes, via required=false |
Medium |
| Field | No | Poor | Via @Autowired(required=false) |
Low |
Prefer constructor injection. Reserve setter injection for genuinely optional dependencies. Never use field injection in production code.
Resolving Multiple Implementations
When you have multiple beans of the same type, you must tell Spring which one to inject.
// Use @Primary to designate a default
@Bean
@Primary
public PaymentProcessor defaultProcessor() {
return new StripeProcessor();
}
// Or use @Qualifier to specify by name
@Service
public class CheckoutService {
private final PaymentProcessor paypalProcessor;
public CheckoutService(@Qualifier("paypalProcessor") PaymentProcessor paypalProcessor) {
this.paypalProcessor = paypalProcessor;
}
}
// Or use @Resource by name
@Resource(name = "braintreeProcessor")
private PaymentProcessor braintreeProcessor;
Bean Lifecycle
Every Spring bean goes through a well-defined lifecycle from instantiation to destruction. Understanding these phases helps you hook into the container at the correct points and avoid the initialization errors that catch developers who skip proper setup hooks.
Lifecycle Phases
graph TB
A[Instantiation] --> B[Populate Properties]
B --> C[setBeanName Callback]
C --> D[setBeanFactory Callback]
D --> E[postProcessBeforeInitialization<br/>BeanPostProcessor]
E --> F[Initialization Callback<br/>@PostConstruct<br/>InitializingBean.afterPropertiesSet<br/>@Bean initMethod]
F --> G[postProcessAfterInitialization<br/>BeanPostProcessor]
G --> H[Bean Ready for Use]
H --> I[Destruction Callback<br/>@PreDestroy<br/>DisposableBean.destroy<br/>@Bean destroyMethod]
I --> J[Bean Destroyed]
Detailed Phase Breakdown
Phase 1: Instantiation
Spring creates a new bean instance using the class’s no-argument constructor. If your class does not have a no-arg constructor and you use constructor injection, Spring uses that constructor instead. This is one reason constructor injection tends to be cleaner.
Phase 2: Populate Properties
Spring injects values into bean properties marked with @Value, @Autowired, or @Resource, including autowiring other beans by type or name.
Phase 3: Initialization Callbacks
During initialization, your bean can run setup code in three ways:
// Option 1: @PostConstruct annotation
@Component
public class DataInitializer {
@PostConstruct
public void init() {
// Runs after property population
cache.warmUp();
}
}
// Option 2: InitializingBean interface
@Component
public class ServiceInitializer implements InitializingBean {
@Override
public void afterPropertiesSet() {
// Runs after all bean properties are set
establishConnections();
}
}
// Option 3: Custom init method in @Bean
@Configuration
public class AppConfig {
@Bean(initMethod = "startup")
public TaskScheduler scheduler() {
return new ThreadPoolTaskScheduler();
}
}
Use @PostConstruct. It comes from jakarta.annotation (formerly javax.annotation), it makes initialization intent explicit in the code, and it works across different DI containers.
Phase 4: Destruction Callbacks
When the application context closes or the bean is removed, cleanup code runs.
@Component
public class ConnectionManager {
private Connection connection;
@PreDestroy
public void cleanup() {
connection.close();
}
}
// Or via DisposableBean
@Component
public class FileHandler implements DisposableBean {
@Override
public void destroy() {
releaseResources();
}
}
BeanPostProcessor
BeanPostProcessor is a special interface that allows you to intercept the bean lifecycle and modify the bean instance before or after initialization.
@Component
public class ValidationPostProcessor implements BeanPostProcessor {
@Override
public Object postProcessBeforeInitialization(Object bean, String beanName) {
if (bean instanceof Validatable) {
((Validatable) bean).validate();
}
return bean;
}
}
Spring itself ships several BeanPostProcessor implementations for features like @Autowired, @PostConstruct, and @PreDestroy annotation processing.
ApplicationContext vs BeanFactory
Spring provides two fundamental container interfaces: BeanFactory and ApplicationContext. Both share a common ancestor, but they serve different use cases.
BeanFactory
BeanFactory is the root interface for Spring’s IoC container. It provides basic DI capabilities with minimal overhead.
BeanFactory factory = new XmlBeanFactory(new ClassPathResource("beans.xml"));
ProductService service = factory.getBean("productService", ProductService.class);
BeanFactory is lazy by default — beans are instantiated only when getBean() is called. This makes it suitable for resource-constrained environments where startup time and memory usage must be minimized.
ApplicationContext
ApplicationContext extends BeanFactory and adds enterprise-grade features. This is the interface you work with in most Spring applications.
// Annotation-based application context
ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);
// ClassPathXmlApplicationContext
ApplicationContext context = new ClassPathXmlApplicationContext("applicationContext.xml");
// FileSystemXmlApplicationContext
ApplicationContext context = new FileSystemXmlApplicationContext("src/main/resources/context.xml");
Comparison Table
| Feature | BeanFactory | ApplicationContext |
|---|---|---|
| Bean instantiation | Lazy | Eager |
| Internationalization | Not supported | Built-in via MessageSource |
| AOP integration | Manual | Automatic proxy creation |
| Web applications | Not designed for | WebApplicationContext support |
| Event publication | Not supported | Built-in event system |
| Resource loading | Manual | Built-in Resource abstraction |
| Enterprise services | None | Transaction management, etc. |
| Startup time | Faster | Slightly slower |
| Memory footprint | Lower | Higher |
When to Use Each
Use BeanFactory only in very specific scenarios: mobile applications, lightweight SDKs, or situations where every kilobyte matters. In virtually all server-side Java applications, ApplicationContext is the correct choice.
In a typical Spring Boot application, you never instantiate either directly — SpringApplication.run() creates a ConfigurableApplicationContext (a hybrid interface) behind the scenes.
Common ApplicationContext Implementations
- AnnotationConfigApplicationContext — Reads configuration from
@Configurationclasses - ClassPathXmlApplicationContext — Loads XML configuration from the classpath
- FileSystemXmlApplicationContext — Loads XML configuration from the filesystem
- WebApplicationContext — Designed for web applications, tied to the ServletContext
When to Use / When NOT to Use
When to Use Spring Core
- Build enterprise Java applications that require loose coupling and testability
- Work with a team where dependency management through configuration prevents integration conflicts
- Need to manage complex object graphs with many dependencies and lifecycles
- Require the rich ecosystem that builds on Spring Core (Spring Data, Spring Security, Spring Cloud)
When NOT to Use Spring Core
- Building a simple command-line utility where the overhead of a DI container is unjustified
- Working in a constrained environment with severe memory limits (some embedded systems)
- Creating a library that should not impose a dependency on Spring
- Building a high-frequency trading system where every microsecond of startup time matters (prefer manual DI or a lighter container)
Common Pitfalls
Circular dependencies occur when two or more beans depend on each other through constructors.
// This causes a circular dependency error
@Service
public class ServiceA {
private final ServiceB serviceB;
public ServiceA(ServiceB serviceB) { this.serviceB = serviceB; }
}
@Service
public class ServiceB {
private final ServiceA serviceA;
public ServiceB(ServiceA serviceA) { this.serviceA = serviceA; }
}
Fix circular dependencies by using setter injection instead of constructor injection for at least one of the circular partners, or extract the shared dependency into a third service.
Bean not found errors happen when the container cannot resolve a dependency. The error message includes the bean name that could not be found and lists the available beans of the expected type.
No qualifying bean of type 'com.example.PaymentProcessor' available
Common causes: the bean is not annotated with a stereotype (@Component, @Service, etc.), the class is not scanned (check @ComponentScan), or the bean is in a configuration class not being processed.
Scoped beans accessed from singleton is a design issue where a bean with a shorter scope (like prototype-scoped) is injected into a singleton. The prototype bean gets created only once, when the singleton is constructed, which defeats its purpose.
@Bean
@Scope("prototype")
public MyHelper myHelper() {
return new MyHelper();
}
@Service
public class SingletonService {
// This helper is created once and reused — not the intended behavior
private final MyHelper myHelper;
public SingletonService(MyHelper myHelper) {
this.myHelper = myHelper;
}
}
Use ObjectFactory<MyHelper> or Provider<MyHelper> to delay prototype bean creation, or use ApplicationContext directly to request a new instance each time.
Conflicting bean definitions occur when multiple @Bean methods or stereotype annotations produce beans with the same name. Spring resolves this based on Primary attributes and BeanDefinitionOverride settings. In Spring Boot 2.1+, bean definition overriding is disabled by default.
Security Notes
Spring Framework Core does not handle authentication or authorization directly — that is Spring Security’s job. But there are still security-relevant considerations at the container level worth knowing.
Avoid storing sensitive data in bean properties. If you must store passwords, API keys, or tokens in Spring configuration, use environment variables or a dedicated secrets manager rather than plain text in configuration files.
// Hardcoded credentials — do not do this
@Value("password123") private String password;
// From environment variable — better
@Value("${DB_PASSWORD}") private String password;
// From Spring Cloud Vault or AWS Secrets Manager — production standard
// @Value("${DB_PASSWORD}") resolves to the secret from Vault/ASM at runtime
Be cautious with @ComponentScan packages. Scanning too broad a package range can inadvertently expose utility classes as Spring beans that should not be managed by the container.
Lazy initialization security implications. Lazy initialization reduces attack surface at startup, but it can cause errors during request processing when the first user action triggers expensive bean creation. Factor this into your threat model before relying on lazy initialization as a security strategy.
Implementation Snippets
Full Spring Configuration Class
@Configuration
@ComponentScan(basePackages = "com.example")
public class ApplicationConfig {
@Bean
public DataSource dataSource(
@Value("${db.url}") String url,
@Value("${db.username}") String username,
@Value("${db.password}") String password) {
return DataSourceBuilder.create()
.url(url)
.username(username)
.password(password)
.build();
}
@Bean
public PlatformTransactionManager transactionManager(DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
}
Multi-Bean Configuration with Qualifier
@Configuration
public class PaymentConfig {
@Bean
@Primary
public PaymentProcessor stripeProcessor() {
return new StripeProcessor();
}
@Bean
public PaymentProcessor paypalProcessor() {
return new PayPalProcessor();
}
@Bean
public PaymentProcessor braintreeProcessor() {
return new BraintreeProcessor();
}
}
Lazy Initialization
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
// Or on a specific bean
@Bean
@Lazy
public ExpensiveService expensiveService() {
return new ExpensiveService();
}
Observability Checklist
Ensuring your Spring beans behave correctly requires visibility into the container and the beans themselves.
- Verify
ApplicationContextstarts successfully without missing bean errors - Confirm
BeanPostProcessorimplementations execute in the expected phase - Monitor initialization order when beans depend on each other
- Check that
@PostConstructand@PreDestroymethods execute without throwing exceptions - Validate that prototype-scoped beans are created on each request, not only once
- Ensure lazy beans do not cause unexpected delays on first access
- Confirm circular dependency detection throws descriptive errors
- Verify property placeholders resolve correctly from environment variables
Trade-off Table
| Design Decision | Benefit | Cost |
|---|---|---|
| Constructor injection | Immutable, testable, explicit dependencies | Verbose with many constructor parameters |
| Setter injection | Flexibility for optional dependencies | Mutable state, harder to reason about |
| Field injection | Concise syntax | Hidden dependencies, untestable, no immutability |
| Eager initialization | Fail-fast at startup | Slower startup, higher memory at launch |
| Lazy initialization | Faster startup | Fail-late, potential latency spikes on first use |
@ComponentScan |
Auto-discovery of components | Implicit bean creation, possible naming conflicts |
@Bean in @Configuration |
Fine-grained control | More verbose, manual instance management |
| Prototype scope | Fresh instance each time | Memory pressure if overused, manual cleanup |
Failure Scenarios
ApplicationContext fails to start. This usually means a configuration problem was caught early. Missing beans, unresolvable property placeholders, and circular dependencies all prevent startup. The error message tells you which bean is the problem and why. Fix the configuration before moving on.
Bean works in development but fails in production. This usually comes from different environment variables, missing profiles, or different component scanning paths between environments. Check your @ActiveProfiles annotation and make sure your test profiles cover all the beans your application needs in production.
Memory grows unbounded with prototype-scoped beans. Prototype-scoped beans are not managed by Spring after creation. If you keep requesting them without releasing references, memory grows. Release references when they are no longer needed, or consider a pooling strategy instead.
Initialization runs twice. This happens when a bean is both scanned via @ComponentScan and registered as a @Bean manually. Spring catches this conflict and throws an error unless bean definition overriding is enabled. Pick one registration mechanism and stick with it.
Singleton bean sees stale state in a prototype bean. If you inject a prototype bean into a singleton via constructor injection, you get one prototype instance created when the singleton was constructed. Use ObjectFactory or Provider to get a fresh instance on each call.
Quick Recap Checklist
- Spring Core implements IoC through its dependency injection container
- Constructor injection is preferred — immutable, testable, explicit
- Setter injection is for optional dependencies only
- Field injection should never appear in production code
- Bean lifecycle: Instantiate -> Populate -> PostProcessBeforeInitialization -> Initialize -> PostProcessAfterInitialization -> Ready -> Destroy
- Use
@PostConstructfor initialization,@PreDestroyfor cleanup -
ApplicationContextextendsBeanFactorywith enterprise features -
BeanFactoryis lazy;ApplicationContextis eager by default - Circular dependencies through constructors break at startup
- Prototype beans injected into singletons get created only once
- Use
@Qualifieror@Primarywhen multiple beans of the same type exist - Spring Boot auto-configuration builds on top of Spring Core
Bean Scopes Deep Dive
Spring provides several bean scopes beyond the default singleton. Each scope controls the lifecycle of the bean instance and when it is created.
Available Scopes
| Scope | Description | Use Case |
|---|---|---|
| singleton | One instance per Spring container (default) | Stateless services, repositories |
| prototype | New instance on each request | Stateful beans, heavy-weight objects |
| request | One instance per HTTP request | Web request-scoped data |
| session | One instance per HTTP session | User-specific data |
| application | One instance per ServletContext | Shared application-level state |
| websocket | One instance per WebSocket session | WebSocket-scoped beans |
Scope Inheritance
Scopes can be combined with custom annotations through ScopeMetadataResolver:
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Scope("prototype")
public @interface HeavyWeight {
}
Choosing the Right Scope
Use singleton for stateless services where shared state is not required. Use prototype when you need a fresh instance per use and are willing to manage cleanup manually. Request and session scopes are web-specific and automatically managed by Spring MVC or Spring WebFlux. Avoid application and websocket scopes unless you have a specific need for extended bean lifecycles.
Interview Questions
Inversion of Control is the broader design principle — the framework controls program flow rather than your code calling framework libraries directly. Dependency Injection is one specific implementation of IoC where the framework injects dependencies into your objects rather than your code constructing them.
Think of IoC as the concept and DI as the mechanism. Other IoC patterns include the Strategy pattern and the Template Method pattern. Spring, Google Guice, and Jakarta CDI all implement IoC through DI.
Spring beans move through distinct phases. The container instantiates the bean using its constructor, then populates properties by injecting autowired dependencies and values. Before initialization runs, BeanPostProcessor.postProcessBeforeInitialization wraps the bean. Initialization callbacks then execute — @PostConstruct methods, InitializingBean.afterPropertiesSet(), or custom initMethod declarations. After initialization, BeanPostProcessor.postProcessAfterInitialization wraps the bean again. The bean is now ready for use. When the container shuts down, destruction callbacks run via @PreDestroy, DisposableBean.destroy(), or destroyMethod, after which the bean is garbage collected.
Constructor injection produces immutable objects because dependencies are stored in final fields. This eliminates an entire category of bugs where a bean field is accessed before it is set. Constructor injection also makes dependencies explicit in the public API — any caller must provide all required dependencies. This makes the class self-documenting and prevents subtle bugs where a developer creates the class without realizing a dependency is null. Testing is simpler because you can instantiate the class directly with mock dependencies. Field injection hides dependencies behind private fields, making both the API and the test strategy unclear.
@Service
public class SingletonService {
// Prototype created once at singleton construction — not what you want
private final MyHelper myHelper;
public SingletonService(MyHelper myHelper) {
this.myHelper = myHelper;
}
}
// Fix: inject ObjectFactory<T> for lazy, fresh instances
@Service
public class SingletonServiceFixed {
private final ObjectFactory<MyHelper> myHelperFactory;
public SingletonServiceFixed(ObjectFactory<MyHelper> myHelperFactory) {
this.myHelperFactory = myHelperFactory;
}
public void process() {
MyHelper helper = myHelperFactory.getObject(); // new instance each call
}
}
Inject ObjectFactory<T> or Provider<T> to delay the lookup. Alternatively, inject ApplicationContext directly and call getBean() — though this couples your code to Spring.
BeanFactory is the minimal IoC container interface — it loads beans lazily and provides basic DI capabilities. ApplicationContext extends BeanFactory and adds enterprise features: internationalization (MessageSource), event publication, automatic AOP proxy creation, and web application support via WebApplicationContext.
In practice, use ApplicationContext for all standard applications. Use BeanFactory only in memory-constrained environments where lazy loading provides meaningful benefit. Spring Boot's SpringApplication.run() creates a ConfigurableApplicationContext, the hybrid interface that supports both programmatic configuration and closing.
@ComponentScan tells Spring where to look for classes annotated with @Component, @Service, @Repository, @Controller, and other stereotypes. By default it scans the package of the annotated configuration class and all sub-packages.
Common pitfalls — and how to fix them:
// ❌ Pitfall: scans entire com.example and all sub-packages
// This picks up utility classes, DTOs, and third-party libs as beans
@ComponentScan(basePackages = "com.example")
// ✅ Fix: scan only the packages you need
@ComponentScan(basePackages = {"com.example.service", "com.example.repository"})
// ✅ Fix: use basePackageClasses to anchor scan to a known type
@ComponentScan(basePackageClasses = {UserService.class, OrderRepository.class})
// ❌ Pitfall: class not in scanned package — silently skipped
package com.xyz.util; // not scanned
@Component
public class MetricsHelper { } // never registered as a bean
// ✅ Fix: verify with ApplicationContext.getBeanDefinitionNames()
String[] beans = context.getBeanDefinitionNames();
Arrays.stream(beans).filter(b -> b.contains("Helper")).forEach(System.out::println);
Use basePackages or basePackageClasses to narrow the scan precisely and avoid inadvertently promoting utility classes to Spring beans.
@Component and its stereotypes mark a class as a candidate for auto-discovery — Spring scans and creates instances of these classes automatically. @Bean lives inside a @Configuration class and defines a bean programmatically, giving you full control over instantiation logic.
Use @Component when the class is yours to annotate and auto-discovery is sufficient. Use @Bean when you need to create beans from third-party classes, apply complex instantiation logic, or define beans that should not be scanned. Mixing both on the same class can cause duplicate bean registration errors.
@Primary designates a default bean when multiple candidates of the same type exist. It is applied at the bean definition level and is used automatically when no specific bean is requested. @Qualifier selects a specific bean by name at the injection point and overrides @Primary.
Use @Primary when one implementation should be the sensible default across the entire application. Use @Qualifier when you need context-specific bean selection that varies by injection point. Combining both is valid — @Qualifier takes precedence at the specific injection site.
Spring uses a multi-step resolution process. First it identifies all beans matching the required type. If exactly one exists, it is used. If multiple exist, Spring looks for a @Primary bean and uses it. If no @Primary exists, it looks for a @Qualifier matching the variable or parameter name. If ambiguity remains, Spring throws NoUniqueBeanDefinitionException.
You can resolve conflicts by adding @Primary to one bean, using @Qualifier at each injection point, or restructuring to eliminate the need for multiple implementations through interfaces.
Component scanning uses a ComponentScanMarker pattern where the scanner looks for classes with stereotype annotations within the configured package hierarchy. Classes can be missed when they are outside the scanned packages, when they lack stereotype annotations entirely, when they are in a package that is excluded via filters, or when the configuration class itself is not picked up by the scanner.
Additionally, if a class is instantiated directly via new rather than through Spring, it will not be a Spring-managed bean regardless of its annotations.
A singleton bean is created once when the container starts (or when first accessed in lazy mode) and lives for the entire application context lifetime. A prototype bean is created fresh each time it is injected or requested via getBean(), and the container does not manage its destruction — you are responsible for cleanup.
This makes prototype beans more expensive per injection but useful for stateful objects where you want a clean slate each time. The cost trade-off means prototypes should be used sparingly and only when truly needed.
@Value injects property values from property sources, environment variables, or SpEL expressions into bean fields. It supports defaults with ${property:default} syntax and SpEL expressions like #{systemProperties['key']}.
Limitations include: it is string-based so no compile-time validation; it mixes configuration with bean logic; it makes testing harder because you must mock property sources; and it creates implicit dependencies that are not visible in constructors. Prefer external configuration classes and constructor injection for required values.
BeanPostProcessor is a container-level hook that runs before and after any bean is initialized — it can wrap or modify any bean in the container. InitializingBean is a callback interface implemented by a specific bean to run custom logic after all properties are set.
Use BeanPostProcessor when you need to intercept and process multiple beans generically, such as for annotation-driven validation or proxy creation. Use InitializingBean (or @PostConstruct) for bean-specific initialization logic.
@PostConstruct is a standard annotation from jakarta.annotation (formerly javax.annotation) that makes initialization intent declarative and method name flexible. InitializingBean is a Spring-specific interface that couples your class to the Spring API and requires implementing a specific method name afterPropertiesSet().
@PostConstruct keeps your code portable across different DI containers and makes it clear what the method does at a glance. It is the recommended approach in modern Spring applications.
BeanCurrentlyInCreationException at startup.
// This causes a circular dependency error
@Service
public class ServiceA {
private final ServiceB serviceB;
public ServiceA(ServiceB serviceB) { this.serviceB = serviceB; }
}
@Service
public class ServiceB {
private final ServiceA serviceA;
public ServiceB(ServiceA serviceA) { this.serviceA = serviceA; }
}
Workarounds: use setter injection instead of constructor injection for at least one of the circular partners; extract the shared dependency into a third bean that both original beans depend on; or redesign the architecture to eliminate the circular relationship. Spring cannot resolve true constructor-level circular dependencies.
ApplicationContext is a read-only interface for the Spring container — you can refresh it but not modify bean definitions. ConfigurableApplicationContext adds methods for programmatic control: refresh(), registerShutdownHook(), adding bean factory post-processors, and modifying the environment.
SpringApplication.run() returns a ConfigurableApplicationContext, which is why you can call close() on it in tests or standalone applications. GenericApplicationContext is a full implementation that supports programmatic bean registration.
Lazy initialization defers bean creation until first access instead of at container startup. This reduces startup time and memory pressure when many beans exist but few are used during typical runs. It is useful in large applications with expensive-to-construct beans that may not be needed in every execution path.
The trade-off is fail-late rather than fail-fast — an error that would have appeared at startup now appears on first access, potentially in a user-facing request. This can delay diagnosis and surface errors in unexpected locations.
Spring Boot auto-configuration uses Spring Core's @Conditional annotations and BeanFactoryPostProcessor / BeanPostProcessor infrastructure to conditionally register beans based on classpath presence, property values, and environment settings. It automatically configures a DataSource if Spring Data JPA is on the classpath, for example.
Auto-configuration is additive to — not a replacement for — explicit Spring Core configuration. When you define a bean explicitly, it overrides any auto-configured bean of the same type. Understanding Spring Core is essential for debugging when auto-configuration behaves unexpectedly.
BeanFactoryPostProcessor operates on the bean definition registry before any beans are instantiated. It can modify bean definition metadata — changing property values, adding or removing bean definitions, or inspecting the factory configuration. This is how Spring Boot's auto-configuration conditionally registers beans based on environment state.
Common implementations include PropertySourcesPlaceholderConfigurer for resolving @Value placeholders and ConfigurationClassPostProcessor for processing @Configuration classes. Unlike BeanPostProcessor, which works on bean instances, BeanFactoryPostProcessor works on bean definitions.
Field injection makes unit testing difficult because you cannot construct the class with its dependencies directly — you need a Spring context or reflection-based injection to populate private fields. This adds test complexity and slows test execution compared to constructor injection where you simply call new MyService(mockDep1, mockDep2).
The inability to instantiate and test a bean without Spring is considered a serious design flaw. Constructor injection makes testing straightforward and is one of the strongest arguments for preferring it over field injection in all production code.
Further Reading
- Spring Boot Configuration Properties — Type-safe configuration using
@ConfigurationPropertiesand relaxed binding - Spring Boot Profiles and Environments — Managing environment-specific beans and properties
- Spring Data JPA — Spring Data repositories built on top of Spring Core
- Spring Security Fundamentals — Security layer built on the Spring IoC container
- Spring Framework Documentation — Official reference for IoC container, bean lifecycle, and dependency injection
- Martin Fowler on Inversion of Control — Foundational article explaining IoC and DI patterns
- Baeldung — Spring Bean Lifecycle — Practical guide to initialization callbacks and destruction
Conclusion
Spring Core provides the foundation that every Spring Boot application builds upon. Inversion of Control shifts object creation and wiring responsibility to the framework, letting you focus on business logic rather than plumbing. Dependency Injection, the primary mechanism for implementing IoC in Spring, comes in three forms: constructor injection for required dependencies, setter injection for optional ones, and field injection which should be avoided in production code due to its testability and immutability drawbacks.
The Bean lifecycle defines exactly how Spring manages your objects from instantiation through destruction. Understanding the callback phases — and knowing when to use @PostConstruct and @PreDestroy rather than the older InitializingBean and DisposableBean interfaces — lets you hook into the container at precisely the right moments. BeanPostProcessor extends this by letting you intercept and modify any bean in the container, which is the basis for much of Spring’s annotation-driven behavior.
ApplicationContext is the full-featured IoC container that most applications use. It extends BeanFactory with internationalization, event publication, automatic AOP proxy creation, and web application support. For virtually all server-side Java applications, ApplicationContext is the correct choice — reserve BeanFactory for resource-constrained environments where lazy loading provides measurable benefit.
Spring’s relaxed binding connects external configuration to typed Java objects without requiring you to match naming conventions exactly. Combined with @ConfigurationProperties, this gives you type-safe, validated, IDE-autocompleteable configuration that adapts cleanly across environments from development laptops to production Kubernetes clusters.
Category
Related Posts
Spring Boot Build Tools: Maven & Gradle
Configure Maven and Gradle for Spring Boot projects—plugins, dependency management, packaging JARs and WARs, and build automation essentials.
Embedded Web Servers in Spring Boot: Tomcat, Jetty, Undertow
Configure embedded servers in Spring Boot: compare Tomcat, Jetty, and Undertow, tune thread pools, enable access logs, and switch implementations.
JUnit 5 & Jupiter: Lifecycle, Nested & Parameterized Tests
Explore JUnit 5 Jupiter features: master test lifecycle annotations, organize tests with @Nested, and parameterize tests with @CsvSource and @MethodSource.