Spring Boot Dependency Injection: From Magic to Mastery
Your Spring Boot application works perfectly until it doesn’t. One day you’re staring at a circular dependency error that makes no sense—Service A depends on Service B, which depends on Service C, which somehow depends on Service A, except you never wrote that dependency explicitly. Or you’re debugging a NoUniqueBeanDefinitionException in production because someone added a second implementation of an interface without realizing the implications. Worse still: a subtle bug where the wrong bean got injected silently, and you only discover it three weeks later when a customer reports corrupted data.
These aren’t edge cases. They’re the inevitable consequence of treating Spring’s dependency injection as magic rather than machinery.
Most Spring Boot tutorials stop at @Autowired and @Component. They show you the what—annotate this class, inject that dependency—but never the how. What actually happens when Spring scans your packages? How does it decide which bean to inject when multiple candidates exist? Why does the order of bean initialization sometimes matter, and what determines that order in the first place?
The engineers who debug these issues in minutes instead of hours understand Spring’s IoC container at a mechanical level. They know that @Autowired is just syntax sugar over a sophisticated resolution algorithm. They understand why constructor injection behaves differently than field injection during circular dependency detection. They can predict exactly when their @PostConstruct method will fire relative to other beans in the context.
This understanding starts with the ApplicationContext—the engine that orchestrates every bean in your application from birth to death.
The ApplicationContext: Your Application’s Central Nervous System
When your Spring Boot application starts, the ApplicationContext orchestrates a complex initialization dance that transforms your annotated classes into a fully wired, production-ready system. Understanding this process separates engineers who debug by intuition from those who debug by knowledge.

