Spring Boot interview deep dive · 2026 edition
Spring Boot: 208 real-world interview questions with professional answers.
Production-flavored Spring Boot 3.x questions and answers covering auto-configuration, JPA, security, resilience, observability, virtual threads, Kubernetes, and Spring AI — the way a senior engineer would actually explain them in an interview.
What makes a strong 2026 Spring Boot answer?
Interviewers are listening for production judgment, not just annotation trivia: why a default exists, when it breaks down, and what you would check first when it fails.
| Approach | Use when | Watch out for |
|---|---|---|
| Spring MVC + virtual threads | Mostly blocking I/O (JDBC, REST calls) and the team wants simple, imperative code with high concurrency. | Virtual threads don't speed up CPU-bound work and still pin on synchronized blocks or native calls. |
| WebFlux (Reactor) | Genuinely high-concurrency streaming I/O with reactive drivers end to end. | One blocking call inside the pipeline stalls the event loop; adds real complexity for little gain on ordinary CRUD services. |
| gRPC / GraphQL edge | Strict low-latency internal service-to-service calls, or clients that need flexible, shaped queries. | Extra schema/codegen tooling and a second protocol to secure and observe alongside REST. |
Topics
Questions and answers
Each answer gives the mechanism, the trade-off, and the production concern that makes the answer stronger.
Spring Boot Fundamentals & Auto-Configuration in 2026
Interviewers use this topic to separate people who can configure a starter from people who understand what Spring Boot is actually doing at startup, because that understanding is what lets you debug a broken context instead of guessing.
1. What does Spring Boot auto-configuration actually do under the hood?
Auto-configuration is a set of ordinary @Configuration classes, shipped inside spring-boot-autoconfigure, that are conditionally registered near the end of context startup. Each class is guarded by @Conditional variants — @ConditionalOnClass, @ConditionalOnMissingBean, @ConditionalOnProperty — so it only contributes beans when its preconditions hold and nothing else has already supplied that bean. Boot discovers which classes to even consider from META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports on the classpath, then evaluates conditions lazily, in a defined order, backing off the moment a user-defined bean of the same type exists.
The mental model that matters in an interview: auto-configuration never overrides your beans, it fills gaps. If you define your own DataSource, DataSourceAutoConfiguration quietly does nothing.
2. How do you debug why Spring Boot did or didn't create a particular bean?
Start the app with --debug (or debug=true) and Boot logs a full Conditions Evaluation Report at shutdown or on demand, listing every auto-configuration class, whether it matched, and the exact condition that made or broke the match. In production-shaped environments, the same data is exposed live via the actuator /actuator/conditions endpoint, which is safer than restarting with a debug flag.
./mvnw spring-boot:run -Dspring-boot.run.arguments=--debug
# relevant excerpt from the report
DataSourceAutoConfiguration#dataSource matched:
- @ConditionalOnMissingBean (types: javax.sql.DataSource) found no beans
HikariAutoConfiguration#hikariDataSource matched:
- @ConditionalOnClass found required class 'com.zaxxer.hikari.HikariDataSource'
If a bean you expect is missing, search the report for its auto-configuration class and read the "did not match" reason first — nine times out of ten it's a missing dependency on the classpath or a property that isn't set the way you assumed.
3. What does @SpringBootApplication actually combine, and when would you split it apart?
@SpringBootApplication is a meta-annotation bundling @SpringBootConfiguration (itself a specialized @Configuration), @EnableAutoConfiguration, and @ComponentScan. The component scan defaults to the package of the annotated class and everything beneath it, which is why the main class conventionally sits at the top of your package tree.
You split it apart when the defaults stop fitting: for example disabling auto-configuration entirely for a stripped-down worker (@SpringBootConfiguration + @Import of a curated bean set), or narrowing @ComponentScan with explicit base packages when the main class can't live at the root because of a modular build layout.
4. Starters vs the Spring Boot BOM — what problem does each one solve?
A starter (spring-boot-starter-web, spring-boot-starter-data-jpa) is a curated dependency aggregator: pulling one in transitively brings every library a given feature needs, with versions already chosen to work together. The BOM (spring-boot-dependencies, imported via <dependencyManagement>) is the opposite direction — it doesn't add any jars to your classpath, it only fixes the version numbers so that when you declare a dependency yourself, or a starter pulls one in transitively, you get the version Boot has tested.
In practice you use both together: the BOM gives you version alignment across the whole dependency graph, starters give you convenient bundles on top of that aligned graph. Declaring a library version explicitly, without a strong reason, fights the BOM and is a common source of "works on my machine" classpath conflicts.
5. What actually happens when Spring Boot "embeds" a server like Tomcat or Netty?
Instead of packaging a WAR that an external servlet container deploys, Boot packages the servlet container itself as a library dependency and boots it programmatically inside your main(). ServletWebServerApplicationContext detects a ServletWebServerFactory bean (chosen from whichever starter is on the classpath — Tomcat by default, Jetty or Undertow if you swap the starter), creates the server, binds your DispatcherServlet to it, and starts listening before SpringApplication.run() returns.
The practical consequence is that the artifact is a self-contained executable jar (java -jar app.jar), there's no separate container version to keep in sync with the app, and reactive stacks get the same treatment with Netty instead of a servlet container under ReactiveWebServerApplicationContext.
6. Walk through the ApplicationContext startup sequence and its event lifecycle.
SpringApplication.run() fires a well-defined sequence of ApplicationEvents that you can hook into with an ApplicationListener or @EventListener: ApplicationStartingEvent before anything is initialized, ApplicationEnvironmentPreparedEvent once the Environment is known but before the context exists, ApplicationContextInitializedEvent, ApplicationPreparedEvent right before refresh, then the context's own refresh lifecycle runs (bean definition loading, BeanFactoryPostProcessors, bean instantiation, BeanPostProcessors), followed by ApplicationStartedEvent, AvailabilityChangeEvent (LivenessState.CORRECT), ApplicationReadyEvent, and finally AvailabilityChangeEvent (ReadinessState.ACCEPTING_TRAFFIC).
@Component
class StartupTimingListener {
@EventListener(ApplicationReadyEvent.class)
void onReady(ApplicationReadyEvent event) {
long uptimeMs = event.getSpringApplication()
.getMainApplicationClass() != null
? System.currentTimeMillis() - event.getTimestamp()
: -1;
log.info("Application ready");
}
}
ApplicationReadyEvent is the right place for "warm the cache" logic; readiness-gated Kubernetes probes should key off ReadinessState.ACCEPTING_TRAFFIC, not just process liveness.
7. How do you write your own auto-configuration in Spring Boot 3.x?
Boot 3 dropped the old spring.factories file for auto-configuration discovery in favor of META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports, a plain list of fully-qualified configuration class names. You write a normal @AutoConfiguration-annotated class, guard it with conditions so it backs off cleanly, and list it in that file inside your library jar.
@AutoConfiguration
@ConditionalOnClass(RateLimiterClient.class)
@EnableConfigurationProperties(RateLimiterProperties.class)
public class RateLimiterAutoConfiguration {
@Bean
@ConditionalOnMissingBean
RateLimiterClient rateLimiterClient(RateLimiterProperties props) {
return new RateLimiterClient(props.host(), props.permitsPerSecond());
}
}
// META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
com.acme.ratelimit.RateLimiterAutoConfiguration
8. What changed in Spring Boot 3.4/3.5 that affects day-to-day development?
3.4 introduced first-class structured logging support (logging.structured.format.console=ecs or logstash) so you get JSON logs without wiring Logback encoders by hand, plus @ServiceConnection maturing further for Testcontainers-backed integration tests so datasource/broker connection details are derived automatically from the running container instead of hardcoded properties. 3.4/3.5 also tightened null-safety annotations (JSpecify) across the framework and continued expanding virtual-thread-aware defaults introduced in 3.2.
None of this is optional trivia in an interview context — being asked "what's new" is really a proxy for "do you keep up with the framework you use daily," so at minimum know structured logging and @ServiceConnection by name and by what problem they solve.
9. What do you need to change to make a Spring Boot app GraalVM native-image friendly?
Native image builds ahead-of-time, so anything that relies on classpath scanning, reflection, dynamic proxies, or resource loading at runtime has to be declared statically at build time instead of discovered lazily. Spring's AOT engine (triggered by the native Maven/Gradle profile, backed by GraalVM's reachability metadata) processes your bean definitions at build time and generates the reflection/proxy/resource hints automatically for anything wired through standard Spring mechanisms.
Where it breaks down is custom reflection, dynamic class loading, or libraries that weren't written with AOT in mind — those need manual RuntimeHintsRegistrar entries. The payoff is real: sub-100ms startup and a fraction of the memory footprint, which matters for scale-to-zero or high-density container deployments.
./mvnw spring-boot:process-aot locally before you ever try a full native build — it surfaces missing hints far faster than a 5-minute native-image compile failing at the end.10. How do you enable and reason about virtual threads in Spring Boot?
Setting spring.threads.virtual.enabled=true on Java 21+ swaps the executors backing the embedded Tomcat request-handling pool (and @Async/TaskExecutor where applicable) to virtual threads instead of the platform thread pool. Because virtual threads are cheap to create and park, blocking I/O — JDBC calls, blocking HTTP clients, synchronous message consumers — stops being the throughput bottleneck it is on a fixed platform-thread pool, without rewriting the code in a reactive style.
spring.threads.virtual.enabled=true
The catch: virtual threads don't help CPU-bound work, and any code that uses synchronized blocks around blocking operations can still pin the underlying carrier thread, defeating the benefit. They also don't remove backpressure concerns from downstream connection pools — a JDBC pool with 20 connections still caps you at 20 concurrent DB calls no matter how many virtual threads are runnable.
11. Why are Java records a natural fit for DTOs in Spring Boot, and where do they fall short?
Records give you an immutable, final class with a canonical constructor, accessors, equals/hashCode/toString generated for free — exactly the shape a request/response DTO needs. Jackson supports them natively for serialization and deserialization, Bean Validation annotations work on the constructor parameters, and immutability rules out an entire class of bugs where a DTO gets mutated after being handed to another layer.
public record CreateOrderRequest(
@NotBlank String sku,
@Positive int quantity,
@Email String customerEmail) {
}
@PostMapping("/orders")
ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest request) {
Order order = orderService.place(request.sku(), request.quantity(), request.customerEmail());
return ResponseEntity.status(HttpStatus.CREATED).body(OrderResponse.from(order));
}
They fall short as JPA entities — entities need mutability, identity semantics that survive field changes, and lazy-loading proxies, all of which fight a record's final, value-based design — and they fall short whenever you need inheritance, since records can't extend another class.
12. What's fundamentally different between "plain Spring" and Spring Boot?
Plain Spring Framework gives you the IoC container, AOP, and the surrounding ecosystem (Spring MVC, Spring Data, Spring Security) but leaves you to wire every bean, choose and configure a server, and manage every version yourself. Spring Boot doesn't add new core capabilities on top of the framework — it's an opinionated packaging layer: auto-configuration, embedded servers, curated dependency versions via the BOM, externalized configuration conventions, and production-ready features (actuator) out of the box.
The tell in an interview is knowing that "Spring Boot" isn't a competing technology to "Spring" — it's Spring, configured for you with sensible defaults you can override one property at a time.
13. When would you deliberately not reach for Spring Boot?
Extremely latency- or memory-constrained services — a Lambda handler that needs to cold-start in milliseconds, an embedded device, a library meant to be framework-agnostic — often don't want Boot's classpath scanning and auto-configuration overhead, even with AOT, and are better served by a lighter framework (Micronaut, Helidon) or no framework at all. Similarly, if you're building a small, single-purpose CLI tool or a library that other teams will embed in their own Spring or non-Spring apps, pulling in the full Boot stack forces your dependency choices onto every consumer.
14. Your app takes 40 seconds to start in production but only 4 seconds locally. How do you find out why?
First isolate whether it's Spring context startup or something before/after it — enable spring.startup.tracking-style timing or simply diff the ApplicationStartedEvent timestamp against process start time in both environments. If context startup itself is slow, run with --debug or capture a BufferingApplicationStartup report (ApplicationStartup SPI) in prod to get per-bean and per-phase timings, since that's the tool built specifically for this.
In practice the usual culprits are environment-specific: DNS resolution timeouts reaching a config server or service registry, a JDBC connection pool eagerly validating connections against a database across a slower network, classpath scanning over a much larger production artifact (shaded/fat jar with extra dependencies not present locally), or a synchronous call to a secrets manager / Vault that's fast on a local mock but slow over the real network path. Reproducing the exact prod classpath and network topology locally, rather than assuming "it's the same code," is what actually finds it.
Dependency Injection & Bean Lifecycle
This is where interviewers check whether you understand the container as a runtime with real proxying and lifecycle rules, not just an annotation you sprinkle on fields — the classic trap questions are all about why a correctly-annotated bean silently doesn't behave as expected.
15. Constructor, field, and setter injection — what are the real trade-offs?
Constructor injection makes required dependencies explicit and immutable, lets you mark fields final, and — critically — makes the class instantiable without a Spring container in a plain unit test, since you just call new. Field injection is the most concise to write but hides the dependency graph, prevents final fields, and can't be constructed without reflection, which is why it's discouraged for anything beyond quick throwaway code. Setter injection is mainly useful for optional dependencies that can be reconfigured after construction, but it leaves a window where the bean exists in a partially-wired, invalid state.
@Service
public class OrderService {
private final PaymentClient paymentClient;
private final OrderRepository orderRepository;
// Single constructor: @Autowired is optional here in Spring 4.3+
public OrderService(PaymentClient paymentClient, OrderRepository orderRepository) {
this.paymentClient = paymentClient;
this.orderRepository = orderRepository;
}
}
16. What are Spring's bean scopes, and what are the thread-safety implications of each?
singleton (the default) creates exactly one instance per container, shared across every thread handling every request, so any mutable instance state is a concurrency hazard. prototype creates a new instance per injection point/lookup, so it's inherently thread-confined but easy to leak if you expect the container to manage its destruction — it doesn't call destroy callbacks automatically. request and session scopes (web-aware) create one instance per HTTP request or per session respectively, backed by a scoped proxy that resolves the real instance from a thread-bound context on each method call.
The practical rule: keep singleton beans stateless, or make any state thread-safe explicitly (atomic types, concurrent collections, or delegation to a scoped/external store); reach for request scope only when you genuinely need per-request mutable state that outlives a single method call.
17. How do you diagnose and fix a circular dependency between beans?
Spring throws BeanCurrentlyInCreationException when constructor injection creates a cycle it can't resolve — bean A's constructor needs bean B, whose constructor needs bean A, and there's no way to hand back a half-built object. The report names both beans and the injection point that closed the loop, so start there rather than guessing.
The correct fix is almost always to break the cycle at the design level — extract the shared behavior into a third bean both depend on, or invert one of the dependencies so it's not mutual. Falling back to field/setter injection or @Lazy on one side makes the cycle "work" by deferring resolution, but it's treating the symptom: it usually signals two responsibilities that should never have needed each other synchronously in the first place.
@Lazy silences the circular-dependency error without addressing why two beans need each other; reach for it only as a deliberate, documented last resort, not a default fix.18. What is a BeanPostProcessor used for, and how does it differ from a BeanFactoryPostProcessor?
A BeanFactoryPostProcessor runs once, early, against the bean definitions before any bean is instantiated — it's for modifying metadata, like PropertySourcesPlaceholderConfigurer resolving ${...} placeholders. A BeanPostProcessor runs per bean instance, around each bean's initialization (postProcessBeforeInitialization/postProcessAfterInitialization), which is exactly where Spring itself implements @Autowired field injection, @PostConstruct invocation, and AOP proxy creation.
@Component
public class AuditLoggingBeanPostProcessor implements BeanPostProcessor {
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (bean instanceof Auditable auditable) {
log.info("Registering auditable bean: {}", beanName);
}
return bean;
}
}
Custom BeanPostProcessors are the extension point for cross-cutting bean-level behavior that isn't naturally AOP, like wrapping a bean in a monitoring decorator or validating that certain beans satisfy an internal contract at startup.
19. @Primary vs @Qualifier — how does Spring actually resolve ambiguous bean injection?
When multiple candidates exist for a type, Spring first checks for a bean explicitly marked @Primary and uses it as the tie-breaker if exactly one exists. @Qualifier (matched against a bean's name or an explicit qualifier value) is more precise and takes priority at each individual injection point, letting you pick a specific bean by name even when a different one is marked @Primary.
@Bean
@Primary
PaymentGateway defaultGateway() { return new StripeGateway(); }
@Bean
@Qualifier("legacy")
PaymentGateway legacyGateway() { return new PaypalGateway(); }
@Service
class RefundService {
RefundService(@Qualifier("legacy") PaymentGateway gateway) { ... } // gets Paypal
}
20. What is ObjectProvider for, and when do you need it instead of a plain injected collection?
ObjectProvider<T> defers bean resolution until you actually call getObject()/getIfAvailable()/stream(), rather than requiring the dependency to be resolvable at the injecting bean's construction time. That matters when the dependency is genuinely optional (no candidates should be a valid state, not a startup error), when you need a fresh prototype-scoped instance per call, or when you want to iterate all implementations of an interface lazily and in a defined order without forcing eager instantiation of every one at container startup.
@Service
class NotificationDispatcher {
private final ObjectProvider<NotificationChannel> channels;
NotificationDispatcher(ObjectProvider<NotificationChannel> channels) {
this.channels = channels;
}
void dispatch(Notification n) {
channels.orderedStream().forEach(channel -> channel.send(n));
}
}
21. Why does @Transactional (or @Async) sometimes silently do nothing?
Both are implemented as AOP proxies wrapping the real bean, and Spring's default proxy mechanism only intercepts calls that arrive through the proxy — from another bean, or from outside the class. A method calling another @Transactional method on this from inside the same class bypasses the proxy entirely, so the annotation on the internal call is silently ignored; there's no error, the transaction (or async dispatch) simply never happens.
@Service
public class ReportService {
public void generate() {
// self-invocation: bypasses the proxy, runs synchronously, no new transaction
processInBackground();
}
@Async
public void processInBackground() { ... }
}
The fix is to move the annotated method into a separate bean and call it through that bean's injected reference, so the call goes through the proxy.
22. CGLIB vs JDK dynamic proxies — how does Spring choose, and what does it mean for final classes?
Spring uses a JDK dynamic proxy when the target bean implements at least one interface and the proxy can be expressed purely in terms of that interface; otherwise (or when proxyTargetClass=true, which Spring Boot sets as the default for AOP) it generates a CGLIB subclass of the concrete class at runtime. CGLIB proxying works by subclassing, so it cannot proxy a final class, and it cannot override a final or private method — any @Transactional/@Async/@Cacheable annotation on such a method is silently unenforceable because the proxy has no way to intercept it.
23. How do you test a bean's business logic without loading the Spring container?
If the class uses constructor injection, you don't need Spring at all for a pure unit test — instantiate it directly with mocked collaborators (Mockito) and assert behavior, which runs in milliseconds and doesn't care about the container. Reach for @SpringBootTest or slice tests (@WebMvcTest, @DataJpaTest) only when you specifically need to verify wiring, HTTP-layer behavior, or real persistence semantics — those are integration tests, not unit tests, and they're an order of magnitude slower.
class OrderServiceTest {
@Test
void placingOrderChargesPayment() {
PaymentClient paymentClient = mock(PaymentClient.class);
OrderRepository repository = mock(OrderRepository.class);
OrderService service = new OrderService(paymentClient, repository);
service.place("SKU-1", 2);
verify(paymentClient).charge(any());
}
}
24. What is @Lazy for on a bean, and what does it cost you?
@Lazy defers a singleton bean's instantiation from container startup until its first actual use, which can shave meaningful time off startup when a bean is expensive to construct but rarely needed (an admin-only client, a rarely-hit integration). The cost is that failures which would normally surface at startup — a misconfigured bean that throws in its constructor — now surface at request time instead, in production, on whatever thread first touches it.
@Lazy broadly — "fail fast at startup" is a deliberate design property you're giving up.25. How do you control bean initialization order when @DependsOn isn't enough?
Spring resolves most ordering automatically through the dependency graph itself — if A's constructor needs B, B is guaranteed to initialize first. @DependsOn exists for cases with no direct injection relationship but a real ordering requirement (a JDBC schema-init bean that must run before anything touches the datasource, even though nothing injects it). For lifecycle-phase ordering after all beans exist — start/stop sequencing of long-running components — SmartLifecycle with an explicit getPhase() value is the correct tool, not annotation ordering tricks.
@PostConstruct runs after dependency injection completes for that single bean but says nothing about ordering relative to sibling beans; don't rely on component-scan or declaration order, since it's not guaranteed.
26. Under load, a service starts returning one customer's data to another. The suspect is a singleton bean — what's happening?
This is the canonical symptom of mutable state stored on a singleton-scoped bean's instance fields. Someone added a field to hold "the current request's customer ID" or a per-call accumulator, and because the bean is a single shared instance across every thread handling every concurrent request, one thread's write clobbers another thread's read between the two — the data leak is a race, so it only shows up under real concurrent load, not in sequential manual testing.
// BUG: mutable field shared across every concurrent request
@Service
public class ReportBuilder {
private String currentCustomerId; // NOT thread-safe
public Report build(String customerId) {
this.currentCustomerId = customerId;
return fetchData(currentCustomerId); // another thread may have overwritten it by now
}
}
// FIX: keep request-scoped state local, not on the bean
@Service
public class ReportBuilder {
public Report build(String customerId) {
return fetchData(customerId); // pure parameter, no shared mutable state
}
}
The fix is almost always to pass the value as a method parameter or local variable instead of a field, or, if genuine per-request state is unavoidable, use request scope or ThreadLocal explicitly with disciplined cleanup — not an instance field on a singleton.
External Configuration, Profiles & Secrets
Interviewers probe this area to see whether you treat configuration as a first-class, environment-aware contract with validation and precedence rules, rather than a grab-bag of properties files that happen to work until one environment quietly diverges from the rest.
27. What is Spring Boot's property source precedence, and what is "relaxed binding"?
Boot merges properties from many sources into one Environment, resolved in a fixed precedence order — roughly: command-line arguments, then SPRING_APPLICATION_JSON, then OS environment variables, then application-{profile}.yml, then application.yml, then defaults — so a value set on the command line always wins over the same key in a properties file. Relaxed binding means a property doesn't need to match a Java field name exactly: my.service.connection-timeout, MY_SERVICE_CONNECTION_TIMEOUT, and my.service.connectionTimeout all bind to the same connectionTimeout field, which is what lets the same @ConfigurationProperties class be configured naturally from both YAML and environment variables.
28. @ConfigurationProperties vs @Value — what are the real trade-offs?
@Value("${...}") is fine for one or two ad hoc reads but doesn't group related settings, doesn't support relaxed binding the same way, can't easily validate as a unit, and scatters the same property key as a string literal across every class that needs it. @ConfigurationProperties binds a whole prefixed namespace onto a single typed, immutable class (ideally a record), gets IDE auto-completion via the annotation processor's metadata, and supports Bean Validation across all its fields at once.
@ConfigurationProperties(prefix = "payments.gateway")
@Validated
public record GatewayProperties(
@NotBlank String baseUrl,
@Min(1) int maxRetries,
Duration timeout) {
}
@Configuration
@EnableConfigurationProperties(GatewayProperties.class)
class GatewayConfig { }
Default to @ConfigurationProperties for anything with more than one related setting; reserve @Value for genuinely single, standalone values.
29. How do you structure profile activation for a multi-region, multi-tenant deployment?
Profiles compose, so the pattern that scales is layering: a base profile per deployment concern (region-us, region-eu) alongside a tenant-tier profile (tier-standard, tier-premium), activated together via spring.profiles.active=region-eu,tier-premium rather than one monolithic profile per combination, which would multiply combinatorially. Each profile-specific YAML document (application-region-eu.yml) only overrides what actually differs for that axis; shared defaults stay in application.yml so environments can't drift on settings that were never meant to vary.
Activating profiles via an environment variable (SPRING_PROFILES_ACTIVE) injected by the deployment platform, rather than baking a profile into the image, keeps the same artifact promotable across regions unchanged.
30. What does spring.config.import do, and when do you reach for it?
spring.config.import lets one configuration file pull in another arbitrary source at load time — a second YAML file, a classpath: resource, or an external system like a config server or a secrets provider (vault://, configtree:) — without hardcoding that source into the fixed application-{profile}.yml naming convention. It's the mechanism Boot uses under the hood for things like configtree:/etc/secrets, mapping a directory of mounted Kubernetes secret files directly into properties.
# application.yml
spring:
config:
import: "configtree:/etc/secrets/"
# each file under /etc/secrets/ becomes a property named after the filename
31. How do you integrate Vault, AWS Secrets Manager, or Kubernetes secrets, and why not just put credentials in application.yml?
Credentials in a YAML file end up in source control history, in every environment's build artifact, and in plaintext on disk on every host that runs the app — none of which survives an audit. The standard approaches instead pull secrets at startup or refresh time from an external store: Spring Cloud Vault or Spring Cloud AWS resolve spring.config.import: vault://secret/myapp / equivalent property sources directly into the Environment, while Kubernetes secrets are typically mounted as files and read via configtree:, keeping the actual secret value out of any config file entirely.
Whichever mechanism you use, the property still ends up as a normal Spring property afterward — the goal is only to change where it's sourced from and who can see the raw value in transit and at rest, not to change how the rest of the app consumes it.
32. Why shouldn't the actuator env/configprops endpoints be exposed publicly?
/actuator/env and /actuator/configprops dump the fully resolved Environment, including values pulled from every property source — which, unless you've been disciplined about sanitization, can include database passwords, API keys, and internal hostnames. Boot does apply automatic sanitization to keys that look sensitive (matching patterns like password, secret, token), but that's a best-effort heuristic, not a guarantee, and it does nothing for a custom property named something it doesn't recognize, like payments.gateway.apiKey spelled differently than expected.
The correct posture is to never expose actuator endpoints beyond health and info on a public network path — bind actuator to a separate management port, gate it behind network policy or authentication, and treat exposing env to the internet as equivalent to exposing your secrets store.
33. How do you detect and prevent configuration drift between environments?
Drift creeps in when someone tweaks a property directly in a running environment (a Kubernetes ConfigMap edited by hand, a Vault value updated without a corresponding code/PR change) instead of through the same pipeline every other environment goes through. The fix is process, not code: treat every environment's configuration as versioned artifacts in the same repository as the application, generated from shared templates with environment-specific overlays, so a diff between environments is a diff you can actually read and review.
On the detection side, a startup-time check that logs (or exports as a metric) a hash or fingerprint of the effective, resolved configuration lets you compare "what's actually running in staging" against "what's actually running in prod" without needing direct access to either — useful when the two have drifted silently and nobody remembers when or why.
34. How would you implement feature flags using Spring Boot's configuration model?
For simple on/off toggles that don't need runtime changes without a restart, a plain @ConfigurationProperties-bound boolean per feature, checked in code, is sufficient and requires no extra infrastructure. For flags that need to flip without a redeploy, or need per-tenant/per-user targeting, you need a source that refreshes live — Spring Cloud Config's /actuator/refresh combined with @RefreshScope, or a dedicated feature-flag service (LaunchDarkly, Unleash, or a simple database-backed table polled on an interval) — because static property files bound once at startup can't change without a restart by design.
@ConfigurationProperties(prefix = "features")
public record FeatureFlags(boolean newCheckoutFlow, boolean betaRecommendations) { }
@Service
class CheckoutController {
private final FeatureFlags flags;
ResponseEntity<?> checkout() {
return flags.newCheckoutFlow() ? newFlow() : legacyFlow();
}
}
35. A property that's misconfigured in exactly one environment silently changed production behavior — how do you even find that?
The dangerous version of this incident is a property that's technically valid — it parses, it binds, the app starts fine — but has a different effective value than intended, so nothing errors and nothing alerts; the app just behaves subtly differently. Concretely: a connection-pool timeout set to 30 in one environment's override file meant to be milliseconds but interpreted as seconds because the type is a bare int instead of a Duration, or a cache TTL that's 0 in one region's YAML because of a copy-paste from a "disable caching for local dev" override that was never meant to reach production.
Finding it means comparing the actual resolved value across environments, not the source files — pull /actuator/configprops (or the conditions/env report) from both, diff the effective values for anything touching the affected code path, since the bug is very often not in the file you'd guess but in a property that source-file review alone won't surface. Once found, the durable fix is almost always to bind that property as a strongly-typed value (Duration instead of int, an enum instead of a raw string) so the same class of ambiguity can't recur silently.
36. How do you make configuration errors fail fast at startup instead of surfacing as runtime bugs?
Bind configuration through @ConfigurationProperties classes annotated with @Validated and Bean Validation constraints on each field — Boot validates the bound object immediately during context startup and throws a ConfigurationPropertiesBindException that names the exact property and constraint that failed, rather than letting a bad value (a negative timeout, a blank required URL) silently propagate until the first request that touches it.
@ConfigurationProperties(prefix = "payments.gateway")
@Validated
public record GatewayProperties(
@NotBlank String baseUrl,
@Min(1) @Max(10) int maxRetries) {
}
// Missing or blank baseUrl fails the container refresh immediately:
// Binding to target org.springframework.boot.context.properties.bind.BindException:
// Property: payments.gateway.baseUrl / Reason: must not be blank
Combine this with a startup smoke check (an ApplicationRunner that pings required downstreams once) for configuration that's syntactically valid but semantically wrong, like a hostname that resolves but points at the wrong environment's database.
37. In Kubernetes, when do you use a ConfigMap vs a Secret vs a plain environment variable?
ConfigMaps are for non-sensitive, environment-specific settings that are fine to view in plaintext via kubectl — feature flags, log levels, non-secret endpoint URLs — and are commonly mounted as a volume so Boot's configtree: support (or a mounted application.yml) can pick them up. Secrets exist for genuinely sensitive values; they're base64-encoded (not encrypted by default — that depends on the cluster's encryption-at-rest and RBAC setup) and should be mounted as files rather than injected as plain environment variables, since environment variables are more easily leaked into process listings, crash dumps, and child processes.
Plain environment variables set directly in the deployment manifest are reasonable for a handful of non-sensitive, rarely-changing values, but stop scaling once you have more than a few — at that point a mounted ConfigMap/Secret and spring.config.import is more maintainable than a long list of manifest-level env vars.
38. What's the resolution order between ${...} placeholders, SpEL expressions, and defaults in @Value?
${property.key:defaultValue} is placeholder syntax resolved against the Environment's merged property sources, using the same precedence order as everywhere else in Boot, with the value after the colon used only if the key is entirely absent from every source. #{...} is SpEL, evaluated separately and capable of referencing other beans, invoking methods, and combining property lookups with logic — the two can be nested (#{'${feature.mode}' == 'beta'}) but placeholders resolve first, then the resulting string is handed to the SpEL parser.
A subtlety worth knowing: an empty string value for a property (as opposed to the key being missing) is a legitimate resolved value and will not trigger the :default fallback — only a genuinely absent key does.
${...} defaults over SpEL wherever possible; SpEL in configuration is powerful but harder to statically verify and easy to overuse into unreadable one-liners.REST APIs, Validation & Error Handling
This section is where interviewers check whether you design APIs for the client that times out and retries, not just the client that succeeds on the first try — expect deep dives into validation boundaries, error contracts, idempotency, and pagination for data that keeps changing underneath the client.
39. What actually happens when a @RestController method returns a Java object?
The returned object never gets rendered by a view resolver — @RestController is @Controller plus @ResponseBody baked in, so the return value is treated as the response body itself. Spring's RequestResponseBodyMethodProcessor asks the configured list of HttpMessageConverter beans which one can write that Java type as one of the media types the client's Accept header allows, and the first match serializes it (Jackson's MappingJackson2HttpMessageConverter for JSON by default). If no converter matches the Accept header, the framework returns 406 Not Acceptable instead of guessing.
@Bean
MappingJackson2HttpMessageConverter jsonConverter(ObjectMapper mapper) {
mapper.registerModule(new JavaTimeModule());
return new MappingJackson2HttpMessageConverter(mapper);
}
40. Why should you never bind or return a JPA entity directly in a REST controller?
An entity used as a request body accepts every field it has, not just the ones you intended to expose — a client can set fields like isAdmin or accountBalance that were never meant to be client-writable, because Jackson happily binds anything with a matching setter. Entities also carry lazy proxies and bidirectional associations that either throw LazyInitializationException during serialization outside a transaction or recurse infinitely across a parent/child relationship. A dedicated request/response DTO decouples your public API contract from schema changes and lets you control exactly which fields are readable and writable per operation.
@RequestBody User directly lets a signup request slip in "role": "ADMIN" alongside the expected fields, and nothing in the entity stops it.41. How do you validate nested objects and apply different rules per operation with Jakarta Validation?
@Valid cascades validation into nested objects as long as the field holding them is itself annotated @Valid — without it, Jakarta Validation only checks the top-level fields and silently skips the nested object entirely. Validation groups let one DTO carry different rule sets for different operations, most commonly "id required on update, absent on create," by tagging constraints with a marker interface and telling the controller which group to enforce via @Validated.
public interface OnCreate {}
public interface OnUpdate {}
public record OrderRequest(
@Null(groups = OnCreate.class)
@NotNull(groups = OnUpdate.class)
Long id,
@NotBlank
String customerEmail,
@Valid @NotNull
Address shippingAddress
) {}
@PostMapping
ResponseEntity<OrderResponse> create(
@RequestBody @Validated(OnCreate.class) OrderRequest request) { ... }
42. How do you implement RFC 9457 ProblemDetail responses consistently across an entire API?
Spring Framework 6 ships ProblemDetail, a first-class type for the standard "type/title/status/detail/instance" fields plus arbitrary extension properties. Centralizing exception-to-response mapping in one @RestControllerAdvice keeps every endpoint's error shape identical regardless of which exception fired, instead of every controller building its own ad-hoc error JSON.
@RestControllerAdvice
class ApiExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
pd.setTitle("Validation failed");
pd.setType(URI.create("https://api.example.com/errors/validation"));
pd.setProperty("errors", ex.getBindingResult().getFieldErrors().stream()
.map(f -> f.getField() + ": " + f.getDefaultMessage())
.toList());
return pd;
}
}
43. How do you design idempotency keys so retried POST requests don't create duplicates?
The client generates a unique Idempotency-Key header per logical operation and sends the same value on every retry of that operation. The server persists the key alongside a fingerprint of the request and the stored result of the first successful execution, then replays that stored response for any repeat instead of re-executing the side effect. The check-then-store step has to be atomic — a plain "SELECT then INSERT" leaves a race window where two concurrent retries both see "not found" and both process — so it needs a unique constraint on the key or an atomic operation like Redis SETNX backing the check.
44. Offset pagination vs cursor (keyset) pagination — which do you use for a feed that changes constantly?
Offset pagination (LIMIT/OFFSET) makes the database scan and discard every skipped row before returning a page, so cost grows with page depth, and because rows can be inserted or deleted between requests, a client paging through a live feed can see duplicates or miss items entirely as everything shifts underneath it. Cursor pagination instead carries the last seen sort key forward as a WHERE predicate ("give me rows after this value"), so the database seeks directly to the right spot regardless of page depth and stays stable even as rows are added or removed elsewhere in the table — the trade-off is you lose the ability to jump straight to an arbitrary page number.
| Offset pagination | Cursor (keyset) pagination | |
|---|---|---|
| Query cost at depth | Grows with page number | Constant regardless of depth |
| Stability under writes | Skips/duplicates rows | Stable — anchored to a value, not a position |
| Random page access | Supported directly | Not supported (sequential only) |
45. How does Spring MVC decide whether to return JSON or XML for the same endpoint?
When a handler method declares multiple producible media types, the ContentNegotiationManager compares them against the request's Accept header (and optionally a format query parameter or path extension, if configured) and picks the best match, falling back to the highest-priority converter's default when the client sends Accept: */*. If none of the declared types satisfy the Accept header, Spring returns 406 Not Acceptable rather than silently defaulting.
@GetMapping(value = "/orders/{id}",
produces = { MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE })
OrderResponse getOrder(@PathVariable Long id) { ... }
46. What's the difference between CORS and CSRF, and why do people confuse fixing one for the other?
CORS is a browser permission mechanism — it relaxes the same-origin policy so a page served from one origin can read responses from your API on another origin; it's a grant, not a defense, and a permissive CORS config doesn't create or fix a CSRF vulnerability by itself. CSRF is an attack where a malicious page on any origin causes the victim's browser to send a request to your site using credentials the browser attaches automatically (session cookies), regardless of whether that malicious origin is allowed by your CORS policy. Stateless APIs authenticated with a bearer token in a header are largely immune to CSRF because the browser won't attach that header on its own; cookie-based session auth needs real CSRF protection (tokens, or SameSite=Strict/Lax cookies) no matter how CORS is configured.
allowedOrigins makes cross-origin reads harder but does nothing against CSRF, since a CSRF request is sent to your own origin — treat them as two separate controls, not one setting.47. URI versioning vs header versioning vs media-type versioning — what are the real trade-offs for a public API?
URI versioning (/v1/orders) is simple, cacheable by CDNs, and immediately visible in logs and documentation, but it bakes the version into the resource's identity and complicates routing once several versions must coexist behind the same paths. Custom header or media-type versioning (Accept: application/vnd.company.order.v2+json) keeps the URL — and therefore the resource identity — stable across versions, but is harder to test manually, less discoverable to a new consumer browsing endpoints, and some intermediary proxies strip custom headers. Most public APIs default to URI versioning for discoverability and accept the routing overhead as the cost of doing business.
48. How do you stream a multi-million-row export without loading it all into memory?
StreamingResponseBody hands you the raw OutputStream and runs your write logic on a separate thread as the response is being sent, so you never have to materialize the full payload as a byte array or a List before returning. Pairing it with a repository method returning a lazily-consumed Stream<T> inside a try-with-resources block means rows are read from the database, written to the client, and discarded incrementally, keeping memory flat regardless of dataset size.
@GetMapping(value = "/orders/export", produces = "text/csv")
StreamingResponseBody exportOrders() {
return outputStream -> {
try (Writer writer = new BufferedWriter(new OutputStreamWriter(outputStream));
Stream<Order> orders = orderRepository.streamAll()) {
orders.forEach(o -> writeCsvRow(writer, o));
}
};
}
49. How would you add rate limiting to a Spring Boot API without hand-rolling a token bucket?
Bucket4j is the common library choice — in-memory for a single instance, or backed by Redis/Hazelcast when multiple instances need a shared counter so a client can't just get a fresh bucket by hitting a different pod. Implement it as a HandlerInterceptor or filter keyed by API key or client IP, returning 429 with a Retry-After header once the bucket is exhausted. For a multi-service architecture, push rate limiting to the API gateway (Spring Cloud Gateway's RequestRateLimiter or an edge proxy) instead of duplicating limiter logic in every downstream service.
@Component
class RateLimitInterceptor implements HandlerInterceptor {
private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
@Override
public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
Bucket bucket = buckets.computeIfAbsent(req.getHeader("X-Api-Key"), k -> newBucket());
if (bucket.tryConsume(1)) return true;
res.setStatus(429);
res.setHeader("Retry-After", "1");
return false;
}
}
50. A flaky network causes a client to retry POST /payments, and the customer gets charged twice. What's the actual fix?
The root cause is that POST is not idempotent by definition — the first request likely succeeded server-side, but the client never saw the response (timeout, dropped connection) and retried, with nothing telling the server the second call is a duplicate rather than a new charge. The correct fix isn't "detect duplicate charges after the fact," it's preventing the second execution from ever happening: require a client-generated idempotency key per checkout attempt, and enforce a unique constraint on that key at the database level so a concurrent duplicate racing in during the retry window is rejected by the constraint rather than slipping past a non-atomic "check then charge" sequence.
51. When should an API return 404 vs 410 vs 409?
404 means the resource doesn't exist at this URI right now, with no claim about whether that's temporary or permanent — the default for "not found." 410 Gone means the resource used to exist here and is permanently gone, which is more informative for soft-deleted or expired resources because it tells the client to stop referencing this URI rather than retry. 409 Conflict means the request itself is fine but clashes with the resource's current state — a concurrent edit, a unique constraint violation, or a stale version on an optimistic-locking update — signaling the client should resolve the conflict and resubmit, not just retry unchanged.
52. RestClient, WebClient, or a declarative @HttpExchange interface — which one for calling a downstream service?
WebClient is the reactive, non-blocking client and earns its complexity when you're already in a reactive pipeline or need genuinely high concurrency per thread. RestClient (Spring Framework 6.1+) is blocking but modern and fluent, and with Java 21 virtual threads a blocking call stops being the throughput problem it used to be, making it the pragmatic default for most services. Declarative HTTP interfaces (@HttpExchange methods on an interface, proxied by HttpServiceProxyFactory over either client) give you a typed contract instead of scattered fluent call sites, which pays off once you're calling the same downstream service from many places.
| RestClient | WebClient | @HttpExchange interface | |
|---|---|---|---|
| Blocking? | Yes | No (reactive) | Either — backed by RestClient or WebClient |
| Best fit | Simple synchronous calls, virtual threads | Reactive pipelines, high fan-out concurrency | Many endpoints on one downstream service |
| Boilerplate | Low | Low-medium | Lowest per call site (interface method) |
Spring Data JPA, Transactions & Persistence
This section tests whether you understand what Hibernate is doing behind the abstraction — transaction proxy mechanics, flush timing, locking strategy, and why code that behaves perfectly locally can fail only under concurrent production load.
53. What are the JPA entity lifecycle states, and how does Hibernate detect changes without an explicit save call?
An entity is transient when it's a plain new object not yet associated with a session, managed/persistent once the session is tracking it (after persist, or after a load/query), detached once the session that loaded it has closed or cleared, and removed once marked for deletion but not yet flushed. Dirty checking is why you rarely see explicit save calls inside a service: at flush time (end of the transaction, or an explicit flush) Hibernate compares each managed entity's current field values against the snapshot it took when the entity was loaded, and generates an UPDATE for every entity whose fields actually changed — no save() call needed as long as the mutation happened on a managed instance within an open session.
@Transactional
public void renameCustomer(Long id, String newName) {
Customer customer = customerRepository.findById(id).orElseThrow();
customer.setName(newName); // no save() call
} // UPDATE issued automatically on commit-time flush
54. How do you detect an N+1 query problem in production, and what are the different ways to fix it?
Detect it with Hibernate statistics (hibernate.generate_statistics=true plus the query-count logging) or a datasource proxy like p6spy in tests that fails a build when a request issues more queries than expected; in production, a spike in per-request DB span count on an APM/OpenTelemetry trace is usually the first visible symptom. The fix depends on shape: a JOIN FETCH in JPQL solves it for one specific query but risks a cartesian product if you fetch-join more than one collection at once; @EntityGraph gives a reusable, named fetch plan across repository methods; and hibernate.default_batch_fetch_size batches lazy collection loads into a single IN (...) query instead of one query per parent when neither of the above is a clean fit.
// Fetch join for a single targeted query
@Query("select o from Order o join fetch o.items where o.customerId = :customerId")
List<Order> findByCustomerWithItems(@Param("customerId") Long customerId);
// Reusable fetch plan applied across repository methods
@EntityGraph(attributePaths = "items")
List<Order> findByStatus(OrderStatus status);
55. Why doesn't calling a @Transactional method from another method in the same class start a transaction?
Spring's declarative @Transactional support is implemented as a proxy (JDK dynamic proxy or CGLIB) wrapped around the bean; the proxy only intercepts calls that arrive from outside, through the reference the container handed out. A call like this.placeOrder() from inside the same class bypasses the proxy entirely and invokes the real method directly, so no transaction is opened and no rollback advice runs even though the annotation is right there. The same reasoning is why @Transactional has no effect on private methods — the proxy has nothing to override.
@Service
class OrderService {
void checkout(Order order) {
placeOrder(order); // self-invocation — bypasses the proxy, no transaction starts
}
@Transactional
void placeOrder(Order order) { ... }
}
56. REQUIRED vs REQUIRES_NEW propagation — give a real use case for REQUIRES_NEW.
REQUIRED, the default, joins an existing transaction if the caller already has one open, or starts a new one otherwise — a rollback anywhere in that chain rolls back the entire chain together. REQUIRES_NEW suspends any existing transaction and starts a fully independent one that commits or rolls back on its own, which is exactly what you want for audit logging: you want the record of "user attempted this action" to persist even if the surrounding business transaction later fails and rolls back.
@Service
class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void record(String event, String actor) {
auditRepository.save(new AuditEntry(event, actor, Instant.now()));
}
}
@Transactional
public void transferFunds(Account from, Account to, BigDecimal amount) {
auditService.record("TRANSFER_ATTEMPTED", from.getOwner());
// if this throws, the audit row above still commits independently
from.debit(amount);
to.credit(amount);
}
57. Walk through a concrete race condition caused by using READ_COMMITTED instead of a stricter isolation level.
Two concurrent transactions both read a bank account balance of $100 under READ_COMMITTED — each sees the last committed value at the moment of its own read, and READ_COMMITTED explicitly allows non-repeatable reads. Both transactions independently conclude there's enough balance to withdraw $80 and both commit, because neither one re-checks the balance against the other's uncommitted write before writing — the account ends up negative. Fixing this reliably needs either row-level locking (SELECT ... FOR UPDATE) so the second writer blocks until the first commits, or optimistic locking via a version column so the second commit fails outright instead of silently overwriting.
58. Optimistic (@Version) vs pessimistic locking — walk through a lost-update scenario each one prevents.
Lost update scenario: two support agents open the same order to edit the shipping address at the same time; without any locking, the second save silently overwrites the first agent's change with no error and no trace it happened. @Version optimistic locking adds a version column that's checked in the UPDATE's WHERE clause — the second commit fails with an OptimisticLockException because the version it read is now stale, forcing a reload-and-retry instead of a silent overwrite. Pessimistic locking (@Lock(PESSIMISTIC_WRITE), backed by SELECT ... FOR UPDATE) prevents the conflict earlier by blocking the second reader from acquiring the row at all until the first transaction commits — better for hot rows under heavy contention where optimistic retries would just thrash against each other.
@Entity
class Order {
@Id Long id;
@Version Long version;
String shippingAddress;
}
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select o from Order o where o.id = :id")
Order findByIdForUpdate(@Param("id") Long id);
| Optimistic (@Version) | Pessimistic (FOR UPDATE) | |
|---|---|---|
| Conflict detection | At commit time, via version mismatch | At read time, via row lock |
| Contention behavior | Loser retries after failing | Loser waits, no failure |
| Best for | Low-contention, occasional conflicts | High-contention hot rows |
59. When should a read endpoint use a projection or DTO query instead of loading full entities?
Loading a full entity pulls every mapped column and keeps the possibility of triggering lazy associations later, which is wasted cost when a listing or report endpoint only actually needs three of twenty columns. An interface-based projection or a JPQL constructor-expression DTO lets Spring Data generate a SELECT with only the needed columns and skips entity hydration and dirty-check snapshotting entirely, which matters at scale on high-traffic read paths. The trade-off is that a projection isn't a managed entity — no lazy loading, no dirty checking — so it's only appropriate for genuinely read-only paths, never for data you intend to mutate and save back.
60. How do you build a search endpoint with many optional filters without a combinatorial explosion of repository methods?
JpaSpecificationExecutor combined with Specification<T> lets each optional filter become its own predicate, built only when that parameter is actually present, then composed with Specification.where(...).and(...) — no repository method explosion, no fragile string-concatenated JPQL. For filtering needs complex enough that Specifications start feeling awkward, Querydsl's generated typesafe predicates are the next step up; Specifications remain the pragmatic default for most dynamic-filter search endpoints.
Specification<Order> spec = Specification.where(null);
if (status != null) {
spec = spec.and((root, q, cb) -> cb.equal(root.get("status"), status));
}
if (createdAfter != null) {
spec = spec.and((root, q, cb) -> cb.greaterThan(root.get("createdAt"), createdAfter));
}
List<Order> results = orderRepository.findAll(spec);
61. How do you evolve a database schema with zero downtime when the app can't go offline for a migration?
Every migration has to be safe to run while both the old and new application versions are simultaneously live during a rolling deploy — split the change into additive steps: add the new nullable column or table first as a pure no-op; deploy an app version that writes to both old and new locations while still reading from the old one; backfill existing rows; deploy a version that reads from the new location; and only after the old code path is fully retired, drop the old column in a later, separate migration. The classic outage trigger is a migration that renames or drops a column the currently-running (old) app instances still reference — during a rolling deploy that column is in active use right up until the last old instance is replaced.
62. What is Open Session in View, and why do many teams disable it in production?
Open Session in View (enabled by default in Spring Boot) keeps the Hibernate session open for the entire HTTP request rather than just the @Transactional service method, so lazy associations can still be fetched later during serialization without throwing LazyInitializationException. The cost is that it holds a checked-out database connection for the whole request — including view rendering, JSON serialization, and anything else that happens after the transaction logically finished — which under load exhausts the connection pool far faster than the actual DB work requires, and it also hides N+1 queries that fire silently during serialization instead of failing fast where they're introduced.
spring.jpa.open-in-view=false without first fixing the fetch strategy will immediately surface every LazyInitializationException that OSIV was quietly masking — plan the DTO/fetch-join/entity-graph fix before flipping the switch.63. The API starts timing out with "Connection is not available, request timed out after 30000ms" — how do you diagnose HikariCP pool exhaustion?
Turn on HikariCP's leak-detection-threshold to get a stack trace logged for any connection held longer than expected — this usually points straight at the culprit. Common causes: a @Transactional method that calls a slow external HTTP call or queue publish inside the transaction boundary, holding a connection idle the whole time; Open Session in View holding connections through slow response serialization; or a pool sized too small for actual concurrent DB-bound load. Fix by shrinking transaction boundaries to just the database work, moving external calls outside @Transactional, and watching hikaricp.connections.active / .pending via Micrometer so exhaustion is visible before it causes timeouts.
64. LazyInitializationException only happens in production under load, never locally — what's going on?
Locally, requests are sequential and fast enough that by the time a lazy field gets accessed during serialization, the session/connection is typically still open even if the code is quietly relying on Open Session in View to make that work. Under production load the same assumption breaks: connections get recycled faster under pool pressure, or — more commonly — the code crosses onto a different thread than the one that opened the transaction, such as inside an @Async method or a CompletableFuture.supplyAsync callback, where the original session has already closed by the time the lazy field is touched. The real fix isn't more connections or a bigger pool — it's removing the implicit dependency on the session still being open, by fetching what you need inside the original transaction (fetch join, entity graph) or mapping to a DTO before the transaction boundary ends.
@Async methods and CompletableFuture callbacks are the most common place code silently crosses out of the original transaction and session onto a thread where neither exists anymore.65. How do you make a bulk insert of 100,000 rows fast instead of taking minutes with Spring Data JPA?
A naive loop of repository.save() calls issues an INSERT (and sometimes a SELECT) per row and flushes per call by default, and if the ID generation strategy is IDENTITY it defeats JDBC batching outright because Hibernate needs each generated key back before continuing. Fix it by switching to a sequence-based generator, setting hibernate.jdbc.batch_size plus order_inserts/order_updates so same-shape statements actually group into JDBC batches, and periodically calling flush() followed by clear() every few hundred rows so the persistence context doesn't grow unbounded and slow every subsequent dirty-check. For a pure bulk load with no need for entity lifecycle callbacks, dropping to JdbcTemplate.batchUpdate or Spring Data JDBC often beats JPA outright.
@Transactional
public void bulkInsert(List<Order> orders) {
for (int i = 0; i < orders.size(); i++) {
entityManager.persist(orders.get(i));
if (i % 500 == 0) {
entityManager.flush();
entityManager.clear();
}
}
}
66. How do you route @Transactional(readOnly=true) queries to a read replica, and what consistency problem does that introduce?
readOnly=true by itself doesn't route anything to a replica — it just hints Hibernate to skip dirty-checking and flush, and hints the JDBC driver. Actual routing needs a routing DataSource, typically an AbstractRoutingDataSource that inspects TransactionSynchronizationManager.isCurrentTransactionReadOnly() and picks a replica connection for read-only transactions and the primary for everything else (or a cluster-aware driver, like Aurora's, that does this automatically). The consistency problem this introduces is a read-your-writes violation from replication lag: a write followed immediately by a read of the same data (save an order, then redirect to fetch it) can hit a replica that hasn't caught up yet and return stale or missing data. Common mitigations are routing the immediate post-write read to the primary explicitly, or simply returning the entity that was just saved instead of re-querying it at all.
class ReadWriteRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return TransactionSynchronizationManager.isCurrentTransactionReadOnly()
? "replica" : "primary";
}
}
67. Soft deletes vs hard deletes — what do you give up by choosing soft deletes?
Soft deleting (a deleted_at column, filtered out via @SQLRestriction or a shared Specification) preserves history and audit trail, supports undo, and avoids cascading foreign-key deletes across a large relational graph. In exchange, every single query anywhere in the codebase must remember to filter out deleted rows — one forgotten join or report query and "deleted" data leaks straight through — unique constraints get complicated since they need to be partial/conditional to ignore soft-deleted rows, and the table grows unbounded, eventually needing its own archival strategy. Hard deletes keep the data model and constraints simple but permanently lose history and force you to deliberately handle dependent rows (cascade, orphan removal, or reassignment) at the moment of deletion.
68. When is Hibernate's second-level cache actually worth the complexity in a Spring Boot service?
Second-level cache (backed by Caffeine locally, or a distributed provider like Redis/Hazelcast via JCache for multi-instance deployments) caches entity state across sessions and transactions, which pays off for reference data that's read constantly and changes rarely — country codes, product categories, feature flags. It's a poor fit for frequently-updated entities or a multi-instance deployment without a distributed provider, because each instance's local cache can silently serve stale data after another instance writes, and keeping caches invalidated across instances adds real operational complexity for a benefit that may not exist yet.
Spring Security, OAuth2 & JWT
Interviewers use this topic to check whether you understand security as a set of explicit, deny-by-default decisions rather than annotations copied from a tutorial — filter ordering, token validation depth, and what actually happens when a token or session goes wrong in production.
69. How does the SecurityFilterChain DSL work, and why does filter order matter?
Spring Security builds a chain of servlet filters, each responsible for one concern: exception translation, authentication, authorization, CSRF, CORS, and so on. The SecurityFilterChain bean is a declarative description of that chain, but the actual filter order is fixed internally by FilterOrderRegistration — your DSL calls configure filters, they don't reorder them arbitrarily. Getting this wrong usually means adding a custom filter in the wrong relative position, for example putting a JWT filter after AuthorizationFilter instead of before it, which makes every request look unauthenticated.
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health", "/auth/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
70. What's the practical difference between authentication and authorization, and where do teams get it wrong with a deny-by-default posture?
Authentication answers "who are you" — verifying a credential or token and populating the SecurityContext. Authorization answers "are you allowed to do this" — evaluating roles, scopes, or ownership against the resource being accessed. A common production bug is confusing the two: a request passes authentication (valid JWT) and the code assumes that's sufficient, when the real check needed is "does this user own this specific order ID." Deny-by-default means every endpoint requires an explicit permit or role; anything not matched falls through to anyRequest().authenticated() rather than an implicit allow.
/orders/{id} belonging to user B. That check has to happen in the service layer or via @PreAuthorize with an ownership expression, not just at the URL/role level.71. What's on your JWT validation checklist beyond just checking the signature?
Signature verification alone is necessary but not sufficient. A defensible checklist includes: signature validity against the correct key (and correct key rotation via JWKS, not a hardcoded secret), issuer (iss) matches the expected authorization server, audience (aud) matches this specific resource server (so a token minted for service A can't be replayed against service B), expiry (exp) and not-before (nbf), and the signing algorithm is pinned rather than trusted from the token header. Spring's JwtDecoder handles most of this when configured with issuer-uri, but audience validation is not automatic and must be added explicitly.
@Bean
JwtDecoder jwtDecoder(OAuth2ResourceServerProperties props) {
NimbusJwtDecoder decoder = JwtDecoders.fromIssuerLocation(props.getJwt().getIssuerUri());
OAuth2TokenValidator<Jwt> audienceValidator = jwt ->
jwt.getAudience().contains("orders-service")
? OAuth2TokenValidatorResult.success()
: OAuth2TokenValidatorResult.failure(new OAuth2Error("invalid_audience"));
decoder.setJwtValidator(JwtValidators.createDefaultWithValidators(audienceValidator));
return decoder;
}
72. What is an algorithm confusion attack against JWT, and how does Spring Security's resource server support prevent it?
Algorithm confusion exploits libraries that trust the alg header inside the token itself. A classic case: the server expects RS256 (asymmetric, verified with a public key), but the attacker resends a token with alg: HS256 and signs it using the server's own public key as if it were an HMAC secret — if the verifier blindly does whatever the header says, it validates. The fix is to never let the token dictate the algorithm; the decoder must be configured to only accept a specific, expected algorithm regardless of what the header claims. Spring's NimbusJwtDecoder built from a JWKS issuer URI is safe by default here because it resolves the key by kid and enforces the algorithm family tied to that key, but a hand-rolled decoder that inspects the header first is exactly the vulnerable pattern.
73. How do you configure a Spring Boot service as an OAuth2 resource server?
Add spring-boot-starter-oauth2-resource-server, point it at the authorization server's issuer, and let Spring Security auto-configure a JwtDecoder that fetches the JWKS and validates signature, issuer, and expiry out of the box. From there, roles or scopes carried in the token claims are mapped into GrantedAuthority objects so hasAuthority/hasRole checks work naturally.
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com/realms/prod
Behind that one property, Spring auto-configures JWKS fetching, caching, and rotation handling — you rarely need to write a custom decoder unless you have non-standard claim mapping.
74. Session-based auth vs stateless JWT: how would you choose for a system with 40 microservices behind an API gateway?
Session-based auth requires a shared session store (Redis, sticky sessions, or a session-replication layer) so any service instance can validate a session ID — this adds a stateful dependency and a network hop per request. Stateless JWT lets each service validate the token locally using a public key, with zero shared state and no extra network call, which scales better horizontally and fits a gateway-fronted microservices topology well. The trade-off is revocation: you can't simply delete a JWT from a store, so logout and permission changes need short expiries plus a refresh-token or deny-list strategy layered on top.
75. When is it safe to disable CSRF protection for an API, and when is that dangerous?
CSRF exploits the browser's automatic inclusion of cookies (and sometimes HTTP Basic credentials) on cross-site requests. If your API is purely stateless and authenticates via a bearer token sent in an Authorization header that JavaScript must explicitly attach, there's no ambient credential for a forged cross-site form or image tag to ride along with, so disabling CSRF is safe and standard for that shape of API. It becomes dangerous the moment any part of the same application still relies on cookie-based session authentication — for example a browser-based login flow, a "remember me" cookie, or a mixed API that also serves server-rendered pages — because then a forged request from another origin can act as the authenticated user.
// Safe: stateless, bearer-token-only API
http.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
// Dangerous to disable: cookie-session-backed endpoints
// keep CSRF enabled and use http.csrf(csrf -> csrf.csrfTokenRepository(...))
76. How should passwords be hashed, and how do you tune the work factor for BCrypt or Argon2?
Passwords must never be stored in reversible form or with a fast general-purpose hash like plain SHA-256; you need a slow, salted, adaptive hash designed to resist brute force even after a database leak. Spring Security's PasswordEncoder abstraction defaults to BCrypt via DelegatingPasswordEncoder, and BCrypt's cost factor controls how many rounds of key derivation run per hash — higher costs mean slower hashing (good against attackers, bad for login latency), so you tune it to the slowest value your login endpoint can tolerate under real traffic, typically targeting 200-500ms per hash on production hardware. Argon2 is the more modern, memory-hard alternative and is preferred for new systems because it resists GPU/ASIC cracking better than BCrypt.
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12); // cost factor tuned to ~300ms/hash
}
77. How do @PreAuthorize method-level checks and URL-level authorization complement each other?
URL-level rules in authorizeHttpRequests are coarse and fast — they reject unauthorized requests before controller code even runs, which is good for broad boundaries like "only ADMIN can hit /admin/**." Method-level security with @PreAuthorize operates deeper, at the service or repository layer, and can express fine-grained, data-dependent rules like ownership that a URL pattern simply can't encode. Relying on URL rules alone leaves a gap: someone can call the service method directly (from another bean, a batch job, or a differently-routed endpoint) and skip the URL filter entirely, so defense in depth means checking authorization again at the point where the actual business decision happens.
@PreAuthorize("hasRole('ADMIN') or #order.ownerId == authentication.name")
public Order getOrder(@P("order") Order order) { ... }
78. What is mass assignment, and how do you prevent it in a Spring Boot REST API?
Mass assignment happens when a request body is bound directly onto a domain entity that has more fields than the client should be allowed to set — for example a User entity with a role or isAdmin field that a signup DTO never intended to expose, but Jackson happily deserializes if the JSON includes it. The fix is to never bind untrusted input straight onto persistence entities: use a dedicated request DTO with only the fields you intend to accept, then explicitly map allowed fields onto the entity in a service method.
@RequestBody UserEntity parameter on a public signup endpoint is a textbook mass-assignment vulnerability — an attacker just adds "role":"ADMIN" to the JSON payload and, if the entity has a setter for it, gets privilege escalation for free.79. How do you secure Actuator endpoints so they don't leak internals in production?
By default, Actuator exposes only /health and /info over HTTP, but teams frequently widen management.endpoints.web.exposure.include for debugging and forget to lock it down again — endpoints like /env, /heapdump, or /beans can leak secrets, connection strings, or the full object graph. The fix is layered: expose only what's needed, put the management port behind its own authenticated SecurityFilterChain (or a separate management port entirely via management.server.port), and require an ADMIN-scoped role for sensitive endpoints even internally.
@Bean
@Order(1)
SecurityFilterChain actuatorChain(HttpSecurity http) throws Exception {
http.securityMatcher(EndpointRequest.toAnyEndpoint())
.authorizeHttpRequests(auth -> auth
.requestMatchers(EndpointRequest.to("health", "info")).permitAll()
.anyRequest().hasRole("ACTUATOR_ADMIN"))
.httpBasic(Customizer.withDefaults());
return http.build();
}
80. A team enabled a wildcard CORS origin with credentials allowed and shipped it — what actually went wrong, and how do you fix it?
Browsers explicitly forbid combining Access-Control-Allow-Origin: * with Access-Control-Allow-Credentials: true because that combination would let any website read authenticated responses on behalf of a logged-in user — cookies or Authorization headers would be sent cross-origin to a wildcard-trusted response. In practice this either breaks silently (browsers reject the response) or, worse, if someone works around it by reflecting the request's Origin header back as the allowed origin, it effectively becomes a wildcard with credentials, which is a real incident: any malicious site can now make authenticated calls to your API using the victim's browser session.
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://app.example.com")); // explicit allowlist
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
81. How do you design refresh token rotation and revocation so a stolen refresh token doesn't grant indefinite access?
Access tokens should be short-lived (minutes), with a longer-lived refresh token used only to mint new access tokens. Rotation means every refresh exchange invalidates the old refresh token and issues a new one; if a refresh token is reused after rotation (which only happens if it was stolen and both the attacker and the legitimate client try to use it), that's a strong signal of compromise and the whole token family should be revoked immediately. This requires persisting refresh token state (or at least a hash and a "used" flag) server-side — a purely stateless refresh token can't be revoked or detect reuse, which is why refresh tokens are usually the one piece of an otherwise stateless JWT system that lives in a database.
82. Production scenario: users report being randomly logged out across services in a stateless JWT architecture — what's the likely cause?
In a stateless design there's no session to "expire" unexpectedly, so random logouts almost always trace to token validation disagreeing between services or over time. Common root causes: clock skew between the auth server and resource servers making exp/nbf checks fail inconsistently; a JWKS key rotation where one service cached the old public key past its TTL and started rejecting newly-signed tokens; inconsistent access-token expiry configuration across services causing one service to reject a token another still accepts; or a load balancer routing requests to instances with stale configuration after a partial deployment. The fix is to centralize and version token configuration, ensure NTP-synced clocks, and add short JWKS cache TTLs with graceful key rotation overlap rather than hard cutover.
Testing Strategy & Testcontainers
This topic separates engineers who know test annotations from engineers who know what each test layer is actually for — how to keep a suite fast without lying to yourself about what it covers, and how to catch the failures that only show up against real infrastructure.
83. How does the test pyramid apply to a real Spring Boot application?
The base layer is plain unit tests with no Spring context at all — pure JUnit 5 and Mockito against a single class, running in milliseconds, covering business logic and edge cases. The middle layer is slice tests that load only the relevant part of the context (@WebMvcTest, @DataJpaTest) to verify wiring at a boundary without paying for the full application context. The top, smallest layer is full integration tests (@SpringBootTest, often with Testcontainers) that boot the real context against real infrastructure to catch what mocks and slices can't — a handful of these covering critical paths is enough; dozens of them is usually a sign logic that belongs in unit tests leaked upward.
84. What's the actual cost difference between @SpringBootTest and a test slice like @WebMvcTest or @DataJpaTest?
@SpringBootTest boots the entire application context — every bean, every auto-configuration, every @Configuration class — which is representative but slow, often seconds per test class, and that cost multiplies across hundreds of tests in CI. @WebMvcTest loads only the web layer (controllers, filters, exception handlers) and lets you mock the service layer, while @DataJpaTest loads only JPA-related beans plus an embedded/test datasource. Spring also caches contexts across test classes with identical configuration, so mixing many slightly-different @SpringBootTest configurations (different @MockBean sets, different profiles) defeats that cache and is a common hidden cause of a slow CI suite.
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mockMvc;
@MockitoBean OrderService orderService; // slice-scoped mock, not full context
}
85. MockMvc vs WebTestClient: when do you reach for each?
MockMvc tests the Spring MVC dispatcher directly without a real HTTP connection, which makes it fast and the natural default for synchronous @WebMvcTest controller tests. WebTestClient is built for reactive (WebFlux) endpoints and streaming responses, and it can also run against a real running server (bindToServer) for true end-to-end HTTP assertions, which MockMvc can't do on its own. If your controllers return Mono/Flux or you need to assert on a live server over an actual socket, use WebTestClient; for a standard blocking MVC app, MockMvc is simpler and sufficient.
86. Why does Testcontainers catch bugs that an in-memory database like H2 hides?
H2 in "PostgreSQL compatibility mode" approximates Postgres syntax but doesn't implement Postgres's actual query planner, constraint enforcement, JSON/JSONB behavior, or locking semantics — so a query that relies on a Postgres-specific function, a partial index, or row-level locking behavior can pass against H2 and fail against real Postgres in production. Testcontainers spins up the actual database image in Docker for the test run, so migrations, dialect-specific SQL, and concurrency behavior are verified against the real engine instead of an approximation.
gen_random_uuid() or a JSONB column with a GIN index can pass silently on H2 and blow up the first time it runs against real Postgres — usually discovered in staging, not CI, if H2 is the only test database.87. How does @DynamicPropertySource wire a Testcontainers-managed container's port into Spring's context?
Containers get a randomly assigned host port to avoid collisions when tests run in parallel, so the datasource URL can't be hardcoded. @DynamicPropertySource runs after the container starts but before the Spring context is built, letting you register the resolved host/port as a property that spring.datasource.url (or similar) can reference.
@Testcontainers
@SpringBootTest
class OrderRepositoryIT {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void registerProps(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
}
88. How do you test @Transactional rollback behavior, and what blind spot does that leave for commit-time constraints?
A test annotated @Transactional (from Spring's test support) automatically rolls back after each test method, which is convenient for isolating database state between tests without manual cleanup. The blind spot is that some constraints — deferred foreign keys, database triggers, or certain unique constraints depending on the database — are only checked at commit time, and since the test transaction never actually commits, those checks silently never run. A bug that only manifests on a real commit (for example a trigger that fires on commit, or a deferred constraint violation) can pass every test and still fail in production.
TestTransaction.flagForCommit() plus an explicit commit, or drop the rollback-only test transaction and clean up manually — otherwise you're testing a scenario that never occurs in production.89. Mocking vs faking vs spying — what's the practical difference and when does each cause problems?
A mock is a bare stand-in whose every interaction you specify explicitly (Mockito's mock()) — great for verifying interactions but brittle if overused, since it couples tests to implementation details like exact call counts. A fake is a real, simplified working implementation (an in-memory Map-backed repository instead of a real database) that behaves correctly for the test's purposes without the mocking framework's rigidity — often more resilient to refactoring. A spy wraps a real object and lets you observe or selectively override behavior, which is useful for partial stubbing but a red flag when used heavily, since it usually means the real object has too many responsibilities to test in isolation.
90. Why use Awaitility instead of Thread.sleep() for asserting on asynchronous behavior?
Thread.sleep() forces a fixed wait that's either too short (flaky failures under load) or wastefully long (slow CI), and it gives no signal about why a test failed — just a timeout. Awaitility polls a condition repeatedly with a configurable interval and timeout, so the test proceeds the instant the condition becomes true and fails with a clear description of what it was waiting for if it doesn't.
await()
.atMost(Duration.ofSeconds(5))
.pollInterval(Duration.ofMillis(100))
.untilAsserted(() ->
assertThat(orderRepository.findById(orderId))
.hasValueSatisfying(o -> assertThat(o.getStatus()).isEqualTo(SHIPPED)));
91. How does ArchUnit enforce architecture boundaries in CI, and what does that catch that code review misses?
ArchUnit writes architectural rules as executable tests — "controllers must not depend on repositories directly," "domain package must not depend on the web package," "no class in service should depend on a class in controller" — and fails the build the moment a violation is introduced. Code review catches this only if the reviewer happens to notice the import; ArchUnit catches it deterministically on every commit, including ones added months later by someone unfamiliar with the original layering decisions.
@ArchTest
static final ArchRule layerRule = layeredArchitecture()
.consideringAllDependencies()
.layer("Controller").definedBy("..controller..")
.layer("Service").definedBy("..service..")
.layer("Repository").definedBy("..repository..")
.whereLayer("Controller").mayNotBeAccessedByAnyLayer()
.whereLayer("Repository").mayOnlyBeAccessedByLayers("Service");
92. What is contract testing between microservices, and why isn't end-to-end testing a substitute for it?
Contract testing (Spring Cloud Contract, Pact) verifies that a producer's API and a consumer's expectations of that API agree, without either side needing the other running — the consumer defines expected request/response shapes, and the producer's build verifies it still satisfies them. End-to-end tests catch the same class of break but only by running the whole system together, which is slow, flaky, and often infeasible to run on every commit across dozens of services; contract tests run fast, independently, and pinpoint exactly which side broke the agreement instead of just reporting "the system is broken somewhere."
93. A test suite is flaky — sometimes green, sometimes red with no code changes. How do you root-cause it?
The usual suspects, roughly in order of frequency: shared mutable test data (two tests writing to the same database row or in-memory singleton without isolation), execution order dependency (a test that only passes because another test ran first and left state behind — surfaced by randomizing test order), and unguarded time or randomness (a test asserting on LocalDate.now() or an unseeded random value that occasionally crosses a boundary). The fix is almost always isolation: a fresh transaction or fresh container per test, explicit test data setup rather than relying on fixtures from another test, and injecting a fixed Clock or seeded random source instead of calling now() or Random directly.
94. Production scenario: all tests pass in CI but the packaged JAR fails to start in staging — what testing gap does this reveal?
This almost always means the tests never actually exercised the artifact that gets deployed. Common causes: tests ran against classes on the IDE/build classpath with different dependency versions than what got shaded into the fat JAR; a profile-specific application-prod.yml property was never validated because tests always run with test or default profile active; or a bean that depends on an external resource (a datasource URL, a secret from a vault) is only ever mocked in tests and never wired against the real startup path. The gap is the missing "does the actual built artifact boot with the actual production-like configuration" check — closing it means adding a smoke test that runs java -jar against the packaged JAR with a close-to-production profile as part of the pipeline, not just running mvn test against source.
Actuator, Micrometer & Distributed Tracing
Interviewers use this topic to see whether you treat observability as a first-class design concern rather than a checkbox — expect probing on what your health checks actually mean, how metric cardinality bites you in production, and how traces stay connected across async and messaging boundaries.
95. Which Actuator endpoints should be exposed publicly, and how do you lock the rest down?
By default Spring Boot only exposes health and info over HTTP; everything else (env, heapdump, beans, shutdown) must be explicitly opted in and then protected. The mistake teams make is flipping management.endpoints.web.exposure.include=* for debugging and forgetting to restrict it before shipping, which leaks environment variables and bean graphs to anyone who can reach the port.
management.endpoints.web.exposure.include=health,info,metrics,prometheus
management.endpoint.health.show-details=when-authorized
management.server.port=8081
management.security.enabled=true
Running Actuator on a separate management port that isn't exposed by the ingress/load balancer is the most reliable control — it removes the need to trust path-based security rules entirely, and you still layer Spring Security on top for anything sensitive that must stay on the main port.
96. What's the real difference between liveness and readiness probes, and why does coupling liveness to a downstream dependency cause restart storms?
Liveness answers "is this process fundamentally broken and should Kubernetes restart it" — deadlock, exhausted thread pool, corrupted internal state. Readiness answers "can this instance currently serve traffic" — and that can legitimately flap based on downstream health without the process itself being broken. If you wire your database or a third-party API check into the liveness probe, a database blip causes every pod to fail liveness simultaneously, Kubernetes restarts all of them at once, and now you have zero capacity while restarted pods reconnect — the outage you were trying to avoid becomes worse and self-inflicted.
management.endpoint.health.group.liveness.include=livenessState,diskSpace
management.endpoint.health.group.readiness.include=readinessState,db,redis
management.endpoint.health.probes.enabled=true
97. Walk through the four core Micrometer meter types and when each one is the right instrument.
Counter only increases and is right for events — requests served, errors thrown, messages consumed. Gauge reports a point-in-time value that can go up or down — queue depth, active connections, cache size — and Micrometer samples it lazily so you must hold a reference to the source object, not a snapshot. Timer records both the count and duration distribution of an event, giving you count, total time, max, and percentiles in one instrument — ideal for request/method latency. DistributionSummary is like Timer but for non-time quantities, such as payload sizes in bytes.
@Bean
MeterRegistryCustomizer<MeterRegistry> queueGauge(OrderQueue queue) {
return registry -> Gauge.builder("orders.queue.depth", queue, OrderQueue::size)
.description("Current depth of the pending order queue")
.register(registry);
}
98. Why is tagging a metric by user ID or raw URL path a production incident waiting to happen?
Every unique combination of tag values creates a distinct time series in the underlying store. Tagging by user ID or an unparameterized URL (/orders/48213 vs /orders/48214 as different tag values instead of a templated /orders/{id}) turns one logical metric into potentially millions of series. Prometheus and most TSDBs degrade badly under cardinality explosions — scrape times balloon, memory usage spikes, and dashboards that used to load instantly start timing out or get their series silently dropped by the backend's cardinality limiter.
// Bad: raw path becomes the tag, one series per order id
Timer.builder("http.request").tag("uri", request.getRequestURI()).register(registry);
// Good: Spring's WebMvcTags already templates the URI
// management.metrics.web.server.request.autotime.enabled=true uses {uri} patterns,
// so /orders/{id} stays one series regardless of how many ids are hit
99. How does Micrometer Tracing with OpenTelemetry propagate a span across an outbound HTTP call and a Kafka message?
Micrometer Tracing replaced Spring Cloud Sleuth as the tracing facade; it delegates to a bridge (usually OTel) that handles the actual context propagation and export. For HTTP, the traced RestClient/WebClient auto-injects traceparent/tracestate W3C headers on outbound calls and the receiving service's instrumentation extracts them to continue the same trace. For Kafka, there's no HTTP header to piggyback on, so the tracing instrumentation injects the context into the record's headers at produce time and extracts it at consume time — this is why plain, unwrapped KafkaTemplate/ConsumerFactory usage without the Micrometer Kafka instrumentation silently breaks the trace into two disconnected pieces.
management.tracing.sampling.probability=0.1
management.otlp.tracing.endpoint=http://otel-collector:4318/v1/traces
// dependency: io.micrometer:micrometer-tracing-bridge-otel
// dependency: io.opentelemetry:opentelemetry-exporter-otlp
100. How do you write a custom health indicator that won't hang the health endpoint if a dependency is slow?
A naive health indicator that calls a downstream service synchronously with no timeout can make the health endpoint itself hang, which is worse than reporting DOWN quickly — orchestrators need a fast, bounded answer. Always wrap the check in an explicit timeout and treat a timeout as DOWN rather than letting the thread block indefinitely.
@Component
class PaymentGatewayHealthIndicator implements HealthIndicator {
private final PaymentGatewayClient client;
@Override
public Health health() {
try {
var future = CompletableFuture.supplyAsync(client::ping);
future.get(500, TimeUnit.MILLISECONDS);
return Health.up().build();
} catch (TimeoutException e) {
return Health.down().withDetail("reason", "ping timed out").build();
} catch (Exception e) {
return Health.down(e).build();
}
}
}
101. How do correlation IDs stay attached to log lines when work hops across thread pools, including virtual threads?
MDC (Mapped Diagnostic Context) is backed by a ThreadLocal, so it doesn't automatically follow work handed off to another thread — an @Async method or an executor submission starts with an empty MDC unless you copy it explicitly. Micrometer's ContextSnapshotFactory (from context-propagation) captures MDC, tracing context, and reactor context together and restores them on the executing thread. Virtual threads don't fix this automatically either — a virtual thread is still a distinct thread from the caller's, so the same propagation mechanism is required.
@Bean
TaskDecorator mdcTaskDecorator() {
return runnable -> {
var contextMap = MDC.getCopyOfContextMap();
return () -> {
try {
if (contextMap != null) MDC.setContextMap(contextMap);
runnable.run();
} finally {
MDC.clear();
}
};
};
}
102. Why should alerting be based on SLOs rather than firing on every metric threshold breach?
Alerting on every spike in every metric produces so much noise that on-call engineers start ignoring pages, which is how real incidents get missed. An SLO-based approach defines a user-facing objective (say, 99.9% of requests under 300ms over 30 days), tracks an error budget against it, and only pages when the burn rate threatens that budget within a meaningful window. This distinguishes a transient blip that self-heals from a trend that will actually breach the promise made to users.
# Multi-window, multi-burn-rate alert (simplified Prometheus rule)
- alert: FastBurnLatencySLO
expr: |
(sum(rate(http_server_requests_seconds_bucket{le="0.3"}[5m]))
/ sum(rate(http_server_requests_seconds_count[5m]))) < 0.999
for: 5m
labels: {severity: page}
103. Production scenario: the Actuator health check reports UP, but users are seeing 500s. What's wrong with the health check design?
This is almost always a coverage gap: the health check verifies things that are easy to check (process is running, database connection pool has a connection) but not the actual failure mode users are hitting — a downstream partner API returning malformed data, a feature flag misconfiguration, a poisoned message stuck in a queue, or a specific code path throwing on certain input. Health checks validate infrastructure reachability, not business logic correctness, so a green health check and a broken feature can coexist indefinitely.
The fix is to close the loop with synthetic transactions or canary requests that exercise the actual user-facing path end to end, and to correlate the health signal with real error-rate metrics and traces rather than trusting health status in isolation. Treat health as necessary, not sufficient.
104. What should the info endpoint expose to actually help during an incident?
During an incident, the first question is almost always "which build is actually running on this pod, and when was it deployed" — the info endpoint should answer that instantly without needing to grep CI logs. Wire in the git commit hash, build timestamp, and application version via the build-info and git-info plugins so it's baked into the artifact rather than relying on environment variables that can be wrong.
management.info.git.mode=full
management.info.env.enabled=true
// build.gradle
springBoot {
buildInfo()
}
// combined with the git-commit-id-plugin populates
// git.commit.id, git.branch, build.version, build.time automatically
105. How do you choose a trace sampling strategy that keeps observability useful without overwhelming your backend at high throughput?
Head-based probabilistic sampling (management.tracing.sampling.probability=0.1) is simple but has a serious flaw: the sampling decision is made before you know whether the request will fail, so your rare, high-value error traces get dropped at the same rate as routine successful ones. Tail-based sampling — deciding after the fact, typically in a collector — lets you always keep errors and slow requests while sampling routine traffic lightly, but requires buffering spans until the trace completes, which costs collector memory and adds latency to export.
A pragmatic middle ground many teams use: sample low at the head (1-5%) to control volume, but force-sample any request with an error status or latency above a threshold regardless of the random draw, so the traces you actually need during an incident are never the ones missing.
106. What Micrometer timer configuration do you need to get accurate p99 latency, and why does the default histogram configuration sometimes lie?
By default, Micrometer's Prometheus registry computes client-side percentiles that cannot be aggregated across instances — averaging p99s from ten pods is not the same number as the true p99 across all their combined traffic. To get percentiles that aggregate correctly across a fleet, you need histogram buckets (publishPercentileHistogram) so Prometheus computes the percentile server-side from the merged bucket counts using histogram_quantile.
@Bean
MeterFilter timerPercentilesFilter() {
return new MeterFilter() {
@Override
public DistributionStatisticConfig configure(Meter.Id id, DistributionStatisticConfig config) {
if (id.getName().equals("http.server.requests")) {
return DistributionStatisticConfig.builder()
.percentilesHistogram(true)
.minimumExpectedValue(Duration.ofMillis(1).toNanos() * 1.0)
.maximumExpectedValue(Duration.ofSeconds(5).toNanos() * 1.0)
.build()
.merge(config);
}
return config;
}
};
}
publishPercentiles(0.99)) are fine for a single instance's dashboard but must never be averaged across replicas — that number is mathematically meaningless.Caching & Performance Tuning
This is where interviewers separate people who've memorized annotation names from people who've actually chased a cache-related production incident — expect follow-ups on proxy mechanics, stampede scenarios, connection pool sizing, and where virtual threads genuinely help versus where they don't.
107. How do @Cacheable, @CachePut, and @CacheEvict actually work under the hood, and why does calling one of these methods from within the same class silently skip caching?
All three are proxy-based, like @Transactional: Spring wraps the bean in a proxy (JDK dynamic proxy for interfaces, CGLIB subclass otherwise) that intercepts the call, checks/updates the cache, and only then delegates to the real method. @Cacheable checks the cache first and skips the method entirely on a hit; @CachePut always runs the method and then updates the cache with the result; @CacheEvict removes an entry, usually after a write. The proxy only intercepts calls that come in through it from outside the bean — a self-invocation (this.getUser(id) called from another method in the same class) bypasses the proxy entirely and hits the real method directly, so the caching annotation is silently ignored.
@Service
class UserService {
@Cacheable("users")
public User getUser(Long id) { return repo.findById(id).orElseThrow(); }
public void warmUp(Long id) {
// BUG: self-invocation bypasses the cache proxy, no caching occurs
User u = this.getUser(id);
}
}
ApplicationContext/@Lazy, never to call the annotated method from inside the same instance.108. Caffeine vs Redis for caching: how do you decide, and can you use both?
Caffeine is an in-process, in-memory cache — no network hop, sub-microsecond access, but the data lives only on one instance, so every pod has its own copy and a cold instance starts with nothing cached. Redis is a separate networked service — shared state across every instance, survives pod restarts, but every lookup pays a network round trip and you now operate another piece of infrastructure. A very common production pattern is a two-level cache: Caffeine as L1 for the hottest keys to avoid network calls entirely, Redis as L2 shared fallback, with Caffeine sized small and short-TTL specifically because it can't be actively invalidated across instances as cleanly as a shared store.
109. What is cache stampede (thundering herd) and how do you mitigate it in a Spring Boot service?
Stampede happens when a hot cache key expires and a burst of concurrent requests all miss simultaneously, all fall through to the origin (database, upstream API) at once, and that origin gets hit with load it wasn't provisioned for — sometimes enough to fall over, which then causes every subsequent request to also miss and retry, compounding the problem. Three standard mitigations: add jitter to TTLs so keys for the same logical resource type don't all expire at the same instant; use request coalescing so only one in-flight request per key actually reaches the origin while others wait on that result; and use a short-lived lock (e.g., a Redis SETNX with expiry) so only the lock-holder repopulates the cache while others either wait briefly or serve stale data.
// Coalescing with Caffeine's built-in async loading cache
AsyncLoadingCache<String, Product> cache = Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(10).plusSeconds(new Random().nextInt(30)))
.buildAsync(key -> productClient.fetch(key));
// Concurrent gets for the same key share the single in-flight load
110. How do you keep a cache consistent with the database when a write and a cache update need to happen together?
The dangerous pattern is updating the cache and the database as two independent steps with no ordering guarantee under failure or concurrency — a race between two writers can leave the cache holding stale data indefinitely with no signal that anything is wrong. The safer default is cache-aside with invalidate-after-commit: write to the database inside the transaction, and only evict (not update) the cache key after the transaction commits successfully, letting the next read repopulate it lazily with fresh data.
@Transactional
public void updatePrice(Long productId, BigDecimal price) {
repo.updatePrice(productId, price);
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override public void afterCommit() {
cacheManager.getCache("products").evict(productId);
}
});
}
111. How do you size a HikariCP connection pool correctly instead of guessing a large number?
The instinct to set maximumPoolSize to something large "for safety" is usually wrong — connections are expensive on the database side (each holds memory and a backend process/thread), and past a certain point more connections in the pool means more contention, not more throughput, because the database's own CPU cores become the bottleneck. HikariCP's own guidance, based on the PostgreSQL wiki formula, is roughly connections = ((core_count * 2) + effective_spindle_count) — for a modern SSD-backed DB, effectively cores * 2 + 1 or so, which is often surprisingly small (10-20) even for busy services.
spring.datasource.hikari.maximum-pool-size=15
spring.datasource.hikari.minimum-idle=15
spring.datasource.hikari.connection-timeout=3000
spring.datasource.hikari.leak-detection-threshold=30000
Beyond the formula, the actual number should come from load testing while watching hikaricp.connections.pending and database CPU together — if pending connections queue up while DB CPU is idle, the pool is too small; if DB CPU is pegged and pending is still zero, adding connections won't help.
112. What's the trade-off in enabling response compression, and when should you turn it off?
Compression (server.compression.enabled=true) trades CPU for bandwidth — it shrinks payloads over the wire but every request now spends cycles compressing on the way out. For text-heavy responses (JSON, HTML) over a slow or metered network it's almost always a net win. It stops paying off for already-compressed or binary payloads (images, PDFs, protobuf), where you burn CPU for negligible size reduction, and it's actively harmful for latency-critical small responses where the mime-type/size threshold isn't tuned, since compressing a 200-byte JSON body costs more in CPU time than it saves in transfer time.
server.compression.enabled=true
server.compression.mime-types=application/json,text/html,text/plain
server.compression.min-response-size=1024
113. Production scenario: response times doubled after adding a cache. What could cause that?
Several real causes, roughly in order of likelihood: the cache key is too specific (includes a timestamp, a request ID, or an unnormalized parameter) so the hit rate is near zero and every request pays cache-lookup overhead on top of the original work; serialization cost for a distributed cache is higher than expected, especially if a generic JSON serializer is reflecting over large object graphs on every get/put; the cache client is blocking on a network call to Redis that's now a new dependency in the hot path, and it has no timeout configured so a slow Redis node stalls every request; or eviction/expiry policy is too aggressive so entries constantly get recomputed anyway, giving you all the cache overhead with none of the benefit.
Diagnosing this always starts with checking the actual hit rate (Caffeine and Spring Cache expose this via Micrometer) before assuming the cache implementation itself is slow — a 5% hit rate cache is worse than no cache at all.
cache.gets{result="hit"} vs cache.gets{result="miss"} before tuning anything else — a low hit rate means the caching strategy is wrong, not the infrastructure.114. How do you diagnose a Spring Boot app that's slow to start, especially in a container/Kubernetes environment with startup probes?
Enable --debug or the startup actuator endpoint (management.endpoint.startup.enabled=true with BufferingApplicationStartup) to get a timed breakdown of every auto-configuration step, rather than guessing. Common real offenders: classpath scanning across too broad a base package, JPA metamodel building and entity scanning across a huge domain model, eager singleton bean initialization that does blocking I/O in a constructor or @PostConstruct (a bean pinging an external service at startup instead of on first use), and Flyway/Liquibase migrations running synchronously against a slow database on every cold start.
curl localhost:8080/actuator/startup | jq '.spring.contexts."application".startupSteps
| sort_by(.duration) | reverse | .[0:5]'
Also check whether AOT/CDS is an option for the workload — Spring Boot 3.x's ahead-of-time processing for native images, or the JVM's Class Data Sharing (-Xshare), can meaningfully cut cold-start time for frequently-restarted pods.
115. When do virtual threads actually help performance, and when do they not help at all?
Virtual threads help I/O-bound workloads with high concurrency and blocking calls — a controller that blocks on a JDBC query, a downstream HTTP call, or file I/O — because the JVM parks the virtual thread and frees the underlying platform (carrier) thread to run other work while waiting, letting you handle far more concurrent requests than a platform-thread-per-request model without rewriting to reactive code. They do nothing for CPU-bound work: a virtual thread computing a tight loop still occupies a carrier thread for the whole computation, so a service bottlenecked on JSON serialization, encryption, or number crunching sees no improvement and gains needless overhead.
spring.threads.virtual.enabled=true
// Tomcat's request-handling thread pool now hands each request
// a virtual thread instead of a platform thread from the pool
116. What is thread pinning with virtual threads, and why does a synchronized block make it worse?
A virtual thread normally unmounts from its carrier thread whenever it blocks, freeing the carrier to run other virtual threads. Pinning happens when the virtual thread cannot unmount — most commonly inside a synchronized block or method, or during a native call/foreign function call — so if that pinned virtual thread then blocks on I/O, it holds its carrier thread hostage for the duration instead of releasing it, and under load this can exhaust the small carrier thread pool and stall the whole application even though thousands of virtual threads are "supported."
// Pinning risk: synchronized + blocking I/O inside it
synchronized (lock) {
result = restClient.get().uri(url).retrieve().body(String.class); // blocks while pinned
}
// Fix: use a ReentrantLock instead, which allows unmounting
lock.lock();
try {
result = restClient.get().uri(url).retrieve().body(String.class);
} finally {
lock.unlock();
}
-Djdk.tracePinnedThreads=full in staging to surface exactly which synchronized blocks are causing pinning before it becomes a production incident.Async, Scheduling & Virtual Threads
This topic tests whether you understand the proxy and thread-boundary mechanics behind @Async and @Scheduled well enough to debug them in production — expect scrutiny on context propagation, overlap and duplication risks across replicas, and where virtual threads change (or don't change) the async story.
117. How does @Async actually work, and why does calling an @Async method from within the same class not run it asynchronously?
Like @Cacheable and @Transactional, @Async is proxy-based: Spring wraps the bean and intercepts external calls to the annotated method, submitting the actual invocation to a TaskExecutor instead of running it on the caller's thread. A self-invocation from another method in the same class calls the real object directly, bypassing the proxy, so the method runs synchronously on the caller's thread with no error or warning — this is one of the most common silent bugs in Spring codebases.
@Service
class NotificationService {
@Async
public void sendEmail(String to) { /* ... */ }
public void onOrderPlaced(Order order) {
this.sendEmail(order.customerEmail()); // BUG: runs synchronously, blocks caller
}
}
118. How do you configure a bounded async executor with a sane rejection policy instead of relying on the default SimpleAsyncTaskExecutor?
The default executor Spring falls back to when none is configured creates a new thread per task with no limit, which is fine for tests but dangerous in production — a burst of async calls can spawn unbounded threads and exhaust memory. A production executor needs an explicit core/max pool size, a bounded queue, and a deliberate rejection policy for when both the pool and queue are full.
@Bean(name = "emailExecutor")
Executor emailExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(16);
executor.setQueueCapacity(200);
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setThreadNamePrefix("email-async-");
executor.initialize();
return executor;
}
@Async("emailExecutor")
public void sendEmail(String to) { /* ... */ }
CallerRunsPolicy applies natural backpressure by making the caller pay the cost directly, which is usually safer than AbortPolicy silently dropping work or an unbounded queue hiding the overload until it OOMs.119. How does exception handling differ between an @Async method returning void and one returning a Future/CompletableFuture?
A void async method has nowhere to surface an exception back to the caller — the caller has already moved on by the time the exception is thrown on the background thread. By default that exception is only logged (via AsyncUncaughtExceptionHandler), and if you don't register a custom handler it's easy for these failures to go completely unnoticed. A method returning CompletableFuture<T> captures the exception in the future itself, so the caller can observe and react to it with .exceptionally() or by catching it when calling .get().
@Configuration
class AsyncConfig implements AsyncConfigurer {
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) ->
log.error("Async method {} failed with params {}", method, params, ex);
}
}
120. @Scheduled fixed-rate vs fixed-delay: what's the actual semantic difference, and how can overlap cause problems?
fixedDelay waits the specified interval after the previous execution finishes before starting the next one — executions never overlap by construction. fixedRate tries to start a new execution every interval measured from the start of the previous one, regardless of whether the previous one has finished; if the task takes longer than the interval, the next invocation queues up (or, with a single-threaded scheduler, runs immediately after the prior one finishes) and executions can pile up faster than they complete, eventually exhausting the scheduler's thread pool or causing concurrent runs of logic that wasn't written to be concurrent-safe.
@Scheduled(fixedDelay = 60_000) // safe: never overlaps
public void reconcileInventory() { ... }
@Scheduled(fixedRate = 60_000) // risky if the task can exceed 60s
public void pollExternalFeed() { ... }
121. How do you prevent the same @Scheduled job from running duplicated across multiple pod replicas?
Spring's @Scheduled has no built-in cluster awareness — every replica runs its own scheduler independently, so a job scheduled to run "once a day" actually runs once per pod, which is a correctness bug for anything non-idempotent (double-charging, duplicate emails, double-counted metrics). The fix is a distributed lock or lease that only one instance can hold at a time, acquired at the start of the job and released (or allowed to expire) at the end, so competing instances back off instead of duplicating work.
@Scheduled(cron = "0 0 1 * * *")
public void runDailyReconciliation() {
boolean acquired = redisLockRegistry.obtain("daily-reconciliation-lock").tryLock();
if (!acquired) return; // another pod already owns this run
try {
reconciliationService.run();
} finally {
redisLockRegistry.obtain("daily-reconciliation-lock").unlock();
}
}
122. How does security, tracing, and MDC context propagate across an executor boundary, and does switching to virtual threads change that?
None of these contexts (Spring Security's SecurityContext, Micrometer's tracing span, MDC) cross a thread boundary automatically, because they're all backed by ThreadLocal storage scoped to the thread that set them. Submitting work to an executor — whether it's a platform thread pool or a virtual-thread-per-task executor — starts the new thread with an empty context unless something explicitly copies it over. Spring Security provides DelegatingSecurityContextExecutor for this; Micrometer's context-propagation library provides a general ContextSnapshot mechanism that several of these hooks into via auto-configuration once the dependency is on the classpath.
Virtual threads don't change this at all — the propagation problem is about thread identity, not thread weight, so the same decorators and snapshot mechanisms are required regardless of whether the executor hands out platform or virtual threads.
io.micrometer:context-propagation on the classpath, Spring Boot 3.x auto-wires MDC and tracing propagation into its own managed executors — but any executor you build yourself with a raw Executors.newVirtualThreadPerTaskExecutor() bypasses that unless you wrap it.123. When is a message queue the right tool instead of just throwing more work at a TaskExecutor?
An in-process executor's queue dies with the JVM — if the pod crashes or restarts mid-task, whatever was queued or in flight is gone with no record it ever existed. A queue (Kafka, SQS, RabbitMQ) gives you durability across restarts, the ability to scale consumers independently from producers, natural retry/dead-letter handling for poison messages, and back-pressure that survives a deploy rather than resetting to empty. Reach for a queue specifically when work must survive a crash, when producer and consumer need to scale independently, or when you need at-least-once delivery guarantees and replay — reach for an in-process executor only for work you're fully willing to lose on restart.
124. How do you implement backpressure or load shedding when async work arrives faster than it can be processed?
Once queue depth is growing unbounded, the system needs to actively refuse or defer work rather than accept it and fall further behind — an unbounded queue just delays the failure and makes it worse when it arrives (OOM, unbounded latency). Practical mechanisms: a bounded queue with a rejection policy that returns 503/429 to the caller so upstream can retry elsewhere or back off; a Resilience4j Bulkhead to cap concurrent async work per downstream dependency; or explicit load shedding that drops low-priority work first when a queue-depth threshold is crossed, preserving capacity for the traffic that matters most.
resilience4j.bulkhead.instances.emailSending.max-concurrent-calls=20
resilience4j.bulkhead.instances.emailSending.max-wait-duration=0
@Bulkhead(name = "emailSending")
@Async("emailExecutor")
public CompletableFuture<Void> sendEmail(String to) { ... }
125. How do you unit test @Scheduled logic deterministically instead of relying on wall-clock timing?
Testing scheduled behavior by actually waiting for the trigger interval makes tests slow and flaky. The fix is to separate the "what runs" from the "when it runs": extract the job's logic into a plain method that takes an injected Clock (or accepts a time parameter) rather than calling Instant.now() or LocalDate.now() directly, so tests can advance a fixed/mutable clock and assert behavior at exact instants without touching real scheduling infrastructure at all.
@Component
class InvoiceJob {
private final Clock clock;
InvoiceJob(Clock clock) { this.clock = clock; }
@Scheduled(cron = "0 0 2 * * *")
public void run() { generateInvoicesFor(LocalDate.now(clock)); }
void generateInvoicesFor(LocalDate billingDate) { ... } // unit test targets this directly
}
// test
Clock fixed = Clock.fixed(Instant.parse("2026-03-01T02:00:00Z"), ZoneOffset.UTC);
new InvoiceJob(fixed).generateInvoicesFor(LocalDate.now(fixed));
126. What are structured concurrency previews in modern Java, and how might they change how async Spring code gets written?
Structured concurrency (finalized as a preview/incubating feature across recent JDK releases) treats a group of related concurrent subtasks as a single unit of work with a clear owner: if one subtask fails, siblings are cancelled, and the parent scope doesn't return until every child has completed, failed, or been cancelled — eliminating the class of bugs where a spawned thread outlives the method that started it or an exception in one branch gets lost while others keep running. For Spring code today, most fan-out/fan-in work is done with CompletableFuture.allOf()/anyOf(), which doesn't enforce that lifetime relationship and makes cancellation propagation manual and error-prone.
// Preview API shape (subject to change across JDK versions)
try (var scope = StructuredTaskScope.open()) {
var user = scope.fork(() -> userClient.fetch(id));
var orders = scope.fork(() -> orderClient.fetchFor(id));
scope.join();
return new Profile(user.get(), orders.get());
}
Expect Spring's async abstractions to eventually offer thinner wrappers around this rather than replacing CompletableFuture entirely, since it's still a preview feature and its exact API has changed between JDK releases.
127. Production scenario: a @Scheduled job silently stopped running after a deploy. How do you investigate?
Start with the most common causes in order of likelihood: check whether the scheduler thread pool got starved — a single-threaded default TaskScheduler means one long-running or blocked job silently prevents every other scheduled job from ever firing, with no error logged anywhere. Next check whether the deploy changed profile-specific configuration and the job's @Scheduled cron expression is now driven by a property that resolved to empty or wrong in the new environment. Then check whether a new distributed lock (added for the multi-replica duplication problem) is stuck held by a crashed instance that never released it, silently starving every replica.
management.endpoint.scheduledtasks.enabled=true
// curl localhost:8081/actuator/scheduledtasks shows every registered
// job and its next/last execution — the first place to look
spring.task.scheduling.pool.size) above 1 — the single-thread default means any one slow job blocks every other scheduled job in the application indefinitely.128. Beyond synchronized blocks, what other virtual thread pinning pitfalls should you watch for in a Spring Boot app?
Native/foreign function calls (JNI, or code going through the Foreign Function & Memory API) pin the carrier thread for their duration since the JVM can't safely unmount a virtual thread mid-native-call — a driver or library that shells out to native code under load can quietly reintroduce platform-thread-style bottlenecks. Some older JDBC drivers and connection pool internals historically used synchronized internally for thread safety before virtual threads existed, so upgrading to virtual threads without also verifying your driver and pool versions are pinning-aware can silently reproduce the exact contention you were trying to eliminate.
The practical mitigation is to load-test with pinning tracing enabled before rolling virtual threads to production, and to keep the JDK current — the JDK has progressively removed synchronized-based pinning from more of its own internals (including, more recently, java.io and networking classes) across releases.
Kafka & Event-Driven Messaging
Interviewers use this topic to separate people who have read about Kafka from people who have been paged at 2 a.m. because of it — expect deep questions on delivery guarantees, ordering, and what happens when a consumer crashes mid-batch.
129. What is the real difference between at-most-once, at-least-once, and exactly-once delivery in Kafka?
At-most-once means the consumer commits its offset before processing, so a crash after commit but before the side effect completes loses the message silently. At-least-once flips the order — process first, commit after — so a crash after processing but before commit causes the same message to be redelivered and reprocessed. Exactly-once is not a Kafka feature you turn on; it is a property of the whole pipeline, achieved either through Kafka's idempotent producer plus transactional reads-process-writes across Kafka topics, or by making the consumer's side effect idempotent so redelivery is harmless.
In practice, almost every production system runs at-least-once with an idempotent consumer, because true exactly-once semantics only hold when every downstream write stays inside Kafka's transactional boundary — the moment you write to a database or call an external API, you are back to at-least-once with idempotency as your safety net.
130. How do you design an idempotent Kafka consumer in practice?
Every event needs a stable, unique business key — an event ID, or a natural key like orderId + eventType — that the consumer can check against a persisted record before applying the side effect. The cleanest implementation is a unique constraint in the same database transaction as the business write, so the "have I seen this" check and the write itself are atomic instead of racing each other.
@Entity
@Table(name = "processed_events",
uniqueConstraints = @UniqueConstraint(columnNames = "event_id"))
public class ProcessedEvent {
@Id
private String eventId;
private Instant processedAt;
}
@Transactional
public void handle(OrderCreatedEvent event) {
try {
processedEventRepository.save(new ProcessedEvent(event.eventId(), Instant.now()));
} catch (DataIntegrityViolationException dup) {
log.info("Duplicate event {} ignored", event.eventId());
return; // already processed, safe no-op
}
invoiceService.createInvoiceFor(event);
}
131. How does consumer group partition assignment work, and what ordering guarantee does Kafka actually give you?
Kafka assigns each partition of a topic to exactly one consumer instance within a consumer group, so parallelism is capped by partition count — more consumers than partitions just leaves some instances idle. Kafka only guarantees ordering within a single partition, not across the topic; two messages produced to different partitions can be consumed out of relative order. This is why producers key messages deliberately — for example keying by orderId ensures every event for that order lands on the same partition and is processed in the order it was produced.
132. How would you design a dead-letter topic so it is actually useful during an incident?
A dead-letter topic that only carries the raw failed payload is nearly useless six weeks later when someone has to triage it — you also need the original headers, the exception type and message, the number of retry attempts already made, and the timestamp of the first and last failure. Spring Kafka's DeadLetterPublishingRecoverer lets you enrich the record before it is republished, and pairing it with a separate alerting topic or metric means the DLT becomes a queue you actively drain rather than a graveyard nobody checks.
@Bean
public DefaultErrorHandler errorHandler(KafkaTemplate<Object, Object> template) {
var recoverer = new DeadLetterPublishingRecoverer(template,
(record, ex) -> new TopicPartition(record.topic() + ".DLT", record.partition()));
var backOff = new ExponentialBackOffWithMaxRetries(5);
backOff.setInitialInterval(500L);
backOff.setMultiplier(2.0);
backOff.setMaxInterval(10_000L);
return new DefaultErrorHandler(recoverer, backOff);
}
133. Why does a retry policy need bounded exponential backoff with jitter instead of a fixed retry interval?
A fixed short interval retries so fast that it can hammer an already-struggling downstream dependency, worsening the exact outage it is trying to recover from. Exponential backoff spaces out retries so transient failures get more breathing room to resolve, and capping the maximum interval keeps recovery bounded rather than growing forever. Jitter — adding a small random offset to each backoff — prevents the "thundering herd" effect where every consumer instance that failed at the same moment also retries at the exact same moment, which just recreates the spike.
134. What is the transactional outbox pattern and why is it needed?
The problem it solves is the "dual write" — writing to your database and publishing to Kafka are two separate systems, so if the process crashes between them, you either lose the event or lose the database change while the other one persists. The outbox pattern writes the business change and a serialized outbox row into the same database transaction, so they are atomic by definition, then a separate poller or change-data-capture process (like Debezium) reads unpublished outbox rows and publishes them to Kafka asynchronously, marking them sent.
@Transactional
public void createOrder(OrderRequest request) {
Order order = orderRepository.save(Order.from(request));
outboxRepository.save(new OutboxEvent(
order.getId(), "OrderCreated", toJson(order), Instant.now()));
// both rows commit together or not at all
}
135. How do you evolve a Kafka message schema without breaking existing consumers?
Backward compatibility means a new schema can still be read by consumers using the old schema, which you get by only adding optional fields with defaults and never removing or renaming existing required fields. Forward compatibility is the opposite — old producers' data can be read by consumers on the new schema. A schema registry (Confluent Schema Registry or Apicurio) enforces this automatically at publish time by rejecting a schema change that breaks the configured compatibility mode, catching the mistake at build or deploy time instead of in production when a consumer throws a deserialization exception.
136. What does growing consumer lag actually tell you, and how do you monitor it?
Consumer lag is the difference between the latest offset produced to a partition and the offset a consumer group has committed — it is the backlog of unread messages. Lag that is steadily growing means the consumer cannot keep up with the produce rate, which is either a throughput problem (too few consumer instances, slow per-message processing) or a stuck consumer (an exception loop, a deadlock, or a downstream call hanging). A momentary lag spike after a deploy or rebalance is normal; a lag that never comes back down under normal load is the signal to page someone.
# Exposed via Kafka's own metrics or the consumer-groups CLI
kafka-consumer-groups.sh --bootstrap-server broker:9092 \
--describe --group order-service-group
137. What happens to in-flight messages during a partition rebalance, and can that cause duplicate processing?
When a consumer joins or leaves a group, Kafka pauses the group and reassigns partitions across the remaining instances. Any message that a consumer had read but not yet committed at the moment of rebalance gets reassigned to another instance and redelivered from the last committed offset — this is a normal source of duplicate delivery, not a bug. Cooperative sticky assignment (the default rebalancing strategy since newer Kafka clients) minimizes disruption by only reassigning the partitions that need to move rather than revoking everything, which shortens the pause and reduces redelivery volume.
138. Production scenario: the same order-created event was processed twice and created two invoices. Walk through the fix.
First confirm it's a delivery duplicate and not a producer bug by checking whether the event ID appears twice on the topic (producer sent it twice) or once but was consumed twice (consumer-side redelivery, likely from a rebalance or a crash between processing and committing the offset). Either way, the root cause is the same: the invoice-creation logic had no idempotency guard, so "receive event" and "create invoice" were treated as safe to repeat.
The fix is the dedup pattern from earlier — add a unique constraint on orderId (or the event ID) in the invoices table or a companion processed-events table, wrapped in the same transaction as the invoice write, so a second delivery fails the insert instead of creating a second row. Then backfill: identify duplicate invoices by matching order IDs, void or credit the extra ones through the normal financial-correction workflow rather than a direct delete, since invoices are often immutable audit records once issued.
139. How do you decide between RabbitMQ and Kafka for a given workload?
Kafka is built around a durable, replayable log — consumers track their own offset and can re-read history, which makes it the right choice for event streaming, audit trails, and cases where multiple independent consumers need the same event stream (analytics, notifications, and order processing all reading the same order-events topic). RabbitMQ is built around smart routing and per-message queue semantics — exchanges, routing keys, priority queues, and per-message TTL — which makes it a better fit for complex task-distribution and work-queue patterns where a message is consumed once and gone, and you need fine-grained routing logic rather than raw throughput.
A rule of thumb: if you need "replay the last hour of events" or "many consumers, same stream," lean Kafka; if you need "route this specific message to this specific worker based on content" with lower operational overhead, RabbitMQ is often simpler to run.
140. How do you test a Kafka producer and consumer without mocking Kafka away entirely?
Mocking KafkaTemplate or the consumer factory tests your code's calls, not its actual behavior against a real broker — serialization bugs, partition/key logic, and consumer configuration mistakes slip through. Testcontainers spins up a real, ephemeral Kafka broker in Docker for the test's lifetime, so you publish a real message, let your actual @KafkaListener consume it, and assert on the resulting side effect, giving you confidence that matches production behavior.
@Testcontainers
@SpringBootTest
class OrderEventConsumerTest {
@Container
static KafkaContainer kafka = new KafkaContainer(DockerImageName.parse("apache/kafka:3.7.0"));
@DynamicPropertySource
static void kafkaProps(DynamicPropertyRegistry registry) {
registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
}
@Test
void consumesOrderCreatedEvent() {
kafkaTemplate.send("order-events", "order-123", orderCreatedJson);
await().atMost(Duration.ofSeconds(5))
.untilAsserted(() -> assertThat(invoiceRepository.count()).isEqualTo(1));
}
}
Microservices Resilience (Resilience4j, Gateway)
This is where interviewers check whether you understand failure as a design input rather than an afterthought — expect questions on where each resilience mechanism belongs, and what happens when two of them interact badly.
141. What do the circuit breaker states closed, open, and half-open actually protect?
A circuit breaker protects the caller, not the failing dependency — its job is to stop your own service from wasting threads, connections, and latency budget on calls that are very likely to fail. In the closed state, calls pass through normally while failures are tracked; once the failure rate crosses a threshold, the breaker opens and fails fast without even attempting the call, freeing up resources immediately. After a configured wait duration, it moves to half-open and allows a small number of trial calls through — if those succeed, it closes again, and if they fail, it reopens.
142. Why should every remote call have an explicit timeout, and how do you budget timeouts across a call chain?
Without a timeout, a hung dependency holds your thread indefinitely, and under load that exhausts your thread pool or connection pool — the caller becomes unavailable even though it wasn't the one that failed. In a chain of service A calling B calling C, each hop's timeout has to be strictly less than the caller's own deadline, or A will time out and return an error to its client while B and C are still working, wasting resources on a response nobody will use.
resilience4j:
timelimiter:
instances:
paymentService:
timeout-duration: 2s # must be < caller's own SLA budget
143. Thread pool bulkhead vs semaphore bulkhead — when do you use each?
A bulkhead limits how much concurrent load one dependency can consume, so a slow or failing dependency can't starve threads that other, healthy dependencies need. A thread pool bulkhead runs calls on a dedicated, bounded executor — it gives true isolation (the calling thread returns immediately and can time out independently) at the cost of thread-context-switch overhead, and is the right choice for blocking calls. A semaphore bulkhead just limits concurrent permits on the calling thread itself — cheaper, no extra threads, but the calling thread still blocks inside the call, so it fits low-latency or reactive call paths better than long-running blocking I/O.
144. What makes a retry "safe," and why can nested retries at multiple layers be dangerous?
A safe retry requires three things: the operation must be idempotent (repeating it doesn't cause a duplicate side effect), the retry count must be bounded, and the backoff must include jitter so retries from many callers don't synchronize into a spike. Retrying a non-idempotent write — like "charge this card" without an idempotency key — turns a transient network blip into a duplicate charge.
Nested retries are dangerous because they multiply, not add: if service A retries 3 times and each attempt calls service B which itself retries 3 times, a single failure at the bottom of the chain can generate up to 9 real calls to the failing dependency, right when it's already struggling. The fix is to decide retry ownership at one layer — typically the outermost caller with business context — and disable or minimize retries deeper in the chain.
145. Why can a fallback that fabricates a "success" response be worse than letting the call fail?
A fallback exists to degrade gracefully, not to lie about what happened. Returning a default value that looks like a normal success — say, silently defaulting a fraud-check call to "approved" when the fraud service is down — hides a real failure from every downstream system and from the business, and by the time anyone notices, incorrect decisions have already been made at scale. A well-designed fallback either does something genuinely safe (return cached last-known-good data, clearly flagged as stale) or surfaces the degradation honestly (a 503 or a explicit "pending manual review" state) so the failure is visible and recoverable.
146. What belongs in an API gateway, and what should deliberately stay out of it?
A gateway is the right place for cross-cutting, request-shape concerns: routing to the correct backend service, authentication/token validation, rate limiting, TLS termination, and basic request/response transformation. It is the wrong place for business logic — anything that needs to know domain rules (pricing, eligibility, workflow state) belongs in the owning service, because putting it in the gateway couples an infrastructure component to business rules that change far more often, and makes it a single point of coupling for every team.
spring:
cloud:
gateway:
routes:
- id: orders-route
uri: lb://order-service
predicates:
- Path=/api/orders/**
filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 50
redis-rate-limiter.burstCapacity: 100
147. Saga pattern with compensating transactions vs two-phase commit — how do you choose for cross-service consistency?
Two-phase commit needs a coordinator to hold locks across every participant until all of them vote to commit, which works within a single database but does not scale across independently deployed services — it creates tight coupling, blocks on the slowest participant, and a coordinator crash can leave resources locked indefinitely. A saga instead runs a sequence of local transactions, each in its own service, and if a later step fails, it runs compensating transactions to undo the earlier steps — for example, "cancel reservation" undoes "reserve inventory."
Sagas trade strict atomicity for availability and service autonomy: there is a window where the system is in an intermediate, eventually-consistent state, so the design has to account for that explicitly (e.g., showing an order as "processing" rather than "confirmed" until every step completes). Most real microservice systems choose sagas because 2PC across service boundaries is operationally fragile.
148. How does service discovery work in Kubernetes without a Eureka-style application-level registry?
Kubernetes bakes service discovery into the platform: a Service object gets a stable virtual IP and DNS name, and kube-proxy (or a service mesh sidecar) load-balances traffic across the healthy Pod endpoints backing that Service, updating automatically as pods scale up, down, or get rescheduled. This replaces what Eureka or Consul used to do at the application layer — clients just resolve order-service.default.svc.cluster.local via normal DNS and Kubernetes handles the rest, so Spring Cloud Netflix-style client-side discovery is largely unnecessary when you're already running on Kubernetes.
149. Production scenario: a slow downstream payment service is causing cascading failures across five other services. How do you contain it?
The immediate priority is containment, not root-causing the payment service yet. Check whether every caller of the payment service has a timeout and a circuit breaker — if not, that's the first fire: add or tighten timeouts so calling threads stop piling up, which should let the circuit breaker trip and start failing fast, taking pressure off the callers' own thread pools. If a caller shares a thread pool or connection pool across multiple downstream calls, isolate the payment call into its own bulkhead so it can't starve requests to unrelated dependencies.
Once containment is in place, check whether the fallback path is safe (queue the payment for async retry, or surface a clear "payment pending" state) rather than silently dropping requests. Only after the bleeding stops do you dig into why the payment service itself got slow — often a database connection pool exhaustion, a GC pause, or a downstream dependency of its own — and that becomes the actual root-cause fix.
150. Resilience4j annotation-based decorators vs the functional API — when would you use each?
The annotation style (@CircuitBreaker, @Retry, @RateLimiter from resilience4j-spring-boot3) is the fastest way to wrap an existing method and reads declaratively, but it only works on Spring-managed beans and applies at the method boundary, which limits how finely you can control composition order. The functional API lets you explicitly build a decorator chain and control the exact order resilience patterns apply — which matters, since wrapping retry around circuit breaker behaves very differently than circuit breaker around retry.
// Annotation style
@CircuitBreaker(name = "paymentService", fallbackMethod = "fallback")
@Retry(name = "paymentService")
public PaymentResult charge(PaymentRequest request) {
return paymentClient.charge(request);
}
// Functional style — explicit, controlled composition order
Supplier<PaymentResult> decorated = Decorators.ofSupplier(() -> paymentClient.charge(request))
.withCircuitBreaker(circuitBreakerRegistry.circuitBreaker("paymentService"))
.withRetry(retryRegistry.retry("paymentService"))
.decorate();
151. Token bucket vs leaky bucket — which rate limiting algorithm fits a public API, and why?
Token bucket allows short bursts up to the bucket's capacity while enforcing a steady average rate over time — a client that's been idle can spend a burst of accumulated tokens quickly, which matches how real API clients behave (idle, then a flurry of calls). Leaky bucket smooths output to a strictly constant rate regardless of how requests arrived, which is better when you need to protect a downstream system that truly cannot tolerate any burst at all, like a legacy system with a fixed processing rate.
For a public API, token bucket is usually the better default because it's more forgiving of normal, bursty client behavior while still capping sustained abuse — Resilience4j's RateLimiter and Spring Cloud Gateway's Redis-backed limiter are both effectively token-bucket implementations.
152. Load shedding vs backpressure — what's the difference, and when do you need each?
Backpressure is a cooperative signal: the consumer tells the producer "slow down, I can't keep up," and the producer adjusts its rate — this only works when both ends understand and honor the same protocol, which is why it's central to reactive streams (Project Reactor, Flux) but doesn't exist for a plain synchronous HTTP endpoint. Load shedding is unilateral: when a service is overloaded and has no way to ask upstream to slow down, it protects itself by outright rejecting some incoming requests (often the lowest-priority ones, or the newest ones under a queue-depth threshold) so the requests it does accept can actually be served within SLA.
In an HTTP-based microservice architecture without an end-to-end reactive pipeline, load shedding (fast-failing with a 503 past a concurrency limit) is usually the practical tool; true backpressure requires the whole path, including the client, to participate.
Reactive Programming & WebFlux
Reactive questions filter out candidates who've only used the imperative Spring MVC stack — expect probing on backpressure, scheduler choice, and the subtle ways blocking code sneaks into a reactive pipeline and quietly kills throughput.
153. What's the real difference between Mono and Flux, and why doesn't anything happen until you subscribe?
Mono<T> represents zero or one asynchronous result; Flux<T> represents zero to many, potentially unbounded. Both are cold, lazy publishers — building a chain of operators like .map() or .filter() just assembles a pipeline description, and no data flows and no side effects run until something subscribes to it. This is different from a Java Stream, which is also lazy but single-threaded and pull-based synchronously; a reactive pipeline is push-based once subscribed, and the same unsubscribed Mono can be reused to produce a fresh execution every time it's subscribed to.
154. How does backpressure actually work in a reactive pipeline — how does a slow consumer signal demand?
Backpressure is built into the Reactive Streams contract itself: a Subscriber doesn't just receive an unbounded flood of items — it calls Subscription.request(n) to explicitly tell the upstream publisher how many items it's ready to handle right now. A slow consumer simply requests smaller batches or waits before requesting more, and a well-behaved publisher (like a database driver's R2DBC reactive stream) respects that and only emits up to the requested amount, rather than buffering unboundedly in memory.
155. Why is calling .block() on a WebFlux event-loop thread dangerous, and how do you isolate a genuinely blocking call?
WebFlux runs on a small, fixed pool of event-loop threads (Netty's worker threads), and the entire model depends on those threads never blocking — one blocked thread can stall every other request multiplexed onto it, not just the one that called .block(). Under load, this quickly cascades into full request timeout across the service even though most of the actual work would have been fine. The fix is never to eliminate blocking calls that are unavoidable (a legacy JDBC driver, a blocking SDK) but to explicitly move them onto a dedicated bounded thread pool with subscribeOn(Schedulers.boundedElastic()), isolating the damage to that pool instead of the event loop.
public Mono<Account> findAccount(String id) {
return Mono.fromCallable(() -> legacyBlockingJdbcRepository.findById(id))
.subscribeOn(Schedulers.boundedElastic());
}
156. subscribeOn vs publishOn — what's the practical difference?
subscribeOn affects where the subscription happens and, by extension, where the source emits from — it applies to the whole chain regardless of where it's placed, and only the first subscribeOn in a chain has effect. publishOn switches the execution context for everything downstream of where it's placed, and you can call it multiple times in a chain to hop between schedulers at specific points.
Flux.range(1, 10)
.subscribeOn(Schedulers.boundedElastic()) // affects the whole source
.map(this::expensiveCpuWork)
.publishOn(Schedulers.parallel()) // switches thread from here on
.map(this::finalTransform)
.subscribe();
157. When is WebFlux actually the right choice, versus when does it just add complexity?
WebFlux earns its complexity when the workload is I/O-bound with high concurrency — many slow, concurrent downstream calls (fan-out to several services, long-lived connections, server-sent event streams) where non-blocking I/O lets a small thread pool serve far more concurrent requests than the thread-per-request model of Spring MVC. It adds complexity for no benefit when the service is CPU-bound, or when it talks to a traditional blocking JDBC datastore without R2DBC — in that case you gain none of the throughput benefit (you're blocking anyway) while paying the full cost of reactive debugging, harder stack traces, and a steeper learning curve for the team.
158. R2DBC vs JDBC — what are the real trade-offs?
JDBC is blocking by design — every call ties up a thread until the database responds — which is fine under the thread-per-request model but defeats the purpose of a reactive pipeline. R2DBC is a genuinely non-blocking database driver spec, so queries integrate into the reactive chain without needing a scheduler hop, keeping the event-loop thread free the whole time. The trade-off is ecosystem maturity: R2DBC driver support, connection pooling tooling, and debugging experience are all less mature than JDBC's decades-old ecosystem, and some advanced JPA features (lazy loading, complex entity graphs) don't have a clean reactive equivalent — Spring Data R2DBC is deliberately simpler than JPA.
159. How do you test a reactive pipeline, including time-dependent logic, with StepVerifier?
StepVerifier subscribes to a Mono or Flux and lets you assert on the exact sequence of emitted values, errors, and completion signals rather than blocking and inspecting a final result — which matters because blocking a reactive pipeline in a test partially defeats the point of testing it reactively. For pipelines that use delays, timeouts, or interval-based operators, StepVerifier.withVirtualTime() lets you fast-forward simulated time instead of your test actually sleeping for real seconds or minutes.
StepVerifier.withVirtualTime(() -> reminderService.sendAfterDelay(Duration.ofMinutes(30)))
.expectSubscription()
.thenAwait(Duration.ofMinutes(30))
.expectNext("reminder-sent")
.verifyComplete();
160. Server-Sent Events vs WebSocket — how do you choose for one-way streaming?
SSE is a simple, HTTP-based, one-directional stream from server to client — it works over plain HTTP/1.1, reconnects automatically in the browser, and integrates trivially with WebFlux's Flux<ServerSentEvent<T>> return type, making it the natural fit for dashboards, live notifications, or progress updates. WebSocket is bidirectional and lower-level, needed when the client also needs to send messages back over the same connection (chat, collaborative editing, gaming) — but it costs more operational complexity: a separate protocol upgrade, no automatic reconnection, and it doesn't play as nicely with standard HTTP infrastructure like some proxies and load balancers.
@GetMapping(path = "/prices", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<PriceUpdate>> streamPrices() {
return priceService.priceUpdates()
.map(p -> ServerSentEvent.builder(p).event("price-update").build());
}
161. Why does reactive context propagation break naive assumptions about ThreadLocal, and how do you fix things like security context or tracing?
ThreadLocal assumes work stays on one thread for its entire lifetime, which reactive pipelines actively violate — a single request can hop across multiple threads as it moves through subscribeOn/publishOn boundaries, so anything stashed in a ThreadLocal (the classic Spring Security SecurityContextHolder, MDC logging context, trace IDs) can simply vanish partway through the chain. Reactor solves this with its own Context, which is immutable and propagates along the subscription chain regardless of which thread executes each step — Spring Security's reactive support stores the authenticated principal there instead of in a ThreadLocal, and structured logging needs an explicit MDC-to-Reactor-Context bridge (or Micrometer's context propagation library) to keep trace IDs intact across thread hops.
162. Production scenario: the reactive service's throughput collapsed after adding a blocking JDBC call inside a flatMap. Diagnose it.
The symptom to look for first is thread starvation on Netty's event-loop pool — thread dump the running service and check if all (or nearly all) event-loop threads are parked inside the JDBC driver's socket read, rather than spread across many different points in the code. That pattern confirms the diagnosis: the blocking call was placed directly inside the reactive chain (inside a flatMap) without a scheduler hop, so it executes on whichever event-loop thread happens to be running that segment of the pipeline, and since there are only a handful of those threads, they saturate almost immediately under concurrent load — every other unrelated request queued behind them stalls too.
The fix is to wrap the blocking call so it runs on Schedulers.boundedElastic() instead of the event loop, moving the risk to a pool designed to absorb blocking work: Mono.fromCallable(() -> blockingRepo.find(id)).subscribeOn(Schedulers.boundedElastic()). Longer term, if this pattern recurs often, it's a signal the service's persistence layer should move to R2DBC, or that WebFlux may not be the right stack for a service this coupled to blocking JDBC.
Cloud-Native: Docker, Kubernetes & GraalVM Native Image
Interviewers use this topic to check whether you've actually run Spring Boot in a cluster — image size and layering, probe wiring, graceful shutdown, memory ergonomics inside cgroups, and how you ship schema changes without downtime.
163. Why do layered JARs matter for Docker build times, and how do you enable them?
A default fat JAR bundles your application classes and every dependency into one archive, so any code change invalidates the whole layer and forces Docker to re-push the entire JAR. Spring Boot's layered JAR format (enabled by default since Boot 2.3) splits the archive into dependency layers, a Spring Boot loader layer, resource layers, and an application-classes layer. Since dependencies rarely change between commits, Docker's layer cache reuses them and only re-copies the thin application layer, cutting rebuild and push times dramatically.
FROM eclipse-temurin:21-jre-jammy AS builder
WORKDIR /app
COPY target/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app
COPY --from=builder /app/dependencies/ ./
COPY --from=builder /app/spring-boot-loader/ ./
COPY --from=builder /app/snapshot-dependencies/ ./
COPY --from=builder /app/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
164. How do you run a Spring Boot container as non-root, and why does it matter?
Running as root inside a container means a container-escape or dependency RCE vulnerability gives an attacker root on the host's namespace-shared kernel. You fix this by creating a dedicated unprivileged user in the image and switching to it before the entrypoint runs, and by pairing it with a minimal base image so there's less attacker surface (no shell, no package manager) to escalate with in the first place.
FROM eclipse-temurin:21-jre-jammy
RUN addgroup --system spring && adduser --system --ingroup spring spring
WORKDIR /app
COPY --chown=spring:spring target/app.jar app.jar
USER spring:spring
ENTRYPOINT ["java", "-jar", "app.jar"]
USER alone isn't enough if the JAR was copied in as root before the switch — use --chown or a subsequent RUN chown so the process can actually read its own files.165. What are container-aware JVM memory ergonomics, and why shouldn't you set the heap equal to the container's memory limit?
Since JDK 10+, the JVM reads cgroup limits directly and sizes the default max heap as a fraction of the container's memory limit (roughly 25% by default via -XX:MaxRAMPercentage), rather than the host's total memory. The heap is only one consumer of process memory — metaspace, thread stacks, direct byte buffers, JIT code cache, and native libraries all live outside it. If you set -Xmx to the full container limit, any of those other regions pushes total RSS over the limit and the kernel OOM-kills the container, often with no GC log warning at all.
# Better: leave headroom for non-heap memory
-XX:MaxRAMPercentage=70.0
-XX:+UseContainerSupport # default on since JDK 10, explicit here for clarity
166. What's the practical difference between a readiness probe and a liveness probe, and how do they interact with a rolling deployment?
Liveness answers "is this process stuck and should Kubernetes restart it?" — a failing liveness probe kills the pod. Readiness answers "can this pod currently serve traffic?" — a failing readiness probe just pulls the pod out of the Service's endpoint list without restarting it. During a rolling deployment, new pods must pass readiness before kubelet adds them to the load balancer, and old pods should fail readiness before they're terminated so in-flight requests aren't routed to a pod that's already shutting down.
management:
endpoint:
health:
probes:
enabled: true
group:
readiness:
include: readinessState,db,diskSpace
liveness:
include: livenessState
167. How do you achieve zero-downtime pod termination with server.shutdown=graceful and Kubernetes' termination grace period?
When Kubernetes decides to terminate a pod, it first marks it "terminating" and removes it from Service endpoints, then sends SIGTERM, waits up to terminationGracePeriodSeconds, and finally SIGKILLs anything still running. There's an inherent race: endpoint removal across all kube-proxy/ingress nodes isn't instant, so requests can still arrive briefly after SIGTERM. Spring Boot's graceful shutdown stops accepting new requests but lets in-flight ones finish within spring.lifecycle.timeout-per-shutdown-phase, and a short preStop sleep hook covers the endpoint-propagation gap.
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 20s
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 5"]
168. What do you actually gain and give up by compiling a Spring Boot app to a GraalVM native image?
Native image compiles ahead-of-time to a standalone binary, giving startup in tens of milliseconds instead of seconds and a much smaller resident memory footprint — valuable for scale-to-zero, CLI tools, and fast-scaling services under bursty load. The cost is a build that takes minutes instead of seconds (closed-world static analysis of every reachable code path), no runtime JIT re-optimization so raw sustained throughput can be slightly lower than a warmed-up JIT, and anything relying on reflection, dynamic proxies, or classpath scanning needs explicit hints or it silently breaks only at native runtime.
169. What does Spring AOT processing do, and why is it required for native image support?
Spring's classic runtime relies heavily on reflection, dynamic proxies, and classpath scanning to build the BeanFactory at startup — none of which GraalVM's closed-world analysis can fully discover on its own. Spring AOT runs the bean-definition and context-initialization logic at build time and generates the equivalent Java source plus runtime-hints registrations (reflection, proxies, resources, serialization) ahead of time, so the native image compiler has a complete, explicit picture of what needs to exist at runtime.
@Configuration
class SearchConfig {
@Bean
@RegisterReflectionForBinding(SearchResult.class)
RestClient searchClient(RestClient.Builder builder) {
return builder.baseUrl("https://search.internal").build();
}
}
170. How do ConfigMaps and Secrets get into a Spring Boot pod, and does the app see updates without a restart?
The two common paths are environment variables (injected at pod creation, immutable for the pod's lifetime) and mounted volumes (files that kubelet updates in place when the ConfigMap/Secret changes, subject to a sync delay of roughly a minute). Spring Boot's default property sources are read once at startup, so env-var-based config never changes without a rolling restart, and even volume-mounted files aren't automatically re-read unless you add Spring Cloud Kubernetes' config watcher or trigger a refresh via an actuator endpoint and @RefreshScope-annotated beans.
171. Scenario: pods start getting OOMKilled right after a traffic increase, but heap usage graphs look fine. What's actually consuming the memory?
OOMKilled is a cgroup RSS event, not a heap event, so the first move is to stop looking at heap and check the other consumers that scale with concurrency: thread stacks (each platform thread reserves ~1MB by default, so a burst of blocking I/O threads under load can add hundreds of MB), direct/native byte buffers used by Netty or JDBC drivers, metaspace growth from dynamic class generation (CGLIB proxies, Hibernate bytecode enhancement), and JIT code cache. A sudden traffic increase that spins up many additional threads or connections is a classic trigger.
# Confirm it's non-heap: compare RSS to -Xmx over time
kubectl top pod my-app-abc123
# Then break down JVM native memory
java -XX:NativeMemoryTracking=summary -jar app.jar
jcmd <pid> VM.native_memory summary
172. Blue-green vs canary deployment for a stateful Spring Boot service — when do you pick which?
Blue-green runs the full new version alongside the full old version and cuts traffic over atomically, giving instant rollback (just flip the switch back) at the cost of running double capacity briefly and needing the datastore/schema to be compatible with both versions simultaneously. Canary shifts a small, increasing percentage of real traffic to the new version while both run, which limits blast radius and lets you validate on real production load, but requires the two versions to safely coexist for longer and needs good metrics-based automated rollback since a slow-burning bug can hide in a low percentage of traffic for a while.
173. How do you design a Spring Boot service for horizontal scaling when the current implementation relies on server-side sessions?
An HTTP session pinned to one pod's memory breaks the moment a load balancer routes a user's next request to a different pod, and it also means a pod restart silently logs users out. The fix is to make the application stateless at the pod level: externalize session state to Redis via Spring Session, put anything genuinely per-request in the JWT/token itself, and keep sticky-session load balancing only as a performance optimization, never as a correctness requirement.
spring:
session:
store-type: redis
data:
redis:
host: redis.internal
port: 6379
174. How do you ship a breaking database schema change during a zero-downtime rolling deployment?
During a rolling deploy, old and new pod versions run against the same database simultaneously, so any single migration that both adds and removes something in one step will break one of the two versions. The expand-contract pattern splits it into safe steps released independently: expand (add the new column/table, keep the old one, have new code write to both), migrate (backfill data, deploy new code that reads from the new shape), then contract (a later, separate deploy removes the old column once no running version references it).
-- Expand phase: additive, safe for old code to ignore
ALTER TABLE orders ADD COLUMN status_v2 VARCHAR(32);
-- Contract phase (separate deploy, after rollout completes):
ALTER TABLE orders DROP COLUMN status;
Spring AI & GenAI Integration
This topic checks whether you can wire an LLM into a Spring Boot service like any other dependency — provider abstraction, retrieval pipelines, tool calling, cost/latency control, and treating AI calls as something you observe and secure, not magic.
175. What does Spring AI's ChatClient abstraction buy you over calling a provider's SDK directly?
ChatClient gives you one fluent, Spring-idiomatic API — prompt building, response mapping, advisors for memory/RAG/logging — that sits on top of a swappable ChatModel implementation. Because the provider (OpenAI, Anthropic, Ollama, Bedrock) is just an auto-configured bean, you can switch providers or run a cheaper local model for tests by changing configuration, not application code, and cross-cutting concerns like retry, observability, and moderation are added once as advisors instead of duplicated per call site.
@Service
class SupportAssistant {
private final ChatClient chatClient;
SupportAssistant(ChatClient.Builder builder) {
this.chatClient = builder
.defaultSystem("You are a concise support assistant.")
.build();
}
String answer(String question) {
return chatClient.prompt()
.user(question)
.call()
.content();
}
}
176. How do you build a RAG pipeline inside a Spring Boot service using a VectorStore?
Retrieval-augmented generation grounds the model in your own data instead of relying purely on its training data. At ingestion time you split documents into chunks sized to preserve semantic coherence, embed each chunk with an EmbeddingModel, and store the vectors plus metadata in a VectorStore (pgvector, Redis, Qdrant). At query time you embed the user's question, run a similarity search to pull the top-k relevant chunks, and inject them into the prompt as context before calling the chat model.
@Service
class KnowledgeBaseIngestor {
private final VectorStore vectorStore;
void ingest(Resource pdf) {
var documents = new TikaDocumentReader(pdf).get();
var chunks = new TokenTextSplitter().apply(documents);
vectorStore.add(chunks);
}
}
// Query time
List<Document> hits = vectorStore.similaritySearch(
SearchRequest.query(question).withTopK(4));
177. How does function/tool calling let a chat model invoke Spring-managed beans?
You register a method (or Function bean) with a name, description, and typed parameters; the model doesn't execute it directly — it returns a structured request naming the tool and arguments, and Spring AI's runtime invokes the actual Spring bean and feeds the result back into the conversation so the model can incorporate it into its final answer. This is what lets an LLM "check an order status" or "look up inventory" by calling real, authenticated application code instead of hallucinating an answer.
@Component
class OrderTools {
@Tool(description = "Look up the current status of an order by id")
String getOrderStatus(String orderId) {
return orderRepository.findStatus(orderId);
}
}
String reply = chatClient.prompt()
.user("Where is order 4821?")
.tools(orderTools)
.call()
.content();
178. How should prompt templates be managed and versioned in a Spring Boot codebase?
Treat prompts as versioned artifacts, not inline string literals scattered through service classes — store them as resource files (or in a dedicated prompt-management table/service) so they can be reviewed, diffed, and rolled back independently of a full deploy. Spring AI's PromptTemplate supports externalized templates with placeholder substitution, which keeps business logic separate from wording changes that product or prompt-engineering teams need to iterate on quickly.
PromptTemplate template = new PromptTemplate(
new ClassPathResource("prompts/support-triage-v3.st"));
Prompt prompt = template.create(Map.of("ticket", ticketBody));
179. How do you stream a chat completion to a client over SSE from a Spring controller?
ChatClient's .stream() mode returns a reactive Flux<String> of incremental tokens rather than blocking for the full response, which you expose directly as a Server-Sent Events endpoint so the browser renders tokens as they arrive instead of waiting on the full generation. This works naturally with WebFlux; on a servlet stack you can still return a Flux from an MVC controller thanks to Spring's reactive-return-type support.
@GetMapping(path = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
Flux<String> streamChat(@RequestParam String question) {
return chatClient.prompt()
.user(question)
.stream()
.content();
}
180. What levers do you have to control the cost and latency of an LLM-backed feature in production?
Cache embeddings for content that doesn't change often so you're not re-embedding the same document on every request; cache full responses for identical or near-identical prompts where determinism is acceptable. Route by task difficulty to a tiered fallback — a small, cheap, fast model for simple classification-style calls and a larger model reserved for genuinely complex reasoning — and cap max-tokens and enforce request timeouts so a runaway generation doesn't hold a thread or rack up cost.
181. How do you secure an LLM-backed endpoint against prompt injection when the model can call tools?
Prompt injection is when untrusted input (a user message, a scraped document fed into RAG context) manipulates the model into ignoring its instructions or invoking tools it shouldn't. Because tool calls execute real Spring beans, treat every tool the same way you'd treat a REST endpoint: apply the caller's actual authorization to the tool implementation itself rather than trusting the model's judgment, constrain tool parameters to validated types instead of free-form strings, and never let retrieved RAG content be treated as instructions — keep it clearly delimited as data in the prompt.
182. How do you get proper observability on AI calls — token usage, latency, and tracing?
An LLM call is an outbound dependency like any other and should be traced the same way: Spring AI's ChatModel calls emit Micrometer observations automatically when Spring AI's observability auto-configuration is on the classpath, producing spans with model name, token counts (prompt/completion/total), and duration that flow into the same OpenTelemetry pipeline as your HTTP and database spans. This lets you correlate a slow user-facing request directly to a slow or token-heavy model call in the same trace, and lets you alert on cost via token-count metrics, not just latency.
management:
tracing:
sampling:
probability: 1.0
observations:
key-values:
application: support-assistant
spring:
ai:
chat:
observations:
include-completion: true
183. What is the Model Context Protocol (MCP), and how does Spring AI integrate with it?
MCP is an open, model-agnostic protocol that standardizes how an LLM application discovers and calls external tools/resources exposed by a separate MCP server, instead of every client hand-rolling its own tool-calling wire format per provider. Spring AI ships both an MCP client (so your Spring Boot app can consume tools exposed by any MCP server — a filesystem, a database, a third-party SaaS) and an MCP server starter (so you can expose your own Spring beans as MCP tools that any MCP-compatible client, including non-Spring ones, can call).
@Bean
ToolCallbackProvider mcpTools(McpSyncClient mcpClient) {
return new SyncMcpToolCallbackProvider(mcpClient);
}
184. Scenario: an AI-powered feature's cost tripled overnight. How do you find out why and put guardrails in place?
Start where you'd start with any cost spike: pull per-endpoint token-usage metrics (which you should already be emitting via Micrometer) broken down by model and route, and check for a recent deploy, a prompt change that grew context size, a new retry loop double-firing calls, or a change in traffic mix toward a heavier model tier. Common root causes are an unbounded RAG retrieval count pulling in far more context than needed, a missing per-user rate limit letting one client hammer the endpoint, or a bug causing retries without backoff on failures.
Once you've found the cause, add guardrails rather than just patching the one incident: hard caps on max-tokens and top-k retrieval, per-tenant rate limits and budgets, alerting on token-spend rate-of-change (not just absolute cost), and a kill switch to fall back to a cheaper model or degrade gracefully if spend crosses a threshold.
System Design & Real-World Scenarios
This is where interviewers test whether you can turn a vague requirement into a concrete design under real failure modes — retries, partial failures, scale, and multi-tenancy — and explain the trade-offs behind your choice.
185. How do you design an order-creation API that safely survives client retries?
Network timeouts mean a client can't tell whether its request succeeded, so it retries — and without protection that creates duplicate orders. The client generates a unique idempotency key per logical operation and sends it in a header; the server, inside the same transaction as the business write, stores the key with the resulting response. A retry with the same key returns the stored response directly without re-executing the business logic, while a first-time key proceeds normally.
@PostMapping("/orders")
ResponseEntity<OrderResponse> createOrder(
@RequestHeader("Idempotency-Key") String key,
@RequestBody OrderRequest request) {
return idempotencyService.executeOnce(key, () -> orderService.place(request));
}
186. How do you reliably publish an "order created" event without losing it or duplicating it?
Writing to the database and publishing to a message broker as two separate operations is not atomic — a crash between them either loses the event or, with a naive retry, duplicates it. The transactional outbox pattern writes the event into an outbox table in the same database transaction as the order write, so both succeed or fail together, and a separate relay process polls (or uses CDC via Debezium) the outbox and publishes to the broker, marking rows sent only after a broker ack. Consumers must still be idempotent, since at-least-once delivery is the best this pattern guarantees.
187. How would you design a rate limiter for a public API shared by thousands of tenants?
Per-tenant limits need to be enforced consistently across every instance of a horizontally scaled service, which rules out in-memory counters unless you accept per-pod-only limits. A token-bucket or sliding-window counter stored in Redis (using INCR with a TTL, or a Lua script for atomicity) gives a shared, low-latency limit check across all pods, and you apply it at the gateway/filter layer before the request reaches business logic so rejected requests cost almost nothing.
@Bean
RedisRateLimiter tenantRateLimiter(RedisConnectionFactory cf) {
return new RedisRateLimiter(cf, 100, 200); // replenish rate, burst capacity
}
429 with a Retry-After header, not a bare error — well-behaved clients back off correctly instead of hammering you harder.188. How do you design a large CSV/report export that won't OOM the service?
Loading the entire result set into a List to build a report is the direct path to an OOM once the dataset grows past what fits comfortably in heap. Stream the database query (JDBC fetch size + a streaming ResultSet, or Spring Data's Stream<T> return type) and write rows to the output as they're read rather than materializing them all first. For genuinely large exports, don't do it inline in the HTTP request at all — accept the request, kick off an async job that streams the result to object storage, and give the client a job id to poll, returning a time-limited signed URL when it's done.
@Transactional(readOnly = true)
public void exportOrders(OutputStream out) {
try (Stream<Order> orders = orderRepository.streamAll()) {
var writer = new CsvWriter(out);
orders.forEach(writer::writeRow);
}
}
189. Schema-per-tenant vs shared-schema with a tenant-id column: how do you choose, and where do you enforce isolation?
Schema (or database)-per-tenant gives the strongest isolation and the easiest per-tenant backup/restore/deletion story, but it doesn't scale operationally past a few hundred tenants — migrations and connection pooling multiply with tenant count. A shared schema with a tenant_id column scales to many thousands of tenants on one set of tables, but isolation now depends entirely on every query correctly filtering by tenant, which you should never leave to individual repository methods to remember — enforce it centrally with row-level security at the database layer, or a Hibernate multi-tenancy filter applied at the session level so a missing WHERE clause is structurally impossible rather than a code-review hope.
190. Walk through zero-downtime schema migration end to end, not just the SQL.
The migration itself is only step two of a three-part rollout. First, ship an application version that can read the old schema shape (this is your safety net). Second, run the additive migration (new column/table, backfill in batches to avoid long locks) while both old and new app versions coexist during the rolling deploy — the new version writes to both shapes if needed. Third, once the rollout is confirmed healthy and no old-version pods remain, ship a follow-up deploy that stops referencing the old shape, and only then run the migration that removes it.
ALTER TABLE or backfill against a hot table can hold locks long enough to cause request timeouts across the fleet — batch backfills and prefer online-migration tooling that avoids long-held locks.191. How do you protect a service from a slow, flaky payment provider without corrupting order state?
Layer three independent protections: a request timeout so one slow call can't hold a thread indefinitely, a bulkhead (a dedicated bounded thread/connection pool for the payment client) so payment slowness can't starve resources needed by unrelated endpoints, and a circuit breaker that stops sending new requests once failures cross a threshold, giving the provider room to recover and failing fast for callers instead of queueing behind a dependency that's already down. Crucially, when a call times out or the breaker is open, the order is neither "paid" nor "failed" — it must go to an explicit PENDING state that a reconciliation job later resolves via the provider's status API, never optimistically assumed either way.
@CircuitBreaker(name = "paymentProvider", fallbackMethod = "markPending")
@TimeLimiter(name = "paymentProvider")
@Bulkhead(name = "paymentProvider")
CompletableFuture<PaymentResult> charge(PaymentRequest request) {
return CompletableFuture.supplyAsync(() -> paymentClient.charge(request));
}
192. How do you design file-upload handling so an attacker can't turn it into remote code execution?
Validate content type by sniffing actual file bytes (magic numbers), not the client-supplied Content-Type header or filename extension, and cap file size before it's fully buffered. Route the upload through a virus/malware scan before it's usable by any other part of the system, store it in object storage isolated from the application's own execution environment (never inside a web-servable directory the app process can execute from), and serve it back to users only via signed, time-limited URLs from that separate storage origin so the app server is never in the direct download path.
193. How do you keep a search index eventually consistent with the source-of-truth database?
Writing to the database and the search index (Elasticsearch/OpenSearch) synchronously in the same request couples your write latency and availability to a system that doesn't need transactional guarantees. Instead, treat the database as the single source of truth and propagate changes asynchronously — via the same outbox/CDC mechanism used for other downstream events — so a consumer updates the index after the fact. Accept and design for eventual consistency (a brief window where a just-created record isn't yet searchable) and make the indexing consumer idempotent so replays or out-of-order delivery don't corrupt the index; a periodic reconciliation job that diffs and repairs drift is the safety net.
194. Walk through a methodical approach to diagnosing a production latency spike.
Start from the golden signals (latency, traffic, errors, saturation) on dashboards to confirm scope — one endpoint, one dependency, or fleet-wide — before touching anything. Pull a distributed trace for a slow request in the affected window and follow the longest span: is time spent in application code, waiting on a connection pool, or inside a downstream call? If it's a pool, check pool saturation metrics and whether a slow downstream is holding connections open; if it's a downstream call, check that dependency's own health. Only after identifying the actual bottleneck do you mitigate — scale out, shed load, or fail fast on the slow dependency — and only afterward do you dig into root cause with time pressure off.
195. How should you structure your answer when an interviewer asks you to explain a technology trade-off decision?
State the actual requirement first (throughput target, consistency need, team size, operational maturity) rather than jumping straight to a tool name — trade-off questions are really requirements questions in disguise. Then name at least two real alternatives you considered and why each partially fits, followed by the failure modes each option introduces (what breaks, under what load, and how you'd notice). Close with the option you chose and the specific trade-off you accepted, stated honestly rather than as if it had no downside — that honesty is usually what the interviewer is actually listening for.
196. How do you design an audit-logging system that's tamper-resistant and doesn't leak secrets or PII?
Audit records need to answer "who did what, when, from where" for security and compliance review, and must be trustworthy even against an attacker who has compromised the application — which means the audit store should be append-only, written by a distinct credential/path from normal application writes, and ideally hash-chained or shipped immediately to a separate system the application itself can't rewrite. Just as important is what never reaches the log: redact request/response bodies, tokenize or hash identifiers instead of logging raw PII, and explicitly deny-list fields like passwords, card numbers, and auth tokens at the serialization layer rather than trusting every call site to remember.
@Component
class AuditingSerializer extends JsonSerializer<AuditEvent> {
private static final Set<String> REDACTED = Set.of("password", "ssn", "cardNumber");
// custom serialization that masks REDACTED fields before persisting
}
Rapid Fire — Quick Concept Checks
These are meant to be answered in under a minute each — interviewers use them to check breadth and whether you keep up with current Spring Boot and Java idioms, not depth.
197. What is ProblemDetail, and why did it replace hand-rolled error response bodies?
ProblemDetail is Spring's implementation of RFC 9457 (formerly 7807), a standardized JSON shape for API error responses with type, title, status, detail, and instance fields. Using it instead of a custom error DTO means every error across your API — and across services that also adopt it — has a consistent, tool-recognizable shape rather than each team inventing its own.
198. What does @ConditionalOnMissingBean do, and where does it show up most?
It registers a bean only if no other bean of that type already exists in the context, which is how Spring Boot's auto-configuration provides sensible defaults (a default ObjectMapper, a default RestTemplateBuilder) that get silently overridden the moment you declare your own bean of the same type. It's the core mechanism behind auto-configuration being "opinionated but overridable."
199. What's a common reason to customize the auto-configured ObjectMapper rather than accept the default?
Common cases: registering the JavaTimeModule behavior explicitly for consistent date formatting, setting FAIL_ON_UNKNOWN_PROPERTIES to false for lenient deserialization of evolving external APIs, or configuring a naming strategy. Do it via a Jackson2ObjectMapperBuilderCustomizer bean rather than constructing a new ObjectMapper from scratch, so you keep Boot's other sensible defaults.
200. @JsonView vs separate DTOs for shaping API responses — what's the trade-off?
@JsonView lets one entity/class serialize differently per endpoint by tagging fields with view interfaces, avoiding duplicate classes, but it couples your persistence/domain model to your API shape and makes it easy to accidentally expose a field by forgetting a view annotation. Dedicated DTOs are more boilerplate but keep the API contract explicit and decoupled from internal model changes — the safer default for anything public-facing.
201. What problem does Awaitility solve in tests?
Awaitility replaces flaky Thread.sleep() calls in tests that assert on asynchronous behavior (message consumption, eventual cache expiry, background job completion) with a polling await().atMost(...).until(...) construct that succeeds as soon as the condition is true and fails fast with a clear timeout instead of a fixed, wasteful wait.
await().atMost(Duration.ofSeconds(5))
.until(() -> orderRepository.findById(id).isPresent());
202. What does ArchUnit check, and why add it to a Spring Boot build?
ArchUnit is a testing library that asserts architectural rules as code — e.g., "controllers must not depend on repositories directly" or "package `internal` must not be accessed from outside its module" — and fails the build like any other test when violated. It turns architecture guidelines that would otherwise only live in a wiki page into something CI actually enforces.
203. What is Spring Modulith, and what problem does it address?
Spring Modulith helps structure a single Spring Boot application into explicit, verified modules (by package) with declared boundaries and allowed dependencies between them, plus module-scoped event publication. It's aimed at teams that want the maintainability benefits of clear module boundaries without paying the operational cost of splitting into microservices prematurely.
204. What is an SBOM, and why does it matter for a Spring Boot application in 2026?
A software bill of materials is a machine-readable manifest of every dependency (and transitive dependency) in your build, typically generated in CycloneDX or SPDX format. It matters because supply-chain vulnerabilities (like Log4Shell) are found in transitive dependencies teams didn't even know they had — an SBOM lets you answer "are we affected?" in minutes via automated scanning instead of manually auditing a dependency tree.
205. What is structured concurrency (StructuredTaskScope), and how does it differ from just spawning virtual threads?
Structured concurrency treats a set of related concurrent subtasks as a single unit with one lifetime: if the parent scope exits, is cancelled, or one subtask fails under a fail-fast policy, all sibling subtasks are cancelled together and errors propagate coherently to the caller. Spawning virtual threads ad hoc gives you cheap concurrency but no such lifecycle guarantee — a forgotten unjoined thread or a swallowed exception in one branch is easy to leak.
try (var scope = StructuredTaskScope.open(Joiner.<String>allSuccessfulOrThrow())) {
var user = scope.fork(() -> userClient.fetch(id));
var orders = scope.fork(() -> orderClient.fetch(id));
scope.join();
return combine(user.get(), orders.get());
}
206. What is Class Data Sharing (CDS), and how does it improve Spring Boot startup time?
CDS pre-parses and archives class metadata (and, with AppCDS/dynamic CDS, application classes too) into a shared archive file that the JVM memory-maps at startup instead of re-parsing every class from scratch. For a Spring Boot app with hundreds of framework classes loaded on every boot, this can meaningfully cut cold-start time — Spring Boot 3.3+ has built-in support for generating and using a CDS archive as an alternative, lower-effort middle ground short of a full native image.
207. What are sequenced collections (Java 21), and what gap did they fill?
The SequencedCollection, SequencedSet, and SequencedMap interfaces retrofit a uniform way to access the first/last element and get a reversed view across List, LinkedHashSet, and LinkedHashMap — getFirst(), getLast(), reversed() — where previously each collection type had its own inconsistent (or missing) way to do the same thing.
208. What is virtual thread pinning, and why does it still come up in 2026 Spring Boot codebases?
Pinning happens when a virtual thread can't unmount from its carrier thread during a blocking operation — historically inside a synchronized block or native frame — which defeats the scalability benefit of virtual threads under load. Later JDKs removed most of these cases (synchronized no longer pins as of JDK 24), but teams running JDK 21 still need to watch for legacy synchronized-heavy code and thread-local-heavy libraries when adopting virtual threads for blocking I/O-bound Spring MVC controllers.
Add your comments for more improvement!
ReplyDelete