Spring Boot Interview Questions for Experienced Developers, Settled by Running Them
Senior Spring Boot interviews are marked on consequences, not definitions. On Spring Boot 4.1.1, a @Transactional method that threw still left its row behind when called through this, and a checked exception committed too. The default @Async pool never grew past 8 threads and queued 992 of 1,000 tasks. And a request still running 30 seconds after SIGTERM was cut off with an empty reply, graceful shutdown or not.
Spring Boot interview questions for experienced developers are rarely harder than the junior ones. They are the same questions asked one step further. "What does @Transactional do" has a textbook answer; "why did this one commit after it threw" is what separates someone who has read about Spring from someone who has been paged by it.
Every answer below came out of one small application on Spring Boot 4.1.1 (Spring Framework 7.0.9, Hibernate 7.4.5, H2, Java 25.0.1). Each probe ran at least twice, except the circular-dependency matrix, which ran once per combination, and every count below was identical across its runs. Timings are medians of the runs, and most of them are dominated by a sleep the test chose on purpose, so they measure behaviour rather than speed.
How does auto-configuration decide what to create?
The definition is "conditional beans". The answer an interviewer remembers is that you can read the decisions. Start the application with --debug and it prints a condition evaluation report. For a web application with JPA and H2:
Positive matches: 89
Negative matches: 63
Unconditional classes: 6
Then define one bean the auto-configuration would have created, a JdbcTemplate with a query timeout, and the same report names you as the reason it stood down:
JdbcTemplateConfiguration:
Did not match:
- @ConditionalOnMissingBean (types: org.springframework.jdbc.core.JdbcOperations;
SearchStrategy: all) found beans of type 'org.springframework.jdbc.core.JdbcOperations'
myJdbcTemplate (OnBeanCondition)
Positive matches went from 89 to 88 and negative from 63 to 64: one decision flipped, and the report says which bean flipped it. Auto-configuration is not magic that you override; it is a list of if statements that step aside for your beans, and the report is the proof.
Why did a @Transactional method commit after it threw?
Two reasons, and a senior answer names both. The first is the call path:
@Transactional
public void insertThenFail(String who) {
jdbc.update("insert into entry(who) values (?)", who);
throw new IllegalStateException("boom");
}
public void callInternally(String who) {
insertThenFail(who); // this.insertThenFail — not the proxy
}
The same method, the same RuntimeException, called two ways:
called from another bean : transaction active = true rows left = 0
called via this : transaction active = false rows left = 1
The annotation is on the method, but the behaviour lives in a proxy wrapped around the bean, and this is not the proxy. The Spring Framework reference says it directly: "only external method calls coming in through the proxy are intercepted."
The second reason is the exception type. Through the proxy, with a real transaction:
IOException, plain @Transactional rows left = 1
IOException, rollbackFor = Exception.class rows left = 0
A checked exception commits. The rollback documentation puts it plainly: "Checked exceptions that are thrown from a transactional method do not result in a rollback in the default configuration."
Common mistake
Both failures are silent. Nothing is logged, the caller sees the exception it expected, and the half-written row only shows up later. A test that asserts on the exception and not on the database passes either way.
How many queries does one findAll() cost?
Ten authors with three books each, a lazy @OneToMany, and a loop that adds up getBooks().size(). Counted with Hibernate's own statistics:
findAll() then getBooks() : books = 30 statements = 11
join fetch query : books = 30 statements = 1
That is the N+1 problem, and the number to say is not "11" but "one plus one per parent": the extra statements follow the number of authors, not the number of books. The pagination article counts the other statement Spring Data runs that nobody wrote.
The follow-up is why the same loop worked in a controller at all. Call it outside any transaction from a plain runner and Hibernate refuses:
org.hibernate.LazyInitializationException: Cannot lazily initialize collection
of role 'demo.Author.books' with key '1' (no session)
From an HTTP request it returned books=30 statements=11, because Spring Boot keeps a session open for the whole request by default — and says so at every startup:
WARN JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled
by default. Therefore, database queries may be performed during view rendering.
Explicitly configure spring.jpa.open-in-view to disable this warning
With spring.jpa.open-in-view=false the same endpoint returned the LazyInitializationException. The default does not remove the N+1; it hides where it happens.
What happens when a prototype bean is injected into a singleton?
A @Scope("prototype") Cart injected into a singleton Checkout, asked for three times, against the same Cart fetched through ObjectProvider<Cart>:
Cart instances created during startup 1
constructor injection, 3 calls new instances 0 same identity every time
ObjectProvider.getObject(), 3 new instances 3 three identities
The prototype scope was honoured exactly once, when the singleton was built. The bean scopes reference explains it: the injection "occurs only once, when the Spring container instantiates the singleton bean". The fix is to inject the provider, not the bean.
What does @Async actually run on?
"A separate thread" is the definition. The measurement is more interesting. Forty @Async tasks, each sleeping 200 ms, on the executor Spring Boot configures for you:
distinct threads 8 peak concurrent 8 elapsed 1,035 ms (task-1, task-2, ...)
The Spring Boot reference describes that pool as using "8 core threads that can grow and shrink according to the load". So give it load. A thousand tasks:
core=8 max=2147483647 queueCapacity=2147483647
right after submitting 1000: poolSize=8 queued=992
after all 1000: largestPoolSize=8
It never grew. A ThreadPoolExecutor only adds threads beyond the core size when the queue refuses a task, and this queue's capacity is Integer.MAX_VALUE. Set queue-capacity=100 and max-size=16 and the same burst behaves differently:
accepted 116 rejected 884 (TaskRejectedException) largestPoolSize 16
So the unbounded default trades rejections for a backlog held in memory, and the maximum pool size only means something once the queue has a limit.
With spring.threads.virtual.enabled=true the same forty tasks got 40 threads and finished in a median 214 ms. And the proxy rule from the transaction section applies here too: @Async called through this from main ran on main.
Does allow-circular-references fix a dependency cycle?
Two beans that need each other, once through constructors and once through fields, with the property off and on:
constructor cycle, property false FAILED "prohibited by default ... As a last resort,
... setting spring.main.allow-circular-references to true"
constructor cycle, property true FAILED "Despite circular references being allowed,
the dependency cycle between beans could not be broken."
field cycle, property false FAILED
field cycle, property true started
The first error message suggests the property, and the property only rescued the field cycle, where Spring can hand out a half-built bean early. A constructor cycle cannot be built in any order, so it failed with the property on as well. That is an argument for constructor injection, not against it: the cycle is reported at startup instead of being papered over.
What happens to a request that is running when the application stops?
A /slow endpoint that takes five seconds, SIGTERM sent one second in, and a second request 500 ms after the signal:
default in-flight: HTTP 200 after 5,031 ms new request: connection refused
server.shutdown=immediate in-flight: empty reply after 1,020 ms JVM exit 541 ms after SIGTERM
Graceful shutdown is the default, which is the part most answers get right. The part they miss is the limit. The same test with a 40-second request:
Shutdown phase 2147482623 ends with 1 bean still running after timeout of 30000ms
Graceful shutdown aborted with one or more requests still active
in-flight: empty reply after 31,067 ms JVM exit 143, 30,150 ms after SIGTERM
Graceful means "up to 30 seconds", set by spring.lifecycle.timeout-per-shutdown-phase. A report export that takes a minute is cut off either way; the difference is only when. Running it as a systemd service covers the exit code that follows, and the microservices example shows the caller's side of a dependency that stops answering.
Answering Spring Boot interview questions out loud
Each question above has a definition and a consequence, and at the experienced level the interviewer is waiting for the consequence. Annotations are applied by a proxy, so this calls skip them. Rollback defaults to unchecked exceptions, so an IOException commits. The @Async queue is unbounded, so the maximum pool size is never reached. Graceful shutdown has a timeout, so long requests still die.
If you want to rebuild the test application yourself, creating a Spring Boot project gets you the empty starting point in a few minutes, and each probe above is a short service and a CommandLineRunner.
[!TAKEAWAY] Know the four silent failures by their numbers: a row left behind after a
thiscall, a commit after a checked exception, 992 tasks queued behind 8 threads, and a request cut off at 30 seconds. None of them logs an error, and each one is a short program you can run on your own machine.
Frequently asked questions
- How do I fix self-invocation without moving the method?
- Call the method through the proxy instead of through this. Injecting ObjectProvider of the bean's own type and calling getObject().insertThenFail() did exactly that in the test application: transaction active, and zero rows left after the exception. Moving the method to a second bean is usually the cleaner design, because a class that needs its own proxy is often doing two jobs.
- Should I turn spring.jpa.open-in-view off?
- For an API, usually yes — and set it explicitly either way, which also removes the startup warning. With it on, a lazy collection touched in a controller silently issues queries outside your service's transaction; with it off, the same code throws LazyInitializationException, which is the failure you want to see in a test rather than as a slow page in production.
- What should replace the default @Async executor?
- Something bounded, with a decision about overflow. With queue-capacity=100 and max-size=16, a burst of 1,000 tasks grew the pool to 16 threads, queued 100 and rejected the other 884 with TaskRejectedException, so the caller has to handle that. On Java 21 or later, spring.threads.virtual.enabled=true is the other answer for tasks that mostly wait: each of 40 tasks got its own thread.
- How do I see why a bean was or was not auto-configured?
- Start the application with --debug, or set debug=true, and read the condition evaluation report. Every auto-configuration is listed under positive or negative matches with the condition that decided it, including the name of the bean that made an @ConditionalOnMissingBean back off.
- How long should the shutdown timeout be?
- Longer than your slowest legitimate request, and shorter than whatever forcibly kills the process after SIGTERM — a container platform or a service manager usually has its own deadline. If the platform's deadline is the shorter of the two, raising spring.lifecycle.timeout-per-shutdown-phase does nothing, because the JVM is gone before the phase ends. Raise both together.