The Startup Sequence
The ApplicationContext serves as the central registry and factory for all beans in your application. During startup, it executes a deterministic sequence: it reads configuration metadata, instantiates bean definitions, resolves dependencies, and finally exposes a fully initialized container ready to serve requests.
This process begins when SpringApplication.run() triggers the creation of an appropriate ApplicationContext implementation—typically AnnotationConfigServletWebServerApplicationContext for web applications. The context then delegates to a BeanFactory to handle the actual bean instantiation and wiring.
Bean Lifecycle: Four Critical Phases
Every Spring-managed bean progresses through distinct lifecycle phases:
Instantiation creates the raw object instance. Spring uses reflection to invoke the constructor, but the object remains incomplete—its dependencies are null, and no initialization logic has executed.
Population injects dependencies into the newly created instance. Spring resolves each dependency from the container, handling constructor arguments, field injections, and setter methods. Circular dependency detection occurs here.
Initialization executes custom setup logic. Spring calls @PostConstruct methods, InitializingBean.afterPropertiesSet(), and custom init methods in that order. Your bean receives fully populated dependencies and can safely interact with them.
Destruction runs cleanup logic during shutdown. Spring invokes @PreDestroy methods and DisposableBean.destroy() to release resources gracefully.
Pro Tip: Startup failures often trace back to exceptions thrown during initialization. Check
@PostConstructmethods first—they execute before the application reports successful startup.
Component Scanning and the Bean Registry
Component scanning converts your @Component, @Service, and @Repository annotations into BeanDefinition objects stored in the bean registry. Spring scans packages specified by @ComponentScan (implicit in @SpringBootApplication), reads class metadata, and registers definitions before instantiating any beans.
This two-phase approach—registration then instantiation—enables Spring to resolve complex dependency graphs. The container knows about all beans before creating any, allowing it to determine instantiation order and detect circular dependencies.
BeanFactory vs ApplicationContext
The BeanFactory interface defines core container functionality: bean instantiation, dependency injection, and lifecycle management. The ApplicationContext extends this foundation with enterprise features: event publication, internationalization, resource loading, and automatic BeanPostProcessor registration.
In practice, you always work with ApplicationContext. Direct BeanFactory usage appears only in memory-constrained environments or framework internals. The ApplicationContext eagerly instantiates singleton beans at startup, while BeanFactory instantiates lazily—a distinction that affects both startup time and fail-fast behavior.
With this foundation in place, the next question becomes: how should you get dependencies into your beans? Constructor injection, field injection, and setter injection each carry distinct trade-offs that affect testability, immutability, and clarity.
Injection Strategies: Constructor vs Field vs Setter
Spring offers three distinct approaches for injecting dependencies into your beans. Each carries specific tradeoffs for testability, immutability, and runtime behavior. Understanding these differences helps you write more maintainable code and avoid subtle bugs that surface only in production.
Constructor Injection: The Recommended Default
Constructor injection declares dependencies as constructor parameters, making them explicit requirements for object instantiation.
@Servicepublic class OrderService {
private final PaymentGateway paymentGateway; private final InventoryService inventoryService; private final NotificationService notificationService;
public OrderService(PaymentGateway paymentGateway, InventoryService inventoryService, NotificationService notificationService) { this.paymentGateway = paymentGateway; this.inventoryService = inventoryService; this.notificationService = notificationService; }}Since Spring 4.3, the @Autowired annotation is optional when a class has a single constructor. This approach provides several advantages:
Immutability: Dependencies declared as final fields cannot be reassigned after construction. The compiler enforces this guarantee, eliminating an entire class of bugs where dependencies are accidentally overwritten. This immutability also makes your beans inherently thread-safe with respect to their dependency references.
Explicit contracts: The constructor signature documents exactly what the class needs to function. New team members immediately understand the dependency graph without scanning for annotations throughout the class body. This visibility proves invaluable during code reviews and when onboarding developers to unfamiliar modules.
Testability: Unit tests instantiate objects directly without Spring’s involvement. Pass mock implementations through the constructor, and you have a fully isolated test. No reflection tricks, no special test runners, no container bootstrapping overhead.
@Testvoid processOrder_withValidPayment_updatesInventory() { PaymentGateway mockPayment = mock(PaymentGateway.class); InventoryService mockInventory = mock(InventoryService.class); NotificationService mockNotification = mock(NotificationService.class);
OrderService service = new OrderService(mockPayment, mockInventory, mockNotification);
when(mockPayment.charge(any())).thenReturn(PaymentResult.SUCCESS); service.processOrder(new Order("ORD-2024-001", 99.99));
verify(mockInventory).decrementStock(any());}Fail-fast initialization: Constructor injection surfaces missing dependencies immediately at application startup. If Spring cannot satisfy a required dependency, the context fails to initialize with a clear error message pointing to the exact problem. This behavior prevents partially-configured applications from reaching production.
Field Injection: Convenient but Problematic
Field injection uses @Autowired directly on fields, hiding dependencies from the class’s public API.
@Servicepublic class ReportGenerator {
@Autowired private DataSource dataSource;
@Autowired private TemplateEngine templateEngine;}This approach creates several testing difficulties. Without Spring’s container, you must use reflection to inject mocks—a fragile technique that breaks when field names change and couples your tests to implementation details. The class also permits instantiation without its dependencies, leading to NullPointerException at runtime rather than clear compilation errors.
Field injection obscures the true complexity of a class. A component with fifteen injected fields might compile cleanly, but that constructor with fifteen parameters would immediately signal a design problem requiring decomposition.
Pro Tip: Many static analysis tools flag field injection as a code smell. If you encounter legacy code using this pattern, refactoring to constructor injection often reveals hidden circular dependencies that were previously masked by Spring’s proxy-based resolution.
Setter Injection: For Optional Dependencies
Setter injection suits genuinely optional collaborators that have sensible default behavior when absent.
@Repositorypublic class CacheableRepository {
private final JdbcTemplate jdbcTemplate; private CacheManager cacheManager;
public CacheableRepository(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; }
@Autowired(required = false) public void setCacheManager(CacheManager cacheManager) { this.cacheManager = cacheManager; }}Here, JdbcTemplate is mandatory while CacheManager enhances performance when available. The repository functions correctly either way. Setter injection also enables reconfiguration after construction, which proves useful for beans that need runtime adjustments in specialized scenarios like A/B testing infrastructure.
How Spring Resolves Injection Points
During BeanFactory initialization, Spring scans each bean definition for injection points. For constructor injection, it identifies the appropriate constructor—preferring the one annotated with @Autowired, or the sole constructor if only one exists. Spring then recursively resolves each parameter type from the container before invoking the constructor.
This recursive resolution means Spring must instantiate dependencies before their dependents, automatically ordering bean creation. Circular dependencies break this model and typically indicate a design flaw requiring architectural review.
When multiple beans match a required type, Spring throws NoUniqueBeanDefinitionException. The next section explores how qualifiers, profiles, and conditional annotations give you precise control over which bean gets selected.
Resolving the Right Bean: Qualifiers, Profiles, and Conditionals
When your application defines multiple beans of the same type, Spring’s autowiring faces an ambiguity problem. The container cannot determine which implementation to inject without explicit guidance. Mastering bean resolution strategies separates configuration chaos from clean, maintainable architectures.
Disambiguating with @Qualifier and @Primary
The simplest resolution mechanism marks one implementation as the default choice using @Primary:
@Configurationpublic class NotificationConfig {
@Bean @Primary public NotificationService emailNotificationService() { return new EmailNotificationService(); }
@Bean public NotificationService smsNotificationService() { return new SmsNotificationService(); }}Any injection point requesting NotificationService receives the email implementation automatically. When you need the SMS variant, @Qualifier provides explicit selection:
@RestControllerpublic class AlertController {
private final NotificationService primaryNotifier; private final NotificationService smsNotifier;
public AlertController( NotificationService primaryNotifier, @Qualifier("smsNotificationService") NotificationService smsNotifier) { this.primaryNotifier = primaryNotifier; this.smsNotifier = smsNotifier; }}The qualifier string matches the bean name by default. This coupling between injection sites and bean names introduces fragility—rename a method and injections break silently at runtime. This becomes particularly problematic in large codebases where bean definitions and their consumers span multiple modules maintained by different teams.
Custom Qualifiers for Type-Safe Resolution
Define custom qualifier annotations to eliminate string-based coupling and establish domain-specific vocabulary:
@Qualifier@Retention(RetentionPolicy.RUNTIME)@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD})public @interface NotificationType { Channel value();
enum Channel { EMAIL, SMS, PUSH }}Apply the custom qualifier at both the bean definition and injection point:
@Bean@NotificationType(Channel.EMAIL)public NotificationService emailService() { return new EmailNotificationService();}public OrderService(@NotificationType(Channel.SMS) NotificationService notifier) { this.notifier = notifier;}Compile-time safety catches typos, and IDE refactoring propagates changes correctly. Custom qualifiers also serve as documentation, making the intent behind each injection immediately clear to developers reading the code months later.
Profile-Based Bean Activation
Profiles activate beans based on runtime environment, eliminating conditional logic scattered throughout your codebase. This approach centralizes environment-specific decisions in configuration classes rather than polluting business logic with if statements checking environment variables:
@Configurationpublic class DataSourceConfig {
@Bean @Profile("development") public DataSource embeddedDataSource() { return new EmbeddedDatabaseBuilder() .setType(EmbeddedDatabaseType.H2) .addScript("schema.sql") .build(); }
@Bean @Profile("production") public DataSource productionDataSource( @Value("${db.url}") String url, @Value("${db.username}") String username, @Value("${db.password}") String password) { HikariDataSource ds = new HikariDataSource(); ds.setJdbcUrl(url); ds.setUsername(username); ds.setPassword(password); return ds; }}Activate profiles via spring.profiles.active=production in properties or SPRING_PROFILES_ACTIVE environment variable. Multiple profiles combine with logical operators: @Profile("cloud & !kubernetes") activates only when running in cloud environments outside Kubernetes. You can also combine profiles using OR logic with @Profile({"staging", "production"}) to share beans across similar environments.
Pro Tip: Use
@Profile("!production")for development-only beans like test data seeders. This negative matching ensures they never accidentally activate in production regardless of which other profiles are enabled.
Conditional Registration for Dynamic Behavior
When profiles lack sufficient granularity, @Conditional annotations evaluate arbitrary conditions at startup. This mechanism powers much of Spring Boot’s auto-configuration magic and enables feature-flag-driven architectures:
@Configurationpublic class CacheConfig {
@Bean @ConditionalOnProperty(name = "cache.provider", havingValue = "redis") public CacheManager redisCacheManager(RedisConnectionFactory factory) { return RedisCacheManager.builder(factory).build(); }
@Bean @ConditionalOnMissingBean(CacheManager.class) public CacheManager inMemoryCacheManager() { return new ConcurrentMapCacheManager(); }}Spring Boot provides numerous conditional annotations: @ConditionalOnClass checks classpath presence, @ConditionalOnBean verifies other beans exist, and @ConditionalOnWebApplication distinguishes servlet from reactive contexts. The @ConditionalOnMissingBean pattern shown above establishes sensible defaults that applications can override simply by defining their own bean.
For complex logic beyond what built-in annotations support, implement the Condition interface directly:
public class FeatureFlagCondition implements Condition {
@Override public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { String flag = context.getEnvironment().getProperty("feature.new-algorithm"); return "enabled".equals(flag); }}Apply custom conditions with @Conditional(FeatureFlagCondition.class) on any bean definition. This approach integrates naturally with external feature flag services—the condition can query remote configuration systems during application startup.
These resolution strategies compose naturally. A bean might require both a specific profile and a conditional property check before registration. Understanding this layered approach becomes critical when debugging why expected beans are missing from the context. Enable debug logging with logging.level.org.springframework.beans.factory=DEBUG to trace the container’s resolution decisions—particularly valuable when bean scopes add another dimension to the resolution process.
Scope Matters: Singleton, Prototype, and Request-Scoped Beans
Every Spring bean has a scope that determines its lifecycle and visibility within the application context. Misunderstanding scope semantics leads to some of the most frustrating bugs in Spring applications—memory leaks, stale data, and race conditions that only manifest under production load.

