Spring Boot Interview — Prototype Injection Gotcha
A prototype bean injected into a singleton yielded one instance for all payments — compliance audit failure real Spring Boot interview questions reveal..
20+ years shipping production code across the stack, with years spent interviewing engineers. Everything here is grounded in real deployments.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- @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
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.
Imagine you want to open a coffee shop. You could design the layout yourself, source every piece of equipment individually, and wire electricity from the street — or you could move into a franchise unit where the espresso machine is already plumbed in, the layout meets code requirements, and the supplier relationships are pre-negotiated. Spring Boot is that franchise unit for Java web applications.
It takes the powerful-but-overwhelming Spring Framework — which requires wiring together dozens of components manually — and pre-configures almost everything based on what it finds in your project. Add a database driver to your dependencies, and Spring Boot connects to the database for you. Add Jackson, and it handles JSON serialization. You focus on the business logic; Spring Boot handles the infrastructure decisions.
When interviewers ask about Spring Boot, they are really asking: 'Do you understand WHY those defaults exist, what conditions trigger them, and when would you override them?' That is the difference between someone who uses the framework and someone who understands it.
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(); } }
- @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()); } }
@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 // } // }
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"]
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(); } }
Transaction Management — The @Transactional Trap That Burns Production
Spring's @Transactional looks innocent. You slap it on a service method and expect ACID. Then a junior calls that method from within the same class, and the transaction silently dies. That's because Spring uses AOP proxies. A self-invocation bypasses the proxy entirely — no transaction started, no rollback on RuntimeException. The WHY: Spring wraps your bean in a JDK dynamic proxy (or CGLIB). Calls from outside go through the proxy's interceptor chain. Internal calls hit 'this', which is the raw object. Interviewers love asking this because it reveals whether you understand proxy mechanics or just memorize annotations. The fix: either self-inject the proxy (circular but works), move the transactional boundary to a separate bean, or use AspectJ weaving (heavy, avoid unless necessary). Remember: @Transactional on private methods is silently ignored. No warning, no exception. Production just breaks quietly.
// io.thecodeforge — java tutorial @Service public class PaymentService { @Transactional public void processPayment(Payment payment) { // inserts payment, then calls internal method updateInventory(payment.getItems()); // THIS WON'T ROLLBACK } @Transactional(propagation = Propagation.REQUIRES_NEW) public void updateInventory(List<Item> items) { // runs without transaction because proxy is bypassed } } // Fix: inject self-proxy @Service public class PaymentService { @Autowired private PaymentService self; @Transactional public void processPayment(Payment payment) { self.updateInventory(payment.getItems()); // proxy invoked } }
Exception Handling in Spring Boot — @ControllerAdvice Is Not a Magic Wand
Every Spring Boot interview eventually lands on exception handling. Candidates babble about @ControllerAdvice and @ExceptionHandler. But the real test is: do you know the order of precedence? Spring's exception resolution follows a strict chain. First, it checks for @ExceptionHandler on the controller itself. Then it moves to @ControllerAdvice classes. After that, HandlerExceptionResolver implementations. Finally, fallback to the container's error page. Most devs slap a global @ControllerAdvice and assume it catches everything. It won't catch exceptions thrown in filters before the DispatcherServlet. It won't handle errors from Spring Security's filter chain. For those, you need ErrorController or a custom Filter with try-catch. The WHY: understanding this chain saves you from silent 500s that bypass your pretty error response. Interviewers want to see you know where the boundaries are. Pro tip: always test your exception handling with a integration test that simulates auth failures and bad requests. Never assume your @ControllerAdvice is the safety net.
// io.thecodeforge — java tutorial @RestController public class OrderController { @GetMapping("/orders/{id}") public Order getOrder(@PathVariable String id) { if ("0".equals(id)) { throw new IllegalArgumentException("Invalid order ID"); } return orderService.findById(id); } // This runs FIRST — controller-level handler @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity<String> handleLocal(IllegalArgumentException ex) { return ResponseEntity.badRequest().body("Controller says: " + ex.getMessage()); } } @ControllerAdvice public class GlobalExceptionHandler { // This runs ONLY if no controller-level handler matches @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity<String> handleGlobal(IllegalArgumentException ex) { return ResponseEntity.badRequest().body("Global says: " + ex.getMessage()); } }
The Singleton That Stole a Prototype — A Payment Audit Compliance Failure
- Never inject a prototype-scoped bean directly into a singleton — Spring resolves field and constructor dependencies once at singleton creation time, effectively converting your prototype into a singleton for the lifetime of the application
- Use ObjectProvider<YourPrototypeBean> and call provider.getObject() each time you need a fresh instance — this is the correct production pattern and works correctly regardless of the consumer's scope
- Write a test that asserts two consecutive provider.getObject() calls return different instances — this is a five-line test that would have caught this exact bug before it reached production
- Field injection actively hid this bug — a constructor that accepted ObjectProvider<ForgeAuditToken> would have made the dependency lifecycle explicit and prompted review during code inspection
- Add static analysis rules (ArchUnit) for scope-related constraints — bugs of this class are invisible at runtime until they cause compliance or data integrity failures
lsof -i :8080tail -100 /var/log/app/startup.log | grep -A 20 'APPLICATION FAILED TO START'jcmd $(pgrep -f spring-boot) GC.heap_dump /tmp/heapdump.hprofkubectl describe pod <pod-name> | grep -A 10 'Last State'jstat -gcutil $(pgrep -f spring-boot) 1000 10jcmd $(pgrep -f spring-boot) GC.heap_infocurl http://localhost:8080/actuator/metrics/hikaricp.connections.activecurl http://localhost:8080/actuator/metrics/hikaricp.connections.pending| 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 |
| File | Command / Code | Purpose |
|---|---|---|
| io | /** | What Is Spring Boot Auto-Configuration and How Does It Actua |
| io | /** | Spring Boot Beans, Scopes, and Dependency Injection |
| io | /** | @ConfigurationProperties vs @Value |
| Dockerfile | FROM eclipse-temurin:21-jdk-jammy AS build | Production-Grade Dockerization for Spring Boot |
| io | /** | The Spring Bean Lifecycle |
| TransactionalSelfInvocation.java | @Service | Transaction Management |
| ExceptionHandlingOrder.java | @RestController | Exception Handling in Spring Boot |
Key takeaways
Common mistakes to avoid
6 patternsUsing field injection (@Autowired on a field) as the default everywhere
Ignoring bean scope when injecting a prototype-scoped bean into a singleton
Committing secrets — API keys, database passwords, signing keys — to application.properties in version control
Not reading the Conditions Evaluation Report when auto-configuration behaves unexpectedly
Using @Value for groups of related properties instead of @ConfigurationProperties
Putting initialization logic that depends on injected beans or AOP in the constructor
Interview Questions on This Topic
Explain the internal mechanics of @SpringBootApplication. What are the roles of @SpringBootConfiguration, @EnableAutoConfiguration, and @ComponentScan?
How does the Spring Boot Actuator help in monitoring production applications? Describe specific endpoints and how you have used them.
Describe the Spring Bean Lifecycle. When exactly is @PostConstruct called, and why would you use it instead of a constructor?
What is the Lombok library used for in Spring Boot applications, and when would you prefer @Builder over @Data?
Design a custom @Conditional annotation. How would you ensure a bean is only loaded when a specific environment variable is set?
name() to allow specifying which environment variable to check. Second, implement the Condition interface: public class OnForgeEnvCondition implements Condition { @Override public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) { String varName = (String) metadata.getAnnotationAttributes(OnForgeEnv.class.getName()).get("name"); return context.getEnvironment().getProperty(varName) != null; } }. Usage: @OnForgeEnv(name = "FORGE_FEATURE_PAYMENTS_ENABLED") on a @Bean method or @Configuration class. Spring evaluates the condition during bean registration — the bean is only registered if FORGE_FEATURE_PAYMENTS_ENABLED exists in the environment. This is exactly the pattern Spring Boot uses internally for all its @ConditionalOn annotations. The key points for an interview answer: use ConditionContext.getEnvironment() rather than System.getenv() directly — Environment resolves the full property source chain including system properties, environment variables, and application.properties in the correct priority order. Return true to register the bean, false to skip it. Conditions are evaluated before any beans are created, so you cannot check for existing beans inside a Condition without understanding the evaluation order implications.What is the difference between @ControllerAdvice and @RestControllerAdvice, and how does Spring determine which @ExceptionHandler to invoke?
Frequently Asked Questions
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.
@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.
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.
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.
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.
20+ years shipping production code across the stack, with years spent interviewing engineers. Everything here is grounded in real deployments.
That's . Mark it forged?
8 min read · try the examples if you haven't