Spring Boot Interview Questions and Answers
Last updated:
Check out 60 of the most common Spring Boot interview questions, then take an AI-powered practice interview
Q1What does Spring Boot actually add on top of the Spring Framework?
BasicFundamentals
Answer
Spring Framework gives you the core machinery: the IoC container, dependency injection, AOP, transaction management, and Spring MVC. What it historically did not give you was a working application without significant assembly: you wired a servlet container, a DispatcherServlet, view resolvers, a DataSource, and a transaction manager yourself, usually across XML or sprawling @Configuration classes. Spring Boot adds four things on top.
First, auto-configuration: on startup it inspects the classpath and your properties, then registers sensible beans automatically, so having H2 on the classpath yields an embedded DataSource and having spring-webmvc yields a configured DispatcherServlet. Second, starter dependencies: curated POMs like spring-boot-starter-web and spring-boot-starter-data-jpa that pull in a compatible, mutually tested set of libraries so you never hand-resolve Jackson or Hibernate versions. Third, an embedded server: Tomcat (or Jetty or Undertow) runs inside your jar, so deployment is java -jar app.jar rather than dropping a WAR into an external container.
Fourth, production-readiness out of the box: the Actuator module exposes health, metrics, env and thread-dump endpoints that operations teams rely on. Interviewers ask this to check whether you understand that Boot is opinionated glue, not a different framework: every Boot application is a Spring application, and any auto-configured bean can be overridden by declaring your own. Saying 'Boot removes boilerplate' is the shallow answer; naming auto-configuration, starters, the embedded server, and Actuator, and explaining that your own @Bean always wins over the auto-configured one, is the answer that lands.
Key Points
- Auto-configuration: classpath plus properties drive automatic bean registration
- Starters: version-aligned dependency bundles, no manual version juggling
- Embedded Tomcat/Jetty/Undertow: deploy with java -jar
- Actuator for health, metrics, and operational endpoints
- Your explicit @Bean definitions always override auto-configuration
Q2What exactly does @SpringBootApplication do when the application starts?
BasicFundamentals
Answer
@SpringBootApplication is a composed annotation combining three others. @SpringBootConfiguration (a specialisation of @Configuration) marks the class as a source of bean definitions. @ComponentScan scans the package of the annotated class and everything below it for stereotypes like @Component, @Service and @RestController, which is why putting your main class in a root package matters: a class outside that package tree is silently never scanned, and 'my bean is not found' bugs are very often just a class sitting in the wrong package. @EnableAutoConfiguration triggers Boot's auto-configuration: it reads the list of candidate configurations from META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports inside each jar, evaluates their @Conditional annotations, and registers the beans whose conditions pass. At runtime, SpringApplication.run() creates the ApplicationContext, applies any ApplicationContextInitializers, publishes lifecycle events (ApplicationStartingEvent through ApplicationReadyEvent), binds the Environment from properties files, env vars and command-line args, refreshes the context (this is where all beans are instantiated), starts the embedded web server, and finally invokes CommandLineRunner and ApplicationRunner beans. A follow-up interviewers like: exclusions. You can switch off a specific auto-configuration with @SpringBootApplication(exclude = DataSourceAutoConfiguration.class) or the spring.autoconfigure.exclude property, which is the standard move when you want a datasource-free service that still has spring-data on the classpath.
// Equivalent to the three annotations below
@SpringBootApplication
public class OrdersApplication {
public static void main(String[] args) {
SpringApplication.run(OrdersApplication.class, args);
}
}
// What it expands to conceptually:
// @SpringBootConfiguration -> this class defines beans
// @ComponentScan -> scan this package and below
// @EnableAutoConfiguration -> apply conditional auto-config
// Excluding an auto-configuration you do not want:
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
public class NotifierApplication { }
Q3What is a starter dependency, and what does spring-boot-starter-web actually pull in?
BasicDependencies
Answer
A starter is a dependency descriptor: a small POM with no code of its own whose only job is to pull in a curated, version-aligned set of libraries for one capability. spring-boot-starter-web brings in spring-web and spring-webmvc (the MVC framework), spring-boot-starter-json (Jackson databind plus the JSR-310 java.time module), and spring-boot-starter-tomcat (the embedded servlet container). Adding that one line to your build gives you a full REST stack with JSON serialisation configured. Similarly, spring-boot-starter-data-jpa pulls Hibernate, Spring Data JPA, and the JDBC infrastructure; spring-boot-starter-security pulls Spring Security's core and web modules; spring-boot-starter-test bundles JUnit 5, Mockito, AssertJ, JSONassert and Spring's test utilities.
The versions are governed by the spring-boot-dependencies BOM, so you almost never write a version number for a managed library: you inherit the version Boot's team tested together for that release. This matters in practice because mixing an old Jackson with a new Spring MVC is a classic source of subtle serialization bugs, and the BOM makes that class of problem disappear. Two follow-ups worth preparing: how to swap a transitive piece (exclude spring-boot-starter-tomcat and add spring-boot-starter-jetty to change servers), and how to override a managed version when a CVE forces it (set the version property, for example jackson-bom.version, in Maven, or use the dependencyManagement block). Interviewers use this question to see whether you treat the build file as an engineered artifact or a pile of copied lines.
<!-- pom.xml: one starter, whole REST stack -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Swap Tomcat for Jetty -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jetty</artifactId>
</dependency>
Q4How does auto-configuration decide which beans to create, and how do you debug it?
BasicAuto-configuration
Answer
Each auto-configuration class is guarded by @Conditional annotations that Boot evaluates at startup. The important ones: @ConditionalOnClass (apply only if a class is on the classpath, so JPA auto-config only runs when Hibernate is present), @ConditionalOnMissingBean (back off if the developer already declared their own bean of that type, which is the mechanism that lets your explicit configuration win), @ConditionalOnProperty (gate on a property value, like spring.flyway.enabled), @ConditionalOnBean, and @ConditionalOnWebApplication. The candidate list itself comes from META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports files in each jar (the older spring.factories mechanism for auto-configuration was removed in Boot 3).
Ordering between auto-configurations is controlled with @AutoConfigureBefore and @AutoConfigureAfter. Debugging is the part interviewers actually probe, because every working engineer has had to answer 'why did this bean (not) get created'. Three tools: start the app with the --debug flag (or debug=true in properties) to print the CONDITIONS EVALUATION REPORT, which lists every auto-configuration under positive and negative matches with the exact condition that passed or failed; hit the Actuator /actuator/conditions endpoint for the same data on a live app; and use @SpringBootApplication(exclude = ...) or spring.autoconfigure.exclude to switch off a configuration you have confirmed is misfiring. A strong answer also mentions the practical implication: because of @ConditionalOnMissingBean, the order 'your beans first, auto-config backs off' means customising Boot is usually just declaring the bean yourself, for example providing your own ObjectMapper or SecurityFilterChain.
Key Points
- @ConditionalOnClass, @ConditionalOnMissingBean, @ConditionalOnProperty drive decisions
- Candidates listed in AutoConfiguration.imports (spring.factories removed for auto-config in Boot 3)
- --debug prints the conditions evaluation report; /actuator/conditions on live apps
- Your own @Bean makes the auto-configured one back off
Q5How does the embedded server model work, and how do you switch or tune it?
BasicWeb
Answer
Spring Boot inverts the traditional Java deployment model. Instead of building a WAR and deploying it into an external Tomcat, the server is a library inside your application: spring-boot-starter-web embeds Tomcat by default, and the app starts it programmatically on the configured port. This is what makes java -jar deployment, Docker images, and Kubernetes rolling updates straightforward: one process, one artifact, no shared container state between apps.
You switch servers by excluding spring-boot-starter-tomcat and adding spring-boot-starter-jetty or spring-boot-starter-undertow; for reactive apps, spring-boot-starter-webflux runs Netty instead. Tuning happens through properties: server.port, server.tomcat.threads.max (default 200 worker threads), server.tomcat.accept-count (the queue when all threads are busy), server.tomcat.max-connections, and server.max-http-request-header-size. Compression is server.compression.enabled=true.
For TLS you configure server.ssl.* with a keystore, though in most Indian production setups TLS terminates at the load balancer or ingress and the app listens on plain HTTP inside the cluster. Two interview follow-ups appear regularly. First, WAR deployment is still possible (extend SpringBootServletInitializer, set packaging to war) but is legacy; know it exists, say you would not choose it for new services. Second, the thread model: with the servlet stack each in-flight request holds a worker thread, which is why long blocking calls exhaust the pool at 200 concurrent requests, and why virtual threads (spring.threads.virtual.enabled=true on Java 21) or WebFlux are the answers when concurrency requirements exceed what platform threads handle comfortably.
# application.yml: common embedded Tomcat tuning
server:
port: 8080
shutdown: graceful
compression:
enabled: true
mime-types: application/json,text/html
tomcat:
threads:
max: 200 # worker threads, one per in-flight request
accept-count: 100 # queued connections when all threads busy
max-connections: 8192
Q6How do @Value and @ConfigurationProperties differ, and when do you use each?
BasicConfiguration
Answer
@Value("${payment.timeout-ms}") injects a single property into a single field, with SpEL support and an optional default (@Value("${payment.timeout-ms:5000}")). It is fine for one-off values but scales badly: the property name is a string scattered across classes, there is no validation, and refactoring is find-and-replace. @ConfigurationProperties(prefix = "payment") binds a whole tree of properties onto a typed object, with relaxed binding (payment.timeout-ms, PAYMENT_TIMEOUTMS, and payment.timeoutMs all bind to the same field), nested objects, lists and maps, and JSR-303 validation when you add @Validated: an @NotNull or @Min violation fails the application at startup with a clear report instead of surfacing as a weird runtime bug hours later. Since Boot 2.6 you can bind to immutable records or constructor-bound classes, which is the 2026 idiom: a Java record with @ConfigurationProperties gives you an immutable, testable config object.
Register it with @EnableConfigurationProperties(PaymentProps.class) or @ConfigurationPropertiesScan. The interview signal here is defaulting to @ConfigurationProperties for any group of related settings and reserving @Value for genuinely single values; mentioning startup-time validation and the spring-boot-configuration-processor (which generates metadata so IDEs autocomplete your custom properties) marks you as someone who has built shared config for a team rather than just read the docs.
// Immutable, validated, constructor-bound configuration
@Validated
@ConfigurationProperties(prefix = "payment")
public record PaymentProps(
@NotBlank String gatewayUrl,
@Min(100) int timeoutMs,
@NotNull Duration retryBackoff
) {}
@SpringBootApplication
@ConfigurationPropertiesScan
public class App { }
# application.yml
payment:
gateway-url: https://api.gateway.example
timeout-ms: 3000
retry-backoff: 500ms
Q7How do Spring profiles work, and how are profile-specific properties resolved?
BasicConfiguration
Answer
Profiles let one artifact behave differently per environment without rebuilding. You activate them with spring.profiles.active (as a property, env var SPRING_PROFILES_ACTIVE, or command-line flag), and Boot then loads application-{profile}.properties or .yml on top of the base application.yml, with the profile-specific file winning for any overlapping key. Beans can be gated with @Profile("prod") on a @Component or @Bean method, and negated with @Profile("!local").
Since Boot 2.4 the model is stricter and more composable: multi-document YAML files use spring.config.activate.on-profile to mark a document as profile-specific, and profile groups (spring.profiles.group.prod=prod-db,prod-kafka) let one flag activate a set. Two gotchas that interviewers fish for. First, spring.profiles.active cannot be set from inside a profile-specific document (Boot 2.4+ throws InvalidConfigDataPropertyException), because that would make resolution circular; use profile groups instead.
Second, the default profile: when nothing is active, Boot runs the 'default' profile, and application-default.yml applies, which surprises teams who assumed base-file-only behaviour. In production practice you keep secrets out of all of these files and inject them via environment variables or a secret manager, using profiles only for structural differences: pool sizes, feature toggles, log levels, local H2 versus real Postgres. A clean answer also mentions @ActiveProfiles("test") for pinning profiles in integration tests.
# application.yml (multi-document form)
spring:
application:
name: orders
---
spring:
config:
activate:
on-profile: prod
datasource:
url: jdbc:postgresql://prod-db:5432/orders
logging:
level:
root: WARN
# Activate: SPRING_PROFILES_ACTIVE=prod java -jar orders.jar
// Bean only in non-prod environments
@Profile("!prod")
@Bean
CommandLineRunner seedData(OrderRepository repo) {
return args -> repo.save(new Order("demo"));
}
Q8Why is constructor injection preferred over field injection in Spring Boot?
BasicDependency Injection
Answer
Field injection (@Autowired directly on a field) looks shorter but loses on every dimension that matters in production code. Constructor injection makes dependencies explicit and mandatory: the object literally cannot be constructed without them, so a missing collaborator fails at startup rather than as a NullPointerException mid-request. It allows fields to be final, giving you immutability and safe publication across threads, which matters because singleton beans are shared by every concurrent request.
It makes unit testing trivial: new OrderService(mockRepo, mockClock) with no Spring context, no reflection utilities. It also surfaces design smells: a constructor with nine parameters is a visible signal the class does too much, whereas nine @Autowired fields hide it. Since Spring 4.3, a class with a single constructor needs no @Autowired annotation at all, so the code stays clean, and Lombok's @RequiredArgsConstructor or Java records reduce it further.
Field injection additionally couples you to the container (you cannot construct the class without reflection) and breaks in native-image scenarios where reflection metadata must be declared. The one legitimate remaining use of setter injection is optional or reconfigurable dependencies, which are rare. Interviewers often extend this into circular dependencies: with constructor injection a cycle fails fast at startup with a BeanCurrentlyInCreationException, which is a feature, because the correct fix is breaking the cycle (extract a third collaborator or use an event), not papering over it with @Lazy. Boot 2.6+ even disallows circular references by default; saying you would restructure rather than set spring.main.allow-circular-references=true is exactly the judgement the question is testing.
@Service
public class OrderService {
private final OrderRepository orders;
private final PaymentClient payments;
// Single constructor: no @Autowired needed since Spring 4.3
public OrderService(OrderRepository orders, PaymentClient payments) {
this.orders = orders;
this.payments = payments;
}
public Order place(OrderRequest req) {
var order = orders.save(Order.from(req));
payments.charge(order);
return order;
}
}
// Unit test needs no Spring context at all:
var service = new OrderService(mock(OrderRepository.class), mock(PaymentClient.class));
Q9What bean scopes exist, and what goes wrong injecting a prototype into a singleton?
BasicDependency Injection
Answer
The default scope is singleton: one instance per ApplicationContext, created at startup (unless lazy) and shared by every consumer. prototype creates a new instance on every injection or getBean() call, and the container does not manage its full lifecycle (destruction callbacks are not invoked). Web-aware scopes add request (one instance per HTTP request), session, and application. The classic trap: injecting a prototype-scoped bean into a singleton.
Injection happens once, when the singleton is created, so the singleton holds a single prototype instance forever and the prototype semantics silently vanish. The fixes are ObjectProvider<MyPrototype> (call getObject() when you need a fresh one), @Lookup methods, or a scoped proxy (@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)), which injects a proxy that resolves a new target per call. The same proxy mechanism is how a request-scoped bean can be injected into a singleton controller.
The deeper point interviewers want: because singletons are shared across concurrent requests, they must be stateless or thread-safe. Mutable instance fields on a @Service are a production incident waiting to happen, one request's data leaking into another's, and this exact bug shows up in real payment-flow postmortems. If you need per-request state, use method parameters, request-scoped beans, or ThreadLocal/ScopedValue carefully, and remember thread-locals interact badly with async execution and virtual-thread pooling unless propagated deliberately.
Key Points
- singleton (default), prototype, request, session, application scopes
- Prototype injected into singleton is resolved once: semantics silently lost
- Fixes: ObjectProvider, @Lookup, or ScopedProxyMode.TARGET_CLASS
- Singletons are shared across requests: keep them stateless
Q10What is the practical difference between @Component, @Service, @Repository and @Controller?
BasicStereotypes
Answer
All four register the class as a bean during component scanning; the differences are semantic and, in two cases, behavioural. @Component is the generic stereotype. @Service marks the business-logic layer and today adds no extra behaviour: its value is architectural communication and giving AOP pointcuts a clean layer to target. @Repository does add behaviour: beans annotated with it get persistence exception translation, meaning raw JDBC or JPA exceptions (SQLException subclasses, PersistenceException) are converted into Spring's consistent DataAccessException hierarchy, so calling code can catch DuplicateKeyException or OptimisticLockingFailureException regardless of the underlying provider. Note that with Spring Data JPA you usually write repository interfaces extending JpaRepository, and the generated implementation already applies translation; you rarely annotate anything with @Repository yourself. @Controller marks a web-layer bean whose methods return view names by default; @RestController combines @Controller with @ResponseBody so every handler's return value is serialised straight into the response body, which is what you want for JSON APIs. A good answer also notes that stereotype annotations are meta-annotated with @Component, which is why custom composed annotations work: you can define your own @DomainService annotation meta-annotated with @Service and scanning still picks it up. Interviewers use this question as a warm-up but listen for the exception-translation detail on @Repository and the @ResponseBody detail on @RestController; candidates who only say 'they are the same, just naming' are marked as surface-level.
Key Points
- All are @Component specialisations picked up by scanning
- @Repository adds DataAccessException translation
- @RestController = @Controller + @ResponseBody (JSON by default)
- @Service is semantic: a stable layer for AOP and architecture rules
Q11How do you map REST endpoints and bind path variables, query params and bodies in Spring MVC?
BasicREST
Answer
Handlers live in @RestController classes. Class-level @RequestMapping("/api/orders") sets the base path, and method-level shortcuts (@GetMapping, @PostMapping, @PutMapping, @PatchMapping, @DeleteMapping) map verbs. @PathVariable binds URI template segments (with type conversion, so a Long parameter for /{id} rejects non-numeric input with a 400), @RequestParam binds query-string values with required/defaultValue options, @RequestBody deserialises the JSON body through Jackson into your DTO, and @RequestHeader reads headers. Return values are serialised to JSON automatically; use ResponseEntity when you need explicit status codes or headers, or @ResponseStatus(HttpStatus.CREATED) when the status is fixed.
Under the hood every request flows through the DispatcherServlet, which consults HandlerMapping to find the method, runs HandlerMethodArgumentResolvers to produce each parameter, invokes the method, and passes the result through HttpMessageConverters (Jackson's MappingJackson2HttpMessageConverter for JSON). Knowing that pipeline is what separates candidates who can debug a 415 Unsupported Media Type (missing or wrong Content-Type, so no converter matched) or a silent null field (Jackson could not see a setter or the JSON key did not match) from those who can only recite annotations. Two idioms worth using in interviews: prefer immutable request DTOs as Java records, which Jackson binds without extra configuration on Boot 3, and never expose JPA entities directly as response bodies, both for API-stability and for lazy-loading serialisation accidents.
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService service;
public OrderController(OrderService service) { this.service = service; }
@GetMapping("/{id}")
public OrderResponse get(@PathVariable Long id) {
return service.find(id);
}
@GetMapping
public Page<OrderResponse> list(
@RequestParam(defaultValue = "PLACED") OrderStatus status,
Pageable pageable) {
return service.byStatus(status, pageable);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public OrderResponse create(@Valid @RequestBody CreateOrderRequest req) {
return service.place(req);
}
}
// Request DTO as a record: Jackson binds it directly on Boot 3
public record CreateOrderRequest(@NotBlank String sku, @Min(1) int quantity) {}
Q12How does bean validation work with @Valid, and what happens when it fails?
BasicValidation
Answer
Add spring-boot-starter-validation (Hibernate Validator, implementing jakarta.validation; note the package moved from javax.validation in Boot 3, which broke many upgrades). Annotate DTO fields with constraints: @NotNull, @NotBlank, @Email, @Min/@Max, @Size, @Pattern, @Positive, and @Valid on nested objects to cascade. Then mark the controller parameter with @Valid (or @Validated): Spring runs the validator during argument resolution, before your handler executes.
On failure for a @RequestBody, Spring throws MethodArgumentNotValidException, which Boot's default handler converts to a 400 with field-level details; for @RequestParam and @PathVariable constraints (which require @Validated on the controller class) you get HandlerMethodValidationException on recent versions. The production-grade pattern is catching these in a @RestControllerAdvice and returning a stable error contract, typically ProblemDetail with a violations array, so mobile and web clients can render per-field messages. Beyond the basics, interviewers probe three things.
Custom constraints: write an annotation plus a ConstraintValidator, for example an @ValidIfsc for Indian bank codes or @ValidGstin for GST numbers, both real cases in fintech codebases. Validation groups: the same DTO validated differently on create versus update by passing group classes to @Validated. Service-layer validation: annotating a @Service with @Validated makes constraints on method parameters enforceable outside the web layer, throwing ConstraintViolationException, useful when the same logic is reached from Kafka listeners and schedulers, not just HTTP. The key understanding: validation is a boundary defence, and unvalidated payloads reaching your persistence layer is how mass-assignment and bad-data incidents happen.
public record RegisterRequest(
@NotBlank @Email String email,
@NotBlank @Size(min = 8, max = 72) String password,
@Pattern(regexp = "^[6-9]\\d{9}$", message = "invalid Indian mobile") String phone
) {}
@PostMapping("/register")
public UserResponse register(@Valid @RequestBody RegisterRequest req) {
return users.register(req);
}
// Custom constraint validator
public class IfscValidator implements ConstraintValidator<ValidIfsc, String> {
public boolean isValid(String value, ConstraintValidatorContext ctx) {
return value != null && value.matches("^[A-Z]{4}0[A-Z0-9]{6}$");
}
}
Q13How do you implement global exception handling with @RestControllerAdvice and ProblemDetail?
BasicError Handling
Answer
The idiomatic Boot 3 approach: a @RestControllerAdvice class containing @ExceptionHandler methods, returning ProblemDetail, the RFC 7807 (now RFC 9457) 'problem details' representation that Spring 6 added natively. Each handler maps one exception family to a status and body: domain not-found exceptions to 404, validation failures to 400 with a field-error array, optimistic-lock conflicts to 409, and an unmatched catch-all to a sanitised 500 that logs the full stack trace server-side but never leaks internals (SQL, class names, stack frames) to the client. Extending ResponseEntityExceptionHandler gives you sensible handling of all built-in Spring MVC exceptions (type mismatches, unreadable bodies, method-not-supported) which you can override selectively.
You can also flip spring.mvc.problemdetails.enabled=true to make Boot's default error responses use the problem-details shape. The design points interviewers listen for: exceptions should be typed and thrown from the domain (OrderNotFoundException, InsufficientBalanceException), not generic RuntimeException with a message; the error contract should be stable and documented because clients parse it; correlation IDs (trace ID from MDC) belong in every error payload so support tickets can be matched to logs; and 4xx versus 5xx discipline matters for alerting, because if bad user input produces 500s, your error-rate alarms become meaningless. A common follow-up is filter-layer errors: exceptions thrown in servlet filters (like a JWT parsing failure in a security filter) never reach @ControllerAdvice, they are handled by Boot's /error mapping via BasicErrorController or by security's AuthenticationEntryPoint, and knowing that distinction is a reliable senior signal.
@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
ProblemDetail notFound(OrderNotFoundException ex) {
var pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
pd.setTitle("Order not found");
return pd;
}
@ExceptionHandler(MethodArgumentNotValidException.class)
ProblemDetail invalid(MethodArgumentNotValidException ex) {
var pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
pd.setTitle("Validation failed");
pd.setProperty("violations", ex.getFieldErrors().stream()
.map(f -> Map.of("field", f.getField(), "message", f.getDefaultMessage()))
.toList());
return pd;
}
@ExceptionHandler(Exception.class)
ProblemDetail fallback(Exception ex) {
// log full stack trace with trace id; return sanitised body
return ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR, "Unexpected error");
}
}
Q14How do Spring Data JPA repositories work, and what are derived query methods?
BasicData Access
Answer
You declare an interface extending JpaRepository<Order, Long> and never write the implementation: at startup Spring Data creates a proxy backed by SimpleJpaRepository, giving you save, findById, findAll, deleteById, count and paging support immediately. Derived query methods extend this: Spring parses the method name at bootstrap and generates the JPQL, so findByStatusAndCreatedAtAfter(OrderStatus status, Instant after) becomes a WHERE clause without any query text, supporting keywords like Containing, IgnoreCase, In, Between, OrderBy, Top3, Distinct, and boolean existsBy / long countBy variants. When names get unwieldy (three-plus conditions), switch to @Query with explicit JPQL, or native SQL with @Query(nativeQuery = true) when you need database-specific features like Postgres ON CONFLICT or window functions.
Modifying queries need @Modifying plus @Transactional, and you should know that bulk @Modifying updates bypass the persistence context, so entities already loaded in the same transaction can go stale (clearAutomatically = true mitigates this). Return types are flexible: Optional<Order>, List, Stream (must be consumed inside a transaction and closed), Page and Slice for pagination. Two interview probes: first, that method-name parsing fails fast at startup with QueryCreationException when a property does not exist, so typos never reach production; second, projections, returning interfaces or records with a subset of columns instead of whole entities, which cuts memory and avoids dragging lazy associations into serialisation. Being able to say when you would drop to JdbcClient or plain SQL (reporting queries, batch jobs) rather than forcing everything through JPA is the mark of practical experience.
public interface OrderRepository extends JpaRepository<Order, Long> {
// Derived query: parsed from the method name at startup
List<Order> findByStatusAndCreatedAtAfter(OrderStatus status, Instant after);
Optional<Order> findTopByUserIdOrderByCreatedAtDesc(Long userId);
boolean existsByIdempotencyKey(String key);
// Explicit JPQL when names get unreadable
@Query("select o from Order o join fetch o.items where o.id = :id")
Optional<Order> findWithItems(@Param("id") Long id);
// Interface projection: select only what you need
interface OrderSummary { Long getId(); BigDecimal getTotal(); }
List<OrderSummary> findByUserId(Long userId);
}
Q15What do the spring.jpa.hibernate.ddl-auto values do, and what is safe in production?
BasicData Access
Answer
The property controls Hibernate's schema generation on startup. none does nothing. validate compares the mapped entities against the live schema and fails startup on mismatch (missing column, wrong type), touching nothing. update attempts to alter the schema additively to match entities: it adds columns and tables but never drops or narrows anything, so renames leave orphan columns and the schema drifts from what any migration history says. create drops and recreates the schema at startup; create-drop additionally drops it at shutdown. Boot's default is create-drop for an embedded database (H2 in tests) and none otherwise. The production answer interviewers want is unambiguous: schema changes belong to a migration tool, Flyway or Liquibase, executed as versioned, reviewed SQL, with ddl-auto set to validate so the app refuses to start against a schema that does not match its entities. update in production is a familiar disaster story: it cannot handle renames (you get both old and new columns, with data split between them), it can lock large tables mid-deploy while adding columns, different instances racing at startup can conflict, and there is no rollback path or audit trail.
A good follow-up you should volunteer: with Flyway on the classpath (spring-boot-starter or flyway-core dependency), Boot auto-runs migrations from classpath:db/migration before JPA initialises, which is exactly the ordering you need for validate to pass. For tests, create-drop against Testcontainers Postgres, or better, run the real Flyway migrations in tests too, so test schema equals production schema.
Key Points
- validate: fail fast on mismatch, change nothing (production choice)
- update: additive-only drift, no renames, no audit trail, avoid in prod
- create / create-drop: throwaway schemas for tests only
- Pair validate with Flyway; Boot runs migrations before JPA initialises
Q16What is Spring Boot Actuator, and how do you expose its endpoints safely?
BasicActuator
Answer
Actuator (spring-boot-starter-actuator) adds operational HTTP endpoints under /actuator: health (aggregated status from contributors like DataSource, Redis, Kafka, disk space), info, metrics (Micrometer meters, per-endpoint latency, JVM memory, GC), env (resolved configuration), loggers (view and change log levels at runtime without restart, invaluable during incidents), threaddump, heapdump, mappings, beans, and conditions. By default only health is exposed over the web; everything else must be opted in with management.endpoints.web.exposure.include. That default is deliberate, because env and heapdump leak secrets and memory contents, and exposed actuators are a standard finding in Indian security audits (Shodan is full of open /actuator/env endpoints).
The safe production pattern: expose health, info, metrics, prometheus; run management endpoints on a separate port (management.server.port=8081) that is reachable from the cluster but never routed through the public ingress; and put Spring Security rules on anything sensitive. Health details are controlled with management.endpoint.health.show-details=when-authorized. Since Boot 3, values in /actuator/env and /actuator/configprops are masked by default and require management.endpoint.env.show-values=when-authorized to reveal.
You should also know health groups: management.endpoint.health.group.readiness.include=db,kafka builds the Kubernetes readiness probe from real dependencies, while liveness stays minimal so a slow database does not get your pods restarted. Mentioning the loggers endpoint for turning one package to DEBUG during a live incident, then back, is the kind of operational detail that reads as genuine production experience.
# application.yml: safe Actuator exposure
management:
server:
port: 8081 # separate management port, not on public ingress
endpoints:
web:
exposure:
include: health,info,metrics,prometheus,loggers
endpoint:
health:
show-details: when-authorized
probes:
enabled: true # /actuator/health/liveness and /readiness
# Change a log level on a live instance (no restart):
# curl -X POST localhost:8081/actuator/loggers/com.goodspace.orders \
# -H 'Content-Type: application/json' -d '{"configuredLevel":"DEBUG"}'
Q17How does the executable 'fat jar' work, and how is it built?
BasicBuild & Deploy
Answer
Running mvn package with the spring-boot-maven-plugin (or the Gradle equivalent's bootJar task) repackages your module jar into an executable jar: your classes under BOOT-INF/classes, every dependency jar nested intact under BOOT-INF/lib, and a small Boot loader (JarLauncher) as the actual Main-Class in the manifest. The loader knows how to read nested jars (plain java cannot), builds the classpath from BOOT-INF/lib, and then invokes your @SpringBootApplication main class named by the Start-Class manifest attribute. The result is one self-contained artifact started with java -jar app.jar, which is the foundation of Boot's Docker and Kubernetes story.
Details that show depth: the original thin jar is preserved alongside (with a .original suffix) because the fat jar is produced by repackaging; a WAR variant exists for legacy external Tomcat but is effectively obsolete for new work; and for containers you should not COPY the fat jar as a single Docker layer. Instead use layered mode: java -Djarmode=tools -jar app.jar extract --layers splits it into dependencies, spring-boot-loader, snapshot-dependencies, and application layers, so a code-only change invalidates just the small application layer and image pushes and pulls move kilobytes instead of the full dependency set. Alternatively, mvn spring-boot:build-image uses Cloud Native Buildpacks to build an optimised OCI image with no Dockerfile at all. Interviewers ask this to check you understand your own deploy pipeline rather than treating the jar as a black box.
# Build and run
./mvnw clean package
java -jar target/orders-1.4.2.jar --spring.profiles.active=prod
# Layered extraction for efficient Docker images (Boot 3.3+ tools mode)
java -Djarmode=tools -jar target/orders-1.4.2.jar extract --layers --destination extracted
# Dockerfile using the layers (deps change rarely, app changes often)
# FROM eclipse-temurin:21-jre
# COPY extracted/dependencies/ ./
# COPY extracted/spring-boot-loader/ ./
# COPY extracted/snapshot-dependencies/ ./
# COPY extracted/application/ ./
# ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
Q18When do you use CommandLineRunner and ApplicationRunner, and when should you not?
BasicLifecycle
Answer
Both are functional interfaces whose run method executes once, after the ApplicationContext is fully refreshed and the embedded server has started, immediately before SpringApplication.run() returns. The only difference is the argument type: CommandLineRunner receives the raw String[] args, ApplicationRunner receives a parsed ApplicationArguments with getOptionValues("name") for --name=value flags and getNonOptionArgs(). Multiple runners are ordered with @Order or the Ordered interface.
Legitimate uses: seeding reference data in dev profiles, warming caches, validating external connectivity at startup, registering with a service directory, or building small CLI-style batch utilities where the whole point of the app is to run and exit (pair with spring.main.web-application-type=none). The 'should not' half is what interviewers listen for. An exception thrown from a runner aborts startup, which is correct for must-have preconditions but wrong for optional warm-ups, so decide deliberately per task.
Long-running blocking work in a runner delays readiness: the app is serving traffic on the web port while your runner still churns, or worse, in Kubernetes your startup probe times out. Heavy data migration does not belong in runners at all (that is Flyway's job, with proper locking across instances); runners racing on multiple replicas is a real bug class, since every pod runs them. For logic that must run before the context is ready, runners are too late: use an ApplicationContextInitializer or an EventListener on ApplicationEnvironmentPreparedEvent instead. Knowing where runners sit in the startup sequence, after context refresh, after the server starts, is the actual point of the question.
@Component
@Order(1)
public class CacheWarmupRunner implements ApplicationRunner {
private final ProductCatalog catalog;
public CacheWarmupRunner(ProductCatalog catalog) { this.catalog = catalog; }
@Override
public void run(ApplicationArguments args) {
if (args.containsOption("skip-warmup")) return; // --skip-warmup
catalog.preloadTopCategories();
}
}
// CLI-style batch app: run and exit, no web server
// spring.main.web-application-type=none
Q19Walk through the bean lifecycle: when do @PostConstruct and @PreDestroy run, and what ordering guarantees exist?
BasicLifecycle
Answer
For a singleton bean the sequence is: constructor runs (with constructor-injected dependencies already resolved), then setter/field injection completes, then Aware callbacks (BeanNameAware, ApplicationContextAware), then BeanPostProcessor beforeInitialization hooks, then @PostConstruct, then InitializingBean.afterPropertiesSet, then any @Bean(initMethod = ...), then BeanPostProcessor afterInitialization, which is where proxies for @Transactional, @Async and @Cacheable are typically woven in. At shutdown, in reverse dependency order: @PreDestroy, DisposableBean.destroy, then destroyMethod. The practical rules that interviewers are checking: never touch injected dependencies in the constructor beyond storing them, because some infrastructure (and any proxy-based behaviour) is not in place yet; @PostConstruct is the correct place for initialisation that needs fully injected state, but calling your own @Transactional or @Cacheable method from @PostConstruct can bypass the proxy behaviour depending on how the bean is wired, so start real work later, on ApplicationReadyEvent, instead.
Cross-bean ordering is by dependency graph, not declaration order; if bean A must initialise after B without a direct reference, @DependsOn("b") expresses it. @PreDestroy only runs on a graceful close (SIGTERM handled by the JVM shutdown hook that Boot registers), never on kill -9 or an OOM kill, so cleanup must be best-effort, and durable state cannot rely on it. Also worth knowing: prototype beans never get destruction callbacks, and lazy singletons (@Lazy or spring.main.lazy-initialization=true) run this whole sequence on first use rather than at startup, which trades startup time for a first-request latency hit.
Key Points
- Order: constructor, injection, @PostConstruct, afterPropertiesSet, then post-processor proxying
- Proxies are applied after init callbacks: self-calls in @PostConstruct can bypass them
- Prefer ApplicationReadyEvent for real work; @PostConstruct for wiring only
- @PreDestroy runs on SIGTERM, never on kill -9 or OOMKill
Q20How does logging work by default, and how do you configure levels and structured output?
BasicLogging
Answer
Boot routes everything through SLF4J with Logback as the default backend (spring-boot-starter-logging comes in transitively). You control levels per package with properties: logging.level.root=INFO, logging.level.org.hibernate.SQL=DEBUG, logging.level.com.yourapp=DEBUG, no XML needed for the common cases. File output is logging.file.name with built-in rotation settings under logging.logback.rollingpolicy.* (max-file-size, max-history, total-size-cap).
When you need full control (async appenders, multiple destinations), drop a logback-spring.xml in resources; the -spring variant matters because it enables Boot extensions like <springProfile name="prod"> and <springProperty> for reading application properties into the log config. For containerised deployments the 2026 default is JSON logs to stdout scraped by the platform, and Boot 3.4 added first-class structured logging: logging.structured.format.console=ecs (or logstash, or gelf) emits JSON in Elastic Common Schema without any custom encoder dependency, and you can add fields via logging.structured.ecs.service.name or a custom StructuredLoggingJsonMembersCustomizer. MDC is the other half of production logging: put a correlation ID (and with Micrometer Tracing, traceId and spanId are added for you) into the MDC so every line from one request carries the same identifiers; without that, multi-instance log debugging is guesswork. Interview probes: why DEBUG on org.hibernate.SQL plus TRACE on org.hibernate.orm.jdbc.bind shows queries with bound parameters (and why that must never run in prod, both for volume and PII); and how the Actuator loggers endpoint changes levels at runtime, which beats redeploying mid-incident.
# application.yml
logging:
level:
root: INFO
com.goodspace.orders: DEBUG
org.hibernate.SQL: DEBUG # dev only: shows SQL
structured:
format:
console: ecs # Boot 3.4+: JSON logs, Elastic Common Schema
# logback-spring.xml snippet: profile-specific appender
# <springProfile name="prod">
# <root level="WARN"><appender-ref ref="JSON_STDOUT"/></root>
# </springProfile>
Q21What does spring-boot-devtools give you, and how is restart different from reload?
BasicTooling
Answer
Devtools (spring-boot-devtools, declared optional so it never ships in the fat jar) speeds up the local loop in three ways. First, automatic restart: it runs your application with two classloaders, a base loader for dependencies that never change and a restart loader for your own classes; when a compiled class on the classpath changes, only the restart loader is thrown away and rebuilt, so the JVM and all library classes stay warm, making restarts a second or two instead of a full cold start. This is restart, not hot reload: state in your beans is lost, the Spring context is rebuilt.
True hot reload of method bodies without restart is JRebel territory or the JVM's limited HotSwap via a debugger; being precise about that distinction is exactly what the question tests. Second, devtools applies development-friendly property defaults, such as disabling template caching (spring.thymeleaf.cache=false and friends) and enabling the H2 console where relevant. Third, LiveReload: an embedded LiveReload server triggers browser refresh when resources change.
Trigger behaviour is tunable: spring.devtools.restart.exclude for paths that should not restart (static assets), and a trigger file (spring.devtools.restart.trigger-file) when you want restarts only on explicit save, a common IntelliJ setup. Also know why it is disabled in production automatically: devtools detects a fully packaged jar launch and turns itself off, and the classloader split can confuse libraries doing instanceof checks across loaders, a rare but real local-only bug (typically 'ClassCastException: X cannot be cast to X'), which you fix by listing that dependency in META-INF/spring-devtools.properties or just excluding devtools.
Key Points
- Two-classloader trick: only your classes reload, dependencies stay warm
- It is a fast restart, context state is lost: not JRebel-style hot reload
- Auto-disabled in packaged production jars
- ClassCastException X-cannot-be-cast-to-X locally is the classloader split
Q22What actually changed in the Spring Boot 2 to 3 migration, and why did upgrades break?
BasicVersions
Answer
Boot 3 (November 2022) was the largest breaking release in the framework's history, and because Boot 2.x support ended, every Indian enterprise ran this migration, making it reliable interview material. The headline changes: Java 17 became the minimum (from Java 8/11), and the entire javax.* Enterprise namespace moved to jakarta.*, so every import of javax.servlet, javax.persistence, javax.validation, javax.annotation became jakarta.*, breaking not just your code but every third-party library that touched those APIs; upgrades stalled for months waiting for library ecosystems (older Hibernate modules, servlet filters, SOAP stacks) to publish jakarta-compatible versions. Spring Framework 6 and Hibernate 6 came along, and Hibernate 6 changed query semantics and type mappings enough to alter generated SQL in places, a subtle source of regressions.
Spring Security 5.7+ removed WebSecurityConfigurerAdapter: configuration became a SecurityFilterChain @Bean with the lambda DSL, and thousands of tutorials went stale overnight. Property migrations were handled by the spring-boot-properties-migrator dependency, which logs deprecated keys at startup. Trailing-slash matching changed default (URLs /users and /users/ stopped matching the same handler), which broke real clients.
On the gains side: native compilation support via GraalVM became first-class (AOT engine), observability moved to Micrometer with Micrometer Tracing replacing Spring Cloud Sleuth, ProblemDetail arrived for error responses, and HTTP interfaces (@HttpExchange) appeared. Good candidates mention tooling: OpenRewrite recipes automated the bulk of javax-to-jakarta rewrites at large firms. If asked 'what would you check first in an upgrade', the answer is dependency compatibility, security config, and integration tests around persistence.
Key Points
- Java 17 baseline; javax.* to jakarta.* across the board
- WebSecurityConfigurerAdapter removed: SecurityFilterChain bean + lambda DSL
- Hibernate 6 changed query/type behaviour: watch generated SQL
- Gains: AOT/native support, Micrometer Tracing, ProblemDetail, @HttpExchange
- OpenRewrite recipes automated most of the mechanical migration
Q23How do you configure CORS properly in a Spring Boot API?
BasicWeb
Answer
Three mechanisms, and choosing the right one is the question. @CrossOrigin on a controller or handler works for quick cases but scatters policy across the codebase. The application-wide MVC approach is a WebMvcConfigurer bean overriding addCorsMappings, declaring allowed origins, methods, headers, credentials and max-age in one place. When Spring Security is present (which is always, in production), there is a critical interaction: the security filter chain runs before MVC, and an unauthenticated preflight OPTIONS request will be rejected before your MVC CORS mapping is ever consulted.
The correct pattern is a CorsConfigurationSource bean plus http.cors(withDefaults()) in the SecurityFilterChain, so the CorsFilter runs early in the security chain and handles preflights. Concrete rules that separate practitioners from tutorial-followers: allowCredentials(true) cannot be combined with allowedOrigins("*") (the browser rejects it, and Spring throws at startup), use allowedOriginPatterns for wildcard subdomains like https://*.goodspace.ai; preflight results are cached by maxAge, so set it (3600) to cut OPTIONS traffic; custom response headers you want JavaScript to read must be listed in exposedHeaders or the browser hides them; and a debugging tell, CORS errors appear in the browser but the server happily returns 200 to curl, because CORS is browser enforcement of server-declared policy, not server-side blocking. Also say where CORS should not be solved: internal service-to-service calls need no CORS (no browser involved), and duplicating CORS at both an API gateway and the app leads to conflicting headers, so own it in exactly one layer.
@Configuration
public class CorsConfig {
@Bean
CorsConfigurationSource corsConfigurationSource() {
var cfg = new CorsConfiguration();
cfg.setAllowedOriginPatterns(List.of("https://*.goodspace.ai"));
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE"));
cfg.setAllowedHeaders(List.of("Authorization", "Content-Type"));
cfg.setExposedHeaders(List.of("X-Total-Count"));
cfg.setAllowCredentials(true);
cfg.setMaxAge(3600L);
var source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", cfg);
return source;
}
}
// In the SecurityFilterChain: http.cors(Customizer.withDefaults());
Q24What happens inside SpringApplication.run(), and which startup events can you hook?
BasicFundamentals
Answer
SpringApplication.run() is a scripted sequence, and knowing its phases lets you hook the right one instead of forcing everything into @PostConstruct. The order: bootstrap context created and ApplicationStartingEvent fired (before any config exists); the Environment is prepared, merging command-line args, OS env vars, and application.yml files, firing ApplicationEnvironmentPreparedEvent (this is where config-driven listeners like logging system initialisation run); the ApplicationContext is created and ApplicationContextInitializers run; bean definitions are loaded and ApplicationPreparedEvent fires; the context refreshes, which instantiates all singleton beans, applies BeanPostProcessors, and starts the embedded web server; ApplicationStartedEvent fires; CommandLineRunner and ApplicationRunner beans execute; finally ApplicationReadyEvent fires, the correct hook for 'the app is fully up' work like announcing to a service registry or starting pollers. On failure at any point, ApplicationFailedEvent fires, and Boot's FailureAnalyzers print the human-readable diagnosis (like 'Port 8080 was already in use' or the bean-cycle diagram) instead of a raw stack trace.
Two practical notes worth volunteering: listeners for the earliest events cannot be @Component beans (the context does not exist yet), they must be registered in META-INF/spring.factories or via SpringApplication.addListeners; and startup performance is measurable with the ApplicationStartup API (BufferingApplicationStartup exposes timings through /actuator/startup), the first tool to reach for when someone asks 'why does the service take 40 seconds to boot'. Lazy initialisation (spring.main.lazy-initialization=true) moves bean creation cost from startup to first request, useful in dev, a trade-off you should call out rather than blanket-recommend for production.
Key Points
- Environment prepared, context refreshed, server started, runners, ApplicationReadyEvent
- Use ApplicationReadyEvent for post-startup work, not @PostConstruct
- FailureAnalyzers turn boot failures into readable diagnoses
- /actuator/startup with BufferingApplicationStartup profiles slow boots
Q25How does @Transactional actually work, and why does self-invocation silently break it?
IntermediateTransactions
Answer
@Transactional is implemented with AOP proxies, not bytecode magic in your class. When the container creates a bean with transactional methods, it wraps it in a proxy (CGLIB subclass by default). Callers hold the proxy; when they invoke the method, the proxy's TransactionInterceptor opens or joins a transaction via the PlatformTransactionManager, binds the connection to the thread through TransactionSynchronizationManager, invokes your method, then commits on normal return or rolls back per the rollback rules.
Everything follows from 'the behaviour lives in the proxy'. Self-invocation: when a method inside the class calls this.otherTransactionalMethod(), the call goes directly to the target object, never touching the proxy, so the annotation on the inner method is silently ignored. That means an inner REQUIRES_NEW does not get a new transaction, and an inner @Transactional inside a non-transactional public method runs with no transaction at all.
Fixes, in order of preference: restructure so the transactional method lives on a different bean (usually the honest design); inject the bean into itself via ObjectProvider and call through the injection; or as a last resort use AopContext.currentProxy() with exposeProxy enabled. Related proxy consequences interviewers chain into: @Transactional on private methods does nothing (the proxy cannot intercept them; Spring Framework 6 logs warnings for this), final methods cannot be proxied by CGLIB, and the same self-invocation trap applies identically to @Cacheable, @Async and @Retryable. Also be ready for 'is the transaction bound to the thread?': yes, via ThreadLocal, which is why passing work to another thread pool mid-transaction detaches it from the transaction, and why virtual threads work fine (each request still runs on one thread) but manual thread hopping does not.
@Service
public class TransferService {
@Transactional
public void transfer(long fromId, long toId, BigDecimal amount) {
debit(fromId, amount);
credit(toId, amount);
// both run in ONE transaction: this.audit() below would too,
// even though audit() declares REQUIRES_NEW (self-invocation!)
audit(fromId, toId, amount);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void audit(long from, long to, BigDecimal amt) {
// IGNORED when called via this.audit(): no new transaction
}
}
// Fix: move audit() to AuditService and inject it,
// so the call crosses a proxy boundary.
Q26Explain transaction propagation levels and the default rollback rules, with real use cases.
IntermediateTransactions
Answer
Propagation defines what happens when a transactional method is entered while a transaction may already be active. REQUIRED (default): join the existing transaction or start one; almost everything uses this. REQUIRES_NEW: suspend the caller's transaction and run in a brand-new one that commits or rolls back independently; the canonical use is audit logging or attempt-tracking that must survive the business transaction rolling back, for example recording a payment attempt even when the payment fails.
Careful: it takes a second connection from the pool while the first is suspended, so nested REQUIRES_NEW under load can deadlock the pool itself. SUPPORTS runs transactionally only if the caller already is; NOT_SUPPORTED suspends any transaction (long read-only work that should not hold a connection's transaction open); NEVER throws if a transaction exists; MANDATORY throws if one does not (a guard for 'this must only be called inside a unit of work'). NESTED uses a JDBC savepoint within the same transaction, so the inner part can roll back without dooming the whole, but it needs driver savepoint support and does not work across JPA flushes cleanly, so it is rare in practice.
Rollback rules: by default Spring rolls back on RuntimeException and Error only; checked exceptions commit. That surprises people, a method throwing a checked IOException after partial writes will commit the partial state unless you declare @Transactional(rollbackFor = Exception.class). The other classic: catching an exception inside the method and swallowing it means Spring never sees it, so no rollback happens, but if the transaction was already marked rollback-only by an inner REQUIRED method that threw, you get UnexpectedRollbackException at commit, a stack trace every senior Java interviewer expects you to have met and be able to explain.
@Service
public class PaymentService {
@Transactional(rollbackFor = Exception.class, timeout = 10)
public Receipt capture(PaymentRequest req) throws GatewayException {
ledger.debit(req); // joins this transaction (REQUIRED)
return gateway.capture(req); // checked exception now rolls back too
}
}
@Service
public class AttemptLogService {
// Survives the caller's rollback: separate transaction, separate connection
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void recordAttempt(long paymentId, String outcome) {
attempts.save(new Attempt(paymentId, outcome, Instant.now()));
}
}
Q27What is the N+1 select problem, and how do you fix it with fetch joins and @EntityGraph?
IntermediateData Access
Answer
N+1 is the signature JPA performance failure: you load N parent entities in one query, then touch a lazy association on each, and Hibernate fires one additional query per parent, N+1 round trips total. A page listing 50 orders that renders each order's items executes 51 queries; at Flipkart-scale traffic that is the difference between a 30ms endpoint and a database incident. It hides in code review because the code looks innocent, orders.getItems() inside a loop or, worse, inside Jackson serialisation of an entity returned directly from a controller.
Detection first: enable SQL logging in dev, watch the query count in integration tests (the io.hypersistence QuickPerf-style assertions or datasource-proxy count listeners work well), and watch p95 latency versus query counts in APM. Fixes, chosen per use case. JOIN FETCH in JPQL (select o from Order o join fetch o.items where ...) loads parent and children in one query; remember distinct semantics for collection joins and that fetch-joining two different collections in one query throws MultipleBagFetchException with List mappings (use Set, or split into two queries). @EntityGraph(attributePaths = "items") on a repository method achieves the same declaratively and composes with derived queries.
Batch fetching (spring.jpa.properties.hibernate.default_batch_fetch_size=50) is the blunt but effective global mitigation: instead of one query per parent, Hibernate loads lazy associations in IN-clause batches, turning N+1 into N/50+1. For read-only endpoints the strongest fix is not fetching entities at all: project directly into DTOs (select new or interface projections), which sidesteps lazy loading entirely. The interview trap to avoid: 'make everything EAGER' is wrong, it globalises the cost to every query that touches the entity and still does not guarantee joins.
// The bug: 1 query for orders, then 1 per order for items
List<Order> orders = orderRepository.findByStatus(PLACED);
orders.forEach(o -> log.info("{}", o.getItems().size())); // N extra queries
// Fix 1: fetch join
@Query("select distinct o from Order o join fetch o.items where o.status = :status")
List<Order> findWithItems(@Param("status") OrderStatus status);
// Fix 2: entity graph on a derived query
@EntityGraph(attributePaths = {"items", "customer"})
List<Order> findByStatus(OrderStatus status);
// Fix 3 (global mitigation): application.yml
// spring.jpa.properties.hibernate.default_batch_fetch_size: 50
Q28What causes LazyInitializationException, and what is the open-in-view debate?
IntermediateData Access
Answer
LazyInitializationException ('could not initialize proxy, no Session') is thrown when you touch a lazy association after the Hibernate Session that loaded the entity has closed: typically an entity escapes the @Transactional service method and something, often Jackson serialising the controller response, walks into an uninitialised collection. The reason many teams never see it is spring.jpa.open-in-view, which Boot defaults to true: it keeps the Session (and therefore the database connection's availability window) open for the entire HTTP request, including view rendering and JSON serialisation, so lazy loading 'just works' in the controller layer. That default is contested, and interviewers love asking why.
Costs of open-in-view: the connection is held across the whole request, so slow serialisation or a downstream call inside the request starves the pool under load; queries fire from the serialisation layer where nobody profiles them, which is exactly where N+1 hides; and the service-layer transaction boundary stops being the real data-access boundary, weakening design discipline. Boot even logs a warning at startup nudging you to choose explicitly. The disciplined setup is spring.jpa.open-in-view=false, then: fetch everything a use case needs inside the service via fetch joins or entity graphs, map to DTOs before returning, and treat any LazyInitializationException in test or staging as a design signal that a fetch plan is missing, not as an error to suppress.
Alternatives people mention, Hibernate.initialize() calls or making associations EAGER, are patches, not designs. A balanced closing point earns credit: for small internal CRUD apps open-in-view's convenience is defensible; for high-concurrency public APIs (payments, commerce) connection-hold time is precious and false, so switch it off and be deliberate.
Key Points
- Lazy proxy touched after Session close: usually during JSON serialisation
- open-in-view=true (the default) masks it by holding the Session per request
- Cost: connection held longer, N+1 hidden in serialisation, weak boundaries
- Fix pattern: open-in-view=false, fetch plans in services, DTOs out
Q29How do you tune HikariCP, and how do you size the pool correctly?
IntermediateData Access
Answer
HikariCP is Boot's default connection pool, configured under spring.datasource.hikari.*. The keys that matter: maximum-pool-size (default 10), minimum-idle (leave equal to max in steady-load services so the pool does not thrash resizing), connection-timeout (default 30s: how long a borrower waits before SQLTransientConnectionException 'Connection is not available, request timed out'), max-lifetime (default 30min: retire connections before the database or an intermediate proxy kills them; set it below your DB/infra idle timeout or you get 'connection reset' errors at low traffic), idle-timeout, keepalive-time (useful behind AWS RDS Proxy or firewalls that silently drop idle TCP), and leak-detection-threshold (log a stack trace when a connection is held longer than the threshold, the single fastest way to find code that forgets to close or holds transactions across remote calls). Sizing is the interview core, and the correct answer is counterintuitive: bigger is usually worse.
A Postgres or MySQL server does real work on a small number of cores; the classic starting formula is pool size = cores * 2 + effective spindles, in practice 10-20 per instance, not 100. Oversized pools increase context switching and lock contention at the database and just move queueing from the app (visible, measurable) into the DB (opaque). Then do the multiplication interviewers check: 20 app instances times 30 connections is 600 server connections, which will hurt an RDS instance; either shrink per-instance pools or put PgBouncer/RDS Proxy in front. Finally, connect pool metrics to symptoms: hikaricp.connections.pending rising with connection-timeout errors during traffic spikes means either pool exhaustion from slow queries (fix the queries or add missing timeouts) or genuinely insufficient capacity, and Micrometer exposes all of these gauges out of the box through Actuator.
# application.yml
spring:
datasource:
url: jdbc:postgresql://db:5432/orders
hikari:
maximum-pool-size: 15
minimum-idle: 15
connection-timeout: 3000 # fail fast; do not queue 30s under spike
max-lifetime: 1500000 # 25 min, below infra idle kill (30 min)
keepalive-time: 300000 # 5 min pings keep NAT/proxies from dropping
leak-detection-threshold: 10000 # stack-trace holders >10s (non-prod)
# Symptom mapping:
# 'Connection is not available, request timed out' = pool exhausted or too-slow queries
# resets at low traffic = max-lifetime longer than server/proxy idle timeout
Q30How do optimistic and pessimistic locking work in JPA, and when do you choose each?
IntermediateData Access
Answer
Optimistic locking assumes conflicts are rare and detects them at write time. Add an @Version column (int or timestamp) to the entity; Hibernate includes it in every UPDATE's WHERE clause and increments it, so if another transaction committed in between, zero rows match and Hibernate throws OptimisticLockException, which Spring translates to ObjectOptimisticLockingFailureException. Your service then retries the whole read-modify-write unit or surfaces a 409 Conflict for the client to refresh, and the retry must re-read the entity, retrying the same stale state just fails again.
This is the right default for typical web workloads: no locks held, no blocking, scales with reads. Pessimistic locking takes real database row locks at read time: repository methods annotated with @Lock(LockModeType.PESSIMISTIC_WRITE) issue SELECT ... FOR UPDATE, blocking competing lockers until commit.
Use it when conflicts are the norm or correctness cannot tolerate retry loops: seat or slot inventory, wallet balance deductions, the 'exactly one worker processes this row' pattern (often with FOR UPDATE SKIP LOCKED via a native query for job queues). Costs: locks hold connections and block, deadlocks become possible (PessimisticLockException; keep lock order consistent and transactions short), and a javax-style lock timeout should be set (jakarta.persistence.lock.timeout hint) so waiters fail rather than pile up. Interviewers often add a distributed twist: JPA locks only protect one database; for cross-service coordination you need database-backed idempotency, or a distributed lock (ShedLock or Redisson), each with their own failure semantics.
The strongest answers name the concrete choice for a scenario: flash-sale inventory decrement is pessimistic (or an atomic UPDATE ... SET qty = qty - 1 WHERE qty > 0, which is often better than either), user-profile edits are optimistic, and read-heavy dashboards need neither.
@Entity
public class WalletAccount {
@Id Long id;
BigDecimal balance;
@Version Long version; // optimistic: WHERE id=? AND version=?
}
public interface WalletRepository extends JpaRepository<WalletAccount, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE) // SELECT ... FOR UPDATE
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
Optional<WalletAccount> findWithLockById(Long id);
}
// Often better than both for counters: atomic conditional update
@Modifying
@Query("update Inventory i set i.qty = i.qty - 1 where i.sku = :sku and i.qty > 0")
int reserve(@Param("sku") String sku); // returns 0 = sold out, no lock wait
Q31How do Pageable, Page and Slice work, and what goes wrong with pagination at scale?
IntermediateData Access
Answer
Spring Data resolves a Pageable controller parameter automatically from ?page=0&size=20&sort=createdAt,desc, and repository methods accepting Pageable return Page<T> or Slice<T>. Page executes two queries: the content query plus a count(*), so it can report totalElements and totalPages. Slice runs only the content query with size+1, telling you hasNext() without the count, which matters because count(*) on large tables is genuinely expensive; for infinite-scroll UIs, Slice (or a plain List with limit) is the right call, and Page is for numbered-page admin grids.
Guardrails you are expected to know: cap the page size (spring.data.web.pageable.max-page-size, default 2000, set it lower) so ?size=100000 cannot OOM your service or hammer the DB; whitelist sortable fields, because sort=anyProperty flows into ORDER BY and unindexed sorts on big tables are a self-inflicted denial of service; and never fetch-join a collection with pagination, Hibernate warns 'firstResult/maxResults specified with collection fetch; applying in memory', meaning it loads the entire result set into memory and paginates there, one of the sneakiest OOM causes in JPA systems. The scale problem interviewers push on is deep offset pagination: OFFSET 500000 forces the database to produce and discard half a million rows, so latency grows linearly with page number, and concurrent inserts make users see duplicates or gaps between pages. The fix is keyset (seek) pagination: order by a stable unique key and filter WHERE (createdAt, id) < (:lastSeenAt, :lastSeenId) LIMIT 20, giving constant-time pages and stable ordering.
Spring Data supports this pattern via ScrollPosition and window/scroll APIs in recent versions, or you write the keyset query yourself. A complete answer states the trade-off: keyset cannot jump to page 47 directly, which is fine for feeds and exports, not for numbered grids.
@GetMapping("/api/orders")
public Slice<OrderSummary> list(
@PageableDefault(size = 20, sort = "createdAt", direction = DESC) Pageable pageable) {
return orders.findByStatus(OrderStatus.PLACED, pageable); // Slice: no count query
}
// Keyset pagination for deep scrolling: constant time at any depth
@Query("""
select o from Order o
where (o.createdAt < :lastAt) or (o.createdAt = :lastAt and o.id < :lastId)
order by o.createdAt desc, o.id desc
""")
List<Order> nextPage(@Param("lastAt") Instant lastAt,
@Param("lastId") Long lastId,
Pageable limit);
Q32How does @Cacheable work, and what are the gotchas with Redis-backed caching?
IntermediateCaching
Answer
Enable with @EnableCaching, then @Cacheable("products") wraps the method in a proxy that checks the cache before invoking: hit returns the cached value without executing the method, miss executes and stores. @CachePut always executes and refreshes the entry; @CacheEvict removes entries (allEntries = true to clear the cache); keys default to a composite of parameters and are customisable with SpEL (key = "#sku") or a KeyGenerator. The provider is whatever CacheManager is on the classpath: ConcurrentHashMap by default (no eviction, no TTL, per-instance, fine for tests only), Caffeine for serious in-process caching (size and TTL via spec strings), and Redis via spring-boot-starter-data-redis for shared cache across instances, configured through RedisCacheConfiguration with per-cache TTLs. Proxy gotchas first, because they are the same family as @Transactional: self-invocation bypasses caching entirely, private methods cannot be cached, and caching a method inside the same class as its caller is the most common 'why is my cache not working' ticket.
Redis-specific gotchas: serialisation, the default JDK serializer produces unreadable, brittle payloads, so configure GenericJackson2JsonRedisSerializer (and be careful evolving classes, cached JSON of an old shape can fail deserialisation after deploy, version your cache names); null handling, @Cacheable caches nulls by default unless unless = "#result == null", and whether you want negative caching is a real design decision; and TTLs are mandatory, an unbounded shared cache is a memory leak with a network hop. Design-level probes: cache stampede (a hot key expiring causes a thundering herd on the DB; mitigate with sync = true on @Cacheable which serialises loading per key per instance, plus jittered TTLs), and invalidation strategy (evict-on-write within the same service is easy; cross-service invalidation needs events, and 'TTL as the consistency contract' is often the honest answer).
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
RedisCacheManagerBuilderCustomizer cacheCustomizer() {
return builder -> builder
.withCacheConfiguration("products",
RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer())))
.withCacheConfiguration("fx-rates",
RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofSeconds(30)));
}
}
@Service
public class ProductService {
@Cacheable(cacheNames = "products", key = "#sku", sync = true,
unless = "#result == null")
public Product bySku(String sku) { return repo.findBySku(sku).orElse(null); }
@CacheEvict(cacheNames = "products", key = "#product.sku")
public Product update(Product product) { return repo.save(product); }
}
Q33How do you use @Async correctly: executor configuration, return types, and exception handling?
IntermediateConcurrency
Answer
@EnableAsync plus @Async makes a method execute on a separate thread via, once again, a proxy, so the self-invocation and private/final-method caveats apply exactly as with @Transactional. Three things must be deliberately configured or you inherit surprises. The executor: define a ThreadPoolTaskExecutor bean (or configure spring.task.execution.* properties: pool.core-size, pool.max-size, pool.queue-capacity, thread-name-prefix), because relying on defaults means an unbounded queue in front of a small pool, which under sustained load becomes an invisible memory leak of queued tasks; also know the counterintuitive ThreadPoolExecutor rule that max-size only kicks in after the queue is full.
Return types: void (fire-and-forget) or CompletableFuture<T>; a value-returning @Async method must wrap its result in CompletableFuture.completedFuture(...) and callers compose on it. Exceptions: a thrown exception from a void @Async method vanishes unless you register an AsyncUncaughtExceptionHandler via AsyncConfigurer, teams discover this when 'the email sender has been failing for two weeks and nobody knew'; with CompletableFuture returns, the exception travels in the future and surfaces on join/get or exceptionally(). Two production concerns to raise unprompted.
Context propagation: SecurityContext, MDC (trace IDs), and locale do not follow the task onto the new thread by default; wrap executors with a TaskDecorator to copy MDC, and use DelegatingSecurityContextAsyncTaskExecutor for security context. Transactions: @Async plus @Transactional on the same method starts the transaction on the async thread (usually what you want), but calling an async method from inside a transaction means the async work cannot see uncommitted data and may run before the commit, the fix for 'send event after commit' is @TransactionalEventListener(phase = AFTER_COMMIT), not @Async alone. Finally, name the alternative honestly: for anything that must survive a crash, an in-memory executor is not a queue; use a durable broker or an outbox.
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
var ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(8);
ex.setMaxPoolSize(16);
ex.setQueueCapacity(500); // bounded: fail loudly, not OOM
ex.setThreadNamePrefix("notif-");
ex.setTaskDecorator(runnable -> { // propagate MDC / trace ids
var ctx = MDC.getCopyOfContextMap();
return () -> { if (ctx != null) MDC.setContextMap(ctx); runnable.run(); };
});
ex.initialize();
return ex;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (t, m, params) -> log.error("async failure in {}", m.getName(), t);
}
}
@Async
public CompletableFuture<ScoreResult> scoreResume(long resumeId) {
return CompletableFuture.completedFuture(scorer.run(resumeId));
}
Q34How does @Scheduled work, and why do scheduled tasks silently stop or overlap in production?
IntermediateScheduling
Answer
@EnableScheduling plus @Scheduled runs methods on a schedule: fixedRate (start-to-start interval), fixedDelay (end-to-start, so slow runs push the next one out), initialDelay, and cron expressions (Spring's six-field format with seconds, plus zone = "Asia/Kolkata" when business schedules matter, otherwise the server's zone applies and a UTC container fires your '9 AM' job at 2:30 PM IST). The failure modes are the real interview content. Single-threaded default: all @Scheduled methods in the app share one scheduler thread unless you set spring.task.scheduling.pool.size, so one long-running or hung task (a stuck HTTP call with no timeout is the classic) delays or completely starves every other scheduled task in the process; symptoms are 'the cleanup job stopped running' with zero errors logged.
Exceptions do not kill the schedule (the next run still fires), but they are only logged, so failures without alerting go unnoticed; wrap task bodies with metrics (a timer plus a 'last success timestamp' gauge that you alert on going stale). Overlap: fixedRate does not start a new run while the previous invocation is still executing on the same scheduler thread, but with a larger pool or async tasks overlap becomes possible, and more importantly, multiple instances: every replica of your service runs every @Scheduled method, so a 'send reminder emails' job on three pods sends three emails. The standard fix is distributed locking with ShedLock (@SchedulerLock with a JDBC or Redis lock provider) or leader election, or moving the work to a proper job system (Quartz for persistent, clustered triggers with misfire handling; a queue-based worker for scale-out). Mentioning that @Scheduled methods must be no-arg, on singleton beans, and that the same proxy rules apply (no self-invocation into @Transactional expecting magic) rounds out a strong answer.
@Component
public class ReconciliationJobs {
// Every 15 minutes, IST business calendar, no overlap across replicas
@Scheduled(cron = "0 */15 * * * *", zone = "Asia/Kolkata")
@SchedulerLock(name = "reconcilePayments",
lockAtMostFor = "14m", lockAtLeastFor = "1m")
public void reconcilePayments() {
reconciler.run();
}
}
# application.yml: do not run all jobs on one thread
spring:
task:
scheduling:
pool:
size: 4
Q35How do you configure Spring Security in Boot 3 with a SecurityFilterChain bean?
IntermediateSecurity
Answer
Since Spring Security 5.7 (mandatory in Boot 3), WebSecurityConfigurerAdapter is gone; you declare a SecurityFilterChain @Bean and configure HttpSecurity with the lambda DSL. Core decisions in every chain: authorizeHttpRequests with requestMatchers ordered most-specific-first, ending in anyRequest().authenticated() (deny-by-default posture, listing public endpoints explicitly with permitAll rather than protecting a blacklist); session policy, which for token-based APIs is SessionCreationPolicy.STATELESS; CSRF, which you disable for stateless bearer-token APIs (CSRF protects cookie-based sessions; if you use cookies for auth, you keep it); and cors(withDefaults()) wired to a CorsConfigurationSource so preflights pass the filter chain. Understand the architecture one level down, because that is where debugging lives: Spring Security is a chain of servlet filters (DelegatingFilterProxy into FilterChainProxy into the individual filters like BearerTokenAuthenticationFilter or UsernamePasswordAuthenticationFilter).
Requests failing authentication never reach your controllers or @ControllerAdvice: a 401 is produced by the AuthenticationEntryPoint and a 403 by the AccessDeniedHandler, and customising error bodies means customising those, not writing an @ExceptionHandler. Multiple SecurityFilterChain beans with @Order and securityMatcher let you give /actuator/** and /api/** different regimes. Method security (@EnableMethodSecurity, then @PreAuthorize("hasRole('ADMIN')") or SpEL over the domain, @PreAuthorize("#userId == authentication.name")) provides defence in depth beneath the URL layer.
Passwords, when you store them, go through a DelegatingPasswordEncoder (BCrypt by default). The follow-ups that separate levels: filter ordering matters and custom filters are placed with addFilterBefore; hasRole("ADMIN") matches the authority ROLE_ADMIN (the prefix trips people constantly); and the SecurityContext is thread-local, so async flows need context propagation, the same story as every other thread-bound Spring feature.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.csrf(csrf -> csrf.disable()) // stateless bearer API
.cors(Customizer.withDefaults())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**", "/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
.exceptionHandling(e -> e
.authenticationEntryPoint(problemDetailEntryPoint()));
return http.build();
}
}
Q36How do you implement JWT authentication as an OAuth2 resource server, and what must you validate?
IntermediateSecurity
Answer
The modern Boot approach is not hand-rolling a JWT filter with jjwt: add spring-boot-starter-oauth2-resource-server and configure oauth2ResourceServer(o -> o.jwt(...)). If tokens come from an identity provider (Keycloak, Auth0, Cognito, an internal issuer), set spring.security.oauth2.resourceserver.jwt.issuer-uri and Spring fetches the JWKS, verifies signatures (rotating keys handled automatically), and validates exp, nbf and the issuer claim out of the box; you add audience validation yourself with a JwtDecoder customised via OAuth2TokenValidator, and forgetting audience validation is a real vulnerability, a token minted for a different service of the same issuer would otherwise be accepted. If you mint tokens yourself, expose a JwtEncoder/JwtDecoder pair with a NimbusJwtDecoder over your key.
Claims map into the Authentication: by default scopes become SCOPE_xxx authorities; a JwtAuthenticationConverter remaps custom claims (roles arrays, tenant IDs) into authorities your @PreAuthorize rules use. The lifecycle design is what interviewers actually probe. Access tokens short-lived (5-15 minutes), refresh tokens longer-lived and rotated on use, with the refresh endpoint being where revocation checks live, because stateless access tokens cannot be individually revoked without reintroducing state; if instant revocation is a requirement, you keep a denylist keyed by the jti claim in Redis with TTL equal to remaining token life, and you say explicitly that this trades away some statelessness.
Storage guidance for browser clients (httpOnly secure cookies versus localStorage and the XSS trade-off), clock skew tolerance on validation, and never putting PII or authorisation-critical data in claims the client can decode are the hygiene points. Common failure you should be able to debug cold: 401 with WWW-Authenticate: Bearer error="invalid_token" and a description like 'An error occurred while attempting to decode the Jwt: Signed JWT rejected', which means key mismatch, wrong issuer, or the token hitting the wrong environment.
# application.yml: verify tokens from your issuer
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.goodspace.ai/realms/prod
// Add audience validation (not done for you):
@Bean
JwtDecoder jwtDecoder(OAuth2ResourceServerProperties props) {
var decoder = JwtDecoders.<NimbusJwtDecoder>fromIssuerLocation(
props.getJwt().getIssuerUri());
var withIssuer = JwtValidators.createDefaultWithIssuer(
props.getJwt().getIssuerUri());
var audience = new JwtClaimValidator<List<String>>("aud",
aud -> aud != null && aud.contains("orders-api"));
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(withIssuer, audience));
return decoder;
}
// Map a custom roles claim to authorities
@Bean
JwtAuthenticationConverter jwtAuthConverter() {
var roles = new JwtGrantedAuthoritiesConverter();
roles.setAuthoritiesClaimName("roles");
roles.setAuthorityPrefix("ROLE_");
var conv = new JwtAuthenticationConverter();
conv.setJwtGrantedAuthoritiesConverter(roles);
return conv;
}
Q37When do you use @SpringBootTest versus @WebMvcTest versus @DataJpaTest, and how do you mock beans?
IntermediateTesting
Answer
The principle is: load the smallest slice that exercises what you are testing, because context size is test time. @SpringBootTest boots the full ApplicationContext, optionally with a real server (webEnvironment = RANDOM_PORT) so you can hit it with TestRestTemplate or WebTestClient; use it for end-to-end wiring tests and keep the count low, since each distinct context configuration costs seconds and is cached across tests only when the configuration matches exactly (a single @MockitoBean difference forks a new cached context, which is why sprinkling mocks across many test classes quietly makes CI minutes explode). @WebMvcTest(OrderController.class) loads only the web layer: your controller, Jackson, validation, @ControllerAdvice, and security filters, no services, no repositories; collaborators are provided as mocks and you drive requests through MockMvc (or the AssertJ-first MockMvcTester introduced in Boot 3.4), asserting status, JSON body via jsonPath, and validation failure shapes. This is where 400-contract and serialisation tests belong. @DataJpaTest loads JPA repositories and entities with transactional-rollback-per-test, and by default swaps in an embedded H2; that default is the interview gotcha, H2 is not Postgres (different SQL dialect, different constraint and locking behaviour), so real teams pin the actual database with @AutoConfigureTestDatabase(replace = NONE) plus Testcontainers. On mocking: @MockBean/@SpyBean were deprecated in Boot 3.4 in favour of Spring Framework's own @MockitoBean/@MockitoSpyBean, same concept, and both replace a bean in the context with a Mockito mock. Complete answers also mention @TestConfiguration for supplying test-only beans, @Transactional-rollback semantics in tests (data 'disappears' after each test, which confuses assertions made from another thread or connection), and that unit tests of services should usually be plain Mockito with no Spring context at all, the fastest tests you can own.
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mvc;
@MockitoBean OrderService service; // Boot 3.4+: replaces @MockBean
@Test
void returns400WhenQuantityInvalid() throws Exception {
mvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"sku\":\"ABC\",\"quantity\":0}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.violations[0].field").value("quantity"));
}
@Test
void returns201OnSuccess() throws Exception {
given(service.place(any())).willReturn(new OrderResponse(1L, "PLACED"));
mvc.perform(post("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"sku\":\"ABC\",\"quantity\":2}"))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.status").value("PLACED"));
}
}
Q38How does Testcontainers integrate with Spring Boot, and what does @ServiceConnection do?
IntermediateTesting
Answer
Testcontainers spins up real infrastructure (Postgres, Redis, Kafka, LocalStack) in Docker containers for the duration of your tests, which kills the H2-versus-production-database class of false-green tests: your JPA queries, Flyway migrations, JSON columns and locking behaviour run against the same engine as production. Historically the wiring was manual: start a PostgreSQLContainer, then use @DynamicPropertySource to push its JDBC URL, username and password into the Spring environment before the context starts. Boot 3.1 collapsed this into @ServiceConnection: annotate the container bean and Boot derives all connection properties automatically, no property plumbing, and it works for a growing catalogue of containers (JDBC databases, Redis, Kafka, RabbitMQ, MongoDB, Elasticsearch and more).
The idiomatic setup is a @TestConfiguration class exposing container @Bean methods, imported by tests that need them; mark containers static or reuse them via singleton pattern so the container starts once per JVM rather than once per test class, which is the difference between a 40-second and a 6-minute CI stage. Boot 3.1 also added a development-time twist worth mentioning: 'run with Testcontainers at development time', a TestApplication main that boots your app locally against containers instead of requiring installed services, overlapping with Docker Compose support. Things interviewers check: that Flyway migrations run against the container automatically (they do, it is just a datasource), that parallel test execution needs care with shared containers and data isolation (truncate between tests or use unique schemas), that CI needs a Docker-capable runner (Docker-in-Docker or a Testcontainers Cloud-style remote daemon), and the honest cost accounting, container tests are integration tests, slower than Mockito unit tests, so the pyramid still applies: many plain unit tests, a solid layer of container-backed integration tests around persistence and messaging, and a few full @SpringBootTest end-to-end flows.
@TestConfiguration(proxyBeanMethods = false)
class TestcontainersConfig {
@Bean
@ServiceConnection // Boot 3.1+: props derived automatically
PostgreSQLContainer<?> postgres() {
return new PostgreSQLContainer<>("postgres:16-alpine");
}
@Bean
@ServiceConnection
RedisContainer redis() {
return new RedisContainer("redis:7-alpine");
}
}
@SpringBootTest
@Import(TestcontainersConfig.class)
class OrderFlowIT {
@Autowired OrderRepository orders;
@Test
void savesAndQueriesAgainstRealPostgres() {
orders.save(Order.demo());
assertThat(orders.findByStatus(OrderStatus.PLACED, Pageable.ofSize(10)))
.hasSize(1);
}
}
Q39RestTemplate, WebClient, RestClient and @HttpExchange interfaces: which do you use in 2026?
IntermediateHTTP Clients
Answer
Four ways to call HTTP from a Boot app, and the selection logic is a favourite interview topic because it maps to framework history. RestTemplate is the classic blocking client: not deprecated, but in maintenance mode, with no new features; existing codebases keep it, new code should not choose it. WebClient (from WebFlux) is the reactive client returning Mono/Flux; it is the right tool inside reactive applications, but pulling spring-webflux into a servlet app just to make HTTP calls, then calling .block() everywhere, is an anti-pattern that interviewers specifically look for you to reject.
RestClient, added in Boot 3.2, is the answer for the majority case: a modern fluent API in the style of WebClient but synchronous and servlet-friendly, no reactive dependency, with proper ClientHttpRequestFactory pluggability (JDK HttpClient, Apache HttpComponents, Jetty) and hooks for interceptors, default headers, and error handling via onStatus. On top of any of these sits the declarative option: @HttpExchange interfaces (annotated with @GetExchange, @PostExchange), where you define a Java interface for the remote API and Spring generates the implementation over a RestClient or WebClient adapter via HttpServiceProxyFactory, the same ergonomics Feign popularised, but native to Spring and without the Spring Cloud dependency. What earns marks beyond naming them: configuring timeouts explicitly (connect and read) because every default-timeout HTTP call is a production incident waiting for a slow dependency; pooling the underlying client rather than building per-request; putting retries and circuit breakers (Resilience4j) around remote calls rather than inside them; propagating tracing headers, which Micrometer's instrumentation does automatically when you build RestClient from the auto-configured RestClient.Builder rather than RestClient.create(); and testing with MockRestServiceServer (works with RestClient since 6.1) or WireMock for contract-level tests.
// Declarative client over RestClient (Boot 3.2+)
public interface GatewayApi {
@PostExchange("/v1/charges")
ChargeResponse charge(@RequestBody ChargeRequest request);
@GetExchange("/v1/charges/{id}")
ChargeResponse get(@PathVariable String id);
}
@Configuration
public class GatewayConfig {
@Bean
GatewayApi gatewayApi(RestClient.Builder builder) { // auto-configured: traced
var client = builder
.baseUrl("https://gateway.internal")
.defaultHeader("X-Api-Version", "2")
.requestFactory(ClientHttpRequestFactories.get(
ClientHttpRequestFactorySettings.DEFAULTS
.withConnectTimeout(Duration.ofSeconds(2))
.withReadTimeout(Duration.ofSeconds(5))))
.build();
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(client))
.build()
.createClient(GatewayApi.class);
}
}
Q40What is the configuration property precedence order, and how does relaxed binding work with environment variables?
IntermediateConfiguration
Answer
Boot resolves every property through an ordered list of PropertySources, higher wins. The order you should know cold, from strongest to weakest for the common sources: devtools global settings (dev only), @TestPropertySource and test properties, command-line arguments (--server.port=9090), SPRING_APPLICATION_JSON (a JSON blob of properties in one env var), OS environment variables, Java system properties (-Dserver.port), profile-specific config files (application-prod.yml), then plain application.yml, with files outside the jar beating the same file packaged inside it, and finally @PropertySource and default properties at the bottom. Within files, later profile documents override earlier ones.
Additional locations come from spring.config.import (including configtree: for mounted Kubernetes secrets, and vault/config-server imports in Spring Cloud setups). The practical consequences: platform-injected env vars always beat whatever is baked into the image, which is exactly what you want for twelve-factor deploys; and a value mysteriously 'not taking effect' is diagnosed with the Actuator /actuator/env endpoint, which shows every PropertySource and which one won for a given key. Relaxed binding is the other half: the canonical property spring.datasource.url can be written as an environment variable SPRING_DATASOURCE_URL, because binding maps dots to underscores and is case-insensitive; kebab-case (max-pool-size), camelCase (maxPoolSize) and underscore forms all bind to the same @ConfigurationProperties field.
Know the list-index convention for env vars (MYAPP_SERVERS_0_HOST for myapp.servers[0].host) and the origin-tracking bonus: Boot records where each value came from, so binding failures print the exact file and line. A strong closing point: precedence is also a security surface, anyone who can set env vars on your host can override your config, which is one more reason /actuator/env must never be publicly exposed.
Key Points
- Command line > SPRING_APPLICATION_JSON > env vars > system props > profile files > application.yml
- External files beat packaged files; profile-specific beats base
- Relaxed binding: SPRING_DATASOURCE_URL == spring.datasource.url
- Debug with /actuator/env: shows every source and the winner per key
- spring.config.import adds config trees (k8s secrets), vault, config server
Q41How do Flyway migrations run in a Spring Boot app, and what are the rules for safe schema changes?
IntermediateData Access
Answer
With flyway-core (plus the database-specific module, like flyway-database-postgresql) on the classpath, Boot auto-configures Flyway to run before JPA initialises, applying versioned scripts from classpath:db/migration named V1__init.sql, V2__add_orders_table.sql (double underscore matters; a single underscore silently fails to parse the version). Flyway records each applied script with a checksum in the flyway_schema_history table, acquires a lock so multiple app instances starting simultaneously do not race, and fails fast on problems: a modified already-applied script triggers a checksum mismatch error at startup (the correct reaction is a new migration, never editing an applied one; flyway repair only for genuinely corrupted history), and a failed migration on databases without transactional DDL can leave a half-applied state that needs manual cleanup. Repeatable migrations (R__views.sql) rerun whenever their checksum changes, right for views and functions.
Key properties: spring.flyway.locations, spring.flyway.baseline-on-migrate=true when adopting Flyway on an existing schema (baseline-version marks the starting point), and spring.flyway.placeholders for environment-specific values. The safe-change discipline is what senior interviews target, because migrations run during deploys with old and new app versions live simultaneously: every change must be backward compatible one version. Expand-and-contract is the pattern: add the new nullable column and deploy code that writes both (expand), backfill, switch reads, then drop the old column in a later release (contract).
Never rename in one step, never add NOT NULL without a default on a large table without thinking about lock behaviour, and know your database's DDL locking characteristics (Postgres ADD COLUMN with a constant default is metadata-only since v11; MySQL varies by operation and version). Pair all of it with spring.jpa.hibernate.ddl-auto=validate so the app cross-checks entities against the migrated schema at startup.
-- src/main/resources/db/migration/V7__add_idempotency_key.sql
-- Expand phase: nullable first, backfill, enforce later
ALTER TABLE payment ADD COLUMN idempotency_key VARCHAR(64);
CREATE UNIQUE INDEX CONCURRENTLY idx_payment_idem
ON payment (idempotency_key) WHERE idempotency_key IS NOT NULL;
# application.yml
spring:
flyway:
locations: classpath:db/migration
baseline-on-migrate: false
jpa:
hibernate:
ddl-auto: validate # entities must match the migrated schema
Q42How do Actuator health groups map to Kubernetes liveness and readiness probes?
IntermediateActuator
Answer
Kubernetes asks two different questions and teams routinely wire them wrong. Liveness: 'is this process broken beyond recovery?', a failing liveness probe restarts the pod. Readiness: 'can this instance take traffic right now?', a failing readiness probe removes the pod from service endpoints without restarting it.
Boot models these natively: when it detects Kubernetes (or when management.endpoint.health.probes.enabled=true), it exposes /actuator/health/liveness backed by the LivenessStateHealthIndicator and /actuator/health/readiness backed by ReadinessStateHealthIndicator, reflecting the internal ApplicationAvailability states (LivenessState.CORRECT/BROKEN, ReadinessState.ACCEPTING_TRAFFIC/REFUSING_TRAFFIC). The critical design rule: external dependencies belong in readiness, never in liveness. If your database goes down and your liveness probe includes the DB health indicator, Kubernetes restarts every pod in a loop while the DB is the actual problem, converting a partial outage into a full one plus a restart storm; this exact misconfiguration shows up in real incident reviews.
Readiness including db, redis and kafka indicators is correct: pods stop receiving traffic until dependencies recover. You compose groups explicitly: management.endpoint.health.group.readiness.include=readinessState,db,redis and keep liveness as just livenessState. Your own checks are HealthIndicator beans (implement health(), return Health.up()/down() with details), and expensive checks should be cached or backgrounded so the probe endpoint stays fast; probe timeouts killing pods because a health check does a slow query is another classic.
Also know that applications can flip their own availability state by publishing AvailabilityChangeEvent (for example, REFUSING_TRAFFIC during a long warm-up), and that startupProbe in Kubernetes covers slow-booting JVM apps so liveness does not kill them mid-startup. Being fluent in this mapping is one of the strongest 'has actually run Boot on Kubernetes' signals available in an interview.
# application.yml
management:
endpoint:
health:
probes:
enabled: true
group:
readiness:
include: readinessState,db,redis # deps gate traffic
liveness:
include: livenessState # deps NEVER restart pods
# k8s deployment snippet
# livenessProbe: httpGet: { path: /actuator/health/liveness, port: 8081 }
# readinessProbe: httpGet: { path: /actuator/health/readiness, port: 8081 }
// Custom indicator, kept cheap
@Component
public class GatewayHealth implements HealthIndicator {
public Health health() {
boolean ok = gatewayPing.lastSuccessWithin(Duration.ofSeconds(30));
return ok ? Health.up().build()
: Health.down().withDetail("lastSuccess", gatewayPing.last()).build();
}
}
Q43How do you expose and use custom metrics with Micrometer and Prometheus?
IntermediateObservability
Answer
Micrometer is the metrics facade Boot instruments itself with: add micrometer-registry-prometheus and expose the prometheus Actuator endpoint, and /actuator/prometheus serves a scrape-ready exposition of JVM metrics (jvm.memory.used, GC pauses, thread counts), HTTP server metrics (http.server.requests as a timer with uri, status, method, exception tags), HikariCP gauges, cache statistics, and executor metrics, with zero code. Custom metrics come from the injected MeterRegistry: Counter for monotonic totals (orders placed, payment failures by gateway), Timer for latency distributions (record around the operation, or annotate methods with @Timed which is aspect-driven and needs TimedAspect registered), Gauge for point-in-time values (queue depth, note gauges hold a reference to the observed object and must not be re-registered per call, a real leak pattern), and DistributionSummary for sizes and amounts. The discipline interviewers listen for is label (tag) cardinality: tags multiply time series, so a userId or orderId tag creates unbounded cardinality that will take down your Prometheus or explode your Grafana Cloud bill; tags must be low-cardinality dimensions like status, gateway, or tier, and Boot itself templatises URIs in http.server.requests (/api/orders/{id}, not each concrete id) for exactly this reason.
Percentiles: enable histogram buckets with management.metrics.distribution.percentiles-histogram.http.server.requests=true so you can compute p95/p99 in PromQL across instances (pre-computed client-side percentiles cannot be aggregated); then the standard alerts are p99 latency, error-rate ratio on status=5xx, and saturation gauges (Hikari pending, executor queue). Since Boot 3, the Observation API (ObservationRegistry, @Observed) unifies this further: one instrumentation point emits both metrics and trace spans. Wire the common tags (application name, region, instance) once via management.metrics.tags.* so every dashboard can filter by service.
@Service
public class PaymentMetrics {
private final Counter failures;
private final Timer captureTimer;
public PaymentMetrics(MeterRegistry registry) {
this.failures = Counter.builder("payments.failures")
.description("Failed captures by gateway and reason")
.tag("gateway", "razorpay") // low cardinality only
.register(registry);
this.captureTimer = Timer.builder("payments.capture.duration")
.publishPercentileHistogram()
.register(registry);
}
public ChargeResult timedCapture(Supplier<ChargeResult> op) {
return captureTimer.record(op);
}
public void failed() { failures.increment(); }
}
# application.yml
management:
metrics:
tags:
application: ${spring.application.name}
distribution:
percentiles-histogram:
http.server.requests: true
Q44How do Spring application events work, and why is @TransactionalEventListener critical for correctness?
IntermediateEvents
Answer
ApplicationEventPublisher.publishEvent(new OrderPlacedEvent(orderId)) delivers an event to every matching @EventListener method in the context. By default this is synchronous and in the same thread: listeners run inside the publisher's call stack, which means inside its transaction if one is active, and a listener exception propagates back and rolls back the publisher's work. That default is both a feature and a trap.
The trap scenario every payments team has hit: a listener sends a confirmation email or a webhook when an order is placed, but the surrounding transaction rolls back after publishing, and the customer receives an email for an order that does not exist. @TransactionalEventListener fixes this by binding listener execution to transaction phases: phase = AFTER_COMMIT (the default) runs the listener only if the transaction commits, with AFTER_ROLLBACK and AFTER_COMPLETION variants for cleanup paths, and BEFORE_COMMIT for validations that should be able to veto. Two subtleties that get probed. First, an AFTER_COMMIT listener runs in the afterCompletion callback where the original transaction is finished but its resources are still bound: starting new transactional work there silently participates in a stale context unless you mark the listener @Transactional(propagation = REQUIRES_NEW) (or make it @Async, hopping threads entirely).
Second, if publishEvent is called with no active transaction, @TransactionalEventListener does not fire by default (fallbackExecution = true changes that), a confusing no-op in test setups. Combining @Async with @TransactionalEventListener gives you 'after commit, off the request thread', the right shape for side effects like notifications, cache invalidation and search-index updates. The honest limitation to state: in-process events are not durable, a crash between commit and listener execution loses the side effect, which is precisely the gap the transactional outbox pattern closes when the side effect must be guaranteed.
public record OrderPlacedEvent(Long orderId) {}
@Service
public class OrderService {
private final ApplicationEventPublisher events;
@Transactional
public Order place(CreateOrderRequest req) {
Order order = orders.save(Order.from(req));
events.publishEvent(new OrderPlacedEvent(order.getId()));
return order; // listener fires only if this commits
}
}
@Component
public class OrderSideEffects {
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onPlaced(OrderPlacedEvent e) {
mailer.sendConfirmation(e.orderId());
searchIndexer.index(e.orderId());
}
}
Q45Servlet filters, HandlerInterceptors and AOP aspects: where does each belong in a Boot app?
IntermediateWeb
Answer
Three interception layers, ordered by distance from your code. Servlet filters (jakarta.servlet.Filter, registered as beans or via FilterRegistrationBean for URL patterns and ordering, typically extending OncePerRequestFilter) sit in front of the entire servlet pipeline, before Spring MVC routing exists. They see raw requests and responses, making them right for concerns that must apply to everything including static resources and error dispatches: request-ID generation into MDC, compression, security (Spring Security is itself a filter chain), and request/response logging, with the caveat that reading the body in a filter consumes the stream, so body-logging needs ContentCachingRequestWrapper.
Exceptions thrown in filters bypass @ControllerAdvice and land in the container's error handling, a debugging fact worth stating. HandlerInterceptors (registered via WebMvcConfigurer.addInterceptors) run after the DispatcherServlet has resolved which handler will execute: preHandle can short-circuit with knowledge of the target controller method (you can read its annotations), postHandle runs after the handler but before view rendering, and afterCompletion always runs, even on exceptions, making it the right place to stop timers and clear MDC. Use interceptors for MVC-aware cross-cutting: tenant resolution from headers into a request-scoped context, per-endpoint rate-limit checks keyed by handler annotations, and API-deprecation headers.
AOP aspects (@Aspect with @Around advice on Spring beans, spring-boot-starter-aop) are not tied to HTTP at all: they wrap bean method invocations anywhere in the application, so they are the tool for service-layer concerns, method timing, custom @Retryable-style behaviour, argument auditing on scheduled jobs and Kafka listeners as well as web requests. Their limits are the proxy rules again: only public external calls through the proxy are advised, self-invocation escapes. The interview shape of this question is usually a scenario: 'log every request with a correlation ID' (filter), 'reject requests to endpoints annotated @Internal from outside the VPC' (interceptor, it can see the annotation), 'time every repository call' (aspect or Micrometer's @Timed).
Key Points
- Filter: pre-routing, sees everything, exceptions skip @ControllerAdvice
- Interceptor: knows the resolved handler method and its annotations
- Aspect: bean-method level, works for Kafka/scheduler code too, proxy rules apply
- Scenario mapping is the interview: correlation ID = filter, annotation gate = interceptor, service timing = aspect
Q46How do you customise Jackson serialisation globally in Spring Boot without breaking framework defaults?
IntermediateWeb
Answer
Boot auto-configures a Jackson ObjectMapper with sensible defaults: JavaTimeModule registered (java.time serialises to ISO-8601 because WRITE_DATES_AS_TIMESTAMPS is disabled by Boot), parameter-names module for record and constructor binding, and integration with MessageConverters for MVC. The first rule of customisation: do not declare your own bare ObjectMapper @Bean unless you intend to own everything, because it replaces the auto-configured one and silently drops Boot's defaults and any module auto-registration; the correct extension points are spring.jackson.* properties (property-naming-strategy, default-property-inclusion, serialization/deserialization feature flags, time-zone, date-format) for simple cases, and a Jackson2ObjectMapperBuilderCustomizer bean for programmatic tweaks, which composes with the auto-configuration instead of replacing it. Common production requirements and their idiomatic answers: snake_case APIs via spring.jackson.property-naming-strategy=SNAKE_CASE (per-DTO override with @JsonNaming); omitting nulls with default-property-inclusion=non_null; tolerant reading with FAIL_ON_UNKNOWN_PROPERTIES=false at the edge (strict inside); money as BigDecimal with a fixed-scale serializer, never double; and custom types via a Module registering JsonSerializer/JsonDeserializer pairs, which auto-registers if you expose the Module as a bean.
Per-field control uses @JsonProperty, @JsonFormat (for the odd endpoint locked to dd-MM-yyyy), @JsonIgnore for write-only fields like password hashes, and @JsonView for role-dependent field visibility, though DTO-per-audience usually beats views for maintainability. Two gotchas with interview mileage: bidirectional JPA relationships serialise into infinite recursion (StackOverflowError) unless broken with @JsonManagedReference/@JsonBackReference or, better, by not serialising entities at all; and Jackson needs either a default constructor or constructor metadata, which records provide cleanly, one more argument for record DTOs. If a service also produces XML or protobuf, remember converters are content-type negotiated, and your Jackson customisation applies only to the JSON converter.
# application.yml: declarative Jackson policy
spring:
jackson:
property-naming-strategy: SNAKE_CASE
default-property-inclusion: non_null
deserialization:
fail-on-unknown-properties: false
// Composes with Boot's auto-config (does NOT replace the mapper)
@Bean
Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() {
return builder -> builder
.serializerByType(BigDecimal.class, new MoneySerializer()) // fixed scale
.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
}
public record OrderResponse(
Long id,
@JsonFormat(pattern = "yyyy-MM-dd") LocalDate deliveryDate,
BigDecimal totalAmount
) {}
Q47Cloud Native Buildpacks versus a hand-written Dockerfile for Boot images: trade-offs and memory behaviour?
IntermediateBuild & Deploy
Answer
Two supported paths to an OCI image. mvn spring-boot:build-image (or gradle bootBuildImage) uses Cloud Native Buildpacks (Paketo): no Dockerfile, and you get accumulated best practice for free, a maintained base image, a non-root user, exploded layered application slices for cache-friendly rebuilds, and the Java memory calculator, which inspects the container's memory limit at startup and derives JVM flags (heap via -Xmx, metaspace, reserved code cache, thread stacks) so the process fits the cgroup limit instead of getting OOMKilled. Configuration goes through environment variables (BPL_JVM_THREAD_COUNT, JAVA_TOOL_OPTIONS, BP_JVM_VERSION at build). The trade-offs: less control over the base OS (relevant when a security team mandates a specific hardened base or you need OS packages), builds require a Docker daemon and are slower cold, and debugging buildpack behaviour is its own skill.
The Dockerfile path gives exact control and is what most Indian platform teams standardise on: a multi-stage build, a JRE-only base (eclipse-temurin:21-jre or a distroless variant), the layered-jar extraction (dependencies copied before application classes so a code change rebuilds only the last layer), a non-root USER, and explicit memory flags, -XX:MaxRAMPercentage=75.0 rather than a hardcoded -Xmx, so the same image behaves correctly across different pod memory limits. Points that score in interviews: modern JVMs are container-aware (they read cgroup limits for default heap sizing, including cgroup v2 support in current JDKs), but the default max heap fraction is conservative, hence MaxRAMPercentage tuning; total process memory is heap plus metaspace plus threads plus direct buffers plus code cache, so a 512Mi limit with a 512Mi heap is guaranteed OOMKill math; and health-probe-aware rolling deploys need the image to start fast, which is where layered caching, CDS and lazy init connect back to image strategy. Whichever path, images should be reproducible in CI, scanned (Trivy or similar), and tagged immutably, 'latest' in a Kubernetes manifest is an incident waiting for a node reschedule.
# Multi-stage Dockerfile with layered jar
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY . .
RUN ./mvnw -q clean package -DskipTests \
&& java -Djarmode=tools -jar target/*.jar extract --layers --destination extracted
FROM eclipse-temurin:21-jre
WORKDIR /app
RUN useradd -r appuser
USER appuser
COPY --from=build /app/extracted/dependencies/ ./
COPY --from=build /app/extracted/spring-boot-loader/ ./
COPY --from=build /app/extracted/snapshot-dependencies/ ./
COPY --from=build /app/extracted/application/ ./
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError"
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
# Or zero-Dockerfile:
# ./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=goodspace/orders:1.4.2
Q48How do you generate OpenAPI documentation with springdoc-openapi, and keep it honest?
IntermediateAPI Design
Answer
springdoc-openapi (the springdoc-openapi-starter-webmvc-ui artifact for Boot 3; the older springfox is abandoned and breaks on Boot 3, worth saying explicitly since legacy projects still carry it) scans your controllers at runtime and serves an OpenAPI 3.1 document at /v3/api-docs plus Swagger UI at /swagger-ui.html. Because it derives the spec from actual handler signatures, return types, @Valid constraints (which become schema constraints like minLength and pattern), ProblemDetail responses, and Pageable parameters, the baseline documentation stays truthful without annotation spam. You then improve precision where inference falls short: @Operation(summary, description) and @ApiResponse for status-code contracts (springdoc cannot guess your 404s and 409s), @Schema on DTO fields for examples and descriptions, @Tag to group endpoints, and a global OpenAPI bean for info, servers, and securitySchemes (declaring the bearer-JWT scheme so 'Authorize' works in the UI and generated clients know auth requirements).
GroupedOpenApi beans split public versus internal APIs into separate documents, which matters when the public one feeds a developer portal. The 'keep it honest' part is the senior content: runtime-generated docs drift less than hand-written ones, but they still drift from intent, so contract-testing the spec in CI (fail the build if the generated /v3/api-docs diverges from the committed spec without an approved change), generating typed clients for the frontend from that artifact (openapi-generator or the TypeScript flavours), and versioning the spec alongside API versions closes the loop; hiding the UI in production (springdoc.swagger-ui.enabled=false, keep the JSON internal) is standard hardening since Swagger UI on a public domain is reconnaissance handed to attackers. Interviewers also like the workflow question, code-first (springdoc, spec generated from code) versus contract-first (spec written, server stubs generated); the pragmatic Indian-enterprise answer is code-first with the CI drift gate for internal services, contract-first where multiple teams or external partners consume the API and the contract is the negotiation artifact.
@Bean
OpenAPI apiInfo() {
return new OpenAPI()
.info(new Info().title("Orders API").version("v2"))
.components(new Components().addSecuritySchemes("bearer",
new SecurityScheme().type(SecurityScheme.Type.HTTP)
.scheme("bearer").bearerFormat("JWT")))
.addSecurityItem(new SecurityRequirement().addList("bearer"));
}
@Operation(summary = "Place an order",
responses = {
@ApiResponse(responseCode = "201", description = "Created"),
@ApiResponse(responseCode = "409", description = "Duplicate idempotency key",
content = @Content(schema = @Schema(implementation = ProblemDetail.class)))
})
@PostMapping
public OrderResponse create(@Valid @RequestBody CreateOrderRequest req) { ... }
# Hide UI outside non-prod
# springdoc.swagger-ui.enabled=false
Q49What does spring.threads.virtual.enabled=true actually change, and what are the pinning gotchas?
AdvancedConcurrency
Answer
On Java 21+, Boot 3.2+ flips the servlet request-handling model with one property: Tomcat executes each request on a new virtual thread instead of a bounded platform-thread pool, and the same switch moves @Async (SimpleAsyncTaskExecutor with virtual threads), @Scheduled, and several integrations (Kafka listener containers, RabbitMQ) onto virtual threads. The consequence: the thread-per-request model stops being the concurrency ceiling. With 200 platform threads, 200 in-flight requests each blocked 2 seconds on a slow downstream caps you at roughly 100 rps with a queue behind it; with virtual threads, blocked requests park cheaply on the JVM scheduler and carrier threads (one per core) keep running other work, so I/O-bound services see dramatically higher concurrency with no reactive rewrite.
That is the honest pitch: virtual threads give you WebFlux-like scalability for blocking code, and for most CRUD-plus-remote-calls services they have largely removed the reason to adopt reactive programming. Now the gotchas interviewers are really asking about. Pinning: a virtual thread that blocks inside a synchronized block pins its carrier thread, serialising throughput; before JDK 24 this was a real hazard in libraries (older MySQL Connector/J versions were a known offender), diagnosed with -Djdk.tracePinnedThreads=full, and JDK 24 (JEP 491) fixed synchronized-based pinning, though Object.wait and native frames can still pin.
Resource semantics change: the connection pool becomes the real limiter, 10,000 concurrent virtual threads all wanting a Hikari connection from a pool of 15 just moves the queue to getConnection, so pool sizing and connection-timeout become the tuning surface. ThreadLocal still works but at virtual-thread cardinality caching heavy objects in ThreadLocals becomes a memory problem (ScopedValue is the successor). And unbounded concurrency is not free: you now need explicit backpressure (rate limiters, bulkheads, semaphores) where the thread pool used to be your implicit one. Virtual threads do not speed up CPU-bound work at all, saying so unprompted separates you from the hype.
# application.yml (Java 21+, Boot 3.2+)
spring:
threads:
virtual:
enabled: true
// Diagnose carrier-thread pinning (pre-JDK 24 especially):
// java -Djdk.tracePinnedThreads=full -jar orders.jar
// Output names the frame holding a monitor while parked
// Explicit bulkhead: unbounded request concurrency still needs limits
@Bean
public Semaphore gatewayPermits() { return new Semaphore(50); }
public ChargeResponse charge(ChargeRequest req) throws InterruptedException {
if (!gatewayPermits.tryAcquire(2, TimeUnit.SECONDS))
throw new TooManyRequestsException();
try { return gatewayApi.charge(req); }
finally { gatewayPermits.release(); }
}
Q50How does GraalVM native image compilation work with Spring Boot, and what breaks?
AdvancedNative
Answer
Native image compiles your application ahead-of-time into a platform executable: startup in tens of milliseconds instead of seconds and a fraction of the resident memory, which is compelling for serverless (Lambda cold starts), CLI tools, and dense Kubernetes packing. The catch is the closed-world assumption: everything reachable must be known at build time, and the dynamic tricks Java frameworks live on, reflection, dynamic proxies, classpath scanning, runtime bytecode generation, either need explicit metadata or simply do not happen. Spring's answer is the AOT engine (Boot 3+): during the build (mvn -Pnative native:compile, or bootBuildImage with BP_NATIVE_IMAGE=true for a container build), Spring evaluates your configuration, decides bean definitions ahead of time, generates programmatic bean-registration code and proxy classes, and emits reachability metadata so GraalVM's native-image tool can see through the framework's dynamism.
Consequences you must know: conditions are evaluated at build time, so @Profile and @ConditionalOnProperty decisions get baked in (you cannot flip a profile at runtime and expect different beans); JIT-era peak throughput is generally somewhat lower than a warmed JVM (improved by profile-guided optimisation), so long-running high-throughput services often still prefer the JVM; and anything reflective outside Spring's knowledge, a JSON library configured manually, a JDBC driver edge case, custom classloading, fails at runtime with ClassNotFoundException or MissingReflectionRegistrationError unless registered. Registration is done via @RegisterReflectionForBinding for DTOs Jackson must see, RuntimeHintsRegistrar implementations for programmatic hints (resources, proxies, serialization), and @ImportRuntimeHints to attach them; third-party coverage comes from the shared GraalVM reachability-metadata repository, and gaps are discovered by running the tracing agent (-agentlib:native-image-agent) against your test suite to record what reflection actually happens. Testing is its own step: native tests (mvn -PnativeTest) run the suite against the compiled binary, and CI needs beefy build machines because native compilation takes minutes and gigabytes. The balanced close interviewers want: native is a deployment-profile decision, not a default; measure your cold-start and memory requirements first.
// Reflection hints for types Jackson binds reflectively at runtime
@Configuration
@RegisterReflectionForBinding({ChargeRequest.class, ChargeResponse.class})
@ImportRuntimeHints(GatewayHints.class)
class NativeConfig {}
class GatewayHints implements RuntimeHintsRegistrar {
@Override
public void registerHints(RuntimeHints hints, ClassLoader cl) {
hints.resources().registerPattern("certs/gateway-ca.pem");
hints.proxies().registerJdkProxy(GatewayApi.class);
}
}
# Build
# ./mvnw -Pnative native:compile (needs GraalVM JDK)
# ./mvnw spring-boot:build-image -Pnative (containerised build)
# Record real reflection usage from tests:
# java -agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image -jar app.jar
Q51How do you write a custom auto-configuration and package it as an internal starter?
AdvancedAuto-configuration
Answer
This is the platform-engineering question: how shared concerns (auth clients, Kafka conventions, tracing setup, your company's HTTP client defaults) get distributed to fifty services without copy-paste. A starter has two modules by convention: an autoconfigure module containing the @AutoConfiguration classes, and a thin starter module that just aggregates dependencies. The mechanics: write a class annotated @AutoConfiguration (the Boot 2.7+ replacement for @Configuration in this role), guard it with conditions, @ConditionalOnClass so it only activates when the consumer has the relevant library, @ConditionalOnProperty for kill switches (goodspace.audit.enabled), and critically @ConditionalOnMissingBean on every @Bean method so any consuming service can override your default by declaring its own bean; forgetting that is how platform teams break product teams.
Bind settings through your own @ConfigurationProperties (prefix like goodspace.audit) and add the spring-boot-configuration-processor so IDEs autocomplete them. Then register the class in META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports, the file Boot actually reads (the spring.factories key stopped working for auto-configurations in Boot 3, a migration gotcha for old internal starters). Ordering relative to Boot's own configurations uses @AutoConfigureAfter/@AutoConfigureBefore, for example after KafkaAutoConfiguration if you decorate its beans.
Test with ApplicationContextRunner, the purpose-built harness: assert the bean appears with the class present, disappears with the property off, and backs off when the user supplies their own. Versioning discipline matters as much as mechanics: starters are libraries, so semantic versioning, a compatibility matrix against Boot versions, and never leaking your transitive dependency choices un-managed into consumers (use a BOM). The interview signal is the back-off philosophy: a good starter provides defaults, never mandates, and every behaviour is overridable and observable.
// goodspace-audit-spring-boot-autoconfigure
@AutoConfiguration
@ConditionalOnClass(KafkaTemplate.class)
@ConditionalOnProperty(prefix = "goodspace.audit", name = "enabled",
havingValue = "true", matchIfMissing = true)
@EnableConfigurationProperties(AuditProps.class)
public class AuditAutoConfiguration {
@Bean
@ConditionalOnMissingBean // consumers can override
public AuditPublisher auditPublisher(KafkaTemplate<String, String> kafka,
AuditProps props) {
return new KafkaAuditPublisher(kafka, props.topic());
}
}
// src/main/resources/META-INF/spring/
// org.springframework.boot.autoconfigure.AutoConfiguration.imports
// com.goodspace.audit.AuditAutoConfiguration
// Test with ApplicationContextRunner
new ApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(AuditAutoConfiguration.class))
.withPropertyValues("goodspace.audit.enabled=false")
.run(ctx -> assertThat(ctx).doesNotHaveBean(AuditPublisher.class));
Q52A Boot service takes 45 seconds to start. How do you diagnose and fix startup time, including CDS and CRaC?
AdvancedPerformance
Answer
Measure before touching anything. Wire BufferingApplicationStartup into SpringApplication and read /actuator/startup: it returns the startup-step tree with per-bean instantiation timings, immediately naming the offenders. The usual suspects, in observed frequency order: a bean doing remote I/O in its constructor or @PostConstruct (fetching config, warming a cache against a slow dependency, an eager connection with a long DNS timeout); classpath bloat from a monolithic dependency tree, since scanning and parsing thousands of classes is real time; Hibernate bootstrapping with ddl-auto validation over hundreds of entities; and eager initialisation of things only used on demand.
Fix categories. Application-level: move I/O out of bean creation into ApplicationReadyEvent listeners or lazy suppliers, trim dependencies, and consider spring.main.lazy-initialization=true selectively (@Lazy on heavy beans is more surgical than the global flag, which just moves latency to the first unlucky request and can hide wiring errors until traffic arrives). JVM-level: Class Data Sharing, which Boot 3.3 made straightforward, extract the jar (java -Djarmode=tools -jar app.jar extract), do a training run with -XX:ArchiveClassesAtExit=app.jsa, then start with -XX:SharedArchiveFile=app.jsa; expect meaningful reductions since class loading dominates Spring startup, and Project Leyden's ahead-of-time cache in recent JDKs (-XX:AOTCache, JEP 483 line) pushes the same idea further.
Checkpoint/restore: CRaC (Coordinated Restore at Checkpoint, supported since Boot 3.2 with org.crac) snapshots a fully warmed JVM and restores it in tens of milliseconds; it requires closing sockets and files around the checkpoint (Boot coordinates its lifecycle), a CRaC-enabled JDK on Linux, and careful handling of secrets and randomness captured in the image. And GraalVM native image is the endpoint of this spectrum when cold start is the dominant requirement. In Kubernetes, also fix the operational symptom directly: a startupProbe with generous failureThreshold stops liveness from killing slow-booting pods, which is sometimes the actual production problem behind 'startup is too slow'.
// Expose startup timings
public static void main(String[] args) {
var app = new SpringApplication(OrdersApplication.class);
app.setApplicationStartup(new BufferingApplicationStartup(2048));
app.run(args);
}
// then: GET /actuator/startup -> per-step durations
# CDS with Boot 3.3+ (train once per build, reuse every start)
java -Djarmode=tools -jar orders.jar extract --destination app
cd app && java -XX:ArchiveClassesAtExit=app.jsa -Dspring.context.exit=onRefresh -jar orders.jar
java -XX:SharedArchiveFile=app.jsa -jar orders.jar
# Selective laziness beats the global flag
# spring.main.lazy-initialization=true (blunt)
# @Lazy on the 3 heavy beans (surgical)
Q53How do you set up distributed tracing with Micrometer Tracing and OpenTelemetry in Boot 3?
AdvancedObservability
Answer
Boot 3 replaced Spring Cloud Sleuth with Micrometer Tracing, a facade over a tracer bridge. The standard 2026 stack: micrometer-tracing-bridge-otel (OpenTelemetry API underneath) plus an exporter, opentelemetry-exporter-otlp, configured with management.otlp.tracing.endpoint pointing at an OTel collector, Tempo, Jaeger, or a vendor backend (SigNoz self-hosted is common with Indian teams controlling observability cost). With Actuator present, auto-configuration instruments the request path end to end: incoming HTTP handled by servlet filters starts or continues a span, outgoing calls through the auto-configured RestClient.Builder/WebClient.Builder inject W3C traceparent headers (build clients from those builders, RestClient.create() is invisible to tracing, a top-three integration mistake), and Kafka and JDBC spans come from their respective instrumentations (JDBC via datasource-micrometer or the OTel agent).
Sampling is management.tracing.sampling.probability; 1.0 in dev, but production at Flipkart-scale traffic runs low head-based probabilities or moves sampling to the collector (tail-based, keeping all error traces and a fraction of successes), and you should articulate that head-versus-tail trade-off. Log correlation completes the story: with tracing active, Boot puts traceId and spanId into the MDC, so structured logs join to traces and 'find the logs for this slow trace' becomes a query rather than archaeology. Two boundaries where traces break and what you do: thread hops, context does not follow work you submit to your own executors unless you wrap them (ContextPropagatingTaskDecorator or Context.taskWrapping), and this is where reciting 'tracing is thread-bound like transactions and security' shows systemic understanding; and messaging, publishing to Kafka should inject trace context into headers so the consumer continues the trace, which spring-kafka's observation support does when you enable it (setObservationEnabled(true) on factories). Custom spans for business operations use the Observation API (@Observed or Observation.createNotStarted(...).observe(runnable)), which emits metrics and spans from one instrumentation, the whole point of the unified model.
<!-- pom.xml -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>
<dependency>
<groupId>io.opentelemetry</groupId>
<artifactId>opentelemetry-exporter-otlp</artifactId>
</dependency>
# application.yml
management:
tracing:
sampling:
probability: 0.1 # head sampling; move to collector for tail-based
otlp:
tracing:
endpoint: http://otel-collector:4318/v1/traces
logging:
pattern:
correlation: "[%X{traceId:-},%X{spanId:-}] "
// Business span + metric from one instrumentation
@Observed(name = "resume.score", contextualName = "score-resume")
public ScoreResult score(long resumeId) { return scorer.run(resumeId); }
Q54How do you handle failures in @KafkaListener consumers: retries, DefaultErrorHandler, and dead-letter topics?
AdvancedMessaging
Answer
spring-kafka's model: a @KafkaListener method receives records from a listener container; if it throws, the DefaultErrorHandler (since spring-kafka 2.8) takes over, replaying the failed record according to a BackOff (say, FixedBackOff or ExponentialBackOffWithMaxRetries), and blocking that partition while it retries, which is the first design fact to state: in-place retries stall every record behind the failure on that partition, so long backoffs belong in non-blocking retry topics, not the main consumer loop. After retries exhaust, a DeadLetterPublishingRecoverer publishes the record to a dead-letter topic (default naming: the original topic name with a .DLT suffix) with headers carrying the exception class, message, stack trace, and original offset, giving operators the full forensic record. Classify exceptions deliberately: addNotRetryableExceptions(DeserializationException.class, MessageConversionException.class, ValidationException.class) sends poison pills straight to the DLT, because retrying a permanently malformed payload just burns partition throughput; conversely a database timeout is retryable.
Deserialization failures deserve their own mention since they crash naive consumers in a loop before your code even runs: wrap deserializers in ErrorHandlingDeserializer so the failure becomes a handled record instead of a container death spiral. For long or layered backoffs, @RetryableTopic gives non-blocking retries by hopping the record through timed retry topics (orders-retry-1000, orders-retry-5000) before the DLT, keeping the main partition flowing at the cost of ordering, and you must say that cost aloud: any retry-topic scheme abandons per-key ordering guarantees during failure handling. Around all of this sits idempotency: rebalances and retries make redelivery a certainty, so consumers must be idempotent (keyed upserts, processed-message tables, or idempotent domain operations), and offset-commit mode matters, the default is commit-after-processing per batch/record (MANUAL_IMMEDIATE with ack for explicit control), giving at-least-once semantics. Finally, DLT hygiene as an operational practice: alert on DLT depth, build a replayer with the original headers, and treat sustained DLT flow as an incident signal, not a garbage bin.
@Bean
DefaultErrorHandler kafkaErrorHandler(KafkaTemplate<Object, Object> template) {
var recoverer = new DeadLetterPublishingRecoverer(template); // topic.DLT
var handler = new DefaultErrorHandler(recoverer,
new ExponentialBackOffWithMaxRetries(3) {{
setInitialInterval(1000); setMultiplier(2.0);
}});
handler.addNotRetryableExceptions(
DeserializationException.class, jakarta.validation.ValidationException.class);
return handler;
}
@KafkaListener(topics = "payment-events", groupId = "ledger",
containerFactory = "kafkaListenerContainerFactory")
public void onPayment(PaymentEvent event, Acknowledgment ack) {
ledger.applyIdempotently(event.eventId(), event); // survive redelivery
ack.acknowledge();
}
# consumer resilience for poison pills
spring.kafka.consumer.value-deserializer: org.springframework.kafka.support.serializer.ErrorHandlingDeserializer
spring.kafka.properties.spring.deserializer.value.delegate.class: org.springframework.kafka.support.serializer.JsonDeserializer
Q55How do you apply circuit breakers, retries and time limiters with Resilience4j in Spring Boot?
AdvancedResilience
Answer
Resilience4j is the standard JVM resilience library since Hystrix went into maintenance, integrated via the resilience4j-spring-boot3 starter with annotations and externalised configuration. The circuit breaker state machine: CLOSED (normal, recording outcomes in a sliding window), OPEN (failure rate over failure-rate-threshold, or slow-call rate over slow-call-rate-threshold, trips it; calls now fail immediately with CallNotPermittedException, protecting both you and the struggling dependency), then after wait-duration-in-open-state, HALF_OPEN admits a limited number of trial calls that decide reopen versus close. Configuration lives in properties per named instance (resilience4j.circuitbreaker.instances.gateway.*): sliding-window-size, minimum-number-of-calls (below it the breaker never trips, a subtle reason 'my breaker does nothing' in low-traffic services), and record-exceptions versus ignore-exceptions, where business exceptions like validation failures must be ignored or your breaker trips on user error.
Usage: @CircuitBreaker(name = "gateway", fallbackMethod = "chargeFallback") on the remote call, with the fallback method matching the signature plus a throwable parameter; the same method can stack @Retry, @TimeLimiter, @Bulkhead and @RateLimiter, and the default aspect order applies (Retry outside CircuitBreaker outside the others by default; each retry attempt is a distinct breaker sample, so order changes semantics and is configurable via resilience4j.retry.retryAspectOrder). Design judgement is what the question grades: fallbacks must be meaningful (cached last-known value, queued-for-later, honest degraded response), not exception-swallowing that converts outages into silent data loss; retries need jittered exponential backoff and must wrap only idempotent operations, retrying a non-idempotent payment capture is how double charges happen (pair with idempotency keys); time limiters bound the latency your callers inherit; bulkheads isolate thread or permit consumption per dependency so one slow downstream cannot exhaust shared capacity. Everything emits Micrometer metrics (resilience4j.circuitbreaker.state, failure rates), and alerting on state transitions turns breakers into an early-warning system. The proxy caveats apply once more: annotation-driven resilience is AOP, so self-invocation bypasses it.
# application.yml
resilience4j:
circuitbreaker:
instances:
gateway:
sliding-window-size: 50
minimum-number-of-calls: 20
failure-rate-threshold: 50
slow-call-duration-threshold: 2s
slow-call-rate-threshold: 80
wait-duration-in-open-state: 30s
ignore-exceptions:
- com.goodspace.payments.ValidationException
retry:
instances:
gateway:
max-attempts: 3
wait-duration: 200ms
enable-exponential-backoff: true
@CircuitBreaker(name = "gateway", fallbackMethod = "chargeFallback")
@Retry(name = "gateway")
public ChargeResponse charge(ChargeRequest req) {
return gatewayApi.charge(req); // idempotency key inside req
}
private ChargeResponse chargeFallback(ChargeRequest req, CallNotPermittedException e) {
return ChargeResponse.queued(req.idempotencyKey()); // honest degradation
}
Q56Your Boot service degrades badly at peak traffic. Walk through the capacity math across Tomcat, Hikari and downstream calls.
AdvancedPerformance
Answer
This is the systems question senior panels use to separate configuration knowledge from mental models. Start with the chain of pools: Tomcat worker threads (server.tomcat.threads.max, default 200) bound concurrent request processing; Hikari connections (default 10) bound concurrent database work; downstream HTTP clients have their own connection pools and timeouts; and each stage queues when saturated (accept-count for Tomcat's TCP backlog, getConnection waits up to connection-timeout for Hikari). Degradation is almost always one pool saturating and queueing invisibly.
Apply Little's Law per stage: concurrency = arrival rate x latency. At 1,000 rps with 50ms mean latency you hold about 50 requests in flight, comfortable; if a downstream dependency degrades to 2s, in-flight jumps toward 2,000, Tomcat's 200 threads saturate, the accept queue fills, clients see connect timeouts, and your service is 'down' although the JVM is healthy and mostly parked on I/O. The interview-grade diagnosis order: check thread-pool saturation (tomcat.threads.busy metric at max), then Hikari (hikaricp.connections.pending rising, 'Connection is not available' errors), then downstream latency in traces, and identify which queue the latency lives in.
Fixes in order of leverage. Timeouts first: every remote call gets a connect and read timeout shorter than your own SLO, because unbounded waits are what convert a slow dependency into your outage; add circuit breakers so failure is fast and bounded. Right-size the ratio: 200 Tomcat threads against 10 connections means 190 threads can be queued on getConnection during DB-heavy load, so either raise the pool modestly toward the cores-based limit, cut thread count, or shorten connection hold time (kill open-in-view, move work out of transactions, fix slow queries, which is usually the real culprit, missing indexes surface exactly this way).
Shed load deliberately: fail fast with 429s at a rate limiter instead of queueing into timeout territory. Then scale horizontally, redoing the multiplication against database capacity (instances x pool size versus what the DB sustains, PgBouncer when that exceeds it). Virtual threads change the thread ceiling but not the connection or downstream ceilings; saying that explicitly shows the model, not the folklore.
Key Points
- Model it as chained pools: Tomcat threads, Hikari connections, downstream clients
- Little's Law per stage: in-flight = rate x latency; find which queue holds the latency
- Metrics: tomcat.threads.busy, hikaricp.connections.pending, trace spans downstream
- Timeouts + circuit breakers before any pool resizing
- Load shedding (fast 429) beats queueing into timeouts
- Virtual threads lift the thread ceiling only, not DB or downstream ceilings
Q57What is new in Spring Boot 4 and Spring Framework 7, and how disruptive is the upgrade?
AdvancedVersions
Answer
Spring Boot 4.0 shipped in November 2025 on Spring Framework 7, and 2026 interview loops at companies planning migrations increasingly probe it. The changes that matter in practice. Null-safety became a first-class contract: the codebase adopted JSpecify annotations (@Nullable, @NonNull from org.jspecify), replacing Spring's older internal annotations, so tools like NullAway and Kotlin interop can enforce nullness across API boundaries; expect gradual compile-time pressure on your own code as libraries follow.
API versioning became native: Spring Framework 7 added first-class versioning to request mappings, an api version attribute on mappings with resolution strategies (header, query parameter, or path based) configured centrally, replacing the header-parsing hacks and duplicated controller trees teams maintained for /v1 and /v2. HTTP clients consolidated further around RestClient and @HttpExchange interfaces, with older RestTemplate usage pushed still further toward legacy status. The framework baseline moved to current Jakarta EE 11 levels (newer Servlet and Persistence specs), the managed dependency stack rolled forward accordingly (Hibernate, Jackson major versions), and Boot modularised its own internals, reorganising the autoconfigure monolith into finer-grained modules, largely invisible if you use starters, visible if you depended on internal artifact names.
Baseline Java remains 17 with everything through Java 25 supported, and the virtual-thread and AOT/native stories continue to deepen rather than change direction. Disruption assessment, which is the actual question: far smaller than 2-to-3 (no namespace rewrite), but non-trivial, deprecated-in-3.x APIs were removed, property migrations apply (the properties-migrator dependency helps again), internal-module renames bite build files, and the Jakarta/Hibernate bumps can shift persistence behaviour, so the playbook is: get clean on 3.5 with zero deprecation warnings first, rely on OpenRewrite recipes for the mechanical parts, and run your integration suite against real databases before and after. Being able to articulate 'what would break for us specifically' beats reciting release notes.
Key Points
- Boot 4.0 (Nov 2025) on Framework 7: Java 17 baseline kept, Java 25 supported
- JSpecify null-safety annotations across the framework's APIs
- Native API versioning in request mappings (header/param/path strategies)
- Jakarta EE 11 alignment; Boot internals modularised
- Upgrade path: clean 3.5 + zero deprecations first, OpenRewrite for mechanics
Q58Your pod keeps getting OOMKilled but heap looks fine. How do you diagnose JVM memory in containers?
AdvancedOperations
Answer
OOMKilled (exit code 137) means the cgroup memory limit was exceeded by the whole process, and the classic confusion is that heap dashboards look healthy because the overage lives outside the heap. Total JVM footprint = heap + metaspace + thread stacks (1MB default per platform thread; 400 threads is 400MB) + direct/native buffers (Netty, NIO file transfers) + code cache + GC and JIT working memory + any native libraries (compression, crypto, image processing). Diagnosis toolkit, in the order you would actually use it: confirm the kill in kubectl describe pod (OOMKilled, exit 137, distinct from the JVM's own OutOfMemoryError, which is a different failure with a stack trace); check what the JVM thinks its budget is (java -XX:+PrintFlagsFinal | grep MaxHeapSize inside the container, verifying container-awareness picked up the cgroup limit); then turn on Native Memory Tracking (-XX:NativeMemoryTracking=summary, then jcmd <pid> VM.native_memory summary) which itemises heap, class, thread, code and internal categories and usually names the culprit immediately, a thread leak (every 'new Thread per request' bug shows here), metaspace growth from classloader leaks (common with devtools-style reloading or dynamic proxies gone wrong), or ballooning direct buffers (cap with -XX:MaxDirectMemorySize).
For heap-side confirmation: -XX:+HeapDumpOnOutOfMemoryError with a dump path on a mounted volume, jcmd GC.heap_dump on demand, and Eclipse MAT's dominator tree for retained-size analysis; the recurring Spring-app offenders are unbounded in-process caches (a Caffeine cache without maximumSize), gauge/meter leaks from re-registering metrics with unique tags, and result sets loaded whole instead of paginated (the collection-fetch pagination trap feeding an OOM). The sizing fix that closes most tickets: set -XX:MaxRAMPercentage=65-75 rather than a fixed Xmx so heap scales with the limit while leaving genuine headroom for non-heap, budget threads deliberately (or reduce stack size, or adopt virtual threads which use heap-allocated stacks), and add -XX:+ExitOnOutOfMemoryError so a truly out-of-memory JVM dies cleanly for Kubernetes to replace instead of limping corrupted. Close with prevention: alert on container memory working set versus limit at 80 percent, not on heap alone.
# Confirm the kill and the JVM's view of its budget
kubectl describe pod orders-7f9c | grep -A3 'Last State' # OOMKilled, exit 137
java -XX:+PrintFlagsFinal -version | grep -E 'MaxHeapSize|MaxRAMPercentage'
# Native Memory Tracking: where the non-heap memory went
# JAVA_TOOL_OPTIONS: -XX:NativeMemoryTracking=summary
jcmd 1 VM.native_memory summary
# Thread (reserved=412MB ...) <- thread leak found
# Production-safe flags for a 1Gi pod limit
JAVA_TOOL_OPTIONS: >-
-XX:MaxRAMPercentage=70.0
-XX:MaxDirectMemorySize=128m
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps
-XX:+ExitOnOutOfMemoryError
Q59How does graceful shutdown work in Spring Boot, and how do you deploy with zero dropped requests?
AdvancedOperations
Answer
server.shutdown=graceful changes what happens on SIGTERM: instead of killing in-flight work, the embedded server stops accepting new connections and waits up to spring.lifecycle.timeout-per-shutdown-phase (default 30s) for active requests to complete before the context closes; @PreDestroy hooks then run in reverse dependency order. But the property alone does not give you zero-drop deploys on Kubernetes, and the gap is exactly what this question tests. The race: when a pod is deleted, kubelet sends SIGTERM and, in parallel, the endpoint controllers remove the pod from Service endpoints; load balancers and kube-proxy converge asynchronously, so for hundreds of milliseconds to seconds after SIGTERM, new requests still arrive at a server that has stopped accepting, surfacing as connection resets and 502s exactly during deploys.
The standard fix is a preStop sleep (lifecycle.preStop: exec sleep 5-10), delaying SIGTERM until endpoint removal has propagated, paired with terminationGracePeriodSeconds comfortably larger than preStop plus the Boot timeout (kubelet SIGKILLs at that deadline regardless, and SIGKILL runs no hooks). Boot cooperates from its side: readiness flips to REFUSING_TRAFFIC early in the shutdown sequence, which the readiness probe reports, accelerating endpoint removal. What graceful shutdown does not cover needs explicit handling: @Scheduled tasks and executor queues, configure ThreadPoolTaskExecutor with setWaitForTasksToCompleteOnShutdown(true) and setAwaitTerminationSeconds so submitted async work drains; Kafka listener containers stop polling and commit cleanly on context close, but long-running message handlers must finish inside the grace window or design for redelivery; and in-flight outbound requests should have timeouts shorter than the drain window or they hold shutdown hostage.
Verify empirically, because every load-balancer stack differs: run a sustained load test and roll the deployment, asserting zero non-2xx; that test in CI is what 'we have zero-downtime deploys' should actually mean. The senior-level cross-connection: everything here assumes at-least-once semantics somewhere, so handlers must tolerate a request or message being repeated after an ill-timed SIGKILL, which loops back to idempotency as the foundation under all deployment safety.
# application.yml
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 25s
# k8s deployment
# terminationGracePeriodSeconds: 40
# lifecycle:
# preStop:
# exec:
# command: ["sh", "-c", "sleep 8"] # let endpoint removal propagate
// Drain async work too: graceful shutdown does not cover your executors
@Bean
ThreadPoolTaskExecutor notifExecutor() {
var ex = new ThreadPoolTaskExecutor();
ex.setCorePoolSize(8);
ex.setQueueCapacity(200);
ex.setWaitForTasksToCompleteOnShutdown(true);
ex.setAwaitTerminationSeconds(20);
return ex;
}
Q60How do you implement the transactional outbox pattern and idempotency for payment-grade reliability?
AdvancedArchitecture
Answer
The dual-write problem: a service that saves an order and publishes an OrderPlaced event to Kafka performs two writes into two systems with no shared transaction, so a crash between them yields either a saved order nobody hears about or an announced order that does not exist; at payment semantics, both are incidents. The transactional outbox solves it by collapsing the dual write into one: inside the same database transaction that mutates state, insert a row into an outbox table (event id, aggregate id, type, JSON payload, created_at, published_at null); commit is now atomic across both. A separate publisher then moves outbox rows to the broker, either a polling relay inside the service, a @Scheduled method selecting unpublished rows FOR UPDATE SKIP LOCKED (so multiple instances share the work without double-claiming), publishing, then marking published, or log-based change data capture with Debezium tailing the outbox table, which removes polling latency and load at the cost of running a CDC pipeline.
Delivery is now at-least-once, never exactly-once, and you should say so plainly: crashes between publish and mark-published produce duplicates by design, which is why the consumer side must be idempotent, dedupe on the event id with a processed-events table (insert-if-absent in the consumer's transaction) or naturally idempotent handlers (keyed upserts). The same idempotency discipline applies on your inbound edge, and it is the half interviewers push into: payment APIs accept an Idempotency-Key header, stored with a unique constraint alongside the response; retries hit the constraint and replay the stored response instead of re-executing, converting client timeouts and double-clicks from double charges into safe no-ops (this is how Razorpay-class gateways behave, and how callers of such gateways must behave in turn). Practical Boot details that earn credit: the outbox insert must share the @Transactional boundary with the domain write (same service method, or an AFTER_COMMIT trap if you get it wrong, the listener approach cannot give atomicity, only the same-transaction insert can); ordering per aggregate comes from partitioning the topic by aggregate id; outbox tables need pruning jobs and depth alerts; and end-to-end tests kill the process between commit and publish to prove the relay recovers, the test almost nobody writes until the first lost event.
@Transactional // ONE transaction: domain write + outbox insert
public Order place(CreateOrderRequest req, String idempotencyKey) {
idempotency.claim(idempotencyKey); // unique-constraint insert; 409 on replay
Order order = orders.save(Order.from(req));
outbox.save(OutboxEvent.of(order.getId(), "OrderPlaced", json(order)));
return order;
}
// Relay: shared safely across instances via SKIP LOCKED
@Scheduled(fixedDelay = 500)
@Transactional
public void relay() {
List<OutboxEvent> batch = outboxRepo.claimUnpublished(100); // FOR UPDATE SKIP LOCKED
for (OutboxEvent e : batch) {
kafka.send("order-events", e.aggregateId(), e.payload()).join();
e.markPublished();
}
}
-- V12__outbox.sql
CREATE TABLE outbox_event (
id UUID PRIMARY KEY, aggregate_id TEXT NOT NULL, type TEXT NOT NULL,
payload JSONB NOT NULL, created_at TIMESTAMPTZ DEFAULT now(),
published_at TIMESTAMPTZ
);
Frequently Asked Questions
What does a Spring Boot developer earn in India in 2026?
The broad band is ₹8-25 LPA for mid-to-senior backend engineers with Spring Boot as the primary stack. The spread inside that band is about employer type more than years: services companies (TCS, Infosys, Wipro) typically pay ₹4-8 LPA for freshers and ₹8-15 LPA at mid-level, while product and fintech companies (Flipkart, PhonePe, Razorpay, Visa, Walmart Global Tech and GCCs generally) pay ₹15-25 LPA at mid-level and cross ₹30-45 LPA for senior engineers who also bring system design, Kafka, and Kubernetes depth. Java-plus-Spring remains the highest-volume backend hiring pipeline in India, so competition is real, but so is the number of open seats.
How long does it take to get interview-ready for Spring Boot roles?
If you already know core Java well (collections, concurrency, JVM basics), 6-8 weeks of focused preparation covers the framework: two weeks on fundamentals (DI, auto-configuration, configuration, REST, validation), two on data and transactions (JPA, N+1, locking, Flyway), and two-plus on the production layer (security, testing with Testcontainers, Actuator, Kafka, and observability). Build one non-trivial project with real integrations rather than five CRUD demos: a payments-flavoured service with idempotency, an outbox, and container deployment exercises 80 percent of what interviews test. If your Java itself is shaky, fix that first; Spring interviews collapse into Java interviews the moment concurrency or memory comes up.
What do interviewers expect from freshers versus experienced candidates?
Freshers are tested on Java fundamentals plus Spring basics: what DI is, constructor injection, @RestController flows, JPA repository usage, and one honest project they can defend line by line; nobody expects a fresher to explain CRaC. At 3-5 years the bar moves to transactions and their proxy semantics, N+1 and fetch strategy, testing slices, security configuration, and debugging production symptoms (pool exhaustion, OOMKills). At senior levels the interview is mostly systems: capacity math across thread and connection pools, Kafka failure semantics, outbox and idempotency design, migration strategy across Boot versions, and the judgement to say when Spring Boot is the wrong tool. Misjudging which bar applies to you, in either direction, is a common and avoidable failure.
Is Spring Boot still worth learning in 2026 given Node.js, Go and Python?
Yes, on volume and durability. Indian enterprise backends, banking, insurance, telecom, e-commerce and the GCC sector run overwhelmingly on Java and Spring Boot, and that installed base generates more openings than any competing backend stack. The framework has also answered its historical criticisms: virtual threads removed the scalability argument for switching to reactive or to Go for typical I/O-bound services, native images answered cold-start complaints, and the Boot 3-to-4 line shows active, disciplined evolution. Node.js (with NestJS) and Go win specific niches (frontend-adjacent teams, infrastructure tooling), and learning one of them alongside is smart career hedging, but as a primary employable skill in India, Spring Boot has the deepest and most stable demand curve.
Spring Boot versus Spring Framework versus Spring Cloud: what is the actual difference?
Spring Framework is the foundation: the IoC container, AOP, transaction management, Spring MVC. Spring Boot sits on top and makes it deployable with minimal ceremony: auto-configuration, starters, the embedded server, and Actuator; every Boot app is a Spring app. Spring Cloud sits above Boot for distributed-systems concerns: config server, service discovery, gateways, and circuit-breaker abstractions, though in Kubernetes-native architectures much of Spring Cloud's original scope (discovery, config) has migrated to the platform, so its footprint in new systems is more selective. Interviewers frequently open with this question; a crisp three-layer answer with the Kubernetes caveat signals you understand the ecosystem's current shape rather than its 2018 shape.
Which Java version should I know for Spring Boot interviews in 2026?
Be fluent in Java 17 as the floor, because Boot 3 and 4 both require it and most Indian production estates sit on 17 or 21. Java 21 matters most for virtual threads, which have become a standard interview topic wherever high-concurrency services are discussed, plus records, sealed types and pattern matching, which show up in modern codebases and code reviews. Knowing what Java 25 brings is a bonus, not a requirement. What actually differentiates candidates is not reciting version features but connecting them to the framework: why records make good DTOs, how virtual threads change Tomcat and pool sizing, and what the Java 17 baseline meant for the Boot 2-to-3 migration your interviewer probably lived through.
Introduction
Spring Boot remains the single most in-demand backend framework in Indian product and services hiring, and the interview bar has moved sharply since the Spring Boot 3 migration wave. Interviewers no longer stop at annotation trivia: they expect you to explain how auto-configuration actually resolves beans, why @Transactional silently does nothing on self-invocation, how HikariCP pool sizing interacts with Tomcat's worker threads, and what virtual threads change about all of it. Payment companies like PhonePe, Razorpay and Paytm, commerce giants like Flipkart and Walmart Global Tech, and every large services firm from TCS to Infosys run Spring Boot at serious scale, so the questions come from production scars, not textbooks.
The 2026 interview loop typically covers four layers. First, fundamentals: starters, auto-configuration conditions, configuration binding, profiles, and the embedded server model. Second, the data layer: Spring Data JPA, the N+1 problem, open-in-view, locking, and Flyway migrations. Third, production behaviour: Actuator health groups for Kubernetes, Micrometer metrics and tracing, graceful shutdown, container memory sizing, and Kafka error handling. Fourth, what changed recently: the Jakarta namespace move in Boot 3, virtual threads and RestClient in 3.2, Testcontainers @ServiceConnection, GraalVM native images, and the Spring Boot 4 / Framework 7 release from late 2025 with JSpecify null-safety and first-class API versioning.
This guide contains 60 questions ordered basic to intermediate to advanced, with the difficulty mix real panels use: roughly 40 percent fundamentals, 40 percent applied production topics, and 20 percent senior-level depth. Each answer explains how the feature behaves in a running service, the gotcha an interviewer is fishing for, and where candidates typically lose the offer. Most technical questions include a compact, modern code example using Boot 3-style APIs (jakarta.* imports, SecurityFilterChain, RestClient) that you can type into a scratch project and verify yourself. Work through the basic section quickly, then spend your energy on transactions, testing slices, observability, and the versions material, since that is where senior offers at Indian product companies are decided.
Ready to practice Spring Boot interviews?
Don't just read, practice these Spring Boot questions live with an AI interviewer that asks follow-ups and scores your answers.