Spring Boot interviews for three years of experience (two to four years) ask less about what an annotation means and more about how it behaved in your project: a scheduled job that ran twice, a cache that served stale data, a migration that broke startup, a test that passed on one database and failed on another. It is written for Spring Boot developers with about two to four years of real work, the stage where you own a feature end to end but someone else still designs the service. Each question shows what the interviewer is checking, the shape of a good answer and a short spoken answer. Swap the stories for your own before the interview.
Search all questions by round, difficulty and level, or save the ones you want to practice.
The ask: what the ticket said and the questions you asked before coding.
The build: the layers you touched, the data change and the tests you wrote.
The release: how it shipped, what you watched afterwards and what you'd do differently.
“At my last company I built a CSV export of orders for our support team. Before coding I asked how big an export could get, and the answer was up to a few hundred thousand rows, so loading everything into a list was out. I added an endpoint that streamed rows from a paged repository query straight into the response, with the filters passed as a small request object. I wrote a unit test for the row formatting, a web layer test for the filters and error cases, and one integration test against a real database for the query. It went out behind a config flag, first for two support users. I watched the logs and the query time for a day, found one filter missing an index, added it with a migration, then switched it on for everyone. Next time I'd ask about the index during planning, not after release.”
A story that ends at 'I raised the PR' with nothing about tests, rollout or checking it in production.
The code: what you wrote and why it seemed fine at the time.
The comment: what the reviewer pointed out and the bug it would have caused.
The habit: what you do differently now, with an example.
“In my first year I wrote a service method that looked up a customer's loyalty points. If the customer wasn't found, I caught the exception, logged it and returned null. Around a call to another service I caught a general Exception and returned an empty list. A senior reviewer pointed out two things. Every caller now had to remember the null check, and one batch job didn't, so it would have crashed far away from the real cause. The empty list was worse: a failed call looked exactly like a customer with no orders. I changed it to throw a specific not-found exception, let our controller advice turn that into a 404 in one place, and let the remote failure travel up as an error instead of fake data. Since then I only catch an exception when I can really handle it, and I never turn a failure into an empty result.”
Saying you can't remember any feedback, or naming only formatting nits.
The estimate: the task and the number you gave.
The miss: the hidden work you didn't see and when you found it.
The change: what you raised once you knew, and what you check before estimating now.
“I was asked to add a 'preferred language' field to our customer API and said one day. The Java part really was an hour. What I missed was everything around it: the column needed a migration on a big table, two other services read our customer response and one of them failed on unknown fields, and the mobile app cached the old shape. It took most of a week. As soon as I found the second consumer on day two, I told my lead and gave a new estimate instead of going quiet. Now, before I give a number for any API change, I check who calls the endpoint, whether the data change needs a migration on a large table, and what tests exist around it. I also give a range and say what it depends on.”
Blaming the requirements or other teams without saying what you would check next time.
Pin what exists: a few tests around the behaviour you are touching, written before the change.
Stay small: no clean-up of the whole class inside this ticket.
Be open: time-box it, tell your lead, and raise the bigger gap as its own ticket.
“I'd write a few tests first, but only around the part I'm changing. With no tests, I can't tell whether my change breaks something the endpoint does today that nobody wrote down. So I'd spend maybe an hour or two on a web layer test that calls the endpoint the way clients do and checks the current response, including one error case. Those tests pin today's behaviour. Then I make my change and add a test for the new rule. What I wouldn't do is refactor the whole controller inside this ticket, because that's where small changes blow up. If writing even those first tests turns out to be hard, say the logic is tangled with static calls, I tell my lead early and we decide together. The bigger test gap goes into its own ticket so it's visible, not silently fixed or ignored.”
Either skipping tests because the change is small, or rewriting the whole endpoint without telling anyone.
Automated tests: unit tests for rules, a web layer test for the edges, one integration test for the query.
Run it: hit it locally with real requests, including bad input.
Look underneath: the SQL it runs, the logs it writes and your own diff.
“I start with unit tests for the service logic, because that's where most of the rules live and those tests are fast. Then a web layer test for the controller: the happy path, a validation failure returning 400, and a missing record returning 404. If I wrote a new query, I add one integration test against a real database in a container, since that's where surprises hide. Then I run the app locally and call the endpoint with curl, including a wrong type and an empty body. I switch on SQL logging while I do that, because it's the easiest way to catch an extra query per row. Last, I read my own diff top to bottom as if I were the reviewer. That usually catches a leftover debug log or a missing null case before anyone else sees it.”
Saying QA will test it, or that you only check it compiles and the happy path works.
Cause: H2 is a different engine, with its own SQL dialect, types and quirks.
Fix: run data tests against the same database you use in production, in a container.
Balance: keep most tests fast unit tests and use the container for query and mapping tests.
“We had a native query that used a PostgreSQL feature for JSON columns. H2 accepted a similar-looking version in its compatibility mode, so the test was green, but on the real database the syntax and the column type behaved differently and it threw an error on the first call. The lesson was that H2 is a different database pretending to be yours. We moved our repository tests to a real PostgreSQL started in a container with Testcontainers, the same major version as production, and let Flyway build the schema so the tests also checked our migrations. The tests got a bit slower, so we kept business rules in plain unit tests and used the container only for queries, mappings and migrations. We haven't had that class of bug since.”
Concluding that native queries are bad, instead of seeing that the test database was the problem.
Setup: a web layer test for just this controller, with the service replaced by a mock.
Act: post a JSON body without the email.
Assert: status 400, the error names the field, and the service was never called.
“I'd use a web layer test for just this controller, so Spring starts MVC, validation and my exception handler but not the database. The service is a mock. Then I post a body with only a name and check three things: the status is 400, the error body names the email field, which is the format our exception handler returns, and the service mock was never touched. That last check matters, because it proves validation stopped the request before any business code ran. I usually write one of these per required field or rule that matters, plus one for a malformed JSON body, since that hits a different exception. They run in a second or two, so they don't slow the build.”
@WebMvcTest(CustomerController.class)
class CustomerControllerTest {
@Autowired MockMvc mvc;
@MockitoBean CustomerService service; // @MockBean on older versions
@Test
void rejectsMissingEmail() throws Exception {
mvc.perform(post("/customers")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\":\"Sam\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors[0].field").value("email"));
verifyNoInteractions(service);
}
}
Only asserting the status code, or starting the whole application and a database for a validation test.
Cause: Order has items, each item points back to its order, and Jackson follows both sides forever.
Quick patch: JSON annotations that skip one side of the relation.
Real fix: return a response object that holds only what the client needs.
“This is a two-way relation. The Order has a list of items and each OrderItem has a field pointing back to its Order. Jackson serializes the order, then each item, then the item's order, then its items again, until it overflows the stack. You can patch it with JSON annotations, like marking the back reference so Jackson skips it, or ignoring that field. But in my project the real fix was to stop returning entities. I added an OrderResponse record with a list of item records that carry only the product, quantity and price. That also stopped a second problem we had: serializing the entity was triggering lazy loads of relations nobody needed. Now the JSON shape is something we choose on purpose, and adding a column to the table doesn't silently change the API.”
Fixing it by making the relation one-way in the database model just to make the JSON work.
Spring limit: the multipart max file size and max request size properties.
Proxy limit: a gateway or reverse proxy can reject large bodies first.
Clear error: catch the size exception and return a helpful message.
“Spring Boot has upload limits set by the spring.servlet.multipart properties, one for a single file and one for the whole request. The defaults are small, so a normal scanned PDF can go over. When that happens, the request fails while parsing, before the controller runs, so my code never saw it. I raised the limits to what the business actually needed, not to something huge, and added a handler in our controller advice for the max upload size exception, so users got a clear message saying the file limit instead of a generic error page. In another project the same symptom came from the reverse proxy in front of the app, which had its own body size limit and answered with 413 without the request ever reaching Spring. So I check both.”
Setting the limit to unlimited without thinking about memory, disk or abuse.
Class-level: the rule needs two fields, so the annotation goes on the class.
Validator: implement ConstraintValidator and leave null checks to @NotNull.
Error: attach the message to the end date field so the client can show it there.
“Since the rule compares two fields, I'd make a custom constraint that goes on the whole class, not on one field. The annotation points to a validator class, and the validator gets the whole request object. It returns true when either date is null, because @NotNull on the fields already reports that, and otherwise checks the end is after the start. Then I put the annotation on the request record, and the @Valid on the controller parameter triggers it like any other rule, so our existing exception handler turns it into a 400. One thing I'd add in the real version: a class-level error has no field name by default, so inside the validator I'd build the violation with the property node set to end date. That way the client can show the message under the right input.”
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = DateRangeValidator.class)
public @interface ValidDateRange {
String message() default "end date must be after start date";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class DateRangeValidator implements ConstraintValidator<ValidDateRange, BookingRequest> {
@Override
public boolean isValid(BookingRequest r, ConstraintValidatorContext ctx) {
if (r == null || r.start() == null || r.end() == null) return true;
return r.end().isAfter(r.start());
}
}
@ValidDateRange
public record BookingRequest(@NotNull LocalDate start, @NotNull LocalDate end) {}
Putting the annotation on one field and trying to read the other field from there.
Cause: many rows share a created date, and the database may return ties in any order on each query.
Fix: add a unique tie-breaker, like the id, to the sort.
Check: a test with several rows on the same timestamp across a page boundary.
“The sort isn't deterministic. Lots of rows were created in the same second, or even the same millisecond from a batch import, and the database only promises order on the columns you sort by. For ties it can return them in any order, and it can change between the query for page 1 and the query for page 2. With limit and offset, that means a tied row can show up on both pages while another one is skipped. The fix was to always add the id as a second sort key, so every row has one fixed position. In Spring Data I built the Pageable with a sort on created date descending and then id descending. I added a test that inserts ten rows with the same timestamp and checks that pages one and two never overlap.”
Sort sort = Sort.by(Sort.Direction.DESC, "createdAt")
.and(Sort.by(Sort.Direction.DESC, "id"));
Pageable page = PageRequest.of(pageNumber, 20, sort);
Page<Order> result = orderRepository.findByCustomerId(customerId, page);
Blaming caching or the frontend without seeing that the sort itself allows ties.
Cause: Flyway stores a checksum for every applied migration and refuses to start when a file no longer matches.
Right fix: put the old file back and add a new migration with the change.
Repair: only when the edit changes nothing in the database, like a comment.
“Flyway keeps a history table with a checksum of every migration it has run. On startup it validates the files against that table, and because I'd edited one that staging had already applied, the checksum didn't match and it stopped the app. That's a feature, not a bug: it's telling me staging and my file now describe different schemas. The right fix was to restore the original file exactly and write a new migration with the next version number that makes my change. That way every environment walks the same steps in the same order. There is a repair command that updates the stored checksums, but I'd only use it when the edit really changes nothing in the database, like fixing a comment. Since then I treat any migration that has left my laptop as read-only.”
Suggesting you clean the staging database or turn off validation so the app starts.
Problem: last write wins, because each save overwrites the whole row.
Version column: @Version makes the update check the version it read, and fail if someone changed it.
Client side: send the version with the edit and answer a conflict with 409.
“It was a classic lost update. Both agents loaded version one of the customer, both edited, and the second save overwrote everything the first had changed. I added a version field to the entity with @Version. Hibernate then adds the version to the update's where clause and bumps it, so if someone else saved in between, the update matches no rows and it throws an optimistic locking exception. The part that's easy to miss is the web layer. Our API loaded the entity fresh on every request, so the version always looked current. I added the version to the response and the update request, and checked it against the loaded entity before saving. Our exception handler turns the conflict into a 409, and the screen tells the agent someone else changed this customer and shows the latest data.”
@Entity
public class Customer {
@Id @GeneratedValue
private Long id;
@Version
private Long version;
private String phone;
// getters and setters
}
Suggesting synchronized in the service, which does nothing across two instances or two separate requests.
equals and hashCode: built from all fields, including an id that is null until save.
toString: walks lazy relations, causing extra queries, lazy errors or endless loops.
Instead: getters and setters, a careful equals, and relations left out of toString.
“@Data generates equals, hashCode and toString over every field, and all three bite with entities. We had a new entity added to a set, then saved. Saving gave it an id, the hashCode changed, and the set couldn't find it any more, so a remove silently did nothing. toString was the other one. A debug log printed an order, toString touched the lazy items list, and outside a transaction that threw a lazy initialization error. With a two-way relation it can even loop until the stack overflows. We switched entities to plain getters and setters, excluded relations from toString, and wrote equals by hand based on the id with a fixed hashCode, which is the usual safe pattern for entities. For request and response objects we just used records.”
Saying Lombok only saves typing and has no effect on how Hibernate behaves.
Cause: each request thread waits on the slow call, and with no read timeout they all end up waiting.
Fix: explicit connect and read timeouts on the HTTP client, sized from what the caller can accept.
Protect: limit retries, and fail fast or fall back while the other side is down.
“Every incoming request to our service made a call to theirs on the same request thread. Their API didn't fail, it just got slow, and our HTTP client had no read timeout set, so each thread sat waiting. Once all the server's request threads were stuck, even endpoints that never called that API stopped responding. Depending on the client and version, the default can be no timeout at all, so I don't trust defaults. The fix was setting a connect timeout of about a second and a read timeout based on how long our caller could actually wait. We also removed a retry loop that tripled the load on a service that was already struggling, and added a small fallback for the one screen that could live without that data. After that, when their API slowed down, only the feature that needed it degraded.”
Suggesting more threads or a bigger server, without mentioning timeouts.
The risk: their table becomes a hidden contract they don't know they have.
Talk first: ask the owners for an API, an event or a view meant for you.
If you must: read-only, agreed with them, written down and tracked.
“Probably not, at least not without asking. The repository is ten minutes, but it quietly turns their table into an API they don't know they have. The next time they rename a column or split the table, my feature breaks in production and neither side saw it coming. So I'd message their team first, explain what I need, and ask if there's an endpoint, an event or a view they're happy to support. Often there's already an endpoint and I just didn't know. If the deadline really can't wait, I'd agree a read-only query with them, ideally against a view they own, note it in the code and in a ticket, and make sure their team knows that the view has a consumer. The shortcut is fine when it's visible. It's the hidden version that hurts.”
Adding the repository without telling anyone because it's in the same database anyway.
Cause: each instance has its own scheduler, so each one runs the job.
Fix options: a shared lock in the database, or run the job in one place only.
Safety net: make the job idempotent so a second run does no harm.
“@Scheduled knows nothing about other instances. Each copy of the app starts its own scheduler, so with two instances the job ran twice at the same minute and both read the same list of customers. We fixed it in two layers. First, we added a lock in the shared database using a small lock library, so whichever instance grabs the lock runs the job and the other skips it. Second, we made the job safe to repeat: each reminder row now gets marked as sent inside the same transaction that queues the email, and the job only picks rows not yet marked. That second part matters because locks can expire, deploys can overlap, and someone might rerun the job by hand. I also added a log line with the instance name, so it's obvious who ran it.”
Suggesting a random delay on each instance, or turning scheduling off on one instance by hand.
Enabled: is async support switched on with @EnableAsync.
Proxy: is the method called from another bean, not from inside its own class.
After it works: errors from void methods only reach a handler, and the pool needs sensible limits.
“First I'd check that async support is switched on with @EnableAsync on a configuration class, because without it the annotation does nothing. In my case that was fine. The real issue was that the sign-up method called sendWelcomeEmail on this, inside the same service. @Async works through a proxy, so a call from inside the class skips it and runs on the same thread. I moved the email method into its own bean and injected it. Once it ran on a separate thread, two more things came up. An exception in a void async method no longer reaches the caller, it only goes to an uncaught exception handler, so I logged failures there properly. And I set the task pool's core size and queue capacity through the spring.task.execution properties, so a burst of sign-ups couldn't pile up without limit.”
Assuming @Async works on any method call, including calls to a method in the same class.
Id per request: take one from a header or create it, in a filter.
MDC: put it in the logging context, print it in the log pattern, clear it at the end.
Gaps: copy it to async threads and pass it to downstream calls.
“I added a servlet filter that runs once per request. It reads a request id header if the caller sent one, otherwise it creates a random id, puts it in the logging MDC and returns it in the response header. The log pattern prints that value on every line, so I can search one id and see the whole request. The filter clears it in a finally block, because request threads are reused and a leftover id would tag the next request's logs. The gap we hit was async work: MDC is per thread, so logs from our @Async email sender had no id until we added a task decorator that copies the context across. We also send the id on outgoing calls, so another team can find the same request in their logs. Newer setups can get this from tracing, but the idea is the same.”
@Component
public class RequestIdFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
String id = Optional.ofNullable(req.getHeader("X-Request-Id"))
.orElse(UUID.randomUUID().toString());
MDC.put("requestId", id);
res.setHeader("X-Request-Id", id);
try {
chain.doFilter(req, res);
} finally {
MDC.remove("requestId");
}
}
}
Putting the id in the MDC without ever clearing it on a pooled thread.
Back-off: Boot only creates its ObjectMapper when none exists, so the new bean replaced it.
Lost settings: Boot's defaults and every spring.jackson property went with it.
Fix: set the option with a property or a customizer bean, and keep Boot's mapper.
“Boot's Jackson auto-configuration only creates an ObjectMapper if the app doesn't already have one. The teammate's bean was a plain new ObjectMapper with one feature turned on, so Boot backed off completely. That mapper never saw Boot's own defaults or our spring.jackson settings in application.yml, which is why dates came out as arrays of numbers instead of ISO strings and the properties seemed to be ignored. The condition report in the startup debug output shows the Jackson configuration was skipped because a bean already existed, which confirmed it. The fix was to delete the bean and set that one feature with a spring.jackson property. For things a property can't express, you add a customizer bean that Boot applies to its own mapper, so you add to its setup instead of replacing it. I now treat defining any bean Boot normally creates as a warning sign.”
Fixing each date field with its own format annotation without asking why the global settings stopped working.
Cause: LocalDateTime.now() uses the JVM's zone and then drops it, so the value means different moments on different machines.
Fix: store an Instant, in a column type that keeps the zone, with Hibernate's JDBC zone set to UTC.
API: send ISO-8601 with an offset, and convert to the user's zone only for display.
“My laptop ran in my local zone, while the server ran in UTC. We set createdAt with LocalDateTime.now(), which takes the clock time in whatever zone the JVM is in and then throws the zone away. The API sent it as a string with no offset, so the web app assumed it was the user's local time. On my laptop that happened to be true. On the server it was UTC shown as if it were local, so it looked off by exactly the zone difference. The fix had three parts. We switched the fields to Instant, so the value means one moment everywhere, and the column to a timestamp type with time zone. We set Hibernate's JDBC time zone to UTC, so it no longer depends on where the JVM runs. And the API now sends ISO-8601 with an offset, so the client converts to the user's zone for display. I also added a test that runs with a non-UTC default zone.”
Fixing it by setting the server's zone to match your laptop.
Stale: nothing evicted or refreshed the entry when the price changed.
Wrong user: the key is built from the parameters, and the method read the user from somewhere else.
Also check: calls from inside the same class skip the cache, and cached objects can be changed by callers.
“Two separate bugs. The stale prices were because we cached reads but the update method never touched the cache, so an entry lived until it expired. I added an evict on the update, keyed the same way as the read. The other-user data was worse. By default the cache key is built from the method's parameters, and our method took only a product id, then read the current user from the security context inside the method to apply their personal discount. So the first user's discounted price got cached under the product id and served to everyone. Putting the user id into the key would have meant one entry per user per product, so instead we cached only the shared base price and applied the personal discount after, outside the cache. Now, before caching anything, I ask two questions: what exactly is the key, and what changes this data.”
Saying you'd just shorten the expiry time, without fixing the key or the eviction.
Not a bean: an object made with new never gets injected.
Too early: a field is filled after the constructor runs, so reading it in the constructor gives null.
Fix: take the value as a constructor parameter, or group it in a @ConfigurationProperties class.
“Since other classes got the value, I knew the property itself was fine, so the problem was how this class got it. There are three usual causes. One, the object was created with new somewhere, so Spring never touched it. Two, the field was static, and @Value doesn't fill static fields. Three, the value was read too early. That was ours: a client class had @Value on a field and used it in the constructor to build a URL. Spring creates the object first and fills fields after, so inside the constructor the field was still null. I moved the value to a constructor parameter with @Value on it, so it's there from the start and the field can be final. Later we grouped those settings into a @ConfigurationProperties record, which also let us validate them at startup.”
@Component
public class PaymentClient {
private final String baseUrl;
public PaymentClient(@Value("${app.payment.base-url}") String baseUrl) {
this.baseUrl = baseUrl; // set before any method can use it
}
}
Adding a null check with a hard-coded fallback URL instead of finding out why the value never arrived.
Old copy: is it shutting down gracefully, finishing in-flight requests first.
New copy: does it get traffic only once it is really ready.
The gap: the load balancer may keep sending to a stopping copy for a moment.
“I'd look at both ends of the deploy. On the old instance, when it gets the stop signal, is graceful shutdown on? It hasn't always been the default, so I check server.shutdown is set to graceful. Then Boot stops taking new requests and gives in-flight ones time to finish, up to the shutdown timeout, instead of cutting them off. On the new instance, I'd check the readiness check. If the platform sends traffic as soon as the process is up, the first requests hit an app that's still warming up. Pointing the readiness probe at the Actuator readiness health group fixed that for us. The last gap was the load balancer. It kept sending requests to the stopping instance for a second or two after shutdown began, so we added a short pause before shutdown starts. After those three changes, deploys stopped showing up in the error graph.”
Saying a few errors during a deploy are normal and can't be avoided.
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.