Spring Boot Interview Questions: Core Concepts Explained With Depth
- Auto-configuration is conditional, not magical — it uses @ConditionalOnClass, @ConditionalOnMissingBean, and @ConditionalOnProperty guards. Your explicit bean definitions always win. The Conditions Evaluation Report (--debug) shows exactly what activated and why.
- Constructor injection is not just a style preference — it enables immutability with final fields, makes dependencies explicit to callers, and makes unit tests trivial without starting a Spring context. Field injection actively hides dependencies and forces tests to use Spring.
- @ConfigurationProperties over @Value for any group of related configuration — you get type-safe binding, JSR-303 validation that fails fast at startup, and IDE autocomplete. @Value produces silent runtime failures when properties are missing or renamed.
- @SpringBootApplication combines @SpringBootConfiguration + @EnableAutoConfiguration + @ComponentScan — three annotations in one
- Auto-configuration uses @ConditionalOnClass and @ConditionalOnMissingBean guards — your explicit beans always win
- Constructor injection is the right default — it enables immutability, makes dependencies explicit, and makes unit tests trivial
- Injecting a prototype bean into a singleton captures it once at startup — use ObjectProvider
for fresh instances each time - Run with --debug flag to print the Conditions Evaluation Report — shows exactly why each auto-configuration fired or was skipped
- @ConfigurationProperties over @Value for grouped config — type safety, validation, and IDE autocomplete for free
Application fails to start — port already in use or Spring context fails to load
lsof -i :8080tail -100 /var/log/app/startup.log | grep -A 20 'APPLICATION FAILED TO START'Application runs out of memory — OOMKill in Kubernetes or OutOfMemoryError in logs
jcmd $(pgrep -f spring-boot) GC.heap_dump /tmp/heapdump.hprofkubectl describe pod <pod-name> | grep -A 10 'Last State'High GC pause times — application latency spikes correlate with garbage collection cycles
jstat -gcutil $(pgrep -f spring-boot) 1000 10jcmd $(pgrep -f spring-boot) GC.heap_infoDatabase connection pool exhaustion — requests queue and eventually timeout under load
curl http://localhost:8080/actuator/metrics/hikaricp.connections.activecurl http://localhost:8080/actuator/metrics/hikaricp.connections.pendingProduction Incident
Production Debug GuideWhen Spring Boot behaves unexpectedly in production, here is the diagnostic sequence. These are ordered by frequency — the first three account for about 70% of the issues I have seen across teams.
Spring Boot has become the default way Java teams build microservices, REST APIs, and enterprise applications. Nearly every Java backend role posted today lists it as a requirement, which means it dominates the technical interview circuit at every seniority level.
Candidates who memorize annotations and definitions collapse under follow-up questions within the first two minutes. Senior interviewers are not testing whether you know what @SpringBootApplication does — they assume you do. They are probing the mechanism: how auto-configuration actually decides what to wire, why constructor injection matters beyond style preference, what happens when you inject a prototype bean into a singleton, and how to debug a missing bean in production without guessing.
I have conducted dozens of Spring Boot technical interviews and reviewed hundreds of candidates. The pattern is consistent: candidates who can explain the conditional assembly model, demonstrate they have read a Conditions Evaluation Report, and describe a real production failure involving bean scope or auto-configuration get offers. Candidates who recite definitions do not.
This guide covers the questions senior interviewers actually ask — with the mechanism behind each answer, production failures that illustrate the concepts, and code examples that demonstrate understanding rather than memorization.
What Is Spring Boot Auto-Configuration and How Does It Actually Work?
Auto-configuration is the heart of Spring Boot and the most misunderstood concept in interviews. Candidates say 'Spring Boot configures itself automatically' — but that is like saying a plane 'flies itself.' True in a superficial sense, but it does not explain the mechanism, and the mechanism is what gets you hired.
When your application starts, Spring Boot scans a file called spring.factories — located inside the spring-boot-autoconfigure JAR — for the key org.springframework.boot.autoconfigure.EnableAutoConfiguration. In Spring Boot 3.x, this moved to META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports. Either way, the file lists hundreds of candidate configuration classes. Each one is annotated with @ConditionalOn guards that function as evaluation criteria: only activate me if specific conditions are true at startup time.
For example, DataSourceAutoConfiguration carries @ConditionalOnClass({DataSource.class, EmbeddedDatabaseType.class}) and @ConditionalOnMissingBean(DataSource.class). If a JDBC driver class is on the classpath and you have not defined your own DataSource bean, both conditions pass and Spring Boot creates a connection pool for you. If you define your own DataSource bean, @ConditionalOnMissingBean fails and the auto-configuration steps aside entirely — your explicit definition wins, no conflict.
This conditional-first design is the insight that separates understanding Spring Boot from just using it. Auto-configuration never overrides what you explicitly define. It fills gaps. The entire model is 'provide sensible defaults that vanish when the user makes a different choice.' Every starter dependency you add to pom.xml pulls in auto-configuration classes with their own conditional guards. Your classpath is the primary configuration signal.
The follow-up question in every senior interview is: 'How would you debug why a particular auto-configuration is not firing?' The answer is the Conditions Evaluation Report — run with --debug or set logging.level.org.springframework.boot.autoconfigure=DEBUG. The report shows every auto-configuration class evaluated at startup, grouped into Positive matches (fired), Negative matches (skipped and why), and Unconditional classes (always run). It shows the exact @Conditional annotation that failed and what value it tested. This report makes auto-configuration completely transparent — there is no magic, only conditions.
package io.thecodeforge.autoconfigdemo; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.jdbc.core.JdbcTemplate; /** * io.thecodeforge: Demonstrating auto-configuration in action. * * What triggers JdbcTemplate auto-configuration here: * 1. H2 is on the classpath (pom.xml: h2, scope=runtime) * -> @ConditionalOnClass(DataSource.class) passes * 2. No DataSource bean is defined by the user * -> @ConditionalOnMissingBean(DataSource.class) passes * 3. Both conditions pass -> DataSourceAutoConfiguration fires * 4. JdbcTemplateAutoConfiguration detects DataSource bean exists * -> Creates JdbcTemplate bean automatically * * Run with: java -jar app.jar --debug * Look for DataSourceAutoConfiguration in "Positive matches" section */ @SpringBootApplication public class AutoConfigurationDemo { public static void main(String[] args) { ConfigurableApplicationContext context = SpringApplication.run(AutoConfigurationDemo.class, args); // This bean was auto-configured — we never wrote a DataSource or JdbcTemplate bean JdbcTemplate jdbcTemplate = context.getBean(JdbcTemplate.class); jdbcTemplate.execute("CREATE TABLE product (id INT, name VARCHAR(50))"); jdbcTemplate.update("INSERT INTO product VALUES (1, 'Forge Wireless Keyboard')"); String productName = jdbcTemplate.queryForObject( "SELECT name FROM product WHERE id = ?", String.class, 1 ); System.out.println("Auto-configured DB result: " + productName); // Now demonstrate overriding: if we had defined a DataSource bean ourselves, // DataSourceAutoConfiguration would have skipped — our bean wins context.close(); } }
//
// Positive matches:
// DataSourceAutoConfiguration matched:
// - @ConditionalOnClass found required classes 'javax.sql.DataSource', 'org.h2.Driver'
// - @ConditionalOnMissingBean (types: javax.sql.DataSource) did not find any beans
//
// JdbcTemplateAutoConfiguration matched:
// - @ConditionalOnClass found required class 'org.springframework.jdbc.core.JdbcTemplate'
// - @ConditionalOnSingleCandidate (types: javax.sql.DataSource) found a primary candidate
//
// Negative matches:
// MongoAutoConfiguration:
// Did not match:
// - @ConditionalOnClass did not find required class 'com.mongodb.client.MongoClient'
//
// Application output:
// Auto-configured DB result: Forge Wireless Keyboard
- @ConditionalOnClass fires only if a specific class is on the classpath — no JDBC driver class means no DataSource auto-configuration, no error, no warning
- @ConditionalOnMissingBean fires only if you have not already defined a bean of that type — your explicit bean always wins, the auto-configured one steps aside
- @ConditionalOnProperty fires only if a specific property is set to a specific value — use this to toggle features on and off via application.properties
- The --debug flag prints the full Conditions Evaluation Report — every auto-configuration class with the exact condition that passed or failed, in plain English
- Spring Boot 3.x uses AutoConfiguration.imports instead of spring.factories — same conditional mechanism, different discovery file location
- The order of auto-configuration evaluation is controlled by @AutoConfigureBefore and @AutoConfigureAfter — relevant when your custom auto-configuration depends on another
Spring Boot Beans, Scopes, and Dependency Injection — The Questions That Trip People Up
Dependency Injection is the backbone of every Spring Boot application, and interviewers probe it specifically because surface-level knowledge collapses fast under follow-up questions. 'What is a Spring bean?' is the easy question. 'What happens when you inject a prototype bean into a singleton?' is the question that separates candidates.
A bean is an object whose complete lifecycle — creation, dependency resolution, initialization, and destruction — is managed by the Spring IoC container. You declare a bean with @Component, @Service, @Repository, @Controller, or @Bean inside a @Configuration class. The 'inversion' in Inversion of Control is that your code no longer instantiates objects with new — the container does, and hands them to you fully assembled.
The three injection styles matter for reasons beyond style. Constructor injection makes dependencies explicit and mandatory — you cannot create the object without providing all its dependencies, which means the compiler enforces completeness. Fields annotated as final with constructor injection are immutable for the object's lifetime. Unit tests can instantiate the class directly with mock objects passed to the constructor — no Spring context, no annotation processing, no test startup time. Field injection (@Autowired on a field) looks clean but hides dependencies from callers, prevents immutability, and forces tests to use reflection or a full Spring context to populate private fields. In a codebase I reviewed at a mid-size company, the test suite took 45 minutes because every test class needed a full Spring context due to field injection across 200+ service classes. Refactoring to constructor injection over two sprints cut test time to under 5 minutes.
Bean scope is where interviews get interesting. Singleton is the default — one shared instance per Spring context, created at startup, shared across all threads simultaneously. Prototype means a fresh instance every time the bean is requested from the context — but this only works if you retrieve it from the context each time. The classic production bug: annotate a bean with @Scope("prototype") and inject it into a singleton via @Autowired. Spring resolves the dependency once during singleton creation and stores the reference. Every subsequent use of that field returns the same prototype instance — scope declared, scope ignored. The fix is injecting ObjectProvider<T> and calling provider.getObject() at runtime, which retrieves a fresh bean from the context on each call.
package io.thecodeforge.dipatterns; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Scope; import org.springframework.stereotype.Component; import org.springframework.stereotype.Service; /** * io.thecodeforge: Demonstrating correct prototype bean usage in a singleton. * * ForgeAuditToken is prototype-scoped — each payment transaction * must receive a fresh instance with its own unique timestamp. * * WRONG pattern (what the production incident used): * @Autowired * private ForgeAuditToken auditToken; // Captured once at startup — always same instance * * CORRECT pattern (what ObjectProvider enables): * Inject ObjectProvider<ForgeAuditToken> and call getObject() per transaction */ @Component @Scope("prototype") // Fresh instance every time getObject() is called class ForgeAuditToken { private final String tokenId; private final long createdAt; public ForgeAuditToken() { // Each instance gets its own UUID — only works if a new instance is created this.tokenId = java.util.UUID.randomUUID().toString(); this.createdAt = System.nanoTime(); } public String getTokenId() { return tokenId; } public long getCreatedAt() { return createdAt; } } @Service public class ForgeOrderService { // ObjectProvider<T>: the correct way to consume a prototype bean from a singleton // Spring injects the provider once (fine — providers are stateless) // provider.getObject() retrieves a fresh ForgeAuditToken from the context each time private final ObjectProvider<ForgeAuditToken> auditTokenProvider; // Constructor injection: dependency is explicit, mandatory, final, and testable public ForgeOrderService(ObjectProvider<ForgeAuditToken> auditTokenProvider) { this.auditTokenProvider = auditTokenProvider; } public void processPayment(String orderId) { // Each call creates a fresh ForgeAuditToken — this is the correct behavior ForgeAuditToken token = auditTokenProvider.getObject(); System.out.printf("Order %s: auditToken=%s, createdAt=%d%n", orderId, token.getTokenId(), token.getCreatedAt()); } // Test helper — demonstrates that two calls produce distinct instances public boolean verifyPrototypeBehavior() { ForgeAuditToken first = auditTokenProvider.getObject(); ForgeAuditToken second = auditTokenProvider.getObject(); // True if prototype scope is working correctly — different objects, different token IDs return first != second && !first.getTokenId().equals(second.getTokenId()); } }
// Order ORD-001: auditToken=f47ac10b-58cc-4372-a567-0e02b2c3d479, createdAt=1718000001001
// Order ORD-002: auditToken=3f2504e0-4f89-11d3-9a0c-0305e82c3301, createdAt=1718000001892
//
// verifyPrototypeBehavior() returns: true
//
// WRONG pattern output (direct @Autowired injection):
// Order ORD-001: auditToken=f47ac10b-58cc-4372-a567-0e02b2c3d479, createdAt=1718000000001
// Order ORD-002: auditToken=f47ac10b-58cc-4372-a567-0e02b2c3d479, createdAt=1718000000001
// Same token ID and timestamp — prototype scope effectively ignored
@ConfigurationProperties vs @Value — Why This Matters Beyond the Interview Room
Every Spring Boot application needs external configuration — database URLs, API keys, timeouts, feature flags. How you bind that configuration determines whether your application fails loudly at startup when configuration is missing, fails silently at runtime, or catches misconfiguration before a single request is processed.
@Value injects a single property value with a SpEL expression: @Value("${forge.payment.gateway.url}"). It works for isolated, one-off properties. It falls apart when you have multiple related properties. Renaming forge.payment.gateway.url to forge.payment.url in application.properties produces no compile-time error — the @Value annotation still references the old name and Spring will inject an empty string or the literal SpEL expression if the property is missing, depending on whether you provide a default. At runtime, this typically manifests as an HTTP call to a malformed URL, a connection timeout, or a NullPointerException several layers deep — none of which obviously points to a misconfigured property name.
@ConfigurationProperties binds a prefix of properties to a typed Java class. All properties under forge.payment are bound to fields on ForgePaymentProperties, with type conversion handled automatically. The class can be annotated with @Validated and carry JSR-303 constraints: @NotBlank on the URL field, @Min(1000) on the timeout field. If a required property is missing or malformed, the application fails to start with an explicit error message pointing to the exact property name. This is the fail-fast principle applied to configuration — you find out on the first startup in a new environment, not when the first payment is processed at 2 AM.
The IDE integration is the other practical advantage. With spring-boot-configuration-processor on the compile classpath, @ConfigurationProperties classes generate metadata that powers IDE autocomplete for application.properties and application.yml. Developers see all available properties with their types and descriptions as they type. @Value provides none of this — every property name is a string literal that the IDE cannot verify.
package io.thecodeforge.config; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.validation.annotation.Validated; /** * io.thecodeforge: Type-safe configuration binding with startup validation. * * Add to pom.xml for IDE autocomplete: * <dependency> * <groupId>org.springframework.boot</groupId> * <artifactId>spring-boot-configuration-processor</artifactId> * <optional>true</optional> * </dependency> * * All fields validated at startup — application fails immediately if * configuration is missing or malformed, not at first use. */ @ConfigurationProperties(prefix = "forge.payment") @Validated public class ForgePaymentProperties { @NotBlank(message = "forge.payment.gateway-url must not be blank") private String gatewayUrl; @NotBlank(message = "forge.payment.api-key must not be blank") private String apiKey; @NotNull @Min(value = 1000, message = "forge.payment.timeout-ms must be at least 1000ms") private Integer timeoutMs; @NotNull @Min(value = 1, message = "forge.payment.max-retries must be at least 1") private Integer maxRetries = 3; // Default value — used if property is absent // Standard getters and setters omitted for brevity // In practice, use @Getter from Lombok or generate with IDE public String getGatewayUrl() { return gatewayUrl; } public void setGatewayUrl(String gatewayUrl) { this.gatewayUrl = gatewayUrl; } public String getApiKey() { return apiKey; } public void setApiKey(String apiKey) { this.apiKey = apiKey; } public Integer getTimeoutMs() { return timeoutMs; } public void setTimeoutMs(Integer timeoutMs) { this.timeoutMs = timeoutMs; } public Integer getMaxRetries() { return maxRetries; } public void setMaxRetries(Integer maxRetries) { this.maxRetries = maxRetries; } } // Enable scanning of @ConfigurationProperties classes: // Add @EnableConfigurationProperties(ForgePaymentProperties.class) to a @Configuration class, // or annotate ForgePaymentProperties itself with @Component. // application.properties: // forge.payment.gateway-url=https://api.forgepay.io/v2 // forge.payment.api-key=${FORGE_PAYMENT_API_KEY} <-- resolved from environment at runtime // forge.payment.timeout-ms=5000 // forge.payment.max-retries=3 // Usage in a service: // @Service // public class ForgePaymentService { // private final ForgePaymentProperties config; // // public ForgePaymentService(ForgePaymentProperties config) { // this.config = config; // } // // public void processPayment(PaymentRequest request) { // // No null checks, no string parsing, no silent misconfiguration // String url = config.getGatewayUrl(); // Type-safe, validated at startup // } // }
//
// APPLICATION FAILED TO START
//
// Description:
// Binding to target org.springframework.boot.context.properties.bind.BindException:
// Failed to bind properties under 'forge.payment' to
// io.thecodeforge.config.ForgePaymentProperties
//
// Reason: forge.payment.gateway-url must not be blank
//
// Action:
// Update your application's configuration. The following
// properties are missing or invalid:
// forge.payment.gateway-url (reason: must not be blank)
//
// Versus @Value behavior with missing property:
// No startup error. Application starts successfully.
// First call to config.getGatewayUrl() returns null or the literal '${forge.payment.gateway-url}'
// NullPointerException thrown on first payment request — production impact.
Production-Grade Dockerization for Spring Boot — What Interviewers Actually Probe
In a senior interview, you are not just asked about code — you are asked about how that code reaches production reliably and securely. Modern Spring Boot applications are almost exclusively deployed via Docker and Kubernetes, and the quality of your Dockerfile is a direct signal of production experience.
Single-stage Dockerfiles — a common starting point — use a JDK image to both compile and run the application. The problem is that the JDK is 400-500MB larger than the JRE, includes compiler tools, diagnostic utilities, and development libraries that a running application never needs, and exposes a significantly larger attack surface. Every unnecessary binary in a production image is a potential vulnerability that your security scanner will flag and your compliance team will question.
Multi-stage builds solve this. Stage 1 uses a JDK image with Maven to compile the application — this stage is heavyweight but temporary. Stage 2 starts fresh from a JRE-only image, copies only the built JAR, and becomes the actual production image. The build stage is discarded entirely — none of its tools, caches, or intermediate files appear in the final image.
Running as root is the other issue interviewers probe. The default behavior without a USER directive is to run as root (UID 0) inside the container. If the application has a vulnerability that allows command execution — a deserialization exploit, a path traversal in a file upload endpoint — the attacker operates with root privileges inside the container. A dedicated non-root user confines any exploit to low-privilege file system access.
Layer caching is the build performance dimension. If you COPY the entire source tree before running the dependency download, every code change — including a one-line fix — invalidates the Maven dependency cache layer and forces a full re-download. Copying pom.xml first and running dependency resolution before copying source means dependency downloads are only re-triggered when pom.xml changes, which is far less frequent than code changes.
# ── Stage 1: Build ──────────────────────────────────────────────────────────── # eclipse-temurin is the preferred base: Adoptium community-maintained, well-scanned # Using Java 21 LTS — the current long-term support version as of 2026 FROM eclipse-temurin:21-jdk-jammy AS build WORKDIR /app # Copy dependency manifest first — layer cache key is pom.xml # When only source code changes, this layer and the next are reused from cache # Saves 60-180 seconds on every code-only rebuild COPY .mvn/ .mvn COPY mvnw pom.xml ./ RUN ./mvnw dependency:go-offline -q # Source code changes here but dependency cache above is preserved COPY src ./src RUN ./mvnw clean package -DskipTests -q # ── Stage 2: Production Runtime ─────────────────────────────────────────────── # JRE only — no compiler, no javac, no Maven, no source code in production image FROM eclipse-temurin:21-jre-jammy WORKDIR /app # Security: dedicated non-root system user # -r: system account (no home directory, no login shell) # If application is compromised, attacker operates as low-privilege 'spring' user RUN groupadd -r springgroup && useradd -r -g springgroup -s /sbin/nologin springuser # Create directories the app needs before switching to non-root user RUN mkdir -p /app/logs /app/tmp && chown -R springuser:springgroup /app # Copy only the built artifact — nothing from Stage 1 comes through except this file COPY --chown=springuser:springgroup --from=build /app/target/*.jar app.jar # Switch to non-root before ENTRYPOINT — all subsequent operations run as springuser USER springuser # JVM flags for container environments: # UseContainerSupport: read memory limits from cgroups, not /proc/meminfo (host RAM) # MaxRAMPercentage: allocate 75% of container memory as heap # ExitOnOutOfMemoryError: fail loudly instead of degrading silently under memory pressure ENTRYPOINT ["java", \ "-XX:+UseContainerSupport", \ "-XX:MaxRAMPercentage=75.0", \ "-XX:+ExitOnOutOfMemoryError", \ "-Dfile.encoding=UTF-8", \ "-jar", "app.jar"]
#
# Image size comparison:
# Single-stage JDK image: 834MB
# Multi-stage JRE image: 248MB (70% reduction)
#
# Security scan results (Trivy):
# Single-stage JDK: 43 CVEs (12 HIGH, 3 CRITICAL)
# Multi-stage JRE: 7 CVEs (1 HIGH, 0 CRITICAL)
#
# Build time comparison (warm cache, code-only change):
# Without layer ordering: 3m 40s (re-downloads all dependencies)
# With layer ordering: 0m 28s (dependency layer cached)
#
# Verify non-root user:
# docker run --rm io.thecodeforge/forge-api:1.0.0 whoami
# springuser
#
# Verify build tools absent from production image:
# docker run --rm io.thecodeforge/forge-api:1.0.0 sh -c 'which mvn || echo absent'
# absent
The Spring Bean Lifecycle — What @PostConstruct Can Do That a Constructor Cannot
The Spring Bean Lifecycle is a standard interview topic, but the follow-up question — 'What is the difference between the constructor and @PostConstruct?' — trips more candidates than it should. Understanding the lifecycle is not just academic. It explains why initialization code in a constructor sometimes silently fails, why AOP proxies do not apply to constructor code, and why database schema validation or connection pre-warming must happen in @PostConstruct rather than in the constructor.
The lifecycle follows a strict sequence. Spring instantiates the bean via its constructor — at this point, only the arguments passed to the constructor are available. Spring has not yet injected field-level @Autowired dependencies (if any exist), has not applied @Value substitutions, and has not applied any BeanPostProcessor transformations including AOP proxy wrapping. If you put initialization logic in the constructor that uses an injected dependency, that dependency is null if it was injected via field, and it exists but the AOP proxy has not been applied yet if it was injected via constructor.
After construction, Spring resolves and injects all remaining dependencies. After injection, Spring calls any BeanPostProcessor before-hooks, which includes applying AOP proxies and transactional proxies. After that, Spring calls @PostConstruct methods. At this point, all dependencies are injected, all proxies are applied, all property values are bound, and the bean is fully assembled. This is the earliest point at which it is safe to perform initialization that depends on injected beans, AOP behavior, or property values.
For databases, this distinction is operationally significant. Calling a repository method in a constructor to pre-warm caches may fail because the transaction infrastructure (a BeanPostProcessor) has not been applied yet. The same call in @PostConstruct succeeds because @Transactional proxies are in place. @PreDestroy mirrors this — it runs before Spring destroys the bean and before dependencies are removed, making it the correct place for cleanup logic like closing custom connections or flushing write buffers.
package io.thecodeforge.lifecycle; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; /** * io.thecodeforge: Demonstrating @PostConstruct and @PreDestroy lifecycle hooks. * * Why this pattern matters: * Constructor: dependencies exist but AOP proxies and @Value bindings are NOT applied yet * @PostConstruct: everything is fully assembled — safe to call transactional methods, * read @Value-bound properties, use any injected dependency * @PreDestroy: called before the bean is destroyed — safe for cleanup logic */ @Component public class ForgeCacheWarmer { private static final Logger log = LoggerFactory.getLogger(ForgeCacheWarmer.class); private final ForgeProductRepository productRepository; private final ForgeProductCache productCache; // Constructor: dependencies are injected, but @Transactional proxy on // productRepository is NOT yet applied. Calling productRepository.findAll() // here would bypass transaction management. public ForgeCacheWarmer( ForgeProductRepository productRepository, ForgeProductCache productCache) { this.productRepository = productRepository; this.productCache = productCache; log.info("ForgeCacheWarmer constructor: dependencies injected, proxies not yet applied"); } // @PostConstruct: AOP proxies applied, @Value bindings resolved, context fully assembled // Safe to call transactional methods, safe to read all properties @PostConstruct public void warmCache() { log.info("@PostConstruct: pre-warming product cache from database"); // productRepository.findAllActive() is @Transactional — works correctly here // because the transactional proxy was applied before @PostConstruct was called productRepository.findAllActive() .forEach(product -> productCache.put(product.getId(), product)); log.info("Cache warmed with {} products", productCache.size()); } // @PreDestroy: called before Spring destroys this bean // Dependencies are still available — safe for cleanup @PreDestroy public void cleanup() { log.info("@PreDestroy: flushing product cache before shutdown"); productCache.evictAll(); } }
// INFO ForgeCacheWarmer - ForgeCacheWarmer constructor: dependencies injected, proxies not yet applied
// INFO ForgeCacheWarmer - @PostConstruct: pre-warming product cache from database
// INFO ForgeCacheWarmer - Cache warmed with 2847 products
// INFO o.s.b.w.embedded.tomcat.TomcatWebServer - Tomcat started on port 8080
//
// Application shutdown log:
// INFO ForgeCacheWarmer - @PreDestroy: flushing product cache before shutdown
// INFO o.s.b.web.embedded.tomcat.TomcatWebServer - Tomcat stopped
//
// If warmCache() were in the constructor instead of @PostConstruct:
// TransactionRequiredException: No EntityManager with actual transaction available
// for current thread — calling @Transactional method before proxy is applied
| Aspect | @Value Injection | @ConfigurationProperties |
|---|---|---|
| Use case | Single, isolated property with no related siblings | Logical group of related properties — database config, payment gateway settings, feature flags |
| Type safety | Limited — String-to-type conversion is implicit and fails silently for complex types | Full — binds to strongly typed fields with automatic conversion and compiler support |
| Validation support | None out of the box — missing properties are null or the literal SpEL expression at runtime | Full JSR-303 validation with @Validated — missing required properties cause startup failure with an explicit error message |
| IDE autocomplete | None — property names are unverified string literals that the IDE cannot navigate or validate | Full autocomplete in application.properties and application.yml with the spring-boot-configuration-processor dependency |
| Refactoring safety | Fragile — renaming a property in application.properties causes a silent null at runtime, not a compile error | Safe — the configuration class is a typed Java object, property name changes surface as compiler errors in the binding |
| Testability | Requires a Spring context or reflection to populate @Value fields in unit tests | Easily instantiated as a plain Java object in unit tests — new ForgePaymentProperties() with setters, no Spring context needed |
| Startup failure behavior | Missing property causes null injection or SpEL literal — failure is deferred to first use of the value | Missing required property causes immediate startup failure with a descriptive error pointing to the exact property name |
| Best for | Truly isolated, one-off values in prototype code or small utilities — two or fewer properties with no validation requirement | Any production application with logically grouped configuration — the additional structure pays off immediately in a team environment |
🎯 Key Takeaways
- Auto-configuration is conditional, not magical — it uses @ConditionalOnClass, @ConditionalOnMissingBean, and @ConditionalOnProperty guards. Your explicit bean definitions always win. The Conditions Evaluation Report (--debug) shows exactly what activated and why.
- Constructor injection is not just a style preference — it enables immutability with final fields, makes dependencies explicit to callers, and makes unit tests trivial without starting a Spring context. Field injection actively hides dependencies and forces tests to use Spring.
- @ConfigurationProperties over @Value for any group of related configuration — you get type-safe binding, JSR-303 validation that fails fast at startup, and IDE autocomplete. @Value produces silent runtime failures when properties are missing or renamed.
- Never inject a prototype-scoped bean directly into a singleton — Spring resolves the dependency once at singleton creation time, silently converting your prototype to a singleton. Use ObjectProvider<T> and call getObject() at the point where you need a fresh instance.
- Use @PostConstruct for initialization that depends on injected dependencies or AOP — constructors run before proxies are applied, so @Transactional and @Cacheable do not work inside constructors. @PostConstruct runs after full context assembly.
- Run with --debug at least once during development on any new service — the Conditions Evaluation Report tells you what auto-configuration activated, what was skipped, and exactly why. It is the fastest path from 'why is this bean missing' to 'I understand the fix.'
⚠ Common Mistakes to Avoid
Interview Questions on This Topic
- QExplain the internal mechanics of @SpringBootApplication. What are the roles of @SpringBootConfiguration, @EnableAutoConfiguration, and @ComponentScan?Mid-levelReveal
- QHow does the Spring Boot Actuator help in monitoring production applications? Describe specific endpoints and how you have used them.Mid-levelReveal
- QDescribe the Spring Bean Lifecycle. When exactly is @PostConstruct called, and why would you use it instead of a constructor?SeniorReveal
- QWhat is the Lombok library used for in Spring Boot applications, and when would you prefer @Builder over @Data?Mid-levelReveal
- QDesign a custom @Conditional annotation. How would you ensure a bean is only loaded when a specific environment variable is set?SeniorReveal
- QWhat is the difference between @ControllerAdvice and @RestControllerAdvice, and how does Spring determine which @ExceptionHandler to invoke?Mid-levelReveal
Frequently Asked Questions
How do you implement Pagination and Sorting in Spring Boot?
Spring Data JPA handles pagination through the Pageable abstraction. Repository methods that accept a Pageable parameter and return Page<T> get automatic SQL LIMIT and OFFSET generation. Create a Pageable with PageRequest.of(pageNumber, pageSize, Sort.by("fieldName").descending()) and pass it to the repository. The returned Page<T> object contains the current page of results plus metadata: getTotalElements(), getTotalPages(), hasNext(), and hasPrevious(). Expose page and size as query parameters on your controller endpoint: public Page<ProductDto> list(@RequestParam int page, @RequestParam int size). Be mindful of deep pagination — OFFSET-based queries get progressively slower at high page numbers because the database must scan and discard all preceding rows. For high-performance pagination on large datasets, consider keyset pagination (WHERE id > :lastSeenId ORDER BY id LIMIT :size) which is O(1) regardless of page depth.
What is the difference between @RestController and @Controller?
@RestController is a composed annotation combining @Controller and @ResponseBody. Every method in a @RestController automatically serializes its return value to the HTTP response body — typically JSON via Jackson. @Controller alone is for server-rendered applications: methods return view names (strings like 'products/list') that the ViewResolver resolves to Thymeleaf templates or JSP files. If you add @ResponseBody to a specific method inside a @Controller, that method behaves like @RestController for just that method — mixing both patterns is unusual but valid for applications that serve both HTML and REST endpoints. For any application building a REST API, @RestController is the correct choice. The @ResponseBody meta-annotation is what makes the serialization happen — @RestController simply makes it the default for every method in the class.
What are Spring Boot Starters and why are they useful?
Starters are curated dependency groups packaged as single Maven or Gradle dependencies. spring-boot-starter-web, for example, pulls in Spring MVC, embedded Tomcat, Jackson for JSON, and all their compatible transitive dependencies in tested, compatible versions. Without starters, you would manually select versions of Spring MVC, Tomcat, Jackson, and their commons dependencies — and version incompatibilities between them are a real problem that starters eliminate entirely. The value is twofold: convenience (one dependency instead of eight) and compatibility (starter versions are tested together by the Spring Boot team). The cost is that each starter also brings in auto-configuration classes that are evaluated at every startup — adding starters for technologies you are not using adds evaluation overhead and can activate unexpected auto-configurations. Audit your pom.xml starters against what you actually use and remove unused ones.
What is the role of application.properties versus application.yml?
Both files serve the same purpose — externalized configuration for Spring Boot applications — and Spring Boot supports both out of the box, loading from the classpath root. application.properties uses flat key-value pairs: spring.datasource.url=jdbc:postgresql://localhost:5432/mydb. application.yml uses YAML's hierarchical indented structure, which makes deeply nested configuration more readable. The same property in YAML: spring: datasource: url: jdbc:postgresql://localhost:5432/mydb. For simple applications with few configuration values, the format is a matter of preference. For microservices with complex nested configuration — multiple data sources, detailed security settings, feature flag groups — YAML's hierarchy reduces repetition and improves readability. One practical difference: YAML does not allow tab indentation, only spaces. A tab character in a YAML file produces a parsing error at startup — a common gotcha for developers switching from properties files.
How do you debug why a specific auto-configuration class did not activate?
The Conditions Evaluation Report is the answer — always. Run the application with --debug as a command-line argument or set logging.level.org.springframework.boot.autoconfigure=DEBUG in application.properties. At startup, Spring Boot prints every auto-configuration class it evaluated, categorized as Positive matches (conditions passed, beans created), Negative matches (at least one condition failed, class skipped), and Unconditional classes (always applied). For negative matches, the report shows the exact @Conditional annotation that failed and what value it evaluated. Common reasons: @ConditionalOnClass failed because the required library JAR is not on the classpath — check your pom.xml. @ConditionalOnMissingBean failed because you defined a bean of the same type — your explicit bean won, which is intentional. @ConditionalOnProperty failed because the required property is not set or has a different value than the havingValue attribute expected — check application.properties. This report makes auto-configuration completely transparent. There is no magic — only conditions and their evaluation results.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.