Top 50 Spring Boot 4 Interview Questions and Answers (2026)
Fifty Spring Boot 4 interview questions with short answers that fit in a sentence or two aloud, from Boot 4.1 and proxies to Spring Security 7, Kafka, batch and Spring AI. Every answer links a deep dive that reproduced the behaviour by running code.
Spring Boot 4 interviews now ask about things a Boot 3 cram sheet gets wrong: why a scheduled job runs three times in production and once on your laptop, why the library you added by its own coordinates has no bean at startup, why a perfectly valid API key still returns 401, and why @Retryable with maxRetries = 3 calls your method four times.
This page has 50 questions, each with an answer short enough to say aloud, grouped from platform basics through security and messaging. Every answer links to one of 39 deep-dive articles on this blog in which the behaviour was reproduced by running code, so you can check the claim, or go as deep as the interview requires. The web-layer basics (the dispatcher, request mapping, binding and validation) are in Top 40 Spring MVC Interview Questions and Answers (2025), and this page assumes them and concentrates on what changed with Boot 4, Framework 7 and Security 7.
Versions and how this was checked. Spring Boot 4.1.1, which is the latest 4.1.x on Maven Central on 22 September 2026 (4.2.0-M1 is a milestone), Spring Framework 7.0.9, Spring Security 7.1.1, Spring Batch 6.0.5 and JDK 25. Those are the versions the deep dives ran against. Retrieved 22 September 2026.
This page has no companion repository and nothing on it was re-run for it. Every answer summarises a result that the deep dive linked beneath it produced by running code, and every code block is quoted verbatim from one of those deep dives. Where a deep dive corrected a widely repeated claim, the answer uses the corrected version.
Use the table to jump to the topic your interview is about. The questions run from platform basics to specialised topics, and each answer is followed by a Deep dive link.
If the interview is about…
Start at question
Versions, support windows, migration blockers, Boot 4 modules and Jackson 3
1 to 4
Configuration binding, profiles, slow startup and Actuator
5 to 9
Containers, Kubernetes, error bodies and API documentation
10 to 14
Proxies, @Transactional, @Async, caching, scheduling and retry
15 to 23
JDBC versus JPA, Flyway versus Liquibase, batch and GraphQL
24 to 30
Filter chain, JWT, resource servers, method security, passkeys, CORS and SSRF
31 to 41
Kafka, RabbitMQ, SSE, gRPC, RSocket and Spring AI
42 to 50
Versions, modules and configuration
Boot 4 changed where things live before it changed what they do, so the first questions are about support windows, jars and property sources. Interviewers use them to find out whether you have actually moved a service, or only read the release notes.
1. Which Spring Boot version should a new or migrating project target today?
The latest 4.1.x, which is 4.1.1 at the time of writing. Boot 3.5 lost open-source support on 30 June 2026 (3.5.16 was the last free patch, and there is no 3.6). Boot 4.0 was released on 20 November 2025 and is supported until 31 December 2026, while Boot 4.1 was released on 10 June 2026 and is supported until 31 July 2027. Stopping at 4.0 buys about four months of runway and guarantees a second migration, because 4.1 removed things that 4.0 had deprecated.
The distinction interviewers like is that end of life does not mean the software suddenly became vulnerable; it means fixes stop arriving from upstream. Commercial support for 3.5 is a separate clock and runs to 2032, so ‘we are on a supported version’ can be true for a vendor and false for your Maven Central builds at the same time. Preferring 4.1 over 4.0 is the author’s opinion about the calendar, not an official Spring position.
Deep dive:Spring Boot 3.x Is EOL. Don’t Stop at 4.0. Target 4.1 Instead, which has the support-window table, the CVE batch that followed the 3.5 end of life, and a case for when 4.0 is still the right choice.
2. What turns a Boot 3 to Boot 4 upgrade from a two-week job into a two-month one?
Five things, and any one of them means you should stop estimating it as a small upgrade. Undertow, because Framework 7 needs Servlet 6.1 and no Undertow release supports it, so the container has to move to Tomcat 11+ or Jetty 12.1+ first as its own project. A Java version below 17, which is a hard floor. Internal starters that still declare auto-configuration in spring.factories, which has been inert since Boot 3.0 removed that loading path; Boot 4’s modular layout is usually just where teams finally notice. Native images, because Framework 7 moved to GraalVM’s unified reachability-metadata format and resource hints changed from regex to glob semantics. And security behaviour nobody has tests for, because the Security 7 request-matcher and CSRF changes move it silently.
The companion advice is about scope: do not combine the upgrade with a JDK jump, a database upgrade, a Hibernate rewrite or a Kubernetes migration in the same branch.
Deep dive:Spring Boot 3.x Is EOL. Don’t Stop at 4.0. Target 4.1 Instead, which also has the two-week plan for one small, well-tested service and an OpenRewrite section on what the recipes will and will not do.
3. Why does adding a library by its own coordinates no longer wire anything on Boot 4?
Boot 3 kept every auto-configuration class in one spring-boot-autoconfigure jar, so depending on org.springframework.kafka:spring-kafka was enough to get a KafkaTemplate. Boot 4 moved each technology’s auto-configuration into its own module, and the starter is what brings that module. A bare spring-rabbit dependency gives you no RabbitTemplate and no RabbitAdmin, because RabbitMQ’s auto-configuration lives in spring-boot-amqp. The same shape shows up with spring-boot-starter-websocket versus a bare spring-websocket, and with RestClient.Builder, which needs spring-boot-starter-restclient rather than the web starter.
The failure is a startup error along the lines of ‘required a bean of type … that could not be found’, from an application that compiled perfectly, which is why it costs an hour rather than a minute. The rule to state in an interview: on Boot 4, depend on the starter, not on the library.
Deep dive:Spring Boot and RabbitMQ: Exchanges, Queues, Bindings and a Working Dead-Letter Queue, where it appears as the first of two dependency decisions before any RabbitMQ code runs.
4. What changed with Jackson in Boot 4, and how do you tell the two families apart?
Boot 4 moves from Jackson 2 to Jackson 3, and the package changes from com.fasterxml.jackson to tools.jackson. Libraries that support both, such as Spring Kafka 4.1 and Spring AMQP 4.1, ship two complete JSON families side by side. The rule the deep dive gives for telling them apart: under Boot 4, if the class name contains a 2 (Jackson2JsonMessageConverter), it belongs to the old family, and if it does not (JacksonJsonMessageConverter), it belongs to Jackson 3. Your IDE will happily suggest the wrong one.
The runtime side is quieter than the compile-time side. Jackson 3 changes the default date format and property order, so a snapshot test of JSON, a cache full of serialised payloads or a downstream consumer that parses your output can change without a single compile error.
Deep dive:Spring Boot 4.1 and Apache Kafka: Producer, Consumer and Serialisation from Scratch, where the two Kafka serialiser families are laid out in a table.
5. What is the difference between @ConfigurationProperties and @Value?
They are two mechanisms, not two styles. @Value("${demo.mail.host}") is a string placeholder resolved during bean post-processing: a name, a lookup and a type conversion, with : for a default and SpEL if you need it. @ConfigurationProperties uses the Binder, which walks the target type, works out from its shape which properties it needs, and constructs the object. A record has one canonical constructor, so Boot uses constructor binding with no annotation at all. That is what gives you validation, IDE metadata and relaxed binding: one canonical name such as demo.relaxed.api-key, and several accepted spellings.
Three details are worth having ready. ‘Value does not support relaxed binding’ is a statement about the Spring Framework, not about Spring Boot. A list is bound from the single highest-precedence source that holds it, so a source with three elements replaces a lower source’s two rather than appending to them. And if IDE auto-completion for your properties quietly stops while the build stays green, suspect the annotation processor: on JDK 23 and later javac no longer runs a processor that is only on the classpath (-proc:none is the new default), so spring-boot-configuration-processor has to be declared as an annotation processor path.
Deep dive:@ConfigurationProperties vs @Value in Spring Boot 4: Binding, Validation and Relaxed Rules, with the spelling matrix, generated and then re-checked by launching a real JVM per spelling.
6. How do profiles and property precedence work, and why did my profile-specific file lose?
Every file you write sits at one position in Boot’s precedence list: application.yaml, application-prod.yaml, an imported config tree and a mounted ConfigMap all count as config data, which is number 3. OS environment variables are number 5, Java system properties come next, and command-line arguments are number 11. Profile-specific files always override application.yaml, and with several profiles active the last one wins (prod,live means application-live.yaml beats application-prod.yaml), but that ordering happens inside item 3 and nothing inside it can reach item 5.
So when the file is definitely loaded, because another key in it took effect, and one value is stale, look for an environment variable before you look for a bug: kubectl exec deploy/my-app -- env | grep -i datasource finds it more often than rereading the file. Two more points. @Profile is a separate mechanism that decides which beans exist, while config activation decides which properties are set; they share names and nothing else. And a ConfigMap mounted as a volume is one file per key holding only the value, so there is no such thing as a profile-specific config tree.
Deep dive:Spring Boot Profiles Done Right: Config Import, Config Trees and Kubernetes ConfigMaps, including the fifteen-line diagnostic that asks the running application which source held a property.
7. How would you find out why a Spring Boot application takes eight seconds to start?
Start by not trusting the one log line. Started ... in 6.6 seconds (process running for 7.4) reports two different clocks: the first runs from SpringApplication.run() to the ready event, the second is the whole JVM, and the 0.8-second gap (jar opening, class verification, JIT warm-up) is not yours to tune. To split the 6.6 seconds you need ApplicationStartup. There is no property for it, because the steps you care about are recorded before any configuration file has been read, so it has to be set on the SpringApplication before run(). This is the main method from the deep dive:
public static void main(String[] args) {
SpringApplication app = new SpringApplication(StartupDiagnosisApplication.class);
int capacity = Integer.getInteger("startup.buffer", 16384);
switch (System.getProperty("startup.tracking", "buffering")) {
case "buffering" -> app.setApplicationStartup(new BufferingApplicationStartup(capacity));
case "jfr" -> app.setApplicationStartup(new FlightRecorderApplicationStartup());
default -> { }
}
app.run(args);
}
Then read the /actuator/startup tree carefully, because steps nest: sorting by duration sends you to optimise a bean that costs nothing. The buffer also truncates silently when it fills, with a 200 status and valid JSON; it keeps the first steps to finish and drops the enclosing ones. Component scanning has a measurable price, since 5,000 classes that are not even beans cost about 560 ms, and lazy initialisation does not reduce it, so the only fix is an explicit scanBasePackages. The honest closing line: a monolith that restarts twice a week and takes eight seconds to start costs sixteen seconds a week, so for most applications it does not matter.
Deep dive:Why Your Spring Boot App Takes 8 Seconds to Start: A Bean-by-Bean Diagnosis, which measured the scan cost and the truncation rather than asserting them.
8. Which Actuator endpoints are exposed by default, and what are the gates?
An endpoint is reachable over HTTP only when three independent gates all say yes: it exists (startup, httpexchanges, auditevents and logfile depend on a bean or a property), its access setting allows it (heapdump and shutdown default to none, so include: "*" is not enough for them), and it is exposed on the web. With the starter and no configuration, only /actuator/health is exposed. Ask for /actuator/env and you get a 404, not a 403, because the endpoint exists and is enabled but was never mapped onto HTTP.
The sentence to remember is that exposure is not access control. Widening exposure decides what exists on HTTP; deciding who may call it is separate security configuration that you have to write. management.endpoint.health.show-details defaults to never, and sanitisation on /actuator/env is a rendering feature of one endpoint rather than a security boundary, because heapdump serves process memory.
Deep dive:Spring Boot Actuator in Production: Every Endpoint, Securing It, and Custom Health Indicators, with a table of every endpoint read from the running application.
9. How do you write a custom health indicator on Boot 4, and what can it break?
Two things change and one thing hurts. The package org.springframework.boot.actuate.health does not exist in Boot 4.1.1: Health, Status and AbstractHealthIndicator moved into a new spring-boot-health module. The interface method was renamed from getHealth(boolean) to health(boolean), so an old override silently stops being an override (add @Override and let the compiler tell you), and Health no longer extends HealthComponent. Prefer AbstractHealthIndicator over the bare interface, since it catches your exceptions and turns them into DOWN.
The thing that hurts is where /actuator/health is read. Most Kubernetes manifests point their probes at it, so one custom indicator that calls a third party is enough to turn the default health endpoint red and take instances out of service when the third party has a bad minute. Question 11 covers keeping dependency checks out of the probe groups.
Deep dive:Spring Boot Actuator in Production: Every Endpoint, Securing It, and Custom Health Indicators, whose third part is about custom indicators and the outage they cause.
These questions test whether you have operated a service, not just written one: how it is packaged, how the orchestrator judges it, how errors leave it and how its API is described.
10. What does ‘image size’ mean, and how would you containerise a Spring Boot 4 service?
Three different numbers hide behind ‘size’: what docker images prints, what a deployment pushes, and what a node pulls. The second is the one that costs you on every deploy. A plain Dockerfile on a JDK base image was 456 MB in the deep dive, and swapping the base for a JRE took it to 377 MB. The layered approach splits the fat jar so that dependencies stay in a layer that does not change when your code does. The image the deep dive recommends is a layered distroless one: 97 MB pushed, about 6 KB pushed after a one-line change, running as non-root with no shell.
Buildpacks are the alternative if you would rather not own a Dockerfile, with one catch: behind a corporate proxy they fail at build time, because the Java buildpack downloads its JRE during the build. On JDK 25 the ahead-of-time cache halves startup (3.356 s to 1.638 s in the measurement), but its training run comes after the application layer, so every code change re-pushes a 15.3 MB cache layer instead of 6 KB, and the cache is only valid for the exact JVM build that wrote it. That trade suits pods that start far more often than you deploy.
Deep dive:Dockerizing Spring Boot 4: Layered Jars, Buildpacks, Distroless and Image Size Benchmarks, which benchmarked nine images and discarded a run that had shared a machine with something else.
11. How should liveness, readiness and graceful shutdown be configured on Kubernetes?
By default each Boot health group holds exactly one thing, the application’s own LivenessState or ReadinessState, not the database and not your custom indicators, and that default is usually right. The classic mistake is putting a shared dependency into a probe. In the deep dive a 60-second downstream outage, with that indicator in the liveness group, killed and restarted both replicas together, so a dependency outage became a total outage of a service that did not even use the dependency. Liveness should hold only what a restart would fix, and readiness only what is specific to this instance.
For rolling updates, Kubernetes runs the preStop hook and the removal from the Service in parallel, and nothing orders them, so a pod that has received SIGTERM can still be sent new connections. Graceful shutdown (on by default, up to 30 seconds per phase) covers the requests already inside the pod, and a five-second preStop sleep covered the ones still arriving and removed the connection failures in all three runs. terminationGracePeriodSeconds then has to cover the sleep plus the drain.
Deep dive:Deploying Spring Boot 4 on Kubernetes: Probes, Graceful Shutdown, Limits and JVM Ergonomics, with a rolling restart measured under load in four configurations.
12. What does the JVM decide from a pod’s CPU and memory limits?
With no JVM options it reads the container’s cgroup limits and chooses a processor count, a garbage collector, a heap size and thread counts, and you can see the result with java -XX:+PrintFlagsFinal -version inside a pod for each resource shape. CPU limits are rounded up (500m is 1 processor, 1500m is 2), and with no limit the JVM sees every core on the node. A popular piece of advice, forcing -XX:ActiveProcessorCount=2 -XX:+UseG1GC to get a ‘proper’ collector into a small pod, made the 1-CPU pod worse in the deep dive: on a single CPU of quota it gave two parallel GC threads plus concurrent marking threads.
For autoscaling, CPU is a poor signal for a JVM, because startup and JIT compilation burn it while serving nothing and a service waiting on a database can be saturated at 20 % CPU; the deep dive scaled on requests in flight instead. Its ‘what to change on Monday’ list is short: take shared dependencies out of probe groups, add a startupProbe and a native preStop: sleep, set -XX:MaxRAMPercentage, and look at CPU throttling before deciding whether a service wants a CPU limit at all.
Deep dive:Deploying Spring Boot 4 on Kubernetes: Probes, Graceful Shutdown, Limits and JVM Ergonomics, whose third part covers JVM ergonomics and scaling on the right signal.
13. How do you return consistent errors from a Spring Boot 4 API?
RFC 9457 problem documents are not on by default: spring.mvc.problemdetails.enabled is false in Boot 4.1.1’s configuration metadata, so out of the box nothing is a problem document. Turning the flag on registers ProblemDetailsExceptionHandler, an empty subclass of ResponseEntityExceptionHandler, which handles Spring MVC’s own exceptions and any ErrorResponse. Your own domain exceptions, an unexpected 500, an exception thrown from a servlet filter and Spring Security’s 401 and 403 all keep Boot’s {"timestamp","status","error","path"} body. That is where most APIs actually sit: two error shapes, split along a line your clients cannot see.
To get one shape everywhere, you write your own @RestControllerAdvice that extends ResponseEntityExceptionHandler, add handlers for Security, and replace Boot’s /error controller; in the deep dive that combination handled thirteen of thirteen failures. Two cautions. Boot’s flag backs off the moment you declare your own ResponseEntityExceptionHandler. And a handled exception logs nothing unless your handler logs it, so put a correlation id in the body and the log line. Converting an existing API is a breaking change for clients that parse Boot’s body.
Deep dive:Global Exception Handling with ProblemDetail (RFC 9457) in Spring Boot 4, which ran thirteen failures under five setups and classified every response.
14. How does springdoc-openapi behave with Spring Framework 7’s built-in API versioning?
Not the way you would hope. When one path has several handlers scoped to different versions, springdoc folds them into a single operation whose response schema is a oneOf of every version, with an arbitrary operationId. It is not an error, so it passes code review, and a TypeScript client generated from /v3/api-docs ended up with one getAccount method typed as a union of every response shape the API had ever returned.
The fix in the deep dive is one GroupedOpenApi per version, each with an OpenApiCustomizer that collapses the merge, driven by a small hand-maintained map from version to schema. Functional endpoints are not a way out despite a changelog entry that name-checks API versioning: @RouterOperation has no version attribute, and with two entries registered for one path only one operation came out in the document. The practical rule is not to trust the plain /v3/api-docs for anything version-specific.
Deep dive:springdoc-openapi with Spring Boot 4.1: Generating, Customising and Versioning Your API Spec, with both the merge and the fix reproduced.
Proxies, transactions, async, caching and resilience
Nearly every question in this section has the same answer underneath: a proxy that does not see the call. Interviewers ask about six different annotations to find out whether you know that.
15. How does Spring AOP work, and why does an annotated method sometimes do nothing?
Spring AOP is a proxy mechanism that borrows AspectJ’s pointcut language. Spring does not modify your bytecode: when a bean matches a pointcut, the container puts a proxy in front of it, and the advice runs only for calls that arrive through that proxy. @Transactional, @Async, @Cacheable, @Retryable and @PreAuthorize are all this one mechanism, which is why their failures look identical: the annotation is present, nothing fails, nothing is logged, and the method behaves as if it were not annotated.
The picture below is the whole idea. The two solid arrows are calls from another bean, and both go through the proxy. The dashed arrow is one method of the bean calling another with this, and it never leaves the object, so the interceptor is not in that call path. That is self-invocation, and it is the most common cause.
The deep dive ran six ways an aspect fails to fire, each with a control proving the mechanism works when used correctly: an @Aspect class without @Component (nothing creates the bean), a pointcut that matches nothing (an empty match set, indistinguishable from an aspect that was never registered), a private method, a final method (the bean is proxied, but the method is inherited rather than overridden), self-invocation, and an object created with new. Almost every case reduces to three questions: is it a bean, is it proxied, and is this call going through the proxy? For self-invocation the fixes, best first, are to move the method to another bean, to inject the bean into itself, or to use AopContext.currentProxy() with exposeProxy = true, which works but couples your code to Spring AOP.
One Boot 4 detail: the starter was renamed. spring-boot-starter-aop last shipped as a GA release in 3.5.16 and spring-boot-starter-aspectj starts at 4.0.0-M3, and the old name fails dependency resolution rather than warning.
Deep dive:Spring AOP Explained: Pointcuts, Advice Types, and Why Your Aspect Isn’t Firing, which generated its designator table by running one advice per designator.
16. How does @Transactional work, and what are the ways it silently does nothing?
It is an AOP proxy. When a method carrying it is called through the proxy, an interceptor asks the PlatformTransactionManager for a transaction according to the propagation rule, invokes your method, commits on a normal return, rolls back on a RuntimeException or Error, and on a checked exception commits and rethrows. Since Spring 6.0 it also works on protected and package-private methods when the proxy is class-based (Boot’s default), and never on private methods.
The deep dive ran six ways it does nothing. Self-invocation. A private method. A checked exception, where the transaction commits on the way out. Swallowing the exception, where the interceptor sees a normal return and commits. A call from @PostConstruct, because the proxy does not exist while the bean is still initialising. And an object created with new. The checked-exception and swallowed-exception cases are the dangerous pair, because they start a transaction and then commit work the code was trying to abandon. Framework 7 lets you change the default globally: @EnableTransactionManagement gained a rollbackOn() attribute taking a RollbackOn enum with RUNTIME_EXCEPTIONS (the historical behaviour) and ALL_EXCEPTIONS. The one-line check to drop into a method you believe is transactional is whether the transaction is actually active; if it prints false, no amount of reasoning about propagation will help.
Deep dive:@Transactional in Spring: Propagation, Isolation, and the Six Ways It Silently Does Nothing, which produced its propagation matrix by asking the transaction manager what it did.
17. What are the propagation levels, and where does UnexpectedRollbackException come from?
REQUIRED (the default) joins the caller’s transaction, and REQUIRES_NEW suspends it and starts a second physical transaction. A physical transaction is one connection, one BEGIN and one COMMIT; REQUIRED maps many logical scopes onto one physical transaction and REQUIRES_NEW gives each scope its own. Because REQUIRES_NEW holds the outer connection while taking another, a pool sized to the number of request threads can deadlock under load. MANDATORY is an assertion that a transaction already exists. SUPPORTS with no transaction is not ‘no writes’: the method still writes, on an auto-commit connection with no rollback available. NOT_SUPPORTED suspends rather than declines.
The follow-up interviewers love is UnexpectedRollbackException. The outer method calls an inner REQUIRED method, and the inner one throws. Its interceptor cannot roll back a transaction it merely joined, so it sets rollback-only on the shared transaction and rethrows. The outer method catches the exception and returns normally, believing it has handled the failure, and then the commit fails. If you must catch and continue, the inner call has to be REQUIRES_NEW. And NESTED, which is described everywhere as savepoints, is unreachable on a stock Spring Boot JPA application because JpaTransactionManager cannot get a savepoint manager from Hibernate’s dialect, whereas it works with a JDBC transaction manager. Use REQUIRES_NEW instead.
Deep dive:@Transactional in Spring: Propagation, Isolation, and the Six Ways It Silently Does Nothing, whose second part is the full matrix of seven propagations.
18. How does @Async work, and what goes wrong with it?
@Async is not a keyword and it is not a thread. It is a marker that a bean post-processor looks for while the context is being built. The proxy’s version of your method wraps your code in a Callable, hands it to an AsyncTaskExecutor and returns immediately. Self-invocation is the trap. This is the example from the deep dive, quoted verbatim:
@Async
public CompletableFuture<String> annotated() { ... }
public CompletableFuture<String> viaSelfInvocation() {
return annotated(); // this.annotated() -- the proxy is not involved
}
Called through the proxy, annotated() ran on a pool thread (task-5). Called through viaSelfInvocation(), it returned a valid, already-completed future on the caller’s own thread (main), so the only symptom was that response times never improved. The other classic failure is the pool. The stock executor has corePoolSize=8 with an unbounded queue and unbounded maximum, and ThreadPoolExecutor only grows past its core size when the queue is full, so raising max-size alone changes nothing. Set queue-capacity deliberately: unbounded means an incident is a heap filling up rather than tasks being rejected, which is worse because it takes the whole process with it.
Boot 4.1 added spring.task.execution.propagate-context, which carries Micrometer context (the trace id, MDC entries) into the async thread. It silently does nothing when Micrometer’s context-propagation is not on the classpath. And the closing question is whether you should use @Async at all: it is in-process fire-and-forget, so if the JVM stops the work is gone and nothing retries it, which is fine for warming a cache and wrong for sending an email or writing an audit record.
Deep dive:@Async in Spring Boot 4: Executors, Virtual Threads and the Self-Invocation Trap, which measures every claim with a method that returns the current thread’s name.
19. How does @Cacheable work, and what are its traps?
The Spring cache abstraction is an interceptor and a map interface. When a bean carries @Cacheable, the proxy checks the cache for a key built from the arguments, and a cache hit is a method that did not run. The traps are the same proxy traps as everywhere else: self-invocation, a non-public method in proxy mode, and @PostConstruct (a warm-up loop in an init method warms nothing) all bypass the cache silently. The ten-second diagnosis is to print AopUtils.isAopProxy(bean) and the bean’s class name; no $$SpringCGLIB$$ means there is no interceptor at all.
The trap that returns the wrong answer rather than no cache is the key. A cache key is built from the arguments and nothing else, not the method name and not the declaring class, so findByIsbn(String) and findByTitle(String) sharing one cache name can collide. Use one cache name per method. Also note that a widely copied line does not compile: @EnableCaching(exposeProxy = true) cannot exist, because the annotation has exactly three attributes (mode, order and proxyTargetClass). Boot’s own advice is not to put @EnableCaching on the main application class, since that makes caching mandatory in every test slice.
Deep dive:The Spring Cache Abstraction: @Cacheable, @CacheEvict, Key Generators and the Self-Invocation Trap, which measured the four traps.
20. Does the Spring cache abstraction expire entries, and which cache provider do you get?
The abstraction has no time-to-live, no time-to-idle, no size limit and no eviction policy. All of that lives in the provider. The default simple provider is a ConcurrentHashMap, so an entry stays until something evicts it or the process ends, which is fine for a twelve-row lookup table and a slow leak for everything else.
Which provider you get when you have not chosen one is decided by the classpath, and the documented order and the shipped order disagree. Boot’s reference page lists Generic, JCache, Hazelcast, Infinispan, Couchbase, Redis, Caffeine, Cache2k, Simple, but the real order is the declaration order of the CacheType enum, which on Boot 4.1.1 swaps Couchbase with Infinispan and puts Cache2k ahead of Caffeine. With both on the classpath you get Cache2k. Two smaller points: a Caffeine cache built without recordStats() reports zero hits and misses, which looks exactly like a cache nobody is using, and in Boot 4 the caching auto-configuration moved into a new spring-boot-cache module.
Deep dive:The Spring Cache Abstraction: @Cacheable, @CacheEvict, Key Generators and the Self-Invocation Trap, whose third part covers TTL, providers and the cache stampede.
21. Why does @Scheduled run three times when you have three replicas, and how do you stop it?
@Scheduled is a per-JVM timer that has no idea other JVMs exist, so three replicas run every job three times. It does not appear in development or staging, where there is usually one instance, and it appears as duplicate work rather than as an error. ShedLock is the usual fix, but @SchedulerLock on its own does nothing: without @EnableSchedulerLock and a LockProvider bean the application starts cleanly at any log level and behaves exactly as if there were no lock, so verify by looking at the lock table, not at the code.
Two more things break it after it works. spring.task.scheduling.pool.size defaults to 1, so every @Scheduled method in the application shares one thread. And a fixedRate job does not skip ticks it could not run; it accumulates them and fires them back to back (thirty-five of forty executions arrived in one burst in the deep dive), so most jobs described as ‘every five minutes’ actually want fixedDelay. The best answer is often to make the job idempotent instead: a lock avoids a second execution, while idempotence means not caring about one. A lock also guarantees at most one execution per lock window, not exactly-once.
Deep dive:@Scheduled, ShedLock and Distributed Cron: Scheduling That Survives Three Replicas, which starts three real replicas against one PostgreSQL database and counts the rows.
22. How does @Retryable in Spring Framework 7 work?
Framework 7 moved retry into the core: @Retryable, @ConcurrencyLimit and RetryTemplate, with no new dependency, and the spring-retry README now says that project has been superseded and archived. Boot does not switch it on. Without @EnableResilientMethods the annotation is just metadata, with no warning at startup and none at call time. The next two blocks are from the deep dive:
@Configuration
@EnableResilientMethods
public class ResilienceConfig {
}
@Service
public class FlakyGateway {
@Retryable
public String defaults() {
return callTheFlakyThing();
}
}
The deep dive read the defaults from the annotation by reflection: any exception is retryable, maxRetries = 3, a delay of 1000 ms, multiplier 1.0, no jitter, no maximum delay and no timeout. The counting is the classic trick question: maxRetries = 3 counts retries after the first call, so a method that always fails is called four times. The caller receives the last original exception, not a wrapper. Self-invocation and final methods bypass it exactly as they do for @Transactional and @Async, so put a test on the retry rather than on the method: make the method fail once and assert that it was invoked twice.
Deep dive:Spring Framework 7’s Built-in Resilience: @Retryable, @ConcurrencyLimit, and What’s Left for Resilience4j, which counted the calls and measured the back-off gaps.
23. What is left for Resilience4j now that Spring has @Retryable?
The circuit breaker, and the metrics. Spring now covers retry and concurrency limiting well enough that a service needing only those can drop a dependency. The migration hazard is off-by-one: Resilience4j’s max-attempts: 3 (and spring-retry’s maxAttempts = 3) is three calls in total, while Spring’s maxRetries = 3 is four, so a mechanical port adds a call to every failing operation and sends a third more traffic at a downstream that is already failing.
Resilience4j’s defaults will not open the breaker in a quiet service: slidingWindowSize and minimumNumberOfCalls are both 100, so until a hundred calls have been recorded the failure rate is not even evaluated, and a service handling a few requests a minute can fail for half an hour with the breaker closed. Set minimum-number-of-calls for your traffic, not for the library’s. If you stack both on one method, Spring’s interceptor runs first, so the breaker counts every retry as a separate call, and once it opens the remaining retries are spent on CallNotPermittedException. If all you use is @Retry and a bulkhead, dropping the library is reasonable.
Deep dive:Spring Framework 7’s Built-in Resilience: @Retryable, @ConcurrencyLimit, and What’s Left for Resilience4j, whose third part measured the breaker and the two libraries on one method.
Data questions are usually about what fails when the happy path stops holding: a lazy collection outside a session, a migration that lands out of order, a batch job that dies at row 47.
24. When would you choose Spring Data JDBC over Spring Data JPA?
JPA gives you a persistence context, lazy loading and dirty checking. Each is exactly what the specification promises, and each requires you to already know it is coming: a lazy collection needs an open session (a batch job that calls a method a web request used to call fails with LazyInitializationException: could not initialize proxy - no session), a fetch join is needed to avoid N+1, dirty checking flushes when it decides to, and orphanRemoval deletes rows. Spring Data JDBC has no session state. findById always returns the whole aggregate at a fixed cost, and saving an aggregate replaces its children, because a child with no repository of its own has no partial-update path.
Choose JDBC when your aggregates are small (single digits to low tens of child rows, since the replace-on-save cost is proportional to aggregate size), when you have been bitten more than once by a lazy-loading exception or an N+1 you did not predict, or when you want the shape of ‘load this thing’ readable from the entity class. Stay on JPA when collections are large or updated incrementally, when the domain benefits from a managed graph (bidirectional relationships, inheritance, second-level caching), or when the team already has deep Hibernate experience. Both can live in one application, with @EnableJpaRepositories and @EnableJdbcRepositories scoped to separate packages, so it is a per-aggregate decision rather than a rewrite.
Deep dive:Spring Data JDBC vs Spring Data JPA in 2026: When Dropping the ORM Is the Right Call, which runs both stacks in one context against a statement-logging DataSource.
25. Flyway or Liquibase, and what happens when something goes wrong?
Both keep a tracking table and run new changes in order at startup, and they fail differently. Flyway is version-ordered and checksum-validated: editing a migration that has already run fails startup before any SQL executes, and an out-of-order migration (V2 merged after V3 was deployed) fails the whole startup by default. Flyway Community has no working rollback: Flyway.undo() compiles and then throws at runtime, so plan every migration as forward-only and write a compensating migration instead. On an existing database, baselineVersion defaults to 1, meaning ‘assume the schema already matches V1’. On Boot 4 the starter is spring-boot-starter-flyway.
Liquibase’s unit of change is the changeset, and it has a real rollback(), but it can only generate one automatically for structural changes, never for data. Its lock table can be slower to release (the default poll rate is 10 seconds, worth checking against your readiness-probe timeout). Liquibase Community 5.0 ships under the Functional Source License rather than Apache 2.0, which the deep dive treats as a narrow compliance question that changes nothing for running the starter in your own application, production included. Running both against one database is a bridge during a migration, never an architecture. And the reason to have either at all: they replace letting Hibernate infer the schema (ddl-auto=update) with a history you can read, review in a pull request and run the same way in every environment.
Deep dive:Flyway vs Liquibase for Spring Boot 4: Migrations, Rollbacks and Baselines, which reproduced both tools’ failure shapes with tests.
26. How do you rename a column with zero downtime?
A single ALTER TABLE ... RENAME COLUMN is correct SQL and a production outage, because a rolling deploy replaces replicas one at a time, so for a while old and new code run against the same schema. Expand-contract does the change as four deploys, each changing exactly one thing: add the new nullable column (schema only); write to both columns (code); read from the new column (code); and drop the old column only once every replica is confirmed on the previous step’s code.
Two details show you have done it. The expand step must also relax the old column’s NOT NULL, because otherwise the final code, which never writes the old column, fails every insert. And the read switch must not ship while a replica is still on the old code, which writes only the old column, or the new reader sees rows with no value. The honest closing point is that you should only do this if the table is live and downtime is unacceptable, since it trades one quick migration for four deploys and weeks of code that handles two column names.
Deep dive:Zero-Downtime Database Migrations: Expand-Contract in Practice with Spring Boot, which ran two live replicas under continuous traffic.
27. How does a Spring Batch job work, and what happens when it fails halfway?
A Job is a named list of Steps. A step is either a Tasklet, which runs once, or a chunk-oriented loop of read, process and write with one transaction per chunk, and a JobRepository records the progress. That is why a duplicate SKU at row 47 with chunk size 10 fails the whole of chunk 5 rather than one row, and why a rerun in a brand-new JVM resumes at row 41 instead of row 1. A processor that returns null filters the item, which is counted separately and is not a failure.
Choose fail-and-restart when a bad row needs a human decision, and faultTolerant().skip(...) with a skipLimit when bad rows are background noise. On a skip, Spring Batch rolls the chunk back once and reprocesses its items one at a time to find the bad one, so the rollback count is not zero. JobBuilderFactory and StepBuilderFactory were removed in Spring Batch 5, and you build steps with the current builders.
Deep dive:Spring Batch on Boot 4.1: Jobs, Steps, Chunk Processing and Restartability, which ran a 60-row import through the clean, poisoned, restart and skip cases.
28. How can a Spring Batch job silently forget that it ever ran?
Boot 4.1 split batch auto-configuration into separate modules. spring-boot-starter-batch alone gives you the infrastructure basics and, critically, a resourceless (in-memory, non-persistent) JobRepository if nothing more specific is configured. spring-boot-starter-batch-jdbc adds the JDBC-backed one. Nothing about compiling or starting the application tells you which one you got, and the failure is silent until a redeploy.
In the deep dive, running the same job twice in two separate JVMs against the same database, with the JDBC autoconfiguration excluded, made run 2 start as if run 1 had never happened: it did not throw the already-complete exception you would expect, it tried to insert the same SKU again and collided with what run 1 had committed. The check is to query information_schema.tables (or your database’s equivalent) for BATCH_JOB_INSTANCE after startup. If the table is not there, you have the resourceless repository.
Deep dive:Spring Batch on Boot 4.1: Jobs, Steps, Chunk Processing and Restartability, whose last section reproduces the forgetful restart.
29. How do you scale a Spring Batch job with partitioning, and what surprises people?
Partitioning runs several identical copies of a chunk-oriented step side by side, each pointed at its own slice, under a manager step. The first surprise is that gridSize is a hint, not a count: the number of partitions is whatever Partitioner.partition(gridSize) returns. MultiResourcePartitioner never reads the argument, so with three shard files and grid-size=10, exactly three workers ran.
The failure that does not look like a failure: TaskExecutorPartitionHandler submits every worker step to its TaskExecutor up front, so an executor with one thread and no queue rejects three of four submissions before any row is read, and the log never mentions rejection. The manager and job reach FAILED, but the rejected worker steps stay parked at STARTING or EXECUTING, and a restart then throws an already-running exception. Spring Batch 6.0 added JobOperator.recover(JobExecution) for exactly this state. On speed, the best measured result was 1.42x on two cores rather than 2x, and over-partitioning a small job is a worse mistake than over-partitioning a large one. If the step already fits its batch window, do not partition.
Deep dive:Spring Batch Partitioning and Parallel Steps: Scaling a 10-Million-Row Job, which measured the speedup on a 10-million-row dataset.
30. What is the N+1 problem in GraphQL, and how does @BatchMapping fix it?
A per-object @SchemaMapping resolver runs once per parent, so a screen listing 30 books with their authors issued 31 SELECT statements, thirty of them identical apart from the id. @BatchMapping answers the same field with one method that receives every pending parent as a List<Book> and returns a Map<Book, Author>, and it registers a per-request DataLoader for you with no BatchLoaderRegistry bean. With five books and five distinct authors, six statements became two: the five author.id=? lookups collapsed into one author.id in (?,?,?,?,?). Build the id list with distinct() so the query is for the distinct authors involved, not for the number of parents.
The other question is non-null propagation. A non-null field that resolves to null cannot fail alone: the null climbs to the nearest ancestor that is allowed to be null. With author: Author! inside [Book!]! and one dangling foreign key, nothing between the failure and the root was nullable, so data itself came back null. That is a deliberate, spec-mandated safety property rather than a bug, because it lets a client trust that a non-null field that is present is never null.
Deep dive:Spring GraphQL 2.0: Schema-First APIs, DataLoader Batching and Killing N+1, which runs the naive and batched resolvers on the same seed data.
Spring Security 7 questions are where interviewers separate people who configured it once from people who debugged it. Almost all of them are about which filter answered first.
31. How is the Spring Security filter chain ordered, and what happens when a custom filter lands in the wrong place?
In a web application, Spring Security is one servlet filter, FilterChainProxy, that holds one or more SecurityFilterChain beans and runs a list of filters in a fixed order. The order does not come from your configuration. It comes from a hard-coded table, FilterOrderRegistration, so moving .csrf(...) below .httpBasic(...) changes nothing. A baseline of csrf, httpBasic, formLogin and one authorization rule produced 16 filters. A custom filter is placed exactly one slot from its anchor: addFilterBefore(f, CsrfFilter.class) gives 1099 and addFilterAfter(f, LogoutFilter.class) gives 1201.
The picture shows the bug interviewers ask about. An API-key filter anchored after LogoutFilter authenticates before the authorization decision and works. The same filter anchored to AuthorizationFilter runs after the decision was already taken against the anonymous principal, so a valid key still gets a 401 and nothing is logged.
The fingerprint is a 401 or 403 with a credential you know is good, no exception anywhere, and a TRACE log (logging.level.org.springframework.security.web.FilterChainProxy=TRACE) showing your filter invoked as the last of n. The fix is to anchor authentication filters to LogoutFilter, not to anything near the bottom of the chain. Two related points. On Security 7, FilterSecurityInterceptor is gone and AuthorizationFilter is the replacement anchor, so every older tutorial that ends with addFilterBefore(myFilter, FilterSecurityInterceptor.class) stops compiling. And ExceptionTranslationFilter turns AccessDeniedException into a 403 and AuthenticationException into a 401 or a login redirect by wrapping the rest of the chain in a try/catch, so a filter that throws from outside that block produces a 500. The most useful habit is to read the live chain from the running application rather than trust a diagram.
Deep dive:The Spring Security Filter Chain Explained: Every Filter, In Order, and How to Debug It, which prints all sixteen filters from a running application and explains where the order comes from.
32. What is the difference between a 401 and a 403, and why does a 403 sometimes arrive as a 401?
A 401 means ‘I do not know who you are’. A 403 means ‘I know who you are, and you may not do this’, which makes it a statement about an identity the server already accepted. The mismatch is the most confusing Spring Security bug report there is. Something in the chain calls response.sendError(403, ...), or lets an exception escape FilterChainProxy, and the servlet container does not write that response itself: it re-dispatches the request internally to /error with DispatcherType.ERROR. Spring Boot registers the security chain for every dispatcher type, so the whole chain runs again.
On that second pass the filters that extend OncePerRequestFilter skip themselves (BasicAuthenticationFilter is one), so the credential is never re-read, while filters that extend GenericFilterBean still run, and AuthorizationFilter is one of those. The second pass is therefore authorised but not authenticated: AuthorizationFilter sees an anonymous caller asking for /error under anyRequest().authenticated(), denies it, and that 401 is what the client receives. The fix is a first filter chain that permits /error, which is not a hole, because the error page is rendered from attributes the container set and cannot be reached by an unauthenticated request except through a dispatch the container started.
Deep dive:CORS, CSRF and SameSite in Spring Boot 4: The Three Settings Everyone Gets Wrong, which proves the mechanism with a one-chain diff.
33. Why would you use a JWT instead of a session, and what are its permanent costs?
A session stores ‘who you are’ on the server: a map from session id to user, set by a cookie, looked up on every request, and deleted with one call when the user logs out. A token inverts the arrangement. The client carries a signed statement of who it is, and any server holding the right key can verify it mathematically, so there is no shared map, no lookup and no sticky sessions. It buys exactly one thing, statelessness, and the price is permanent: you can no longer un-say something you have said. A signed token is valid until it expires, everywhere, for anyone holding it.
The second thing to state is that a JWT is signed, not encrypted. It is three dot-separated segments that you can decode with a shell built-in and no key, so anything in the claims is readable by the holder and by any proxy that logs the header. And the closing honesty: if you have one server-rendered application and need sessions, a session cookie is simpler, revocable by design and needs no key management, and if you need federated identity you want an authorization server such as Keycloak rather than hand-rolled token code.
Deep dive:Spring Security 7.1 JWT Authentication: The Complete Guide (Spring Boot 4.1), which builds login, refresh with rotation, replay and revocation, with a status-code contract pinned by tests.
34. What does Spring Security not validate in a JWT by default?
The audience, and anything custom. Moving from a hand-written filter to oauth2ResourceServer() silently drops every custom check that lived inside the filter, and the build still passes. In the deep dive a refresh token, signed by the same key with a valid exp, iss and aud, passed every default validator, because nothing in the framework had heard of the token_type claim. The checks have to move explicitly onto the decoder, as in this snippet from the deep dive:
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(issuer), // exp, nbf, iss
AudienceValidator.forAudience(audience), // aud - NOT included by default
new AccessTokenTypeValidator())); // your token_type check
AudienceValidator and AccessTokenTypeValidator are that project’s own classes. JwtValidators.createDefaultWithIssuer validates exp, nbf and iss and does not validate aud. In an estate where every service trusts the same issuer, a token minted for the reporting API is accepted by the payments API without complaint, which is a confused-deputy vulnerability that arrives by default. Security 7 also changes things you will not see in the compiler: every bearer-token authentication now carries a FACTOR_BEARER authority alongside your own, and every 401 now advertises resource metadata (RFC 9728), which a client that parses WWW-Authenticate strictly will notice.
Deep dive:Spring Security 7.1 JWT Authentication: The Complete Guide (Spring Boot 4.1), whose third part lists what the defaults do not do.
35. What does issuer-uri actually do in a Spring Boot resource server?
It is one property, and it does nothing until the first token arrives. This is the configuration from the deep dive:
Boot creates a SupplierJwtDecoder, and on the first token it fetches {issuer-uri}/.well-known/openid-configuration, compares the issuer inside that document against yours (a mismatch fails the whole decoder, not one token), then fetches the jwks_uri document. The laziness is deliberate, since your service starts even when the authorization server is down, and its cost is that misconfiguration shows up as a failed request rather than a failed startup. Setting jwk-set-uri alongside skips discovery.
Three follow-ups. iss is compared with String.equals, not normalised, so a trailing slash is enough to fail a token that was signed by the right key. setJwtValidator replaces the entire validator stack, so installing only an audience validator quietly drops the issuer and expiry checks. And key rotation is failure-driven: the JWK Set is re-fetched when, and only when, a token arrives signed by a key that is not in the cache. The commonly repeated ‘cached for five minutes and rotated automatically’ is half true: the deep dive found that supplying a Spring Cache removes that five-minute expiry, leaving only the cache’s own TTL, and the reference documentation recommends supplying a cache without mentioning it.
Deep dive:Spring Security OAuth2 Resource Server: JWT Validation, JWKS and Key Rotation, which walks the live object graph inside the decoder to prove the cache layers.
36. What changed if you run your own OAuth2 or OIDC provider on Spring Authorization Server?
The project is no longer separate. There is no <spring-authorization-server.version> property in spring-boot-dependencies:4.1.1, and there is no 2.x GA: 2.0.0-M1 and M2 were abandoned and the line was renumbered 7.0.0 to align with Spring Security. The applyDefaultSecurity(http) call that every 2024 tutorial opens with is gone, because the configurer and its package moved into spring-security-config, which produces four compile errors.
The changes that matter more are silent defaults. PKCE is now mandatory for every client you did not think about, and the Spring OAuth2 client sends it by default, which is the only reason Spring-to-Spring integrations survived; a client without PKCE never reaches a login page and lands on a generic error page four hops from the cause. And aud defaults to the client id, not the API, with no per-client audience setting on RegisteredClient, so an OAuth2TokenCustomizer<JwtEncodingContext> is the only place to set it. Declare OAuth2AuthorizationService and OAuth2AuthorizationConsentService yourself, because the in-memory versions are per instance. Then ask whether you should be doing this at all: the deep dive’s provider is about 700 lines and a demo, and if what you need is ‘login’, use Keycloak or a hosted identity provider.
Deep dive:Spring Authorization Server: Running Your Own OAuth2 / OIDC Provider (Spring Boot 4.1), which compiled one program against two jar versions to print what the defaults became.
37. How does method security work, and when does @PreAuthorize silently do nothing?
@PreAuthorize is not a keyword the JVM understands. It is an annotation that an AOP advisor matches on a proxy wrapped around your bean, intercepting calls that arrive from outside that bean. Nothing switches it on for you: Boot’s security auto-configuration does not enable method security, so @EnableMethodSecurity is yours to add, and @Secured and @RolesAllowed compile, read correctly in review and do nothing until their attributes are switched on.
The silent failures follow from the proxy. Self-invocation. Methods a CGLIB subclass cannot override: final, static and private are unchecked (a public final method gets a warning buried in the startup log, static and private get nothing), while a package-private method is secured, contradicting the ‘only public methods’ folklore, and a final class refuses to start. And @PreFilter handed an immutable collection such as List.of(...) or Stream.toList(), which cannot be filtered in place. Denials throw AuthorizationDeniedException, and code written against AccessDeniedException still works. Everything here applies to @Transactional unchanged, and a test that asserts only the happy path passes identically whether the annotation works or not, so test the denial. If your rules are about URLs and roles, authorizeHttpRequests is simpler and has none of these traps.
Deep dive:Method Security in Spring Security 7: @PreAuthorize, @PostAuthorize and the Proxy Traps, which invokes every annotated method as two different users.
38. How do passkeys work with Spring Security 7, and what do the defaults leave out?
A passkey is a key pair with an origin stapled to it. The authenticator (a phone, a laptop’s secure enclave, a USB key or a password manager) generates the pair, the private half never leaves it, and the public half goes to your server. Each credential is bound to a relying-party id, which is a domain, and every signature covers the origin the browser reports, so a phishing page cannot use an assertion it somehow obtained. A password can be typed into the wrong box; a passkey structurally cannot.
Spring Security 6.4 added support inside spring-security-web, and as of 7.0 it lives in a new artifact with the same package names, which makes the upgrade awkward. Boot auto-configures none of it, so there are no spring.security.webauthn.* properties and every relying-party setting is Java configuration. The rpId and the allowed origin are two settings, not one: http://127.0.0.1:8080 and http://localhost:8080 are different origins, and getting them out of step produces a flat 401, so read the server log with logging.level.com.webauthn4j: DEBUG rather than the HTTP response. The defaults are attestation: none and userVerification: preferred, both weaker than they read, and the signature counter is not enforced by default. The real work is recovery: an email magic link as the only reset path moves your security level into your users’ inboxes.
Deep dive:Passkeys and WebAuthn with Spring Security 7: Passwordless Login That Actually Works, which also covers one-time-token login and the missing rate limit on /ott/generate.
39. How do CORS, CSRF and SameSite interact in a Boot 4 application?
They answer three different questions. CORS decides whether the browser lets your JavaScript read a response, SameSite decides whether the browser sends the cookie at all, and CSRF protects state-changing requests. A Boot application can be told about CORS in two places that do not talk to each other: the MVC layer (addCorsMappings, @CrossOrigin), which is read inside DispatcherServlet, and the security layer (HttpSecurity.cors(...)), which puts a CorsFilter into the chain at order 1000. The security chain finishes before DispatcherServlet is entered, so when it rejects a preflight, MVC’s CORS configuration is not merely ignored, the code that reads it never runs. A preflight is an OPTIONS request with two headers and no credentials, so test it with curl and no -u.
Further details that come up: moving CORS from addCorsMappings to a CorsConfigurationSource bean loses a default, because CorsRegistration defaults maxAge to 1800 seconds and a bare CorsConfiguration does not. A wildcard origin is legal to configure and illegal to serve for a credentialed request, and Spring enforces it on the first request rather than at startup. Over plain http in development SameSite=None; Secure collapses, because Secure is only honoured from a trustworthy origin. And a same-site deployment, with the SPA and the API on one registrable domain, deletes most of this.
Deep dive:CORS, CSRF and SameSite in Spring Boot 4: The Three Settings Everyone Gets Wrong, with a lookup table from symptom to setting.
40. How should one Spring Boot service call another on behalf of a user?
There are three answers to ‘whose identity should arrive at service B’. Relay the user’s token unchanged, which is about ten lines and needs no OAuth2 client machinery. Exchange it for a token meant for the next hop, where the catch is wiring rather than concept: OAuth2AuthorizedClientProviderBuilder‘s defaults do not include token exchange. Or use client credentials, which is the right answer for work with no user attached and the wrong one whenever someone will later ask ‘who did this?’. The deep dive’s honest default is relay inside a boundary and exchange across one.
The uncomfortable finding is that a default resource server checks the signature and the expiry and very nearly nothing else, so a token minted for a different service by the same issuer is accepted; validate the audience everywhere. Two traps: SecurityContextHolder is a ThreadLocal, so a relay that runs on another thread (a virtual thread from an executor, in the deep dive) has no token and returns 401, and Spring Cloud Gateway Server MVC’s tokenRelay() needs a SecurityFilterChain bean, or every call through the gateway returns 401 with WWW-Authenticate: Basic. With mTLS, identity is the subject plus the fact that a CA in the trust store vouched for it, and certificate-bound tokens (RFC 8705) make a stolen token useless without the certificate. Ask first whether you need any of this: two applications in one VPC with no partner access do not need an authorization server, and a design you can reason about at 3 a.m. is worth something.
Deep dive:Securing Spring Boot Microservices: Token Relay, Service-to-Service JWT and mTLS, which compares relay, exchange and client credentials with five endpoints.
41. How do you defend a Spring Boot 4.1 service against SSRF?
Server-side request forgery is what happens when your application fetches a URL that a stranger supplied (link previews, webhook validators, ‘import from URL’, server-side PDF rendering) and dials it from inside your network, where the firewall thinks you are trustworthy. Boot 4.1 added InetAddressFilter: declare one bean and every auto-configured HTTP client refuses to connect to a destination that does not match it. The mistake is in reading the name backwards, because a match means permit. It is an allow list: InetAddressFilter.externalAddresses() allows only the public internet, InetAddressFilter.of("203.0.113.0/24") allows only those destinations, and externalAddresses().andNot("203.0.113.0/24") allows the public internet minus a range. Passing your private ranges to of(...) allows exactly the traffic you meant to block.
URL validation cannot do this job, because 0x7f.0.0.1, 2130706433, 127.1, a hostname you control that resolves to loopback, and a redirect from a public URL to a private one all reach loopback without the string 127.0.0.1 appearing in the request; the check has to happen on the resolved address. How strong it is depends on the HTTP client: with Apache httpclient5 on the classpath it hooks name resolution, while the JDK HttpClient exposes no resolver, so Boot filters in the ProxySelector instead, and the deep dive advises putting httpclient5 on the classpath if the filter is load-bearing. It can also silently do nothing: spring-boot-starter-web alone neither puts InetAddressFilter on the classpath nor gives you an auto-configured RestClient.Builder, and there is no property to enable it per environment. A blocked call throws FilteredHostException, a plain RuntimeException that becomes a bare 500 unless you catch it. The filter is worth having and it is not sufficient on its own.
Deep dive:HTTP Client SSRF Mitigation in Spring Boot 4.1: The InetAddressFilter Everyone Will Configure Backwards, which exploits its own vulnerable endpoint before and after the filter.
Messaging questions test whether you know what the broker guarantees and, more usefully, what it does not.
42. How do a Kafka producer and consumer work in Spring Boot 4.1, and what do the defaults give you?
KafkaTemplate.send is asynchronous and returns a CompletableFuture<SendResult<K, V>>, and discarding it discards the only notification you will get: the method returns normally, the record may never reach the broker and nothing in your code notices. Since Kafka 3.0 the client defaults are acks=all and enable.idempotence=true, and Boot sets nothing on the producer beyond bootstrap servers and serialisers, so a stock Boot application already has a durable, deduplicating producer. An old runbook that sets acks=1 or retries=0 is now a downgrade.
Kafka orders records within a partition, not within a topic, so the key is not a label: it decides which records are ordered relative to each other. A null key is not a key (records are spread by the sticky partitioner, so nothing about their order is guaranteed), and adding partitions repartitions every key. On the consumer side the container owns the poll loop, the offset commit and the rebalance listener. The default acknowledgement mode is BATCH, which is at-least-once delivery, so your listener must be idempotent. ‘My listener never fires’ is usually auto.offset.reset: the default is latest, so a brand-new consumer group skips everything already in the topic. On Boot 4 depend on the Kafka starter rather than bare spring-kafka, or KafkaTemplate is missing at context refresh.
Deep dive:Spring Boot 4.1 and Apache Kafka: Producer, Consumer and Serialisation from Scratch, which runs a real broker in-process, so every claim is a transcript.
43. What does Spring Kafka do when a listener throws, and how do you handle poison pills?
Configure nothing and you get ten deliveries with zero milliseconds between them, and then the record is dropped. The stock handler is a DefaultErrorHandler with SeekUtils.DEFAULT_BACK_OFF, and the default recoverer logs and lets the offset move on; there is no dead-letter topic unless you create one. Two kinds of failure need different machinery: a transient failure benefits from retrying, and a permanent one does not, so classify exceptions so that a permanent failure gets exactly one delivery before the dead-letter topic.
A record whose bytes cannot be deserialised fails inside poll(), where no error handler is in the path, the offset cannot advance and the same record fails on every poll, so the partition stops outright. The fix is ErrorHandlingDeserializer. Blocking retries stall the partition and max.poll.interval.ms (five minutes by default) is your ceiling, so a back-off schedule that can exceed it gets the consumer evicted. The dead-letter suffix is -dlt, not the older .DLT, and using the wrong name is a warning per record, not an exception. @RetryableTopic retries without blocking (in the deep dive ok-1 was processed while transient-1 was still two retries from giving up), at the price of losing per-key ordering. The shortlist in order of value: ErrorHandlingDeserializer, a recoverer, exception classification, and alerts on dead-letter depth.
Deep dive:Kafka Error Handling with Spring Kafka 4.1: DLT, Retry Topics and Poison Pills, which measures the default back-off by running it.
44. What does Kafka’s exactly-once guarantee actually cover?
Kafka’s exactly-once is atomic across Kafka partitions and Kafka consumer offsets. That is the entire scope. It does not mean your listener runs once, and it cannot include your database. Idempotent producers solve one specific problem: the acknowledgement is lost, the producer retries, and without idempotence the broker would hold the record twice. Transactions add atomic writes across partitions and let a consumer offset commit be one of those writes, which is the read-process-write loop. In Spring you configure a stable transaction-id-prefix (unique per instance, never random, because its job is fencing zombie producers) and isolation-level: read_committed on every consumer of a transactional topic.
The boundary is your database: there is no two-phase commit between Kafka and a database, and Spring’s ChainedKafkaTransactionManager existed for this and is deprecated. The sentence to give an interviewer is that exactly-once processing equals at-least-once delivery plus idempotent side effects. One configuration trap: setting only acks=1 silently disables idempotence with no warning, while acks=1 together with enable.idempotence=true throws a ConfigException, so set idempotence explicitly. And the cost is per transaction, not per record.
Deep dive:Exactly-Once with Spring Kafka on Boot 4: Idempotent Producers and Transactions, which ran fifteen tests against a real in-JVM broker and corrected a widely repeated claim about consumer lag.
45. How does RabbitMQ route messages, and how do you build a dead-letter queue that works?
A producer never publishes to a queue; it publishes to an exchange with a routing key, and bindings decide where the message lands. Publish with a routing key that nothing is bound to and the broker discards the message: convertAndSend returns normally and nothing is logged, anywhere. Publisher returns say the broker had nowhere to route the message, and publisher confirms (publisher-confirm-type: correlated) say the broker took responsibility for it, and you usually want both.
There is no ‘send to DLQ’ operation in AMQP. A dead-letter exchange is a queue argument, and messages arrive there as a side effect of specific events. A basicNack with requeue=true is an infinite loop that never touches the dead-letter queue and never raises an error, so requeue is only correct for a transient failure that has a delay. Queue arguments are immutable: redeclaring an existing queue with different arguments is a channel error with reply code 406. The auto acknowledgement mode means the container decides based on whether your listener threw, and prefetch, which Boot publishes no default for, is the most effective knob nobody touches. Remember that the RabbitMQ auto-configuration lives in spring-boot-amqp on Boot 4 (question 3).
Deep dive:Spring Boot and RabbitMQ: Exchanges, Queues, Bindings and a Working Dead-Letter Queue, which makes a dead-letter queue work end to end.
46. Kafka, RabbitMQ or Pulsar: how do you choose?
Almost every difference follows from where the message lives. Kafka is an append-only, partitioned log: the broker keeps every record for the retention period and remembers only an offset per consumer, and reading removes nothing. RabbitMQ is a router with queues: the broker owns each message until a consumer acknowledges it, then deletes it, and it can route, expire and dead-letter on its own. Pulsar is also a log, but brokers are stateless and BookKeeper holds the data; acknowledgement moves a cursor, and a message is deleted once every subscription has passed it unless a retention policy says otherwise.
The deciding questions are what happens to ordering when the second consumer arrives, whether you can read the message again after a bad deploy, and what each costs to operate, which is a better question than throughput because throughput numbers come from hardware you do not have. The question that dissolves the choice is scale: for one producer, one consumer and fewer than a hundred messages a second, all three work, and the decision is about what your team can operate at three in the morning. If the honest answer is ‘we do not know yet’, the deep dive argues for Kafka on grounds that have nothing to do with the technology: you can hire for it, your monitoring vendor supports it, and its failure modes are documented by thousands of people.
Deep dive:Kafka vs RabbitMQ vs Pulsar for Java Teams: A Decision Framework with Benchmarks, which measures ordering, replay and operating footprint instead of throughput.
47. Server-Sent Events or WebSocket?
The question that decides it is small: does the client need to send anything after the first request? If not (a dashboard, a progress bar, a notification feed, a log tail), SSE is the whole answer, and it is a return type rather than a subsystem. SseEmitter lives in spring-webmvc, so the web starter is all you need, and there is no SSE starter. If the client does need to speak, WebSocket with STOMP gives you routing, at the price of real work: STOMP sessions live in one JVM’s memory, so two instances behind a load balancer do not share subscriptions and you will need a broker relay.
SSE has a lifecycle worth knowing. A stream’s life is decided by the SseEmitter constructor argument, then spring.mvc.async.request-timeout, then the servlet container’s default, and a timeout is invisible to the client by design: the response was committed with a 200, so all the server can do is close the socket and the browser reconnects, which is why a dashboard can appear to reconnect every thirty seconds. On Boot 4, use spring-boot-starter-websocket rather than a bare spring-websocket. Three cases where neither is right: updates every few minutes (poll instead), a chat on one server with no plan for a second, and a client that is another service.
Deep dive:Server-Sent Events and WebSocket on Spring Boot 4: SseEmitter, STOMP, and Which to Pick, which also measures the buffer limit that actually fires.
48. How do you use gRPC on Spring Boot 4, and what goes wrong in production?
With Boot 4, gRPC is a first-class starter, maintained by the Boot team and managed by the Boot BOM. Know that ‘Spring gRPC’ now names two projects with separate version numbers, and that the earlier community starter org.springframework.grpc:spring-grpc-spring-boot-starter stops at 1.0.3 and is not what Boot 4 uses. The failure that takes down services is that gRPC has no default deadline: a call with none waits forever, nothing warns you, and it surfaces the day a downstream is slow and a thread pool fills.
Deadlines are absolute and they propagate. If service A calls B with two seconds remaining, B sees two seconds, not a fresh two, so a five-hop chain cannot multiply its budget. Set the deadline at the edge and never reset it at an inner hop. Cancellation does not interrupt your thread; gRPC sets a flag on the Context, and a handler that never checks it keeps computing. Any exception that escapes a handler becomes UNKNOWN with a null description, so map domain exceptions centrally with @GrpcAdvice and @GrpcExceptionHandler, and choose status codes carefully because retry policies and breakers key off them. The default maximum message size is 4 MB, per message, enforced by the receiver and configured separately on each side, and the in-process test transport cannot enforce it at all, so a test can pass where production fails.
Deep dive:Spring gRPC with Spring Boot 4: A First-Class Starter, and the Failures Nobody Warns You About, which reproduces each of these failures.
49. RSocket, gRPC or WebSocket: which one, and what decides it?
The deep dive serves the same two operations, fetch one quote and stream N quotes, from one Spring Boot 4.1 application over all three, so the comparison is between protocols rather than between three different applications. For request and response on a warm connection gRPC and RSocket were indistinguishable. What decides it is back-pressure, meaning what happens when a consumer stops keeping up. RSocket is the only one with demand signalling, plus resumption and leasing, at the cost of ecosystem (client libraries outside Java and JavaScript are thin) and Mono and Flux in your signatures. gRPC costs a build step, a schema you must version and an HTTP/2 story, and in exchange gives you the best-supported cross-language RPC and deadlines. A raw WebSocket is a bidirectional pipe, and routing, correlation, error shape, versioning and demand are all yours to invent.
Read the numbers with the author’s own caveat: client and server ran in one process over loopback, so there was no network and the figures show shape rather than absolute cost. And know the three cases where the answer is none of them: request and response between two services that already speak HTTP (a RestClient with a timeout is fine), streaming that is really batching (paginated HTTP has back-pressure for free, which is request(n) with a URL), and events that must outlive the connection, since none of these three persists anything and a dropped connection loses what was in flight. That last case is a broker.
Deep dive:RSocket vs gRPC vs WebSocket on Spring Boot 4.1: When Each One Wins, which measured back-pressure by giving each server an unbounded stream.
50. What breaks when you move from Spring AI 1.x to 2.0, and in what order do you fix it?
Boot 4 is not optional: Spring AI 2.0 is built on the Boot 4 dependency model and cannot be loaded in a Boot 3 context, so there is no partial upgrade. The order the deep dive recommends: move Boot 3 to Boot 4 alone, with Spring AI still on 1.1.x, and ship that. Then bump the Spring AI BOM to 2.0.0 and clear the compile errors (options classes lose their setters and use builders only; internalToolExecutionEnabled is removed, not renamed; several modules were renamed). Then fix chat memory, where every advisor call site now needs an explicit conversation ID because the default-ID constant is gone. Do not fix that compile error by reaching for ChatMemory.CONVERSATION_ID: it still exists, but it is the metadata key you pass an ID with, the opposite of the removed default value "default".
The last two steps are where the risk is, because nothing fails until a user notices. Structured output changes silently: Kotlin properties that are optional in their primary constructor are no longer in the schema’s required array, and so are properties annotated @JsonProperty(required = false). And anything that serialises should be integration-tested, because Jackson 3 changes the default date format and property order. Steps one to three are compiler-driven and finite; steps four and five are not.
Deep dive:Spring AI 1.x to 2.0: The Migration Guide (What Breaks, and What Breaks Silently), which tests the changes offline with a canned ChatModel and no API key.
Should you memorise all fifty? No, and the page is arranged so that you do not have to. An interviewer who asks about @Transactional wants to hear that it is a proxy and to be told one case where the annotation is present and does nothing; a failure you can describe from experience is worth more than a definition. If you only have an evening, read questions 15, 16, 31 and 32 first, because the proxy and the filter chain explain more of Spring’s behaviour than any other two ideas, then take one question from each remaining section and follow its deep-dive link. Most of the deep dives have a runnable companion project, so you can watch the failure happen instead of memorising the sentence that describes it.
No. Questions 1 to 4 and 9 are specific to the Boot 4 line, and the security, Kafka and batch answers use the Spring Security 7.1, Spring Kafka 4.1 and Spring Batch 6 releases. The proxy questions (15 to 23) describe mechanisms that predate Boot 4, and they are the ones that carry over to a Boot 3 codebase unchanged.
Where are the Spring MVC basics such as DispatcherServlet and request mapping?
Read the answer, then say it aloud in two sentences without the details. If an interviewer pushes, the detail after the first sentence (the number, the class name, the fingerprint of the failure) is what shows you have seen it. The Deep dive line under each answer is where those details were reproduced.
How was this page checked?
It has no companion repository, because it is a summary. Each answer restates a result from the deep dive linked beneath it, where the behaviour was reproduced by running code and, in most cases, committed with its output. For this page the deep dives were re-read against each answer and the code blocks were copied from them verbatim, but none of the programs were re-run. Where a deep dive corrected a widely repeated claim (the cache-provider order, the Actuator env masking, the JWKS cache expiry), the answer uses the corrected version.
Conclusion
Fifty questions come down to a handful of ideas: the proxy sees only calls from outside, the filter chain is ordered by a fixed table, Boot 4 puts auto-configuration in the starter rather than the library, most defaults are safe until you assume they are stronger than they are, and every guarantee has a boundary you should be able to name. If you can explain each of those with the failure that shows it, the individual questions mostly answer themselves.
No Comments yet!