Singleton: The Default That Demands Respect
Spring creates exactly one instance of each singleton-scoped bean per application context. This instance is shared across all injection points, all threads, and the entire application lifetime.
@Componentpublic class UserRateLimiter { private final Map<String, AtomicInteger> requestCounts = new ConcurrentHashMap<>();
public boolean allowRequest(String userId) { return requestCounts .computeIfAbsent(userId, k -> new AtomicInteger(0)) .incrementAndGet() <= 100; }}This map grows with every unique user and never shrinks. In a long-running application processing millions of users, this becomes a memory leak. Singleton beans must either be stateless or explicitly manage their state lifecycle.
Pro Tip: Run your application with
-XX:+HeapDumpOnOutOfMemoryErrorin staging environments. When singleton state accumulates unexpectedly, heap dumps reveal which beans are holding references.
The Prototype-Singleton Injection Trap
Prototype scope promises a fresh instance on every request from the container. The trap: when you inject a prototype into a singleton, the injection happens once at singleton creation time.
@Componentpublic class ReportGenerator { private final ReportContext context; // Injected once!
public ReportGenerator(ReportContext context) { this.context = context; }
public Report generate(String reportId) { context.setReportId(reportId); // Overwrites previous state return context.build(); }}
@Component@Scope("prototype")public class ReportContext { private String reportId; // Builder state...}Two concurrent calls to generate() will corrupt each other’s state. The prototype scope is effectively ignored because the singleton holds a single reference.
ObjectProvider: The Scope-Aware Solution
ObjectProvider<T> defers bean resolution to the point of use, respecting prototype semantics:
@Componentpublic class ReportGenerator { private final ObjectProvider<ReportContext> contextProvider;
public ReportGenerator(ObjectProvider<ReportContext> contextProvider) { this.contextProvider = contextProvider; }
public Report generate(String reportId) { ReportContext context = contextProvider.getObject(); // Fresh instance context.setReportId(reportId); return context.build(); }}Each call to getObject() retrieves a new prototype instance. This pattern also provides null-safety through getIfAvailable() and stream access via stream() for collecting multiple implementations.
Request and Session Scopes
Web applications gain access to request and session scopes, binding bean lifecycle to HTTP semantics:
@Component@RequestScopepublic class RequestAuditContext { private final List<String> operations = new ArrayList<>(); private final Instant startTime = Instant.now();
public void recordOperation(String operation) { operations.add(operation); }
public AuditLog buildLog() { return new AuditLog(operations, startTime, Instant.now()); }}Spring creates this bean when the first injection occurs during request processing and destroys it when the response completes. Behind the scenes, Spring uses scoped proxies—CGLIB subclasses that delegate to the correct instance based on the current thread’s request context.
Attempting to access request-scoped beans outside an HTTP context throws IllegalStateException. Background jobs and async methods require explicit scope binding through RequestContextHolder or redesigning the bean’s scope.
The interplay between scopes creates subtle dependencies. A request-scoped bean can safely inject singletons, but a singleton injecting a request-scoped bean needs a scoped proxy. Spring handles this automatically with @RequestScope, but understanding the mechanism helps when debugging proxy-related stack traces.
These scoping bugs share a common characteristic: they work perfectly in single-threaded tests and fail unpredictably under concurrent load. The next section examines another pattern that passes tests but causes production failures—circular dependencies.
Circular Dependencies: Detection, Prevention, and Recovery
Circular dependencies occur when Bean A requires Bean B, and Bean B requires Bean A—either directly or through a chain of dependencies. Spring’s IoC container must instantiate beans in a specific order, and circular references make this impossible without intervention.
How Spring Detects Circular Dependencies
During application startup, Spring maintains a set of beans currently being created. When the container attempts to inject a bean that’s already in this “creation” state, it detects a cycle:
@Servicepublic class OrderService { private final PaymentService paymentService;
public OrderService(PaymentService paymentService) { this.paymentService = paymentService; }}@Servicepublic class PaymentService { private final OrderService orderService;
public PaymentService(OrderService orderService) { this.orderService = orderService; }}Starting your application with this configuration throws a BeanCurrentlyInCreationException, and Spring logs the exact cycle: orderService → paymentService → orderService.
The Spring Boot 2.6+ Behavioral Change
Prior to Spring Boot 2.6, the container resolved circular dependencies between singleton beans automatically using early reference exposure—injecting a partially constructed proxy. Spring Boot 2.6 disabled this by default because it masks architectural problems and creates subtle bugs where beans receive incompletely initialized dependencies.
You can re-enable the legacy behavior with spring.main.allow-circular-references=true, but this treats the symptom, not the disease.
Architectural Patterns to Eliminate Cycles
Extract a shared dependency. Circular references often indicate that two services share a responsibility that belongs in a third component:
@Servicepublic class OrderPaymentCoordinator { private final OrderRepository orderRepository; private final PaymentGateway paymentGateway;
public OrderPaymentCoordinator(OrderRepository orderRepository, PaymentGateway paymentGateway) { this.orderRepository = orderRepository; this.paymentGateway = paymentGateway; }
public void processOrderPayment(Long orderId, PaymentDetails details) { Order order = orderRepository.findById(orderId).orElseThrow(); paymentGateway.charge(details); order.markAsPaid(); orderRepository.save(order); }}Use domain events. Replace direct method calls with Spring’s ApplicationEventPublisher. The OrderService publishes an OrderCreatedEvent, and a separate listener in the payment domain reacts to it—no bidirectional coupling required.
When @Lazy Is Appropriate
The @Lazy annotation delays bean instantiation until first use, breaking the cycle at startup:
@Servicepublic class PaymentService { private final OrderService orderService;
public PaymentService(@Lazy OrderService orderService) { this.orderService = orderService; }}Pro Tip: Use
@Lazyonly as a temporary measure while refactoring, or in genuine cases where one direction of the dependency is rarely exercised. Treat it as technical debt that requires a tracking ticket.
@Lazy is appropriate when integrating with legacy code you cannot immediately refactor, or when the circular call path exists only in edge cases. It remains inappropriate as a permanent solution for core application flows—you’re hiding a design flaw that will surface as unexpected NullPointerException or initialization order bugs.
The goal is always elimination, not avoidance. A clean dependency graph pays dividends in testability, startup performance, and cognitive load when onboarding new team members.
With circular dependencies resolved, your beans initialize predictably. But how do you verify that your DI configuration behaves correctly under test conditions without spinning up the entire container?
Testing with Dependency Injection: Beyond @MockBean
The way you design your beans directly impacts how testable your application becomes. Constructor injection transforms testing from a framework-dependent exercise into straightforward unit testing that runs in milliseconds. Understanding the full spectrum of testing approaches—from isolated unit tests to integration tests with surgical bean replacement—enables you to build a test suite that’s both comprehensive and fast.
Pure Unit Tests Without Spring
When your service uses constructor injection, testing requires no Spring context at all:
class OrderServiceTest {
private OrderService orderService; private PaymentGateway paymentGateway; private InventoryClient inventoryClient;
@BeforeEach void setUp() { paymentGateway = mock(PaymentGateway.class); inventoryClient = mock(InventoryClient.class); orderService = new OrderService(paymentGateway, inventoryClient); }
@Test void shouldProcessOrderWhenInventoryAvailable() { when(inventoryClient.checkStock("SKU-8842")).thenReturn(true); when(paymentGateway.charge(any())).thenReturn(PaymentResult.success());
Order result = orderService.process(new OrderRequest("SKU-8842", 2));
assertThat(result.getStatus()).isEqualTo(OrderStatus.CONFIRMED); }}This test executes in under 10 milliseconds. Compare that to @SpringBootTest with @MockBean, which bootstraps the entire application context—often taking several seconds. Field injection with @Autowired makes this approach impossible; you’d need reflection or a Spring context to inject dependencies.
The performance difference compounds across a large codebase. A service with 200 unit tests running at 10ms each completes in 2 seconds. Those same tests using @SpringBootTest might take 10 minutes. This speed difference fundamentally changes how developers interact with tests—fast tests get run constantly, slow tests get skipped until CI catches failures hours later.
@TestConfiguration for Integration Tests
When you need the real Spring context but want to swap specific beans, @TestConfiguration provides surgical control:
@SpringBootTestclass PaymentIntegrationTest {
@TestConfiguration static class TestConfig { @Bean @Primary PaymentGateway testPaymentGateway() { return new StubPaymentGateway("sandbox-key-7291"); } }
@Autowired private OrderService orderService;
@Test void shouldIntegrateWithPaymentGateway() { Order result = orderService.process(new OrderRequest("SKU-1234", 1)); assertThat(result.getPaymentId()).startsWith("sandbox-"); }}The @Primary annotation ensures your test bean takes precedence over the production implementation. This approach loads the full context once, letting you test real integration paths with controlled external dependencies. Unlike @MockBean, which creates a Mockito mock and can trigger context recreation, @TestConfiguration defines a concrete replacement that integrates naturally with Spring’s caching mechanism.
Sliced Tests and Bean Subsets
Spring Boot’s test slices load minimal context subsets for focused testing:
@WebMvcTest(ProductController.class)class ProductControllerTest {
@Autowired private MockMvc mockMvc;
@MockBean private ProductService productService;
@Test void shouldReturnProductDetails() throws Exception { when(productService.findById(42L)) .thenReturn(new Product(42L, "Mechanical Keyboard", 149.99));
mockMvc.perform(get("/api/products/42")) .andExpect(status().isOk()) .andExpect(jsonPath("$.name").value("Mechanical Keyboard")); }}@WebMvcTest loads only web layer beans—controllers, filters, and MVC configuration. @DataJpaTest loads repositories and JPA infrastructure. These slices avoid loading unrelated beans, dramatically reducing test startup time. Other useful slices include @JsonTest for JSON serialization logic and @RestClientTest for testing REST client components in isolation.
Pro Tip: Use
@Importto explicitly add beans a sliced test needs rather than broadening to@SpringBootTest. This keeps tests fast while providing necessary dependencies.
Context Caching and Performance
Spring caches application contexts across tests. Tests sharing identical configuration reuse the same context, but subtle differences force new context creation:
- Different
@MockBeandeclarations - Different
@TestPropertySourcevalues - Different active profiles
A test suite with 50 unique context configurations pays the startup cost 50 times. Group tests by context requirements. Consider creating shared base test classes that define common @MockBean sets, allowing multiple test classes to share a single cached context. This strategy requires discipline—adding a new @MockBean to a shared base class affects all subclasses—but the performance gains justify the coordination overhead.
Monitor your test execution with --debug to see context creation events. Each “Started Application” log line represents a fresh context load—and an opportunity to consolidate. Tools like Spring’s test context framework report cache statistics, helping you identify which test configurations are creating the most contexts.
The investment in proper DI patterns pays dividends in test maintainability. But faster tests mean nothing if your application takes 30 seconds to start in production. Let’s examine how lazy initialization and ahead-of-time compilation can dramatically reduce startup time.
Optimizing Startup: Lazy Initialization and AOT Compilation
Spring Boot applications in containerized and serverless environments face a critical challenge: startup time directly impacts cost and user experience. A function that takes 15 seconds to cold start defeats the purpose of elastic scaling. When you’re paying by the millisecond for compute time, every second of initialization translates directly to higher bills and frustrated users waiting for responses. Understanding lazy initialization and ahead-of-time compilation gives you the tools to dramatically reduce startup latency.
Global Lazy Initialization
By default, Spring creates all singleton beans at startup. This eager initialization catches configuration errors early but extends startup time significantly in large applications. Spring Boot 2.2 introduced global lazy initialization:
spring: main: lazy-initialization: trueWith this setting, beans are created only when first requested. A large application with 500 beans might see startup time drop from 12 seconds to 3 seconds. However, this shifts initialization errors from startup to runtime—a trade-off that works well in development but requires careful consideration in production. The first request to your application may experience latency as dependent beans initialize on demand, so consider warming up critical paths immediately after deployment.
For granular control, annotate specific beans rather than enabling global lazy initialization:
@Configurationpublic class ExpensiveServiceConfig {
@Bean @Lazy public ReportingEngine reportingEngine(DataSource dataSource) { // Heavy initialization deferred until first use return new ReportingEngine(dataSource); }}Pro Tip: Use the startup actuator endpoint to identify slow-initializing beans. Add
spring-boot-starter-actuatorand access/actuator/startupto see exactly which beans consume the most initialization time. This data-driven approach lets you target optimization efforts precisely where they’ll have the greatest impact.
Spring Boot 3 and AOT Compilation
Spring Boot 3 introduced ahead-of-time compilation, transforming how the framework handles dependency injection for GraalVM native images. Traditional Spring relies heavily on runtime reflection—scanning the classpath, processing annotations, creating proxies dynamically. AOT compilation shifts this work to build time, eliminating reflection overhead entirely.
plugins { id 'org.graalvm.buildtools.native' version '0.9.28'}During the build, Spring generates optimized code that replaces reflection-based bean discovery with direct instantiation calls. The resulting native image starts in milliseconds rather than seconds, with significantly reduced memory footprint—often using 50-80% less RAM than JVM deployments.
AOT compilation requires explicit registration of any reflection, resources, or proxies your application uses. Spring handles most cases automatically through its built-in analysis, but custom beans with dynamic behavior may need hints:
@ImportRuntimeHints(CustomHintsRegistrar.class)@Configurationpublic class NativeConfig {}
class CustomHintsRegistrar implements RuntimeHintsRegistrar { @Override public void registerHints(RuntimeHints hints, ClassLoader classLoader) { hints.reflection().registerType(LegacyAdapter.class, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS); }}Conditional Bean Registration
Combine lazy initialization with conditional registration to exclude beans entirely based on the deployment context:
@Configuration@ConditionalOnProperty(name = "scheduler.enabled", havingValue = "true")public class SchedulerConfig {
@Bean public JobScheduler jobScheduler() { return new QuartzJobScheduler(); }}For serverless deployments, disable schedulers, health check endpoints, metrics collectors, and other components irrelevant to request handling. Each excluded bean accelerates startup and reduces memory consumption. Profile your application to identify beans that exist solely for operational concerns that cloud platforms already provide—these are prime candidates for conditional exclusion.
With startup optimized for production, you have the foundation to build applications that scale efficiently. The dependency injection patterns covered throughout this series—from constructor injection to conditional beans—form the architectural vocabulary that distinguishes maintainable Spring applications from tangled legacy codebases.
Key Takeaways
- Use constructor injection by default—it makes dependencies explicit, enables immutability, and simplifies testing without Spring context
- Profile your startup with spring.main.lazy-initialization and the startup actuator endpoint to identify beans that slow down cold starts
- Refactor circular dependencies by extracting shared logic into a third service rather than using @Lazy as a permanent fix
- Leverage @TestConfiguration and constructor injection to write unit tests that run in milliseconds instead of seconds