Spring / Spring7 Intermediate to Advanced Interview questions
1. What is the difference between Spring Framework 6 and Spring Framework 7?
Spring 6 and Spring 7 are both generational releases, but 7 builds a further layer of change on top of what 6 already introduced.
| Aspect | Spring 6 | Spring 7 |
| Jakarta EE | EE 9, later EE 10 | EE 11 (Servlet 6.1, JPA 3.2) |
| Java baseline | 17 (recommended up to 21) | 17 (recommended up to 25) |
| API versioning | No built-in support | Native version attribute |
| Null safety | Spring's own annotations | JSpecify annotations |
| Bean registration | @Bean / @Component only | Adds BeanRegistrar |
For most applications the upgrade is a moderate migration rather than a rewrite: the programming model (IoC, DI, MVC, AOP) is unchanged, but the baseline bumps and RestTemplate's deprecation mean dependency and client-code updates are unavoidable.
2. How does Spring Boot 4 differ from Spring Boot 3 in terms of baseline requirements?
Spring Boot 4 sits on Spring Framework 7, so its baseline shift mirrors the framework's own jump.
| Requirement | Spring Boot 3 | Spring Boot 4 |
| Underlying framework | Spring Framework 6 | Spring Framework 7 |
| Jakarta EE | EE 9/10 | EE 11 |
| Servlet container | Tomcat 10, Jetty 11 | Tomcat 11+, Jetty 12.1+ |
| Minimum Java | 17 (Java 11 dropped in 3.x already) | 17, but 25 strongly recommended |
Beyond baselines, Spring Boot 4 also continues a modularization push - splitting some starters into finer-grained artifacts - so teams pull in less unused code, which matters more for container-dense and serverless deployments than it did in Boot 3.
3. Which is better and why: upgrading directly from Spring Boot 2 to Spring Boot 4, or upgrading incrementally through Spring Boot 3?
An incremental upgrade - Boot 2 to Boot 3, then Boot 3 to Boot 4 - is almost always the safer choice for anything beyond a small application, even though it takes two migration cycles instead of one.
Going step by step means each hop only has to absorb one generation's worth of breaking changes (the javax.* to jakarta.* package rename in the Boot 2-to-3 move, then the Jakarta EE 11 and RestTemplate-deprecation changes in the Boot 3-to-4 move), and each intermediate version's compiler warnings and migration guides tell you exactly what broke and why. Jumping straight from Boot 2 to Boot 4 means absorbing both sets of breaking changes at once, with a much larger, harder-to-bisect diff - a failing test after the jump could stem from either generation's changes, and rollback becomes an all-or-nothing decision.
A direct jump can make sense for a genuinely small, low-risk service where the team is confident about the full change surface, but for most production codebases the incremental path trades a bit more calendar time for dramatically lower migration risk.
4. Why did Spring Framework 7 move from Jakarta EE 10 to Jakarta EE 11?
Jakarta EE 10 was the baseline Spring 6 introduced back in 2022, and the Jakarta EE community has continued advancing the underlying specifications since then - Servlet 6.1, WebSocket 2.2, Bean Validation 3.1, and JPA 3.2 all carry refinements that Servlet 6.0 and its 2022-era siblings didn't have.
Staying on Jakarta EE 10 indefinitely would mean Spring couldn't take advantage of those specification-level improvements, and would eventually fall out of step with the application servers themselves - Tomcat and Jetty's newest major releases target the newer Jakarta EE generation, so a framework stuck on the older spec level would either need compatibility shims or would simply stop being deployable on current server versions. Moving to Jakarta EE 11 alongside the broader Spring Framework 7 generational bump lets Spring track the servlet ecosystem's own forward motion in one coordinated step rather than patching around an aging baseline indefinitely.
5. What happens when a Spring Boot 3 / Spring 6 application is upgraded to Spring Boot 4 / Spring 7 without addressing RestTemplate and other deprecations?
The application still compiles and runs - RestTemplate and similar APIs are deprecated, not removed, in Spring Framework 7.1, so nothing breaks immediately. What changes is the risk profile going forward.
// still works after upgrading, but now emits a deprecation warning RestTemplate restTemplate = new RestTemplate(); restTemplate.getForObject(url, Product.class);
Left unaddressed, the code accumulates compiler warnings that clutter build output and hide genuinely new warnings. It also misses out on features that only the newer client APIs understand - for instance, the framework's native version attribute for API versioning is understood by RestClient, WebClient, and @HttpExchange clients, but not by RestTemplate, so any code still calling downstream versioned endpoints through RestTemplate has to fall back to manually setting headers instead of using the declarative mechanism. Most importantly, the deprecated APIs are slated for outright removal in a future Spring 8 release with no announced date yet, so deferring the migration just shifts the work to a future, less-planned moment - likely under time pressure when the removal actually lands.
6. What is the difference between Spring Security 6 and Spring Security 7?
Spring Security 7 (paired with Spring Framework 7) makes several changes that go beyond a routine dependency bump.
| Aspect | Spring Security 6 | Spring Security 7 |
| Multi-factor auth | No first-class support | Native MFA authorization managers |
| OAuth2 password grant | Present, discouraged | Removed entirely |
| PKCE / dynamic registration | Opt-in on the Authorization Server | Enabled by default |
| Null safety | Spring's own annotations | JSpecify |
| SAML2 / Kerberos | Legacy dependency chains | Modernized, legacy deps removed |
The MFA support is the headline feature - it was one of the most long-requested capabilities in the project's history - while the OAuth2 defaults changes reflect a broader push toward secure-by-default configuration rather than secure-if-configured-correctly.
7. How does multi-factor authentication (MFA) work natively in Spring Security 7?
Spring Security 7 models each completed authentication factor as an authority granted to the Authentication object, and provides authorization managers that check for a required combination of those factor authorities before granting access.
http.authorizeHttpRequests(auth -> auth .requestMatchers("/admin/**").hasAuthority("ROLE_ADMIN") .anyRequest().access(new AllAuthoritiesAuthorizationManager("MFA_SMS", "MFA_TOTP")) );
AllAuthoritiesAuthorizationManager requires every listed factor authority to be present, which is the typical "must complete SMS and TOTP" case; Spring Security 7.1 adds AllRequiredFactorsAuthorizationManager.anyOf() for the more flexible "any one of these factor combinations is acceptable" case. A new Authentication.Builder lets the application add factor details to an existing Authentication after a successful secondary challenge, without rebuilding the token from scratch and losing prior state. The framework's default login page also understands factor.type and factor.reason query parameters, so it can render the correct next-step challenge screen automatically once the first factor succeeds.
8. Why was the OAuth2 password grant removed in Spring Security 7?
The Resource Owner Password Credentials grant requires the client application to collect a user's raw username and password directly and forward them to the authorization server, rather than redirecting the user to authenticate on the identity provider's own page. That design has been flagged as insecure by the OAuth 2.0 Security Best Current Practice guidance for years: it trains users to type credentials into third-party client applications (a textbook phishing pattern), it gives no path to federated identity or single sign-on, and it can't support MFA challenges cleanly since the client, not the authorization server, is the one collecting credentials.
Spring Security 7 removes the grant outright rather than continuing to support it as a discouraged option, pushing every client - including first-party ones - toward the authorization code flow, now paired with PKCE enabled by default. This is consistent with the release's broader theme of shifting risky-but-legal configurations to simply not being available, rather than relying on documentation to steer developers away from them.
9. How does Spring Security 7 improve OAuth2 client support for HTTP service clients like RestClient?
Spring Security 7 adds OAuth2 support directly into the builders for HTTP service clients, so an outbound call to a protected downstream API can have a bearer token attached automatically instead of the application manually managing token acquisition and refresh in an interceptor.
@Bean @ClientRegistrationId("inventory-service") public InventoryClient inventoryClient(RestClient.Builder builder) { RestClient client = builder.baseUrl("https://inventory.internal").build(); HttpServiceProxyFactory factory = HttpServiceProxyFactory .builderFor(RestClientAdapter.create(client)).build(); return factory.createClient(InventoryClient.class); }
Placing @ClientRegistrationId at the class level, rather than repeating it on every method, is a smaller but meaningful convenience addition - previously each individual call site needed its own registration-id annotation even when a whole client interface always talked to the same downstream service. Together these changes reduce the amount of hand-written token-management plumbing needed for service-to-service OAuth2 calls.
10. When should you choose PKCE over the classic authorization code flow in Spring Security 7's Authorization Server?
PKCE (Proof Key for Code Exchange) adds a client-generated code_verifier/code_challenge pair to the authorization code flow, so that even if an attacker intercepts the authorization code, they can't exchange it for a token without also having the original verifier the client held onto locally.
The classic authorization code flow, without PKCE, relies on a confidential client secret to prove the token-exchange request is legitimate - that works fine for a traditional server-side application that can keep a secret safely on the backend, out of reach of end users. It breaks down for public clients that can't store a secret securely: single-page applications running entirely in the browser, and native mobile apps whose binaries can be decompiled to extract any embedded secret. For those clients, PKCE is not just recommended but effectively mandatory, since there is no secret to protect in the first place.
Because Spring Security 7's Authorization Server now enables PKCE and Dynamic Client Registration by default, the framework's own defaults already steer every client - public or confidential - toward the safer configuration unless a team deliberately opts out.
11. Explain the execution flow of a request through the Spring Security filter chain?
Every request to a secured application first passes through a single servlet Filter, FilterChainProxy, which delegates to an ordered list of security filters matched to that request's SecurityFilterChain.
flowchart LR
A[Request] --> B[FilterChainProxy]
B --> C[SecurityContextHolderFilter]
C --> D[CsrfFilter]
D --> E[Authentication Filter]
E --> F[ExceptionTranslationFilter]
F --> G[AuthorizationFilter]
G --> H[DispatcherServlet / Controller]
Early filters establish context: SecurityContextHolderFilter loads any existing authentication from the session (or leaves it empty for a stateless JWT setup), and CsrfFilter validates state-changing requests against a CSRF token. An authentication filter appropriate to the configured mechanism - form login, HTTP Basic, a bearer-token resolver, or an OAuth2 login filter - attempts to authenticate the request if it isn't already. ExceptionTranslationFilter wraps everything downstream of it, catching AuthenticationException and AccessDeniedException to redirect to a login page or return a 401/403 as appropriate rather than letting them propagate as generic 500 errors. Finally, AuthorizationFilter makes the actual access decision by consulting the configured AuthorizationManager rules (like the requestMatchers(...).hasAuthority(...) chain) before allowing the request through to DispatcherServlet and the controller.
12. How do you prevent duplicate form submissions caused by double-clicking a submit button in a Spring MVC application?
No single technique fully solves this on its own, so a layered approach works best. Client-side, disabling the submit button in the click handler stops most accidental double-clicks, but it's not reliable on its own since JavaScript can be disabled, slow, or bypassed by a fast enough second click before the handler runs.
form.addEventListener("submit", () => { submitButton.disabled = true; });
Server-side, the synchronizer token pattern embeds a one-time token in the rendered form, tied to the session; on submission, the server checks and immediately invalidates that token, so a second submission carrying the same (now-invalidated) token is rejected. The Post/Redirect/Get pattern helps with a related but different case - refreshing or navigating back after a successful submit - by turning the post-submit page into a GET, though it does nothing about two POSTs fired before either completes. For APIs and modern SPA-driven forms, an idempotency key generated once per logical submission and sent as a header, checked against a short-lived store like Redis, catches duplicates regardless of what triggered them - a double-click, a flaky retry, or a resubmitted request.
13. How would you implement idempotency-key based duplicate request detection in a Spring REST API?
The client generates a unique key - typically a UUID - once per logical action, and sends it as a header (for example, Idempotency-Key) on the request, reusing the same key if it needs to retry.
@Component public class IdempotencyInterceptor implements HandlerInterceptor { private final StringRedisTemplate redis; public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) { String key = req.getHeader("Idempotency-Key"); if (key == null) return true; Boolean isNew = redis.opsForValue() .setIfAbsent("idem:" + key, "processing", Duration.ofMinutes(10)); if (Boolean.FALSE.equals(isNew)) { res.setStatus(409); // already seen - reject or return cached result return false; } return true; } }
A Redis SETNX-style atomic check-and-set is a natural fit: it's fast, and the built-in expiry means stale keys are cleaned up automatically without a background job. For stronger guarantees than "reject the duplicate," the handler can instead store the first request's actual response body against the key once processing completes, so a genuine retry (not just a rejected duplicate) gets back the exact same result rather than an error - which matters for clients that legitimately need to know the outcome of a request they're not sure succeeded.
14. Why is the Post/Redirect/Get pattern not sufficient on its own to prevent double-click duplicate submissions?
Post/Redirect/Get solves a specific, later problem: it stops a browser from re-issuing the original POST when the user refreshes the page or clicks back after the first submission already completed and redirected. Because the address bar now shows a GET URL, a refresh just re-fetches that GET, not the original POST.
Double-clicking a submit button is a different, earlier problem. Both clicks fire before the server has had a chance to process and redirect from the first one - two POST requests are dispatched to the server essentially back to back, often only milliseconds apart, well before any redirect response exists to protect against. PRG has nothing to say about that window at all, since by definition it only comes into play after a successful redirect has already happened.
That's why a double-click specifically needs either a client-side safeguard (disabling the button immediately) or a server-side one that can reject a second concurrent request before it's fully processed (a synchronizer token invalidated on first use, or an idempotency key check) - PRG addresses the resubmission case, not the concurrent-double-dispatch case.
15. Why doesn't @Transactional work when called from within the same class (self-invocation)?
Spring's default @Transactional support is implemented with AOP proxies - a JDK dynamic proxy if the target implements an interface, or a CGLIB-generated subclass otherwise. The transactional behavior (starting, committing, rolling back) is advice woven in at the proxy layer, not inside the target class's own bytecode.
@Service public class OrderService { public void placeOrder(Order order) { // called via "this." - bypasses the proxy entirely this.saveOrder(order); } @Transactional public void saveOrder(Order order) { /* ... */ } }
When placeOrder calls this.saveOrder(order), that's a plain Java virtual method call on the raw target object - the call never goes back out through the proxy that Spring registered in the container, so none of the transactional interceptor logic runs. saveOrder executes with no transaction at all, silently, which is what makes this bug particularly hard to catch in testing.
Common fixes: split the transactional method into a separate collaborator bean and inject it, so the call genuinely goes through a proxy; inject the bean's own proxy back into itself (via @Lazy self-autowiring or ApplicationContext.getBean(...)) and call through that reference; or switch to AspectJ compile-time or load-time weaving, which instruments the actual bytecode rather than relying on a wrapper proxy, so self-invocation is advised correctly.
16. How does Spring resolve circular dependencies between singleton beans?
For singleton beans wired via setter or field injection, Spring can resolve a circular reference using a three-level cache during bean creation: singletonObjects (fully created beans), earlySingletonObjects (early references already exposed), and singletonFactories (factories that can produce an early reference on demand).
flowchart TD
A[Start creating Bean A] --> B[Instantiate A, expose early reference in singletonFactories]
B --> C[Populate A's properties, needs Bean B]
C --> D[Start creating Bean B]
D --> E[B needs A - fetches A's early reference from cache]
E --> F[B finishes construction using early A]
F --> G[A finishes construction using completed B]
When Bean A's construction needs Bean B, and Bean B needs Bean A back, Spring exposes an early, not-yet-fully-populated reference to A in the third-level cache as soon as A is instantiated (before its own dependencies are injected). When B is created and asks for A, it receives that early reference instead of triggering a second, infinitely-recursive creation of A - B completes using it, and then A's own property population finishes using the now-complete B.
This mechanism only works for setter/field injection, because it depends on being able to hand out a partially-constructed object before its dependencies are filled in. Constructor injection cycles - where A's constructor itself requires B, and B's constructor requires A - cannot be resolved this way, since neither bean can even be instantiated (let alone exposed early) without the other already existing; Spring throws BeanCurrentlyInCreationException in that case.
17. Explain the internal working of the DispatcherServlet request-handling flow in Spring MVC?
DispatcherServlet coordinates a well-defined internal pipeline for every request, delegating to a series of collaborator components rather than handling anything itself.
sequenceDiagram
participant C as Client
participant DS as DispatcherServlet
participant HM as HandlerMapping
participant HA as HandlerAdapter
participant Ctrl as Controller
participant VR as ViewResolver/Converter
C->>DS: HTTP Request
DS->>HM: resolve handler
HM-->>DS: HandlerExecutionChain
DS->>HA: invoke handler
HA->>Ctrl: call controller method
Ctrl-->>HA: return value
HA-->>DS: ModelAndView or body
DS->>VR: resolve view / convert body
VR-->>C: HTTP Response
First, a HandlerMapping (typically RequestMappingHandlerMapping) matches the request URL and method to a HandlerExecutionChain, bundling the target controller method with any applicable interceptors. A HandlerAdapter then runs each interceptor's preHandle, resolves the controller method's arguments via registered HandlerMethodArgumentResolvers (path variables, request bodies, headers), and invokes the method.
The return value is processed by a matching HandlerMethodReturnValueHandler: for a traditional @Controller, a returned view name becomes a ModelAndView that a ViewResolver later resolves and renders; for @ResponseBody/@RestController, the object is instead handed directly to an HttpMessageConverter to be serialized straight into the response. Interceptors' postHandle and afterCompletion callbacks run afterward, and if any step throws, the configured chain of HandlerExceptionResolvers - including the one backing @ExceptionHandler methods - gets a chance to convert the exception into a proper response before it becomes a generic error.
18. Explain the internal working of Spring AOP proxy creation?
Spring AOP proxies are created lazily, at bean-instantiation time, by a BeanPostProcessor - specifically a subclass of AbstractAutoProxyCreator - that inspects each new bean against the registered aspects' pointcuts to decide whether it needs advising at all.
flowchart TD
A[Bean instantiated] --> B{Matches any pointcut?}
B -- No --> C[Return plain bean]
B -- Yes --> D{Implements an interface?}
D -- Yes --> E[JDK dynamic proxy]
D -- No, or proxyTargetClass=true --> F[CGLIB subclass proxy]
If the target class implements at least one interface, Spring defaults to a JDK dynamic proxy - a java.lang.reflect.Proxy instance implementing the same interfaces, where every call is routed through an InvocationHandler that first runs the matched advice chain (converted internally into a chain of MethodInterceptors) and then, if appropriate, invokes the real target. If the class implements no interfaces, or proxyTargetClass=true is configured, Spring instead uses CGLIB, generating a runtime subclass of the target class itself that overrides advised methods to insert the same interception logic.
This mechanism explains two well-known constraints: CGLIB-based proxies can't advise final methods (a subclass can't override them) and can't be applied to final classes at all; and advice only ever runs for calls that arrive through the proxy reference the container handed out - a call made directly on this from inside the target class bypasses the proxy and its interception logic entirely, which is exactly why self-invoked @Transactional calls silently skip their transaction.
19. How is a GraalVM native image build different from a traditional JAR deployment in Spring Boot 4?
A traditional JAR deployment ships portable bytecode that runs on any matching JVM: the JVM interprets and JIT-compiles code as it runs, resolves reflection and dynamic proxies at runtime, and pays a warm-up cost (several seconds of startup, growing memory as the JIT optimizes hot paths) in exchange for broad portability and full dynamic-language features.
| Aspect | JAR on JVM | GraalVM native image |
| Startup time | Seconds | Milliseconds |
| Memory footprint | Higher, grows with JIT | Low, fixed ahead of time |
| Portability | Any machine with a matching JVM | One binary per OS/architecture |
| Reflection/dynamic proxies | Fully dynamic at runtime | Must be known ahead of time (via AOT hints) |
| Build time | Fast | Much slower (whole-program analysis) |
A native image instead compiles the application ahead of time into a self-contained, platform-specific executable: Spring's own AOT processing step precomputes the application context so the GraalVM compiler knows exactly which beans, proxies, and reflective accesses are needed, bakes only those reachable code paths into the binary, and skips the JVM's runtime class-loading machinery entirely - trading a much longer, heavier build and reduced runtime flexibility for near-instant startup and a small, predictable memory footprint.
20. When should you choose WebFlux over Spring MVC in a Spring Framework 7 application?
WebFlux earns its complexity when an application is genuinely I/O-bound at high concurrency with a reactive-native downstream stack - for example, a gateway service fanning out to dozens of other services concurrently, a service using R2DBC or reactive MongoDB drivers end to end, or one serving long-lived streaming connections like Server-Sent Events where non-blocking backpressure actually matters.
Historically, the other strong argument for WebFlux was thread efficiency: a blocking MVC application ties up one platform thread per in-flight request, capping concurrency at the thread pool size, while WebFlux's event-loop model handles far more concurrent connections with a small, fixed number of threads. Spring Framework 7's virtual thread support changes that calculus significantly - enabling spring.threads.virtual.enabled=true lets a traditional, imperative Spring MVC application handle massive concurrent blocking I/O cheaply too, without adopting reactive programming at all.
That narrows WebFlux's practical advantage to cases where the whole stack is already reactive (blocking JDBC calls inside a WebFlux pipeline are an anti-pattern that defeats the model), or where the streaming/backpressure semantics of Flux genuinely fit the problem - not simply "high concurrency," which virtual threads now address from the MVC side.
21. How do you troubleshoot a bean that fails to initialize due to a circular dependency?
The first step is reading the BeanCurrentlyInCreationException stack trace carefully - Spring lists the full cycle of bean names it detected, which immediately tells you which beans are involved and, from their constructors, whether the cycle runs through constructor injection (unresolvable automatically) or setter/field injection (normally resolved automatically, so if it's still failing here, something else is interfering, like a BeanPostProcessor needing the bean too early).
@Service public class OrderService { @Lazy public OrderService(PricingService pricingService) { this.pricingService = pricingService; } }
For a genuine constructor-injection cycle, the most common fixes are: annotate one of the constructor parameters with @Lazy so Spring injects a lazily-initialized proxy instead of forcing eager resolution during construction, breaking the deadlock; refactor one side to setter injection so the three-level cache can resolve it; or, often the better long-term fix, extract the shared behavior both beans depend on into a third bean that each can depend on independently, removing the cycle altogether rather than just working around it.
22. What happens when two beans of the same type exist without a @Primary or @Qualifier?
Spring fails fast at context startup with a NoUniqueBeanDefinitionException, listing the names of every candidate bean it found matching the requested type, rather than guessing which one the application meant.
NoUniqueBeanDefinitionException: expected single matching bean but found 2: stripeGateway, paypalGateway
There are three common ways to resolve it. Add @Primary to whichever bean should be the sensible default across the application, so unqualified injection points resolve to it automatically while other injection points can still explicitly request the alternative via @Qualifier. Add @Qualifier("beanName") directly at the specific injection point when there's no sensible "default" and each usage genuinely needs to pick deliberately. Or, as a fallback Spring itself uses, name the injected field or constructor parameter to exactly match one bean's name - after by-type matching is ambiguous, Spring tries by-name matching - though this is fragile since it depends on parameter names being preserved and is generally considered a less explicit, less recommended approach than the first two.
23. How can you optimize Spring Boot 4 application startup time?
Several complementary techniques target different parts of the startup cost.
- Lazy initialization (
spring.main.lazy-initialization=true) defers bean creation until first use rather than eagerly building the entire context up front, though it trades some request-time latency for faster boot. - Trim auto-configuration with
spring.autoconfigure.excludefor starters that are on the classpath but not actually needed, since each auto-configuration class Spring evaluates - even ones that back off - adds to startup work. - AOT processing (
mvn spring-boot:process-aot, or building a GraalVM native image) precomputes the application context at build time, which is the single biggest lever, especially combined with a native image. - Enable virtual threads so the initial thread-pool warm-up is cheaper.
- Profile the boot sequence with the
--debugflag orConditionEvaluationReportto see exactly which auto-configuration classes and conditions are consuming time, rather than guessing.
Narrowing component-scanned base packages and avoiding heavy work inside @PostConstruct methods or static initializers rounds out the list - both add straight-line time to context refresh regardless of the other optimizations applied.
24. Why should you avoid field injection in production Spring codebases?
Field injection hides a class's real dependencies from anyone reading its public API - the constructor signature says nothing about what the class actually needs, so understanding its requirements means scanning every field for @Autowired annotations instead of reading one method signature.
// field injection - hidden dependency, can't be final @Autowired private PaymentGateway gateway; // constructor injection - explicit, can be final private final PaymentGateway gateway; public OrderService(PaymentGateway gateway) { this.gateway = gateway; }
It also blocks marking the field final, so nothing in the language stops a dependency from being reassigned later, and it means the class can't be instantiated at all outside a DI container without resorting to reflection tricks like ReflectionTestUtils.setField in tests - true, container-free unit tests with plain new and hand-wired mocks become impossible. Finally, field injection delays failure: since injection happens after the constructor runs, a missing or misconfigured dependency surfaces as a NullPointerException later, whenever the field is first used, rather than immediately and clearly at object-construction time the way a missing constructor argument would.
25. How is the API version resolved when a client sends no version header in Spring Framework 7?
The outcome depends on how the application configured its ApiVersionStrategy. If a default version is set via ApiVersionConfigurer.setDefaultVersion(...), an unversioned request is treated as if it had requested that default, and gets routed accordingly - this is the common choice for keeping older, pre-versioning clients working unchanged after versioning is introduced.
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void configureApiVersioning(ApiVersionConfigurer configurer) { configurer.useRequestHeader("API-Version").setDefaultVersion("1"); } }
If no default is configured and a mapping explicitly requires a version, Spring rejects the request with a client error rather than guessing which handler was intended. If an unversioned handler method also exists alongside versioned ones for the same path, that unversioned method typically acts as a catch-all for requests carrying no version at all. Spring's baseline-version syntax (version = "1.2+") adds further nuance: a handler declared this way matches any request version at or above 1.2, so as new minor versions are introduced, older baseline-declared handlers keep serving them without needing a new mapping for every increment.
26. Which is better and why: RestClient or WebClient for a blocking Spring MVC service?
RestClient is the better fit for a classic, blocking Spring MVC application. It's purpose-built for synchronous use - a call to .retrieve().body(Product.class) returns the object directly, with no Mono/Flux wrapping or .block() calls needed - which matches how an MVC controller thread already operates and keeps the client code simple to read, debug, and unit test with plain mocks.
// RestClient: fits naturally in a blocking MVC method Product product = restClient.get().uri("/products/{id}", id).retrieve().body(Product.class); // WebClient in the same MVC method: adds reactive overhead for no benefit Product product = webClient.get().uri("/products/{id}", id).retrieve() .bodyToMono(Product.class).block();
WebClient earns its place specifically inside a WebFlux, reactive stack, or when a service genuinely needs to fan out multiple concurrent downstream calls and compose their results non-blockingly with Mono/Flux operators. Reaching for WebClient inside a blocking MVC controller just to "use the newer API" adds a layer of reactive wrapping and an explicit .block() call that provides no real concurrency benefit in a thread-per-request (or virtual-thread) model, while making the code harder to follow than the equivalent RestClient call.
27. How does Spring Framework 7 take advantage of virtual threads compared to the traditional platform-thread model?
A traditional Spring MVC deployment runs each request on a thread drawn from a bounded platform-thread pool - Tomcat's default is around 200 threads. Every blocking call a request makes (a JDBC query, a downstream HTTP call) ties up one of those full OS threads for the entire wait, so total concurrent in-flight requests is hard-capped at the pool size regardless of how much of that time is spent simply waiting rather than computing.
spring.threads.virtual.enabled=true
Setting this property swaps the executor backing the embedded servlet container - and Spring's @Async/TaskExecutor infrastructure - to use JDK virtual threads instead. A virtual thread that blocks on I/O parks cheaply rather than occupying a scarce OS (platform) thread, so thousands of concurrent, mostly-waiting requests can be in flight using only a handful of actual OS threads underneath, all while the application code stays fully synchronous and imperative - no reactive rewrite required.
The gain is specific to I/O-bound waiting, though: virtual threads don't make CPU-bound work faster, since CPU-bound code still needs an actual core to run on. There's also a known pitfall - a virtual thread that blocks inside a synchronized block (or certain native/JNI calls) gets pinned to its underlying OS thread instead of being able to unmount while waiting, which can silently reintroduce the old scalability ceiling in code with heavy synchronized sections.
28. Why doesn't a @Scheduled method run concurrently with itself by default in Spring?
Spring's default scheduling infrastructure behind @EnableScheduling uses a single-threaded TaskScheduler unless the application explicitly supplies its own multi-threaded one. With only one thread servicing every @Scheduled method, if a given execution takes longer than the method's fixed interval, the next trigger simply waits its turn in that single thread's queue rather than starting a second, overlapping execution.
@Configuration public class SchedulingConfig { @Bean public TaskScheduler taskScheduler() { ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); scheduler.setPoolSize(5); // now different @Scheduled methods can overlap return scheduler; } }
Even after configuring a larger pool so different scheduled methods can run concurrently with each other, a single scheduled method is still, by design, treated as one logical unit of periodic work rather than something meant to have multiple copies of itself running at once - Spring doesn't automatically re-enter a method's next scheduled execution while a previous run of that same method is still in flight, since that would typically indicate the fixed interval is too aggressive for the work involved rather than something to paper over with concurrency. Genuine concurrent execution of the same logical task, if actually needed, has to be built deliberately - for example, by having the scheduled trigger just enqueue independent units of work onto a separate worker pool.
29. How do you troubleshoot a NoSuchBeanDefinitionException in a Spring application?
The exception means Spring searched the context for a bean of the requested type (or name) and found none, so troubleshooting is really a process of figuring out why the bean never got registered.
- Check that the class actually carries a stereotype annotation (
@Component,@Service, etc.) or is registered via an explicit@Beanmethod - a class with none of these is just a plain object as far as Spring is concerned. - Verify the class sits inside the component-scanned package tree; a class outside the base package (or an unusually structured multi-module project) is invisible to
@ComponentScanunless the base package is widened or the class is explicitly imported. - Check for a
@Conditional,@ConditionalOnProperty, or@Profileguarding the bean that isn't currently satisfied - run with--debugto print the auto-configuration/condition evaluation report and see exactly which conditions passed or failed. - If the lookup was by name (via
@QualifierorgetBean("name")), double-check for a typo against the actual registered bean name, which defaults to the class name with a lowercase first letter, or the@Beanmethod name.
30. Explain the lifecycle of a request handled through an HTTP Interface client (@HttpExchange) in Spring 7?
An HTTP Interface client is a plain interface annotated with @HttpExchange-family annotations; at startup, HttpServiceProxyFactory generates a JDK dynamic proxy implementing that interface, so the application never writes an implementation by hand.
sequenceDiagram
participant App as Application code
participant Proxy as Generated proxy
participant Adapter as RestClientAdapter/WebClientAdapter
participant Client as RestClient/WebClient
App->>Proxy: call interface method
Proxy->>Proxy: build HttpRequestValues from args/annotations
Proxy->>Adapter: delegate exchange
Adapter->>Client: perform HTTP call
Client-->>Adapter: HTTP response
Adapter-->>Proxy: raw response
Proxy-->>App: converted return value
When application code calls an interface method, the proxy's invocation handler builds an HttpRequestValues object from the method's arguments and annotations - path variables, query params, headers, request body - and, if the method declares a version attribute, attaches that to the request through the configured API-versioning strategy. It then delegates to an adapter (RestClientAdapter wrapping a RestClient for synchronous use, or WebClientAdapter wrapping a WebClient for reactive use), which performs the actual HTTP exchange. The adapter's underlying client applies whatever interceptors, error handling, and observation/tracing instrumentation it's configured with, receives the response, and the proxy converts the response body into the method's declared return type via message converters before handing it back - synchronously as a plain object if backed by RestClient, or wrapped in a Mono/Flux if the interface method itself is declared reactively.
31. What is the difference between @ControllerAdvice and a Filter for handling errors in Spring MVC?
@ControllerAdvice paired with @ExceptionHandler operates inside the Spring MVC dispatch layer - it only catches exceptions thrown after HandlerMapping has already matched a controller and the request is being processed by, or on the way into, that controller's code (including argument resolution and interceptor logic). It works with typed model objects and has full access to Spring's HttpMessageConverters, so returning a structured JSON error body with the correct status code is straightforward.
| Aspect | @ControllerAdvice | Filter |
| Runs | Inside MVC dispatch | At the servlet container level, before DispatcherServlet |
| Catches | Exceptions from controller code | Anything, including requests that never reach a controller |
| Works with | Typed model objects, message converters | Raw HttpServletRequest/Response |
A Filter runs earlier and more broadly, wrapping DispatcherServlet itself, so it can intercept problems that never reach a controller at all - a malformed request a security filter rejects, or an exception thrown by another filter. In practice, the two are complementary: use @ControllerAdvice to translate business and validation exceptions into consistent structured error responses, and use filters for cross-cutting infrastructure concerns - authentication failures, request logging, global CORS handling - that need to run whether or not a controller handler was ever reached.
32. Why is JSpecify considered a breaking change for some Kotlin Spring projects upgrading to Spring Framework 7?
Kotlin's compiler enforces null-safety at compile time, and it does so for Spring's own APIs by reading whichever nullability annotations Spring's method signatures carry - previously Spring's own org.springframework.lang.Nullable/NonNull annotations, now JSpecify's @Nullable/@NonNull in Spring Framework 7.
// if a return type's nullability annotation changes, // Kotlin's inferred type for it changes too val user: User = userLookup.findByEmail(email) // was non-null, may now require User?
Two things compound the risk on upgrade. First, the annotation set itself changed, so even a semantically identical @Nullable usage is now read via a different type Kotlin's compiler resolves differently in edge cases. Second, and more consequential, Spring 7 tightened nullability checking to only honor annotations declared directly on a method, no longer inheriting them from an overridden superclass or interface method - so a method that relied on inherited annotations for its nullability contract may now appear unannotated (and therefore platform-typed or defaulted) to Kotlin's compiler. The practical effect is that an upgrade can silently change which Spring API calls Kotlin considers nullable, surfacing as new compiler warnings, new smart-cast failures, or previously safe !! non-null assertions becoming genuinely risky - it is rarely a hard compile error across the board, but it is not a safe, unreviewed drop-in upgrade for a Kotlin codebase either.
33. How do you optimize a Spring Data JPA application to avoid the N+1 query problem?
The N+1 problem shows up when loading a list of parent entities triggers one query for the list, then a separate lazy-loading query for each row's related association - a hundred orders each lazily fetching their line items means a hundred and one round trips to the database instead of two.
@Query("SELECT o FROM Order o JOIN FETCH o.lineItems WHERE o.status = :status") List<Order> findByStatusWithItems(@Param("status") String status); // or, without changing the entity's default fetch type globally @EntityGraph(attributePaths = "lineItems") List<Order> findByStatus(String status);
JOIN FETCH in a JPQL query eagerly pulls the association into the same single query. @EntityGraph achieves the same result for a specific repository method without changing the entity's default (and normally sensible) lazy-fetch type everywhere else. For associations that can't cleanly be joined - a collection accessed across many different code paths - Hibernate's batch fetching (hibernate.default_batch_fetch_size, or @BatchSize on the association) groups the lazy loads into batched IN (...) queries instead of one row at a time, which is a lower-effort fix when a targeted JOIN FETCH isn't practical. Finally, when the use case only needs a handful of fields, a Spring Data projection or DTO query avoids hydrating full entity graphs at all, sidestepping the N+1 question entirely for read-heavy endpoints.
34. When would you choose NESTED over REQUIRES_NEW transaction propagation?
Choose NESTED when an inner operation should be able to fail and roll back on its own, without ending or suspending the outer transaction around it - useful for something like a batch import where one bad record should be skipped without discarding everything already processed successfully.
@Transactional public void importBatch(List<Record> records) { for (Record r : records) { try { recordService.saveNested(r); // NESTED - savepoint per record } catch (ValidationException e) { log.warn("Skipping invalid record {}", r.getId()); } } } @Transactional(propagation = Propagation.NESTED) public void saveNested(Record r) { /* ... */ }
NESTED works by creating a JDBC savepoint within the same physical transaction and connection; if the nested unit fails, only that savepoint rolls back, and the outer transaction can catch the exception and continue using the same connection. This requires a PlatformTransactionManager that supports savepoints, such as DataSourceTransactionManager - it isn't universally available across every transaction manager.
Choose REQUIRES_NEW instead when the inner operation genuinely needs to be independent - committed or rolled back on its own regardless of what happens afterward in the outer transaction, such as writing an audit log entry that must persist even if the outer business operation later fails. REQUIRES_NEW physically suspends the outer transaction and starts a completely separate one on its own connection, rather than a savepoint sharing the same transaction - a heavier operation, but a stronger independence guarantee than a savepoint provides.
35. How does Micrometer observability integrate with Spring Framework 7's tracing support?
Spring Framework 7's own instrumentation - HTTP client and server calls, @Scheduled executions, messaging listeners - emits Micrometer Observations through a shared ObservationRegistry rather than each subsystem hand-rolling its own metrics or trace spans separately.
flowchart LR
A[Instrumented call e.g. RestClient request] --> B[Observation via ObservationRegistry]
B --> C[Micrometer Metrics - Timer/Counter]
B --> D[Micrometer Tracing - Span]
D --> E[Tracing backend e.g. Zipkin/OTel collector]
A single Observation around, say, an outbound RestClient call fans out to two consumers at once: the metrics side turns it into a timer (latency) and counter (call volume, tagged by status), while Micrometer Tracing turns the same observation into a span, complete with the current trace context. That trace context propagates automatically across process boundaries - an inbound request's trace ID carries through to any outbound RestClient, WebClient, or @HttpExchange call made while handling it, so a chain of microservice calls produces one connected, end-to-end trace in a backend like Zipkin or any OpenTelemetry-compatible collector, without each service needing to manually thread trace headers through its own client calls.
The practical benefit is that instrumenting one call site (or relying on Spring's own built-in instrumentation) yields both a metric and a trace span from a single source of truth, rather than maintaining separate, potentially inconsistent manual instrumentation for each.
36. Why is @Order important when multiple Filters or HandlerInterceptors are registered in a Spring application?
Filters and interceptors frequently depend on state or side effects a previous one is expected to have already set up, so the sequence they run in isn't cosmetic - it's part of the application's correctness.
@Bean public FilterRegistrationBean<RequestLoggingFilter> loggingFilter() { FilterRegistrationBean<RequestLoggingFilter> reg = new FilterRegistrationBean<>(new RequestLoggingFilter()); reg.setOrder(1); // run early, before security decisions are made return reg; }
A few common examples: a CORS filter typically needs to run early enough to short-circuit preflight OPTIONS requests before an authentication filter has a chance to reject them for missing credentials the browser was never going to send on a preflight anyway; a request-logging filter usually wants to run before a security filter that might wrap or consume the request body; and a filter that opens a database transaction or a Hibernate session (the "open session in view" pattern) has to run before any interceptor or filter downstream that assumes an active session is available.
Without an explicit order - via @Order, implementing Ordered/PriorityOrdered, or setOrder() on a FilterRegistrationBean - Spring falls back to registration or classpath-discovery order, which is fragile: it can silently change when a dependency is upgraded, a new starter is added, or component scanning happens to discover classes in a different order, producing intermittent, hard-to-diagnose bugs where a filter's assumption about "what already ran" is quietly violated.
37. How does Spring Framework 7's BeanRegistrar differ from a traditional @Configuration class with multiple @Bean methods?
A @Configuration class with several @Bean methods is fundamentally declarative: each method runs and produces exactly one bean, unconditionally, unless individually decorated with @Conditional-family annotations - registering a variable number of beans, or beans whose type or name depends on runtime data, quickly turns into a stack of conditional annotations that gets hard to follow.
// @Bean approach - one bean per method, conditions bolted on @Bean @ConditionalOnProperty("feature.metrics.enabled") public MeterRegistry meterRegistry() { return new SimpleMeterRegistry(); } // BeanRegistrar approach - imperative, can loop and branch freely class FeatureBeansRegistrar implements BeanRegistrar { public void register(BeanRegistry registry, Environment env) { if (env.getProperty("feature.metrics.enabled", Boolean.class, false)) { registry.registerBean("meterRegistry", MeterRegistry.class, spec -> spec.supplier(ctx -> new SimpleMeterRegistry())); } } }
BeanRegistrar flips that model to imperative: a single register() method receives a BeanRegistry and the Environment directly, so it can use ordinary Java control flow - loops, conditionals, computed bean names - to register however many beans of whatever types the runtime situation calls for, all in one place rather than scattered across many separately-conditioned methods. The trade-off is readability at a glance: a page of @Bean methods is easy to skim and see every bean the configuration produces, while a BeanRegistrar's output depends on running its logic mentally (or in a debugger). That's why it's best reserved for genuinely dynamic registration scenarios - often framework or library code - rather than replacing straightforward, static @Bean methods across the board.
38. What is the difference between @RequestMapping's version attribute and content negotiation via the Accept header for API evolution?
The version attribute is a dedicated mechanism built specifically for API lifecycle evolution: it's declared once per handler method, resolved consistently through a configurable, application-wide strategy (header, path segment, query parameter, or media type), understood natively by client-side tooling like RestClient, @HttpExchange, and testing utilities such as RestTestClient, and supports semantic version comparisons - a handler declared with version = "1.2+" matches any request at or above that version, and the framework can surface deprecation signals for older versions automatically.
// dedicated versioning @GetMapping(path = "/products", version = "2") public List<ProductV2> listV2() { } // versioning piggybacked on content negotiation @GetMapping(path = "/products", produces = "application/vnd.company.product.v2+json") public List<ProductV2> listV2ViaMediaType() { }
Content negotiation via the Accept header and the produces attribute is a general-purpose mechanism meant for choosing a representation format - JSON versus XML versus a custom vendor type - and can be repurposed for versioning, as the older application/vnd.company.v1+json convention did, but doing so means hand-building the version-comparison and deprecation logic yourself, and conflates two independent concerns (format and version) into a single header value. Spring Framework 7's native version attribute keeps those concerns cleanly separate: Accept/produces still decides the representation format, while version independently decides which generation of the API handles the request.
39. How do you troubleshoot slow Spring Boot 4 native image builds?
Native image build time is dominated by GraalVM's whole-program reachability analysis, so troubleshooting mostly means finding what's making that analysis larger or slower than it needs to be.
- Check the memory allocated to the
native-imagebuild tool itself - it's a memory-hungry AOT compiler distinct from the application's own runtime heap, and an under-resourced build machine can turn a merely slow build into a thrashing one. - Audit reflection/proxy configuration (the AOT-generated hints, or any hand-written
reflect-config.json) for entries that aren't actually exercised - every registered reflective access point widens what the analysis has to reason about. - Trim unused dependencies and auto-configurations from the classpath, since AOT has to analyze everything reachable from the application's entry points, including code paths the application never actually uses at runtime.
- Use GraalVM's build reporting (
-H:+BuildReport) to see which phase - analysis, compilation, or image writing - is actually taking the time, rather than guessing. - Cache the GraalVM distribution and the build tool's dependency cache in CI, since a cold environment pays the full download-and-setup cost on every run in addition to the analysis itself.
40. Why do many teams delay adopting GraalVM native images despite the startup-time benefits in Spring Boot 4?
The benefits are real but narrow, while the costs land on every build, for every developer, regardless of whether the deployment shape actually needs them.
Build times for a native image are dramatically longer than a standard JAR build - what's a few seconds with a normal Maven/Gradle build can become minutes, which slows down CI feedback loops and makes local iterate-and-test cycles noticeably more painful. Some libraries, particularly ones that lean heavily on runtime reflection, dynamic proxies, or bytecode generation that isn't native-image-aware, need extra reachability metadata to work at all under AOT compilation, and a few simply don't work without workarounds or forks. Debugging a native binary also has far less mature tooling than attaching a debugger or profiler to a running JVM, which matters when something goes wrong in production.
Most importantly, the actual payoff - near-instant startup and a small, fixed memory footprint - matters most for specific deployment shapes: serverless functions that cold-start per invocation, or container-dense clusters that scale instances up and down frequently. A team running a modest, steadily-warm fleet of long-lived JVM instances doesn't experience those cold-start or density problems in the first place, so the reduced build speed and flexibility often isn't worth trading for a startup-time benefit that a warm, always-on JVM instance was never going to feel.
