Spring Boot interviews for 10+ years of experience go after what only production teaches: transactions that roll back when nobody asked, writes that never reach the database, Hibernate reordering or multiplying your SQL, proxies that quietly vanish and pods killed while the heap looks fine. Then they move to design and people: multi-tenancy, pulling a service out of a monolith, leading an incident, keeping many teams current and a project that failed. It is written for Spring Boot engineers with around eight to fifteen years of experience, interviewing for senior, staff or lead roles. Each question shows what the interviewer is checking, a shape for your answer and a sample to adapt with a story from your own work.
Search all questions by round, difficulty and level, or save the ones you want to practice.
One transaction: with REQUIRED the inner method joins the outer transaction, so there is only one real transaction.
Rollback-only: when the exception leaves the inner bean's proxy, Spring marks that shared transaction rollback-only; catching it later changes nothing.
Fixes: check first instead of relying on the exception, use noRollbackFor for an expected business exception, or give truly independent work its own transaction.
“With the default REQUIRED propagation, the inner method doesn't start a transaction, it joins the outer one. So there's only one real transaction. When the runtime exception passes back out through the inner bean's proxy, Spring can't roll back just that part, so it marks the whole shared transaction as rollback-only. The outer method catches the exception and carries on, but at commit the transaction manager sees the flag, rolls everything back and throws UnexpectedRollbackException, so the rollback isn't silent. The fix depends on intent. If the failure is expected, like a bad row in an import, I'd check for it before calling, or mark that exception with noRollbackFor. If the inner work really is independent, it gets REQUIRES_NEW, knowing that costs a second connection. With JPA I'm extra careful, because after a persistence exception the session itself may be in a bad state, so carrying on in it is risky anyway.”
@Transactional
public void importAll(List<Row> rows) {
for (Row row : rows) {
try {
rowService.importRow(row); // @Transactional, joins this transaction
} catch (InvalidRowException e) {
log.warn("skipped row {}", row.id()); // too late: already rollback-only
}
}
}
Saying the catch block prevents the rollback, or fixing it by making every method REQUIRES_NEW without thinking about connections.
Timing: after commit, the original transaction is done, but its connection and persistence context are still bound to the thread.
The trap: a repository call with REQUIRED joins that finished transaction; the save lands in the persistence context and no commit ever follows.
Fix: run the listener's write with REQUIRES_NEW, or hand the work to another thread or a queue.
“The listener runs after the commit, but before Spring has cleaned up the transaction's resources. The connection and the persistence context are still bound to the thread. So when the listener calls the repository, the default REQUIRED propagation sees an existing transaction and joins it. With JPA, save just puts the entity in the persistence context, and since that transaction has already committed, there's no flush and no commit coming. The row is quietly dropped. Spring's own docs warn about this. The fix is to run the listener's write in its own transaction with REQUIRES_NEW, or to hand the work to an async executor or a queue so it runs on a clean thread. I'd also remember that if the event is published with no transaction active, an after-commit listener doesn't run at all unless fallbackExecution is set, which surprises people in batch code.”
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void onOrderPlaced(OrderPlaced event) {
auditRepository.save(AuditEntry.of(event));
}
Assuming the listener gets a fresh transaction of its own, or blaming the repository instead of the propagation.
Two connections per request: the outer transaction holds one; REQUIRES_NEW suspends it and borrows another.
Starvation: when every pool slot is held by an outer transaction, all threads wait for a second connection that never frees up.
Fix: keep nested new transactions off the hot path, write the audit after commit or through a queue, or size the pool above the threads that can nest.
“REQUIRES_NEW suspends the current transaction, but it doesn't give that connection back. It borrows a second one from the pool. So each request needs two connections at the same moment. Picture a pool of ten and ten busy request threads: all ten take a connection for the outer transaction, then all ten ask for a second one for the audit. Nothing is free, nobody can finish, and they all wait until the pool's timeout fires. The database looks idle because nobody is actually querying. A thread dump shows every worker parked inside the pool's getConnection. To fix it, I'd get the nested transaction off the hot path: write the audit row in the outer transaction if it should roll back with it, or after commit or through a queue if it shouldn't. If nesting really has to stay, the pool must be larger than the number of threads that can nest, and I'd cap request concurrency to match.”
Only raising the pool size without explaining why the requests are waiting on each other.
Order: BeanPostProcessors are created before normal beans, so their dependencies are created early too.
Effect: those early beans miss later processors, including the one that wraps beans in transaction and async proxies.
Fix: keep processors free of business beans, declare their @Bean methods static, and look up dependencies lazily with ObjectProvider.
“Spring creates all BeanPostProcessors before the regular beans, because they have to be ready to process them. If a processor has a dependency, say an OrderService, then OrderService gets created during that early phase, before every processor is registered. The processor that builds the proxies for @Transactional and @Async may not be active yet, so OrderService ends up as a plain object with no proxy. The annotations are still in the code, but nothing acts on them. Spring does log a line saying the bean is not eligible for getting processed by all BeanPostProcessors, and most people scroll straight past it. The fix is to keep processors lean: no business beans injected, @Bean methods for processors declared static so they don't drag their configuration class in early, and if a processor truly needs another bean, it fetches it lazily through ObjectProvider at the moment it's used.”
Blaming self-invocation without noticing the log line or the processor's dependency.
How the proxy works: Boot proxies beans by subclassing them, and each overridden method forwards to the real bean.
Final methods: can't be overridden, so the call runs on the proxy instance, whose fields were never injected, and no transaction starts.
Fix: no final on proxied methods; in Kotlin, the Spring compiler plugin opens annotated classes and their members.
“Spring Boot proxies beans by creating a subclass at runtime. Each method the proxy can override runs the transaction logic and then calls the real bean, which has all its dependencies. A final method can't be overridden, so when someone calls it on the proxy, the original method body runs on the proxy object itself. That proxy was created without running your constructor or any injection, so its fields are null, and the first use of the repository throws. There's no transaction either, so even without the crash the annotation would do nothing. Kotlin makes this easy to hit, because classes and methods are final by default. The kotlin-spring compiler plugin opens classes carrying Spring annotations like @Component and @Transactional, and a module that forgot to apply it brings the bug back. Spring can log a note about final methods, but it's easy to miss.”
@Service
public class InvoiceService {
private final InvoiceRepository repo;
public InvoiceService(InvoiceRepository repo) { this.repo = repo; }
@Transactional
public final void close(long id) { // final: runs on the proxy, repo is null
repo.findById(id).ifPresent(Invoice::close);
}
}
Blaming the repository bean or the database, without looking at how the proxy calls the method.
Cause: an IN list binds one parameter per value, so every list size is a different SQL string with its own cache entries.
Padding: Hibernate's in-clause parameter padding rounds the count up to a power of two, so a few shapes cover every size.
Big lists: split them into chunks, or use an array parameter or a temporary table where the database supports it.
“An IN list with one bound parameter per value means a list of three ids and a list of four ids are two different SQL statements. Hibernate caches a query plan per statement, and the driver and the database cache prepared statements per statement, so with a thousand possible sizes you get up to a thousand near-identical entries in each. The caches are bounded, but they fill with junk and push out the plans you actually reuse, and the database keeps parsing new statements. The quick fix is Hibernate's in-clause parameter padding setting. It rounds the parameter count up to the next power of two and repeats the last value, so a handful of shapes cover every list size. For really big lists I'd split them into chunks, since some databases cap the number of items in an IN list, or pass an array parameter or join against a temporary table where the database supports it.”
spring:
jpa:
properties:
hibernate.query.in_clause_parameter_padding: true
Blaming a leak in Hibernate without noticing that every list size creates a new statement.
Cause: a join to a collection repeats parent rows, so Hibernate can't use SQL limit and offset; it loads everything and pages in memory, with a warning in the log.
Why tests missed it: with small test data, loading everything looks instant.
Fix: page the parent ids first, then fetch those parents with their items, or use batch fetching instead of the join.
“When you fetch join a collection, each order row repeats once per item. A limit of twenty rows might cover only three orders, so Hibernate can't push the page down to SQL. Instead it drops the limit, loads every matching row, builds all the orders and cuts the page out in memory. It logs a warning about applying pagination in memory, but that's easy to miss. With test data it's instant. With a production table it loads everything on every page request. The fix I use is two queries: first select one page of order ids with proper SQL paging and a stable order, then fetch those orders with their items using a fetch join on an IN clause. Another option is to drop the join and set a batch fetch size, so the items load in a few batched queries per page. Either way, I'd add a test that checks the query count or fails on that warning.”
Blaming the database or adding an index without noticing the paging happens in memory.
Why Hibernate refuses: two List collections joined in one query multiply the rows, and Hibernate can't tell real duplicates in a bag from join duplicates.
Why Set is worse than it looks: the error goes away, but the query still returns items times payments rows for every order.
Fix: fetch one collection per query for the same orders, or use batch fetching for the second collection.
“Joining two collections in one query gives a Cartesian product: an order with twenty items and ten payments comes back as two hundred rows. A List without an order column is a bag in Hibernate, and a bag is allowed to hold duplicates, so Hibernate can't tell which repeated rows are real and which come from the join. That's why it refuses to fetch two bags at once. Switching to Sets removes that ambiguity, so the error goes away, but the database still sends back every combination, and with real data that's a lot of rows to transfer and throw away. The fix I use is one collection per query. First load the orders with their items, then run a second query that fetches the same orders with their payments. Hibernate merges both into the same entities in the persistence context. Batch fetching for the second collection works too.”
Calling the switch to Sets a fix without noticing the row count it produces.
Flush order: Hibernate doesn't run SQL in the order you call it; at flush it runs inserts first and deletes last.
Result: the new row is inserted while the old one still exists, so the unique constraint fails.
Fix: update the existing row instead, or flush right after the delete so it reaches the database first.
“Hibernate doesn't send SQL when you call delete or save. It queues the changes and, at flush time, runs them in a fixed order by type: inserts first, then updates, then collection changes, and deletes last. That order avoids a lot of foreign key problems, but here it bites. The insert for the new address runs while the old row is still there, so the unique constraint fails, even though the code deleted first. The cleanest fix is often not to delete at all: update the existing row with the new values, which is also one statement instead of two. If it really has to be a delete and an insert, I call flush right after the delete so it reaches the database before the insert is queued. I'd leave a comment there, because the next person will think that flush is pointless and remove it.”
addressRepository.delete(oldDefault);
addressRepository.flush(); // send the delete before the insert is queued
addressRepository.save(newDefault);
Assuming Hibernate runs SQL in the order the code calls it.
Cause: a new transitive dependency put Jackson's XML module on the classpath, so Boot registered an XML message converter.
Who sees it: clients whose Accept header ranks XML above a wildcard, such as browsers, now get XML.
Prevention: declare produces on the API, diff the dependency tree in CI, and compare the conditions report after upgrades.
“In Spring Boot the classpath is configuration. If some library pulls in Jackson's XML data format module, Boot sees it and registers an XML message converter next to the JSON one. Content negotiation then looks at each client's Accept header. Clients asking for JSON are fine, but some send a header that lists application/xml ahead of a wildcard, browsers do this for example, and they now get XML back. I'd confirm it with the dependency tree to find which library brought the module in, then exclude it, or declare produces as application/json on the API so negotiation can't pick anything else. For the whole class of problem, I'd diff the resolved dependency tree in CI on every upgrade, keep a contract test that checks response content types, and compare the auto-configuration conditions report before and after, so new auto-configured beans show up in review instead of in production.”
Searching the application code for a change instead of looking at the classpath.
Back-off: defining your own DataSource replaces Boot's, and JPA, the default transaction manager and migrations bind to the primary one.
Transactions: @Transactional uses one transaction manager; writes through the other DataSource are outside it.
Fix: explicit configuration per database with qualified transaction managers, and no cross-database atomicity without an outbox or a saga.
“Most Boot auto-configuration backs off as soon as you define your own bean of that type. So once you define DataSources yourself, you own them. With two, you mark one @Primary, and everything auto-configured binds to that one: JPA, the default transaction manager and the migration tool. A @Transactional method uses that primary transaction manager. A write through a JdbcTemplate on the reporting DataSource runs on a different connection and isn't part of that transaction, so a rollback leaves it in place. The fix is to configure each database explicitly: its own properties, its own transaction manager with a qualifier, and a migration setup per database. And I'd be honest that two databases can't commit atomically without distributed transactions, which I avoid. Instead I make one of them the source of truth and move data to the other with an outbox or an idempotent sync job.”
Believing one @Transactional automatically covers both databases.
Two registrations: Boot registers any Filter bean with the servlet container, and addFilterBefore adds it to the security chain as well.
Result: it runs once inside the security chain and once as a plain servlet filter, in an order you didn't choose.
Fix: turn off the servlet registration with a disabled FilterRegistrationBean, or don't make the filter a bean at all.
“Spring Boot registers every bean that implements Filter with the servlet container on its own. The security filter chain is a separate list that lives inside one servlet filter. So when the class is a @Component and you also call addFilterBefore in the security configuration, it's registered twice: once in the security chain, where you placed it, and once as a plain servlet filter, wherever Boot's default ordering puts it. It runs twice, and the second run happens at a point in the servlet chain nobody chose. Extending OncePerRequestFilter can hide the second run, but that's hiding it, not fixing it. The clean fix is to create the filter inside the security configuration without making it a bean, or keep it a bean and add a FilterRegistrationBean for it with enabled set to false. I'd also add a test that counts invocations so it doesn't creep back.”
@Bean
FilterRegistrationBean<TokenAuthFilter> tokenFilterRegistration(TokenAuthFilter filter) {
FilterRegistrationBean<TokenAuthFilter> reg = new FilterRegistrationBean<>(filter);
reg.setEnabled(false); // keep it only in the security chain
return reg;
}
Not knowing Boot auto-registers Filter beans, so the fix turns into guessing with @Order.
The cache key: a context is reused only when the configuration is identical: classes, profiles, properties and the set of mocked beans.
Usual culprits: a different set of mock beans in each class, one-off test properties, and @DirtiesContext used as a habit.
Fix at scale: a few shared test setups, slice tests where they fit, and the cache's debug statistics to prove it worked.
“Spring's test framework caches application contexts and reuses one whenever a test class has exactly the same configuration. The key includes the configuration classes, active profiles, test properties and the set of beans replaced with mocks. So every class with its own combination of mock beans, or its own test property, gets a brand new context. @DirtiesContext throws a context away on purpose, and teams sprinkle it around to fix flaky tests. First I'd turn on debug logging for the context cache, which reports hits, misses and size, to see how many distinct contexts we build. Then I'd standardise: a few shared base setups with an agreed set of mocks, slice tests for the web and data layers, and @DirtiesContext only where a test really breaks shared state. One more trap: cached contexts stay alive, so many of them can each hold a connection pool, and the suite runs out of database connections.”
Throwing bigger CI machines or parallel runs at it without asking why the context keeps restarting.
Outside the heap: metaspace, thread stacks, the code cache, GC structures, direct buffers used by networking libraries, and native allocations.
Measure: compare the pod's resident memory with the heap, then use Native Memory Tracking, a thread count and direct buffer metrics.
Fix: size the heap as a share of the container limit with real headroom, cap thread pools and direct memory, and alert on non-heap growth.
“The container is killed on total process memory, and the heap is only part of it. The rest is metaspace for classes, a stack for every thread, the JIT's code cache, the garbage collector's own structures, direct buffers that Netty and other I/O libraries allocate, and plain native allocations. So first I'd compare the pod's resident memory with the heap's committed size. Then I'd start the JVM with Native Memory Tracking and use jcmd to get a breakdown by area. Common findings are too many threads, like a huge Tomcat pool plus several executors, or direct buffers growing under a busy HTTP client. Memory used by native libraries, or fragmentation in the C allocator, won't show up in that breakdown at all, so a gap there is a clue too. The fix is heap sizing with headroom, capped pools, a direct memory limit, and Actuator's JVM metrics on a dashboard.”
java -XX:MaxRAMPercentage=70 -XX:NativeMemoryTracking=summary -jar app.jar
jcmd 1 VM.native_memory summary
Raising -Xmx, which leaves even less room for everything that lives outside the heap.
Stabilise: the deploy is the prime suspect, so roll back first and diagnose after.
Careful with scaling: more pods can overload the database or a downstream service and turn one outage into several.
Keep evidence: take thread dumps or a short profile from one pod before it's replaced, and give one person the job of updates.
“The change that just went out is the most likely cause, so my first call is to roll back, not to debug live. Before anyone scales out, I'd ask what each extra pod adds to the shared systems. If the CPU is going into retries or a hot loop, more pods mean more connections and more load on the database and downstream services, and one failing service becomes three. While the rollback runs, I'd ask one engineer to take a few thread dumps a few seconds apart, or a short profile, from one bad pod, so we keep the evidence once those pods are gone. One person posts updates, so the people fixing aren't answering questions. When the old version is back and the graphs are normal, we find the cause from the dumps and the diff, and the fix goes out to a single canary pod first.”
Debugging live for an hour before rolling back, or scaling out without checking what it does to the database.
Boundaries first: inside the monolith, stop cross-module joins and entity references; other modules call billing through an interface.
Own the data: billing tables get one owner; other modules keep ids and read through an API or events, not joins.
Move gradually: route traffic behind a flag, run both paths with reconciliation, and keep the rollback path until the numbers match.
“The code is the easy part. The real coupling is the shared JPA model, where an Order entity has a direct relation to Invoice and reports join across everything. So I'd start inside the monolith. Billing becomes a module with a public interface, other modules stop holding references to its entities and keep ids instead, and I add architecture tests that fail when someone crosses the line. Queries that joined across modules get rewritten to go through that interface. Once billing's tables are only touched by billing code, pulling it out is mostly moving the module and replacing in-process calls with an API or events. For data other services need to read, billing publishes events through an outbox. I'd put the new service behind a flag, run old and new side by side with reconciliation for a while, and keep the rollback path until the numbers match.”
Starting with a new service on the same shared database, which keeps all the coupling and adds network calls.
Pick the model: shared tables are cheapest; a schema or database per tenant isolates better but multiplies migrations and connections.
Resolve once: take the tenant from the verified token at the edge, keep it in a request context, and clear it when the request ends.
Enforce centrally: a tenant filter in the data layer or row-level security in the database, tenant-aware caches, and the tenant passed explicitly to async work.
“I'd pick the model from the customers and their rules. Shared tables with a tenant column are cheapest and simplest to run, but one missed filter leaks data. A schema or database per tenant isolates better and makes a single tenant's restore easy, but every migration runs many times and connection pools multiply. Many products mix them: shared tables by default, a dedicated database for the few large customers who need it. In the app, the tenant is resolved once, from the verified token, never from a header anyone can set, and kept in a request context that's cleared at the end, because threads get reused. Then isolation is enforced in one place: a tenant filter in the data layer, row-level security in the database, or a routing data source that picks the tenant's schema. Caches are keyed by tenant, async tasks get the tenant passed in, and migrations run from a job, not at every app's startup.”
Adding a tenant condition by hand to every query and trusting everyone to remember it.
Find the real cost: measure where the money goes; oversized pods and idle replicas usually matter more than startup.
Cheap wins first: right-sized containers and heaps, fewer replicas when idle, class data sharing, or native images for the few services that need fast startup.
Frame the migration: people cost, risk and time next to the saving, and a pilot before any fleet-wide decision.
“I'd first turn it into numbers: what we spend on these services and what drives it. Often it isn't the framework. It's containers sized far bigger than their measured use, heaps set without regard to the container limit, and replicas running all night with no traffic. Those fixes are cheap and fast. Startup time only matters where we scale to zero or scale up in a hurry, and for those few services Spring's ahead-of-time support with native images, or class data sharing, gives much of what a lighter framework would, without a rewrite. Then I'd put the migration in honest terms: months of engineering, retraining, new failure modes and libraries we'd have to replace, set against the saving. My recommendation would usually be to do the tuning now, pilot a native image on one service that scales up and down a lot, and revisit the framework only if the numbers still point there.”
Treating it as a framework popularity debate, or promising savings without measuring.
The problem: services drifting versions behind, with security fixes piling up.
Make it easy: a shared BOM, automated update pull requests, and a platform team that does the first upgrade and writes the guide.
Enforce lightly: a supported-version policy with dates, a dashboard of who's behind, and a build warning before it becomes a failure.
“At my last company we had about forty Spring Boot services, and some were two major versions behind, which meant security fixes we couldn't take. Rules alone hadn't worked. So I made staying current the easy path. We moved everyone onto one internal BOM, set up automated pull requests for patch and minor updates, and my team did the first major upgrade on our own services and wrote down every breaking change we hit. For enforcement, we published a supported-version window with dates, put up a dashboard showing who was behind, and made the build warn for a quarter before it failed. The teams furthest behind got a pairing session, not a telling-off. Within about two quarters nearly everyone was on the current line, and later upgrades took days instead of months because each one was small.”
A story where the standard was only a document, or where one team did everyone's upgrades forever.
The gap: they knew the annotations but not what happens underneath, so every surprise became a blocker.
What you did: debugged real issues together with them driving, and taught the tools: conditions report, breakpoints in proxies, SQL logging.
The outcome: they handled these problems alone and started teaching others.
“I had a developer on my team who shipped features quickly but lost days whenever something odd happened, like a bean not being created or a transaction not rolling back. He'd try annotations until something worked. Instead of fixing things for him, I started pairing whenever he hit one, with him at the keyboard. I'd ask what Spring was supposed to do, and we'd check: the conditions report for missing beans, a breakpoint in the transaction interceptor to see whether the proxy was even there, SQL logging for JPA surprises. I also gave him a small real task, writing one auto-configuration for our team, which made him learn how it works underneath. After a few months he was the person others pinged about strange startup errors, and he ran a session for the team on reading the conditions report.”
A story where you kept solving the problems yourself and called it mentoring.
The bet: what you set out to do and why it looked right at the time.
Where it went wrong: the specific wrong assumption, not bad luck or other people.
What changed: the concrete practice you adopted afterwards.
“At my last company I led moving our checkout services onto a reactive stack, based on load test results from one service. I got two things wrong. I assumed the whole path could be non-blocking, but our payment client library was blocking, and the way we wrapped it tied up event loop threads under real traffic. And I moved three services at once instead of one. After a bad evening at peak, we rolled back to the MVC versions we'd kept deployable. I owned it in the review. What changed for me: I now insist on a production pilot of one service before any wider rollout, I list every dependency on the critical path and test each one under realistic load, and I keep the old path running until the new one has proven itself through a full business cycle.”
Blaming the framework or the team, or picking a failure that cost nothing.
Ask for the why: surprises where the obvious answer is wrong, like a @Transactional that does nothing.
Real debugging: a broken app or a stack trace, and watch how they find the cause.
Judgement: a design trade-off with no single right answer, and how they reason about it.
“Memorised answers break down when you ask why, or what happens next. So I ask about surprises: a transaction that didn't roll back, a bean that wasn't created, a job that ran twice. A strong senior talks about proxies, conditions and thread boundaries, and says how they'd prove it, not just which annotation to add. I also like a short debugging exercise: a small app that fails to start or fires too many queries, and they talk me through finding the cause. How they use the logs and the conditions report tells me a lot. Then one judgement question, like whether to split a service or adopt a new stack, where I listen for trade-offs and for questions back to me. I score against written criteria, so two interviewers mean the same thing by strong, and I don't mark anyone down for not recalling a property name they'd look up.”
Hiring on annotation trivia or property names, with no debugging or judgement.
ClapAssist is an AI interview assistant for Mac and Windows. It listens to the interview on your computer and shows you what to say, in short lines you can read while you talk. Your live interview audio and screen are never stored. Your resume and notes are saved to your account so the app fills them in on any computer. It stays out of screen share on every plan, including Free; only you can see it.