Spring Interview Questions and Answers

Last updated:

Check out 60 of the most common Spring interview questions, then take an AI-powered practice interview

JavaSpring BootSpring SecurityJPAMicroservices
60+
Questions
24
Basic
24
Intermediate
12
Advanced
Q1

What does inversion of control mean in Spring, and how do BeanFactory and ApplicationContext differ?

BasicFundamentals

Answer

Inversion of control means your classes no longer construct their own dependencies with new; the Spring container constructs every object (a bean), wires its dependencies, and manages its lifecycle. Your code declares what it needs through constructors, and the container satisfies those needs at startup. This is what makes Spring code testable: in a unit test you pass a mock into the constructor yourself, no container required.

BeanFactory is the minimal container interface: it lazily instantiates beans on first getBean() call and provides basic DI. ApplicationContext extends BeanFactory and is what every real application uses. It eagerly instantiates all singleton beans at startup (so misconfiguration fails at boot, not at 2 AM), adds ApplicationEvent publishing, MessageSource internationalisation, resource loading via ResourceLoader, and automatic registration of BeanPostProcessor and BeanFactoryPostProcessor beans.

In practice you touch concrete contexts like AnnotationConfigApplicationContext in tests or plain-Java bootstraps, while Spring Boot creates the right context for you inside SpringApplication.run(). Interviewers ask this to check whether you understand that eager singleton instantiation is a deliberate fail-fast design choice: a missing bean or an unsatisfiable constructor dependency produces UnsatisfiedDependencyException during startup rather than a NullPointerException in production. Be ready to say that BeanFactory is effectively an SPI for framework integrators, and that choosing it to 'save memory' is a red flag answer in 2026.

// Plain Spring bootstrap (what Boot does for you under the hood)
AnnotationConfigApplicationContext ctx =
    new AnnotationConfigApplicationContext(AppConfig.class);

OrderService orders = ctx.getBean(OrderService.class);

@Configuration
class AppConfig {
  @Bean
  PaymentGateway paymentGateway() {
    return new RazorpayGateway();
  }

  @Bean
  OrderService orderService(PaymentGateway gateway) {
    // container passes the gateway bean in; no 'new RazorpayGateway()' here
    return new OrderService(gateway);
  }
}

Key Points

  • Container builds and wires beans; classes declare dependencies via constructors
  • ApplicationContext = BeanFactory + events, i18n, resources, post-processor auto-registration
  • Singletons are instantiated eagerly at startup: fail-fast by design
  • UnsatisfiedDependencyException at boot beats NullPointerException in production
Q2

Constructor, setter, and field injection: which does the Spring team recommend and why is field injection discouraged?

BasicDependency Injection

Answer

Constructor injection is the recommended style, and since Spring 4.3 you do not even need @Autowired on a class with a single constructor. The reasons are concrete. First, immutability: constructor-injected fields can be final, so a bean can never exist in a half-wired state.

Second, testability: you can instantiate the class in a plain JUnit test with new and hand it mocks, without Spring, reflection, or ReflectionTestUtils. Third, fail-fast: missing dependencies blow up at startup with UnsatisfiedDependencyException instead of a NullPointerException later. Fourth, design feedback: a constructor with nine parameters is visibly screaming that the class violates single responsibility, while nine @Autowired fields hide the smell.

Field injection (@Autowired directly on a private field) works via reflection, cannot produce final fields, makes the class unconstructable without a container, and silently couples your code to Spring. Setter injection has one legitimate niche: genuinely optional dependencies that can be reconfigured, which is rare. With Lombok, @RequiredArgsConstructor on the class plus private final fields gives you constructor injection with zero boilerplate, and that combination is the de facto standard in Indian product companies.

A follow-up interviewers like: what happens with two constructors? You must mark one with @Autowired, otherwise Spring cannot decide, and if a required bean is genuinely optional you inject ObjectProvider<T> or use @Autowired(required = false) on a setter rather than making constructor arguments nullable.

@Service
public class SettlementService {
  private final LedgerRepository ledger;   // final: immutable after construction
  private final ClockProvider clock;

  // single constructor: @Autowired is implicit since Spring 4.3
  public SettlementService(LedgerRepository ledger, ClockProvider clock) {
    this.ledger = ledger;
    this.clock = clock;
  }
}

// Unit test needs no Spring at all:
var service = new SettlementService(mockLedger, fixedClock);
💡 Pro Tip: If an interviewer asks you to justify constructor injection, lead with final fields and container-free unit tests, those two answers show you have actually written tests, not just read blogs.
Q3

When do you use @Bean in a @Configuration class instead of @Component, and what does proxyBeanMethods do?

BasicConfiguration

Answer

@Component (and its stereotypes) is for classes you own: annotate the class, let component scanning pick it up. @Bean is for objects you do not own or that need construction logic: third-party clients (an S3Client, a RestClient, a Kafka producer), objects built from config values, or beans where you want to choose the implementation at wiring time. The @Bean method body is your factory, so you can read properties, call builders, and wrap decorators. The subtle part is @Configuration's proxyBeanMethods behaviour.

By default (proxyBeanMethods = true), Spring subclasses your configuration class with CGLIB so that calling one @Bean method from another returns the existing singleton from the container instead of executing the method body again. Without that proxy, a config where dataSource() is called by two other @Bean methods would create two connection pools, a genuinely expensive bug. Setting @Configuration(proxyBeanMethods = false), which Spring Boot's own auto-configurations do everywhere, skips the CGLIB subclass for faster startup and native-image friendliness, but then you must never call one @Bean method from another; instead you declare the dependency as a method parameter and let the container inject it.

Interviewers use this to separate people who have read Boot's source from people who have only used it. Also worth saying: @Bean methods can appear in plain @Component classes ('lite mode'), which behaves exactly like proxyBeanMethods = false.

@Configuration(proxyBeanMethods = false)
public class HttpConfig {

  @Bean
  RestClient razorpayClient(RestClient.Builder builder,
                            @Value("${razorpay.base-url}") String baseUrl) {
    return builder
        .baseUrl(baseUrl)
        .defaultHeader("X-Client", "settlement-svc")
        .build();
  }

  @Bean
  SettlementClient settlementClient(RestClient razorpayClient) {
    // dependency arrives as a parameter, we never call razorpayClient() directly
    return new SettlementClient(razorpayClient);
  }
}

Key Points

  • @Component for your classes, @Bean for third-party or constructed objects
  • @Configuration CGLIB-proxies bean methods so inter-method calls return singletons
  • proxyBeanMethods = false: faster startup, but inject dependencies as method parameters
  • @Bean inside @Component = lite mode, no proxying
Q4

What do @Service, @Repository, and @Controller add beyond plain @Component?

BasicFundamentals

Answer

All three are meta-annotated with @Component, so component scanning treats them identically for bean registration. The differences are semantics plus, in two cases, real behaviour. @Repository activates persistence exception translation: a PersistenceExceptionTranslationPostProcessor wraps the bean so that vendor-specific exceptions (Hibernate's ConstraintViolationException, JDBC's SQLIntegrityConstraintViolationException) are converted into Spring's consistent DataAccessException hierarchy, for example DataIntegrityViolationException or DuplicateKeyException. That lets your service layer catch one portable exception type regardless of whether the store is Postgres, MySQL, or MongoDB.

Note that with Spring Data JPA you usually extend JpaRepository interfaces and translation is applied to the generated proxy anyway, so @Repository on those interfaces is redundant, a detail interviewers enjoy. @Controller marks a class for the MVC HandlerMapping machinery: RequestMappingHandlerMapping only scans @Controller (and @RestController) beans for @RequestMapping methods, so a @Service with @GetMapping methods silently maps nothing. @Service currently adds no extra behaviour; it is pure intent documentation and a stable pointcut target for AOP (a pointcut like within(@org.springframework.stereotype.Service *) is common for service-layer metrics or transaction defaults). The deeper point worth making in an interview: stereotypes make architecture greppable. When every layer is honestly annotated, tooling, aspects, and new joiners can all rely on the annotation to know what a class is for.

Key Points

  • All are @Component meta-annotations, identical for scanning
  • @Repository = DataAccessException translation via a post-processor
  • @Controller/@RestController is required for @RequestMapping detection
  • @Service is semantic today, but a stable AOP pointcut target
Q5

Which bean scopes does Spring provide, and what goes wrong when you inject a prototype bean into a singleton?

BasicContainer

Answer

Core scopes: singleton (default, one instance per container), prototype (new instance per injection or getBean() call), and in web applications request, session, and application, plus websocket. Singleton beans must therefore be stateless or thread-safe, because every request thread shares the same instance; keeping mutable per-request state in a singleton field is a classic concurrency bug that shows up only under load. The famous trap: injecting a prototype into a singleton.

The prototype is created exactly once, at the moment the singleton is wired, and then reused forever, silently defeating the scope. Spring never proactively 'refreshes' it. The fixes, in order of preference: inject ObjectProvider<MyPrototype> and call getObject() whenever you need a fresh instance; use @Lookup on an abstract method that Spring overrides at runtime; or declare the prototype with proxyMode = ScopedProxyMode.TARGET_CLASS so a CGLIB proxy defers to a new instance per invocation (this is also how request-scoped beans get injected into singletons safely). Two more prototype gotchas worth volunteering: Spring does not call destruction callbacks (@PreDestroy) on prototypes, since it does not track them after creation, so resources they hold must be closed by the caller; and prototype scope is not a substitute for thread safety, two threads calling the same injected reference still share one object unless each explicitly requests its own.

@Component
@Scope(value = ConfigurableBeanFactory.SCOPE_PROTOTYPE)
public class ReportBuilder { /* mutable, per-use state */ }

@Service
public class ReportService {
  private final ObjectProvider<ReportBuilder> builders;

  public ReportService(ObjectProvider<ReportBuilder> builders) {
    this.builders = builders;
  }

  public Report monthly(YearMonth m) {
    ReportBuilder fresh = builders.getObject(); // new instance every call
    return fresh.forMonth(m).build();
  }
}
💡 Pro Tip: If you mention @Lookup, also mention you have to make the method abstract or return null in the body; interviewers check whether you have actually used it.
Q6

Walk through the lifecycle of a Spring bean from instantiation to destruction. Where do @PostConstruct and @PreDestroy fit?

BasicContainer

Answer

The container first reads bean definitions (from annotations or @Bean methods), then for each singleton: (1) instantiate via constructor, resolving constructor arguments first; (2) populate other dependencies; (3) run Aware callbacks (BeanNameAware, ApplicationContextAware) if implemented; (4) call every BeanPostProcessor's postProcessBeforeInitialization; (5) run initialisation: @PostConstruct methods, then InitializingBean.afterPropertiesSet(), then any initMethod declared on @Bean; (6) call postProcessAfterInitialization on every BeanPostProcessor, which is where AOP proxies (for @Transactional, @Cacheable, @Async) are typically substituted for the raw bean; (7) the bean serves traffic. On context shutdown, singletons get @PreDestroy, then DisposableBean.destroy(), then the destroyMethod. Practical implications interviewers probe: @PostConstruct is the right place for logic that needs injected dependencies, because the constructor runs before wiring is complete for anything not passed through it; heavy startup work in @PostConstruct delays readiness, so Kubernetes deployments should pair it with proper liveness and readiness probes via Actuator; and because AOP proxying happens after initialisation, calling a @Transactional method from @PostConstruct on the same bean will not be transactional.

Prefer @PostConstruct and @PreDestroy (standard jakarta.annotation) over the Spring-specific interfaces, they keep classes framework-agnostic. For beans you register with @Bean but whose source you cannot annotate, use @Bean(initMethod = "start", destroyMethod = "close"); Spring also infers close() and shutdown() as destroy methods automatically.

@Component
public class KafkaBridge implements DisposableBean {

  private final KafkaConsumerFactory factory;
  private volatile Consumer<String, String> consumer;

  public KafkaBridge(KafkaConsumerFactory factory) { this.factory = factory; }

  @PostConstruct           // dependencies are wired by now
  void start() {
    consumer = factory.create("settlement-events");
  }

  @Override                // context shutdown: called before destroyMethod
  public void destroy() {
    if (consumer != null) consumer.close(Duration.ofSeconds(5));
  }
}
Q7

How does Spring resolve which bean to inject when several candidates match, and how do @Primary and @Qualifier interact?

BasicDependency Injection

Answer

Autowiring resolves by type first. If exactly one bean of the requested type exists, it wins. If several match, Spring narrows by qualifier, then by @Primary, then by parameter-name-matches-bean-name as a last resort; if ambiguity survives, startup fails with NoUniqueBeanDefinitionException listing every candidate, and if none match you get NoSuchBeanDefinitionException. @Primary marks one bean as the default among its type: any unqualified injection point receives it. @Qualifier("name") on the injection point overrides everything, including @Primary, and selects the bean whose name (or declared qualifier) matches.

The clean pattern for multiple implementations is a custom qualifier annotation (meta-annotated with @Qualifier) instead of magic strings, which survives refactors and is typo-proof at compile time. Two related tools worth naming: injecting List<PaymentProvider> or Map<String, PaymentProvider> collects all beans of a type (the map keys are bean names), which is the idiomatic strategy-pattern wiring in Spring, and @Order or the Ordered interface controls the list's ordering. Also mention ObjectProvider<T> for optional dependencies: getIfAvailable() returns null instead of failing when no candidate exists, which beats @Autowired(required = false) because it works with constructor injection. In interviews, the strategy-map trick (Map<String, PaymentProvider> keyed by bean name to route UPI vs card vs netbanking) is a strong practical answer that shows production experience with Indian payment stacks.

public interface PaymentProvider { PaymentResult charge(Order o); }

@Component("upi")      class UpiProvider implements PaymentProvider { /* ... */ }
@Component("card")     class CardProvider implements PaymentProvider { /* ... */ }
@Primary
@Component("netbank")  class NetBankingProvider implements PaymentProvider { /* ... */ }

@Service
public class CheckoutService {
  private final Map<String, PaymentProvider> providers; // keys = bean names

  public CheckoutService(Map<String, PaymentProvider> providers) {
    this.providers = providers;
  }

  public PaymentResult pay(String method, Order o) {
    return providers.getOrDefault(method, providers.get("netbank")).charge(o);
  }
}
Q8

What does @SpringBootApplication actually combine, and why does its package location matter?

BasicSpring Boot

Answer

@SpringBootApplication is a composed annotation equal to three others. @SpringBootConfiguration (itself a @Configuration) marks the class as a source of bean definitions and lets tests locate the primary configuration via @SpringBootTest. @EnableAutoConfiguration switches on Boot's auto-configuration engine, which imports configuration classes listed in each jar's META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports file, filtered through @Conditional checks. @ComponentScan scans for components starting from the package of the annotated class downward, with default filters. That last part is why package location matters so much: if your main class sits in com.goodspace.api but half your services live in com.goodspace.core, they are simply never scanned, no error, the beans just do not exist, and you get NoSuchBeanDefinitionException at the first injection point. The convention is to place the main class in the root package of the project so everything below it is covered.

When you genuinely need to scan sideways, use scanBasePackages on the annotation, but treat that as a smell in a single-module app. Useful extras interviewers appreciate: exclude specific auto-configurations with @SpringBootApplication(exclude = DataSourceAutoConfiguration.class) when a starter drags in something you do not want; and SpringApplication.run() returns the ConfigurableApplicationContext, so you can grab beans in a CommandLineRunner-style bootstrap. Knowing that auto-configuration classes are ordinary @Configuration classes applied after your own (they mostly use @ConditionalOnMissingBean to back off) demystifies most 'why is Boot ignoring my bean' incidents.

// src/main/java/com/goodspace/Application.java  <- root package on purpose
@SpringBootApplication(
    exclude = { DataSourceAutoConfiguration.class } // no DB in this service
)
public class Application {
  public static void main(String[] args) {
    SpringApplication.run(Application.class, args);
  }
}

// Equivalent to:
// @SpringBootConfiguration
// @EnableAutoConfiguration(exclude = ...)
// @ComponentScan  (from com.goodspace downward)
Q9

How does Spring Boot auto-configuration decide what to configure, and how do you find out why a bean was or was not created?

BasicSpring Boot

Answer

Auto-configuration is a set of ordinary @Configuration classes shipped in spring-boot-autoconfigure and in third-party starters, registered via META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports. Each class and bean method is guarded by conditions: @ConditionalOnClass (activate only if a class is on the classpath, so adding the spring-kafka dependency is what 'turns on' Kafka support), @ConditionalOnMissingBean (back off if you defined your own bean of that type, which is the whole override mechanism), @ConditionalOnProperty (gate on a config key), @ConditionalOnWebApplication, and ordering hints like @AutoConfigureAfter. This is why Boot feels magical but is actually deterministic: DataSourceAutoConfiguration exists because hikari and a JDBC driver are on the classpath, and it yields the moment you declare your own DataSource bean.

Debugging tools every candidate should name: start the app with --debug (or logging.level.org.springframework.boot.autoconfigure=DEBUG) to print the CONDITIONS EVALUATION REPORT, which lists every auto-configuration as a positive or negative match with the exact condition that decided it; or hit the Actuator conditions endpoint (/actuator/conditions) on a running service. To disable something, prefer the spring.autoconfigure.exclude property or the exclude attribute over classpath surgery. In interviews, walking through 'add dependency, condition matches, @ConditionalOnMissingBean lets me override' in one breath signals you understand Boot rather than merely use it.

# application.yml: gate and override auto-config
spring:
  autoconfigure:
    exclude:
      - org.springframework.boot.autoconfigure.mongo.MongoAutoConfiguration

# See exactly why each auto-configuration matched or not:
#   java -jar app.jar --debug
# ============================
# CONDITIONS EVALUATION REPORT
# ============================
# DataSourceAutoConfiguration matched:
#    - @ConditionalOnClass found required classes 'javax.sql.DataSource', ...

Key Points

  • Registered via AutoConfiguration.imports, guarded by @Conditional annotations
  • @ConditionalOnClass keys off the classpath; @ConditionalOnMissingBean enables overriding
  • --debug prints the conditions evaluation report; /actuator/conditions on live apps
  • Disable via spring.autoconfigure.exclude or the exclude attribute
Q10

How do Spring profiles work, and how do profile-specific property files layer over application.yml?

BasicConfiguration

Answer

Profiles let one artifact carry environment-specific wiring. Activate them with spring.profiles.active (property, env var SPRING_PROFILES_ACTIVE, or --spring.profiles.active=prod on the command line); multiple profiles can be active at once. Beans annotated @Profile("prod") register only when that profile is active, and @Profile("!prod") inverts it, useful for a fake payment gateway bean in dev that is replaced by the real one in prod.

Property layering: application.yml always loads; application-{profile}.yml loads on top when its profile is active, overriding matching keys. Inside a single YAML file you can also use documents separated by --- with spring.config.activate.on-profile to scope a block. Boot 2.4 reworked this processing: spring.profiles inside documents was replaced by spring.config.activate.on-profile, and profile groups (spring.profiles.group.prod=prod-db,prod-mq) let one flag pull in a set.

Related keys worth naming: spring.profiles.default for what applies when nothing is set, and spring.config.import for pulling extra files or configtree secrets. The production discipline interviewers look for: profiles should select wiring, not carry secrets; credentials belong in environment variables or a secret manager (Vault, AWS Secrets Manager, or Infisical, which several Indian teams use), layered over the files by Boot's property precedence where OS environment variables outrank packaged YAML. Also flag the classic mistake: annotating a @Configuration class with @Profile hides every bean in it, including ones another profile silently needed, producing confusing NoSuchBeanDefinitionException only in one environment.

# application.yml (shared defaults)
server.port: 8080
payments.gateway.timeout: 3s

---
spring.config.activate.on-profile: prod
payments.gateway.timeout: 800ms

# application-prod.yml would work identically as a separate file

Key Points

  • Activate via spring.profiles.active / SPRING_PROFILES_ACTIVE
  • application-{profile}.yml overrides application.yml key-by-key
  • @Profile on beans/configs gates registration; supports negation
  • Secrets come from env vars or a secret manager, never profile files
Q11

@Value versus @ConfigurationProperties: when is each appropriate, and how do you validate configuration at startup?

BasicConfiguration

Answer

@Value("${key}") injects a single property, supports SpEL and defaults (${key:fallback}), and is fine for one-off values. It scales badly: keys scatter across the codebase as strings, there is no metadata for IDE completion, no relaxed binding, and no grouping. @ConfigurationProperties(prefix = "payments") binds a whole prefix onto a typed object, with relaxed binding (payments.max-retry, PAYMENTS_MAXRETRY, and payments.maxRetry all bind to maxRetry), nested objects, Duration and DataSize conversion out of the box (timeout: 800ms just works), and immutable binding via constructor with defaults using @DefaultValue. Since Boot 3, records make this beautiful: a record with @ConfigurationProperties gives you an immutable, compact config type.

Register via @EnableConfigurationProperties(PaymentsProps.class) or @ConfigurationPropertiesScan; the class itself needs no @Component. Validation: annotate the class with @Validated and put jakarta.validation constraints (@NotBlank, @Min, @DurationMin) on fields; binding failures then abort startup with a readable report of every violated constraint, which is exactly the fail-fast behaviour you want, a typo'd database URL should kill the pod before it takes traffic. Adding the spring-boot-configuration-processor annotation processor generates metadata so IDEs autocomplete your custom keys. The interview-grade summary: @Value for a stray value or SpEL expression, @ConfigurationProperties for any group of related settings, always validated, preferably as a record.

@Validated
@ConfigurationProperties(prefix = "payments.gateway")
public record GatewayProps(
    @NotBlank String baseUrl,
    @DefaultValue("3") @Min(1) int maxRetry,
    @DefaultValue("800ms") Duration timeout
) {}

@SpringBootApplication
@ConfigurationPropertiesScan   // picks up all @ConfigurationProperties types
public class Application { /* ... */ }

// application.yml
// payments:
//   gateway:
//     base-url: https://api.razorpay.com
//     timeout: 500ms
Q12

What is the difference between @Controller and @RestController, and what role does @ResponseBody play?

BasicSpring MVC

Answer

@Controller marks a class for the MVC handler machinery; by default its handler methods return view names, which a ViewResolver turns into a rendered template (Thymeleaf, JSP). @RestController is simply @Controller plus @ResponseBody applied at the class level. @ResponseBody changes the contract of every handler method: the return value is written directly to the HTTP response body through an HttpMessageConverter instead of being interpreted as a view name. For JSON, that converter is MappingJackson2HttpMessageConverter backed by Jackson (Boot auto-configures the ObjectMapper; you can customise it globally via a Jackson2ObjectMapperBuilderCustomizer bean, and per-app settings live under spring.jackson.* keys such as spring.jackson.default-property-inclusion=non_null). Content negotiation picks the converter using the request's Accept header.

In an API-only service you use @RestController everywhere and never think about views; the distinction matters when a service mixes server-rendered pages with API endpoints, where you annotate individual API methods with @ResponseBody inside a plain @Controller. Return-type nuances worth knowing: returning ResponseEntity<T> gives you explicit status and header control (ResponseEntity.created(location).body(dto)); returning a plain DTO defaults to 200 with converter-serialised body; @ResponseStatus(HttpStatus.CREATED) on the method sets a fixed status without the ResponseEntity ceremony. A common bug interviewers describe: a @Controller method returning a string like "ok" triggers view resolution and a 500 about a missing template, because without @ResponseBody that string is a view name, not a payload.

@RestController
@RequestMapping("/api/v1/jobs")
public class JobController {

  private final JobService jobs;
  public JobController(JobService jobs) { this.jobs = jobs; }

  @GetMapping("/{id}")
  public JobDto find(@PathVariable long id) {
    return jobs.find(id);            // -> Jackson -> JSON body
  }

  @PostMapping
  public ResponseEntity<JobDto> create(@RequestBody @Valid CreateJobDto in) {
    JobDto saved = jobs.create(in);
    return ResponseEntity
        .created(URI.create("/api/v1/jobs/" + saved.id()))
        .body(saved);                // 201 + Location header
  }
}
Q13

How do @PathVariable, @RequestParam, and @RequestBody differ, and what are their common binding pitfalls?

BasicSpring MVC

Answer

@PathVariable extracts a segment of the URL template (/jobs/{id}); @RequestParam reads query-string or form parameters (?page=2); @RequestBody deserialises the HTTP body into an object through an HttpMessageConverter, for JSON via Jackson. Pitfalls that come up in real code reviews: @RequestParam is required by default, so a missing parameter yields a 400 MissingServletRequestParameterException; make it optional with required = false plus a sensible defaultValue, or declare the parameter as Optional<Integer>. Type conversion failures (?page=abc into an int) throw MethodArgumentTypeMismatchException, which you should map to a clean 400 in your @RestControllerAdvice rather than leaking a stack trace.

Only one @RequestBody is allowed per method because the request stream is read once. For binding many query parameters, skip the annotation entirely and declare a POJO parameter: Spring treats it as @ModelAttribute and binds setters by name, which keeps search endpoints with ten filters readable. Historically, whether you could omit the name in @PathVariable("id") depended on the -parameters compiler flag; Spring Boot's build plugins enable it, but Spring Framework 6.1+ hard-requires it for name-based resolution, and a missing flag produces an explicit exception telling you to recompile.

Also know @RequestHeader for headers, @CookieValue for cookies, and MultipartFile parameters for uploads (limits under spring.servlet.multipart.max-file-size). Mentioning that GET binding POJOs must not be annotated @RequestBody, a mistake juniors make weekly, lands well.

public record JobSearch(String city, String skill,
                        Integer minLpa, Integer page) {}

@RestController
@RequestMapping("/api/v1/jobs")
public class JobSearchController {

  // /api/v1/jobs/42
  @GetMapping("/{id}")
  JobDto one(@PathVariable long id) { /* ... */ }

  // /api/v1/jobs?city=Noida&skill=java&page=0
  @GetMapping
  Page<JobDto> search(JobSearch filters,           // bound as @ModelAttribute
                      @RequestParam(defaultValue = "20") int size) {
    /* ... */
  }

  @PostMapping
  JobDto create(@RequestBody @Valid CreateJobDto body) { /* ... */ }
}
Q14

Trace an HTTP request through the DispatcherServlet. Which components decide the handler and render the response?

BasicSpring MVC

Answer

Every request to a Spring MVC app hits one servlet: DispatcherServlet, the front controller, mapped to / by Boot. The flow: (1) DispatcherServlet consults its list of HandlerMapping beans; RequestMappingHandlerMapping matches the URL, HTTP method, params, and headers against every @RequestMapping method it indexed at startup and returns a HandlerExecutionChain, the handler plus applicable HandlerInterceptor instances. (2) preHandle runs on each interceptor; returning false short-circuits the request. (3) The matching HandlerAdapter (RequestMappingHandlerAdapter for annotated controllers) invokes the method, first resolving each parameter through HandlerMethodArgumentResolver instances, this is what powers @PathVariable, @RequestBody, and even custom resolvers like an @CurrentUser annotation. (4) The return value goes through HandlerMethodReturnValueHandler: for @ResponseBody it is serialised by an HttpMessageConverter chosen via content negotiation; for view controllers a ViewResolver resolves the view name and renders. (5) postHandle then afterCompletion run on interceptors (afterCompletion runs even on exceptions, so it is the place for cleanup and timing metrics). (6) Exceptions bubble to HandlerExceptionResolver instances; ExceptionHandlerExceptionResolver routes them to @ExceptionHandler methods, including @ControllerAdvice ones. Knowing this chain has practical payoffs: you can register a custom argument resolver via WebMvcConfigurer.addArgumentResolvers to inject the authenticated tenant into every handler, and you know interceptors cannot see the deserialised body (they run before argument resolution), which is why body-dependent logic belongs in the handler or a service, not an interceptor.

Key Points

  • HandlerMapping finds the method; HandlerAdapter invokes it
  • Argument resolvers power @PathVariable/@RequestBody and custom injections
  • Interceptors: preHandle -> handler -> postHandle -> afterCompletion
  • @ExceptionHandler resolution happens via HandlerExceptionResolver at the end
Q15

How does request validation work with jakarta.validation, and how do you return field-level errors from MethodArgumentNotValidException?

BasicValidation

Answer

Add spring-boot-starter-validation (it stopped being transitively included with the web starter back in Boot 2.3, a fact that still catches people: constraints silently do nothing without it, because Hibernate Validator is missing from the classpath). Annotate DTO fields with jakarta.validation constraints: @NotBlank, @Email, @Size, @Min, @Pattern, @Positive, and nest validation with @Valid on child objects and List<@Valid Item> elements. Trigger validation by annotating the controller parameter with @Valid (standard) or Spring's @Validated (which adds group support).

When body validation fails, Spring throws MethodArgumentNotValidException before your handler runs; the BindingResult inside carries every FieldError with field name, rejected value, and message. The clean pattern is one @ExceptionHandler in a @RestControllerAdvice that flattens those into a stable JSON contract, since Spring 6 ideally a ProblemDetail with a custom errors property, so every service in the organisation reports validation failures identically. Two adjacent cases to name: constraint violations on @RequestParam or @PathVariable require @Validated on the controller class and used to surface as ConstraintViolationException (Spring 6.1 introduced HandlerMethodValidationException for built-in method validation), and messages can be externalised to messages.properties for localisation using {jakarta.validation.constraints.NotBlank.message}-style keys or custom codes.

Custom rules get their own annotation plus a ConstraintValidator implementation. Interviewers often ask where validation should live: at the boundary via annotations for shape, in the service for business rules that need repository access, never trusting the client.

public record RegisterDto(
    @NotBlank @Email String email,
    @NotBlank @Size(min = 8, max = 72) String password,
    @Pattern(regexp = "^[6-9]\\d{9}$", message = "invalid Indian mobile")
    String phone) {}

@RestControllerAdvice
public class ValidationAdvice {
  @ExceptionHandler(MethodArgumentNotValidException.class)
  ProblemDetail onInvalid(MethodArgumentNotValidException ex) {
    ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.UNPROCESSABLE_ENTITY);
    pd.setTitle("Validation failed");
    pd.setProperty("errors", ex.getBindingResult().getFieldErrors().stream()
        .map(f -> Map.of("field", f.getField(),
                         "message", f.getDefaultMessage()))
        .toList());
    return pd;
  }
}
Q16

How do you build consistent API error responses with @RestControllerAdvice and Spring 6's ProblemDetail?

BasicError Handling

Answer

@RestControllerAdvice declares a class whose @ExceptionHandler methods apply across all controllers (scopable via basePackages or annotations attributes). Each handler catches an exception type and converts it into a response; since Spring Framework 6 the idiomatic return type is ProblemDetail, an implementation of RFC 7807 (application/problem+json) with standard fields (type, title, status, detail, instance) plus arbitrary extension properties via setProperty(). This gives every service a machine-readable, uniform error contract, which matters enormously once a frontend or partner integrates against a dozen Spring services.

Spring can also emit RFC 7807 for its own built-in exceptions (404s, 405s, unreadable bodies) when you set spring.mvc.problemdetails.enabled=true, or by extending ResponseEntityExceptionHandler, which already maps the framework exception hierarchy and lets you override individual cases. Production practices worth stating: map domain exceptions (OrderNotFoundException, InsufficientBalanceException) to precise statuses; never leak stack traces or SQL in detail fields; log the full exception server-side with a correlation id and echo only that id to the client; and register a catch-all Exception handler returning 500 with a generic message so unexpected failures still honour the contract. Ordering nuance: the most specific matching handler wins across all advice beans, and @Order on advice classes breaks ties. Also know that exceptions thrown inside filters (for example a JWT filter in Spring Security) never reach @ControllerAdvice, because advice lives inside the DispatcherServlet; those need an AuthenticationEntryPoint or a dedicated filter-level handler.

@RestControllerAdvice
public class ApiErrorAdvice extends ResponseEntityExceptionHandler {

  @ExceptionHandler(OrderNotFoundException.class)
  ProblemDetail notFound(OrderNotFoundException ex) {
    ProblemDetail pd = ProblemDetail.forStatusAndDetail(
        HttpStatus.NOT_FOUND, ex.getMessage());
    pd.setType(URI.create("https://api.goodspace.ai/errors/order-not-found"));
    pd.setProperty("orderId", ex.orderId());
    return pd;
  }

  @ExceptionHandler(Exception.class)   // catch-all keeps the contract uniform
  ProblemDetail unexpected(Exception ex) {
    // log with correlation id here; never expose internals
    return ProblemDetail.forStatusAndDetail(
        HttpStatus.INTERNAL_SERVER_ERROR, "Unexpected error");
  }
}
Q17

What do Spring Boot starters and the dependency BOM actually manage, and why should you avoid pinning library versions yourself?

BasicSpring Boot

Answer

A starter (spring-boot-starter-web, -data-jpa, -security, -actuator, -validation, -test) is an empty-ish jar whose only job is a curated dependency list: pull one coordinate, get a coherent, mutually tested set of libraries. spring-boot-starter-web brings Spring MVC, Jackson, and embedded Tomcat; swap Tomcat for Undertow or Jetty by excluding spring-boot-starter-tomcat and adding the alternative starter. The versions themselves come from Boot's BOM (spring-boot-dependencies), imported automatically by the Spring Boot Gradle plugin or the Maven parent; it pins tested versions for hundreds of libraries, Jackson, Hibernate, Micrometer, Kafka clients, and exposes override knobs (Maven properties like <jackson-bom.version>, Gradle ext properties) if you must deviate. The reason not to hand-pin versions: the BOM's combinations are integration-tested together, and a manually bumped Jackson or Hibernate frequently breaks in subtle ways (serialisation defaults, dialect behaviour) that surface at runtime, not compile time.

Upgrading Boot then becomes one version bump instead of an afternoon of dependency archaeology, which is also how you receive CVE patches quickly, an argument that matters after the string of Java-ecosystem CVEs Indian security teams now screen for. Related tooling to name: ./mvnw dependency:tree or ./gradlew dependencies to trace where a version came from; spring-boot-starter-parent versus importing the BOM directly when your company mandates its own parent POM; and third-party starters (springdoc-openapi, shedlock-spring) that follow the same convention of dependencies plus auto-configuration.

Key Points

  • Starters = curated dependency sets; the BOM pins tested versions
  • Swap servers by excluding spring-boot-starter-tomcat
  • Never hand-pin what the BOM manages; override via documented properties
  • One Boot bump upgrades the whole tested stack, including CVE fixes
Q18

How does an executable Spring Boot jar serve HTTP without an external Tomcat, and which server.* properties matter in production?

BasicSpring Boot

Answer

Boot inverts the old deployment model: instead of building a war and dropping it into a Tomcat installation, the server is a library inside your jar. spring-boot-starter-web pulls in embedded Tomcat; at startup, ServletWebServerFactoryAutoConfiguration creates a TomcatServletWebServerFactory, starts Tomcat on server.port (default 8080), and registers the DispatcherServlet programmatically, no web.xml, no servlet container to patch separately. The 'fat jar' produced by the Boot Maven/Gradle plugin nests dependency jars under BOOT-INF/lib with a custom launcher, so java -jar app.jar is the entire deployment story, which is exactly what Docker wants; Boot also supports layered jars and CDS-friendly extraction for slimmer images and faster starts. Production-relevant keys: server.port; server.shutdown=graceful with spring.lifecycle.timeout-per-shutdown-phase=30s so in-flight requests finish during rolling deploys (Kubernetes preStop plus graceful shutdown eliminates 502s); server.tomcat.threads.max (default 200) and server.tomcat.accept-count for load shaping; server.tomcat.max-connections; server.forward-headers-strategy=framework when behind an ALB or nginx so redirects and request.getScheme() respect X-Forwarded-Proto; and server.compression.enabled for text-heavy APIs.

TLS is now handled cleanly through SSL bundles (spring.ssl.bundle.*) rather than raw keystore properties, with reloadable certificates. War deployment still exists (extend SpringBootServletInitializer) but is legacy; interviewers mainly want to hear that you understand the embedded model and its graceful-shutdown and proxy-header implications, because both cause real incidents when missed.

# application.yml: production server settings that prevent real incidents
server:
  port: 8080
  shutdown: graceful          # finish in-flight requests on SIGTERM
  forward-headers-strategy: framework   # honour X-Forwarded-* behind ALB
  tomcat:
    threads:
      max: 200
    accept-count: 100

spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s
Q19

How do Spring Data JPA repositories generate queries from method names, and when should you switch to @Query?

BasicSpring Data JPA

Answer

Declare an interface extending JpaRepository<Entity, Id> and Spring Data generates the implementation at startup as a proxy backed by SimpleJpaRepository. You inherit save, findById, findAll, deleteById, count, and friends. Derived queries parse the method name: findByEmailIgnoreCase, findByCityAndStatusOrderByCreatedAtDesc, existsByPan, countByStatus, findTop10BySkillOrderBySalaryDesc; the parser understands And, Or, Between, LessThan, Like, In, IsNull, True/False, IgnoreCase, and Top/First limiting.

Return types are flexible: Optional<T> for single results, List<T>, Page<T> with a Pageable parameter, Stream<T> for cursoring, boolean for exists. Switch to @Query when the name gets absurd (three conditions is a reasonable ceiling), when you need joins, aggregation, subqueries, or DTO projection via JPQL constructor expressions, or when you need native SQL (@Query(nativeQuery = true)) for window functions and database-specific features. Modifying statements need @Modifying plus a transaction, and by default they bypass and stale the persistence context, so pair them with clearAutomatically = true when the same transaction reads afterwards. Two production notes that score points: derived queries are validated at startup, so a typo like findByEmial fails boot rather than runtime, one of the best features of the module; and every method name is effectively a query contract, so renaming an entity field silently changes SQL, which is why teams at scale prefer explicit @Query for anything non-trivial plus @Param named parameters for readability.

public interface CandidateRepository extends JpaRepository<Candidate, Long> {

  Optional<Candidate> findByEmailIgnoreCase(String email);

  Page<Candidate> findByCityAndActiveTrue(String city, Pageable pageable);

  @Query("""
      select new com.goodspace.dto.CandidateCard(c.id, c.name, c.headline)
      from Candidate c
      join c.skills s
      where s.name = :skill and c.expectedLpa <= :maxLpa
      """)
  List<CandidateCard> searchBySkill(@Param("skill") String skill,
                                    @Param("maxLpa") int maxLpa);

  @Modifying(clearAutomatically = true)
  @Query("update Candidate c set c.active = false where c.lastSeen < :cutoff")
  int deactivateStale(@Param("cutoff") Instant cutoff);
}
Q20

What actually broke when Spring Boot 3 moved from javax to jakarta, and what are the platform baselines for Spring Framework 6?

BasicVersions & Migration

Answer

Spring Framework 6 and Boot 3 adopted Jakarta EE 9+, where every javax.* EE package was renamed to jakarta.*: javax.servlet became jakarta.servlet, javax.persistence became jakarta.persistence, javax.validation became jakarta.validation, javax.annotation.PostConstruct became jakarta.annotation.PostConstruct. This is a source-incompatible rename, so migration means rewriting imports (OpenRewrite recipes and IntelliJ's migration tooling automate most of it) and, more painfully, replacing every third-party library that still compiles against javax: old servlet filters, swagger annotations from springfox (dead; migrate to springdoc-openapi), older Hibernate, Ehcache 2, and any internal company jar touching servlet or JPA APIs. Mixed classpaths fail at runtime with ClassNotFoundException: javax.servlet.Filter or NoClassDefFoundError even though the code compiled, which is the signature symptom to name.

Baselines: Framework 6 / Boot 3 require Java 17 minimum (Boot 3.x runs happily on 21, which you want for virtual threads), Jakarta EE 9/10 APIs, Hibernate 6 (its own migration: new ID sequence behaviour, changed dialect configuration where spring.jpa.properties.hibernate.dialect is usually unnecessary now), and Tomcat 10. Also gone in the Spring Security shipped with Boot 3: WebSecurityConfigurerAdapter, forcing the SecurityFilterChain bean style. Boot 3 additionally replaced the old properties-migration pain with spring-boot-properties-migrator, which logs renamed keys at startup. Interviewers ask this because Indian services companies (TCS, Infosys) run large Boot 2 to 3 migration programs, and knowing the failure signatures means you have actually done one.

Key Points

  • javax.* -> jakarta.* is a hard rename: servlet, persistence, validation, annotations
  • Symptom of mixed jars: NoClassDefFoundError on javax classes at runtime
  • Baselines: Java 17+, Hibernate 6, Tomcat 10; springfox is dead, use springdoc
  • WebSecurityConfigurerAdapter removed; OpenRewrite automates most import moves
Q21

Which Actuator endpoints matter in production, and how do you expose them without leaking sensitive data?

BasicObservability

Answer

spring-boot-starter-actuator adds operational endpoints under /actuator. The ones that matter: /health (aggregated HealthIndicator results, with liveness and readiness groups at /health/liveness and /health/readiness that map directly onto Kubernetes probes once you set management.endpoint.health.probes.enabled=true), /metrics and /prometheus (Micrometer meters for JVM memory, GC, HikariCP pool usage, http.server.requests latency percentiles), /info, /env, /loggers (change log levels at runtime with a POST, invaluable mid-incident), /threaddump, /heapdump, and /conditions for auto-configuration debugging. Exposure is deliberately conservative: over HTTP only /health is exposed by default; everything else needs management.endpoints.web.exposure.include.

The security rules every candidate should recite: never wildcard-expose in production, because /env and /heapdump leak secrets and memory contents (Indian security audits flag exposed actuators constantly, and the Spring4Shell era taught everyone that /actuator/env plus misconfiguration is a real attack path); put actuator behind Spring Security with a dedicated role using EndpointRequest.toAnyEndpoint() matchers; or better, move it to a separate internal port with management.server.port=9090 so your public load balancer never routes to it, while Prometheus scrapes the internal port. Health details are hidden by default too; management.endpoint.health.show-details=when-authorized keeps DB hostnames out of anonymous responses. Custom health checks are one small class implementing HealthIndicator, for example verifying a downstream payment gateway, and custom info via InfoContributor.

# application.yml: safe production actuator posture
management:
  server:
    port: 9090                 # internal-only port, not behind the public LB
  endpoints:
    web:
      exposure:
        include: health,info,prometheus,loggers,metrics
  endpoint:
    health:
      probes:
        enabled: true          # /actuator/health/{liveness,readiness}
      show-details: when-authorized
Q22

What does @Transactional guarantee, and why are checked exceptions not rolled back by default?

BasicTransactions

Answer

@Transactional wraps a method so that a transaction begins before it runs and either commits on normal return or rolls back on failure, via PlatformTransactionManager (JpaTransactionManager for JPA, auto-configured by Boot). Everything inside, repository calls, JDBC work on the same DataSource, participates in one atomic unit. Default rollback rules trip people constantly: Spring rolls back on RuntimeException and Error only; checked exceptions commit.

That default was inherited from EJB, on the theory that checked exceptions represent recoverable business outcomes; in modern code it is mostly a trap, so declare @Transactional(rollbackFor = Exception.class) when checked exceptions cross the boundary, or better, keep your domain exceptions unchecked. Where to put it: on service-layer methods that define a business operation, not on controllers (transactions spanning serialisation are wasteful) and not on repositories (Spring Data methods already run in their own short transactions; you want one transaction around the whole use case so partial writes cannot commit). Useful attributes: readOnly = true lets Hibernate skip dirty checking and can route to replicas in some setups; timeout aborts runaway operations; isolation raises the level per method. Two behavioural facts that mark a strong candidate: the transaction is bound to the current thread through ThreadLocal resources, so work handed to another thread (an @Async method, a parallel stream) is silently outside it; and a transaction marked rollback-only by an inner method makes the outer commit throw UnexpectedRollbackException, which confuses teams the first time an inner service swallows an exception.

@Service
public class TransferService {
  private final AccountRepository accounts;
  private final LedgerRepository ledger;

  public TransferService(AccountRepository a, LedgerRepository l) {
    this.accounts = a; this.ledger = l;
  }

  @Transactional(rollbackFor = Exception.class, timeout = 5)
  public void transfer(long fromId, long toId, BigDecimal amount) {
    Account from = accounts.findByIdForUpdate(fromId).orElseThrow();
    Account to   = accounts.findByIdForUpdate(toId).orElseThrow();
    from.debit(amount);          // both writes commit together
    to.credit(amount);           // or neither does
    ledger.record(fromId, toId, amount);
  }

  @Transactional(readOnly = true)
  public List<TxnDto> history(long accountId) { /* ... */ }
}
Q23

RestTemplate, WebClient, and RestClient: which HTTP client should a Spring MVC service use in 2026?

BasicHTTP Clients

Answer

RestTemplate is the classic blocking client. It is in maintenance mode, not deprecated-for-removal, but it accumulates no new features and its template-method API (getForObject, exchange) is clunky for headers, error handling, and streaming. WebClient, from spring-webflux, is the reactive client returning Mono/Flux; it is excellent inside a reactive stack, but pulling Reactor into a plain MVC service just to make HTTP calls, then finishing every call with .block(), adds a dependency and a paradigm for zero benefit, and .block() inside a reactive event loop is an outright bug.

RestClient, introduced in Spring Framework 6.1 (Boot 3.2), is the right default for MVC services: a synchronous client with WebClient's fluent API, built on the same infrastructure as RestTemplate (message converters, ClientHttpRequestFactory), so timeouts, interceptors, and observability integrate cleanly. Boot auto-configures a RestClient.Builder with Micrometer observation support, meaning downstream call latency shows up in http.client.requests metrics and traces automatically. Configure per-client base URLs, default headers, and error handling via onStatus handlers.

Always set connect and read timeouts explicitly on the underlying request factory (defaults being infinite or too generous is a classic cascading-failure ingredient), and pair external calls with Resilience4j timeouts and circuit breakers. For declarative clients, Spring 6 HTTP interfaces (@HttpExchange-annotated interfaces backed by RestClient or WebClient via HttpServiceProxyFactory) replace Feign in new code. Interview summary: new MVC code uses RestClient, reactive code uses WebClient, RestTemplate survives in legacy but should not be chosen fresh.

@Configuration(proxyBeanMethods = false)
class ClientConfig {
  @Bean
  RestClient gstClient(RestClient.Builder builder) {
    var factory = ClientHttpRequestFactorySettings.defaults()
        .withConnectTimeout(Duration.ofSeconds(2))
        .withReadTimeout(Duration.ofSeconds(5));
    return builder
        .baseUrl("https://api.gst-partner.in")
        .requestFactory(ClientHttpRequestFactories.get(factory))
        .build();
  }
}

// usage
GstDetails details = gstClient.get()
    .uri("/v1/gstin/{id}", gstin)
    .retrieve()
    .onStatus(HttpStatusCode::is4xxClientError,
        (req, res) -> { throw new InvalidGstinException(gstin); })
    .body(GstDetails.class);
Q24

A colleague says 'we use Spring, not Spring Boot'. What concretely would their project have to do that Boot does for them?

BasicFundamentals

Answer

Spring Framework provides the programming model: the IoC container, AOP, transaction abstraction, Spring MVC, WebFlux, and the resource/environment machinery. Boot is an opinionated layer on top that removes deployment and configuration work. Concretely, a Framework-only project must: choose and pin compatible versions for every dependency by hand (no spring-boot-dependencies BOM); write its own configuration for DataSource, EntityManagerFactory, JpaTransactionManager, Jackson ObjectMapper, and the MVC setup that auto-configuration would otherwise derive from the classpath; provision and manage an external servlet container (Tomcat/Jetty installs, war packaging, web.xml or a WebApplicationInitializer) instead of java -jar with an embedded server; build its own equivalent of application.yml profiles and relaxed property binding, or wire PropertySourcesPlaceholderConfigurer manually; and recreate operational endpoints (health checks, metrics) that Actuator plus Micrometer give for free.

It also loses the starter convention and the auto-configured test slices (@SpringBootTest, @WebMvcTest, @DataJpaTest). That world still exists in older Indian enterprise estates (banks running wars on managed WebSphere/Tomcat farms, common in TCS and Infosys client projects), which is why the question matters. The sharp way to close the answer: Boot removes decisions, not capabilities; every Boot behaviour can be overridden by defining your own bean because auto-configuration backs off via @ConditionalOnMissingBean, so 'Boot is inflexible' usually means the speaker has not read how the conditions work. Since Boot 3, features like native-image AOT, SSL bundles, and virtual-thread enablement are effectively Boot-only conveniences layered on Framework capabilities.

Key Points

  • Framework = programming model; Boot = auto-config + starters + embedded server + Actuator
  • Without Boot: manual DataSource/MVC/Jackson wiring, external Tomcat, hand-pinned versions
  • Auto-configuration always backs off to user-defined beans
  • Boot removes decisions, not capabilities
Q25

Why does calling a @Transactional method from another method of the same class skip the transaction, and how do you fix it?

IntermediateTransactions

Answer

Because @Transactional is implemented with AOP proxies, not bytecode weaving. The container does not hand out your bean; it hands out a proxy that opens the transaction, delegates to the real object, and commits or rolls back. When code inside the bean calls this.otherMethod(), the call goes directly to the target instance, bypassing the proxy, so the transactional interceptor never runs.

No error, no warning: the inner method simply executes in whatever transaction context the outer method had, or none. The same mechanics silently disable @Cacheable, @Async, @Retryable, and method security on self-invocation, which makes this the single highest-yield Spring interview question. Related consequences: with CGLIB proxying (the default for classes), private methods cannot be advised at all because the generated subclass cannot override them, and final methods are skipped with only a debug log; protected/public non-final methods work.

Fixes, best first: restructure so the transactional boundary is a separate bean, which usually improves the design anyway (a TransferService calling a LedgerWriter); inject the bean into itself lazily (a self-reference obtained via ObjectProvider or @Lazy on a self field) and call through the proxy; or use TransactionTemplate for programmatic transactions where annotation placement fights you. AopContext.currentProxy() with exposeProxy = true exists but couples code to Spring internals; treat it as a last resort. Interviewers often follow up with 'how would you detect this in review': any this-call to a method carrying a proxied annotation is the tell.

@Service
public class EnrollmentService {
  private final EnrollmentWriter writer;   // separate bean = proxy honoured

  public EnrollmentService(EnrollmentWriter writer) { this.writer = writer; }

  public void enrollBatch(List<Long> userIds) {
    for (Long id : userIds) {
      writer.enrollOne(id);   // each call crosses a proxy boundary
    }
  }
}

@Component
class EnrollmentWriter {
  @Transactional   // works: invoked through the container-provided proxy
  public void enrollOne(Long userId) { /* ... */ }
}

// BROKEN version: this.enrollOne(id) inside the same class
// would run with NO transaction interceptor at all.
💡 Pro Tip: Say the phrase 'the proxy is bypassed on self-invocation' early; it is the exact sentence interviewers are listening for, then show you know it also breaks @Cacheable and @Async.
Q26

Compare transaction propagation levels REQUIRED, REQUIRES_NEW, and NESTED with a concrete use case for each.

IntermediateTransactions

Answer

Propagation controls what a @Transactional method does about an existing transaction. REQUIRED (default): join the caller's transaction, or start one if none exists; one atomic unit, one commit. This is right for normal business composition, order creation and inventory decrement must live or die together.

REQUIRES_NEW: suspend the caller's transaction and run an independent one that commits on its own. Use it when an inner outcome must survive an outer rollback: writing an audit row or a payment-attempt record that must persist even when the payment itself fails and rolls back. Costs people forget: the suspended transaction keeps holding its locks and its connection, and the new transaction takes a second connection from the Hikari pool, so REQUIRES_NEW inside a loop is a pool-exhaustion recipe, and deadlocks between the outer and inner transactions on the same rows are possible.

NESTED: create a JDBC savepoint within the same transaction; if the nested part fails you roll back to the savepoint but the outer transaction can continue and commit. Good for optional sub-steps, importing 10,000 rows where each row's failure should discard only that row. NESTED needs a JDBC-savepoint-capable setup (works with DataSourceTransactionManager; JpaTransactionManager does not support it, which is exactly the trap interviewers set).

Also name the rest briefly: SUPPORTS, MANDATORY (throws IllegalTransactionStateException if no transaction), NEVER, NOT_SUPPORTED (suspend and run non-transactionally, useful around slow external calls). And the classic follow-up: REQUIRES_NEW on a method called via this does nothing, because self-invocation bypasses the proxy.

@Service
public class PaymentService {

  private final AttemptLogger attemptLogger;
  private final GatewayClient gateway;

  public PaymentService(AttemptLogger a, GatewayClient g) {
    this.attemptLogger = a; this.gateway = g;
  }

  @Transactional  // REQUIRED (default)
  public void charge(Order order) {
    attemptLogger.record(order.id());        // survives rollback below
    GatewayResult r = gateway.charge(order); // may throw
    order.markPaid(r.reference());
  }
}

@Component
class AttemptLogger {
  @Transactional(propagation = Propagation.REQUIRES_NEW)
  public void record(long orderId) {
    // independent txn + independent pool connection:
    // commits even if charge() rolls back afterwards
  }
}
Q27

JDK dynamic proxies versus CGLIB in Spring AOP: when is each used, and what limitations do they impose on your classes?

IntermediateAOP

Answer

Spring AOP is proxy-based. A JDK dynamic proxy implements the target's interfaces via java.lang.reflect.Proxy; it can only be used when the bean implements at least one interface, and the proxy is castable only to those interfaces, not the concrete class. CGLIB proxying generates a runtime subclass of the target class, so it works for interface-less classes and remains castable to the concrete type.

Historically Spring preferred JDK proxies when interfaces existed, but Spring Boot has long set proxyTargetClass = true by default (spring.aop.proxy-target-class defaults to true), so CGLIB is what you get in practice unless you override it. The limitations follow from the mechanics. CGLIB: final classes cannot be proxied at all (startup failure when advice applies), final methods cannot be intercepted (silently skipped), private methods are invisible to advice, and the proxy calls the super constructor, so heavy constructor logic runs twice conceptually (Spring uses Objenesis to avoid re-running constructors for the proxy instance).

JDK proxies: any cast to the implementation class throws ClassCastException, a bug that appears exactly when someone injects the concrete type instead of the interface. Both share the self-invocation blind spot, since interception happens only at the proxy boundary. Contrast with AspectJ weaving (compile-time or load-time), which modifies bytecode and therefore advises self-calls, private methods, and even constructors, at the cost of build/agent complexity; @EnableAspectJAutoProxy despite its name still configures proxy-based Spring AOP. Interview-grade detail: @Transactional on an interface default method plus JDK proxies has subtle visibility rules, another reason class-based proxies are the safer default.

Key Points

  • Boot defaults to CGLIB (proxy-target-class=true); JDK proxies need interfaces
  • CGLIB: no final classes/methods, no private method advice
  • JDK: proxy castable only to interfaces, not the concrete class
  • Full AspectJ weaving fixes self-invocation but complicates the build
Q28

Write an @Aspect that times every service-layer method. Explain the pointcut expression and where @Around can go wrong.

IntermediateAOP

Answer

An aspect is a @Component annotated @Aspect containing advice bound to pointcuts. Pointcut designators you should know: execution(modifiers? ret-type declaring-type?.name(params)) matches method signatures; within(com.goodspace.service..*) matches by type/package; @annotation(com.goodspace.Timed) matches methods carrying an annotation; @within matches classes carrying one; bean(name) matches by bean name; args() binds arguments. Combine with &&, ||, !.

For a service-layer timer, within(@org.springframework.stereotype.Service *) or execution(* com.goodspace..service..*(..)) are the standard shapes. @Around advice receives a ProceedingJoinPoint; you must call proceed() exactly once and return its result. The classic mistakes interviewers probe: swallowing the return value (returning null from advice on a method returning a primitive causes NullPointerException on unboxing); catching Throwable and not rethrowing, which silently converts failures into successes and, worse, suppresses transaction rollback because the transactional interceptor above never sees the exception; forgetting that advice ordering across aspects follows @Order (transaction interceptor ordering versus your aspect matters when you want to measure inside or outside the transaction); and writing pointcuts so broad they proxy infrastructure beans and slow startup. Also worth saying: for pure timing, Micrometer's @Timed with TimedAspect already exists, and writing custom aspects is best reserved for cross-cutting business concerns like tenant checks, idempotency keys, or audit logging, in interviews, name the trade-off before writing a custom one.

@Aspect
@Component
public class ServiceTimingAspect {

  private final MeterRegistry registry;
  public ServiceTimingAspect(MeterRegistry registry) { this.registry = registry; }

  @Around("within(@org.springframework.stereotype.Service *)")
  public Object time(ProceedingJoinPoint pjp) throws Throwable {
    long start = System.nanoTime();
    try {
      return pjp.proceed();               // exactly once, result returned
    } finally {
      registry.timer("service.latency",
              "method", pjp.getSignature().toShortString())
          .record(System.nanoTime() - start, TimeUnit.NANOSECONDS);
    }
  }
}
Q29

How do you detect and fix the N+1 select problem in a Spring Data JPA service?

IntermediateSpring Data JPA

Answer

N+1 happens when loading N parent entities triggers one additional query per parent to fetch a lazy association: load 50 jobs, then 50 more selects for each job's company as the serializer or template touches job.getCompany(). Detection: set spring.jpa.properties.hibernate.generate_statistics=true and watch the query counts Hibernate logs per session; turn on SQL logging (logging.level.org.hibernate.SQL=DEBUG) in dev and eyeball repeated identical selects; use datasource-proxy or the digma/hypersistence utilities to assert query counts in tests, a test asserting 'this endpoint issues at most 3 queries' is the only durable fix. Never 'fix' it by making associations EAGER: that trades explicit N+1 for implicit joins everywhere and cartesian blowups; keep @ManyToOne(fetch = FetchType.LAZY) (note @ManyToOne defaults to EAGER in JPA, so you must override it, a detail worth stating).

Real fixes: a fetch join in JPQL (select j from Job j join fetch j.company where ...) loads the association in the same query; @EntityGraph(attributePaths = "company") on the repository method does the same declaratively and composes with Pageable; for collections, avoid join-fetching multiple bags at once (Hibernate throws MultipleBagFetchException), instead split queries or use hibernate.default_batch_fetch_size, which turns N lazy loads into ceil(N/batch) IN-clause selects and is the single cheapest global mitigation; or step away from entities entirely and select a DTO projection with exactly the columns the endpoint needs. Paginating with join fetch on a collection makes Hibernate paginate in memory (HHH000104 warning), so paginate ids first, then fetch, that follow-up separates senior candidates.

public interface JobRepository extends JpaRepository<Job, Long> {

  // Fix 1: fetch join, one query
  @Query("select j from Job j join fetch j.company where j.active = true")
  List<Job> findActiveWithCompany();

  // Fix 2: entity graph, composes with pagination
  @EntityGraph(attributePaths = {"company"})
  Page<Job> findByActiveTrue(Pageable pageable);
}

# Fix 3 (global mitigation), application.yml:
# spring.jpa.properties.hibernate.default_batch_fetch_size: 32
Q30

What causes LazyInitializationException, and why is leaving spring.jpa.open-in-view enabled considered harmful?

IntermediateSpring Data JPA

Answer

LazyInitializationException is thrown when you touch a lazy association after the Hibernate Session that loaded the entity is closed: 'could not initialize proxy, no Session'. Typical trigger: a service returns an entity, the transaction ends, and Jackson serialises it in the controller, hitting job.getCompany() with no session. Spring Boot 'solves' this by default with open-in-view (OSIV): spring.jpa.open-in-view defaults to true, keeping the EntityManager open for the entire HTTP request via OpenEntityManagerInViewInterceptor, so lazy loads during view rendering or JSON serialisation work.

Why that is a trap: first, it hides N+1 problems, serialisation lazily fires unbounded queries outside any transaction you are watching; second, the database connection can be held for the full request duration, including time spent calling slow external services, which under load exhausts the Hikari pool and produces mysterious connection timeouts (the classic symptom: pool exhaustion that only appears when a downstream API slows down); third, writes during rendering happen outside your intended transactional boundary. Boot even logs a startup warning nudging you to make the choice explicit. The disciplined setup: spring.jpa.open-in-view=false, then design services to return fully-shaped DTOs, fetch what the endpoint needs with fetch joins/@EntityGraph/projections inside the transaction.

If you disable OSIV on a legacy codebase, expect a wave of LazyInitializationException that is actually a map of every hidden N+1 you had. Mentioning that entities should generally not be serialised directly (bidirectional relationships also cause infinite recursion without @JsonIgnore) rounds out the answer.

Key Points

  • LIE = lazy access after session close, usually during Jackson serialisation
  • OSIV (default true) holds the EntityManager and often the connection per request
  • Consequences: hidden N+1, pool exhaustion when downstream calls are slow
  • Set open-in-view=false and return DTOs shaped inside the transaction
Q31

How do Pageable, Page, and Slice work, and when does offset pagination become a performance problem?

IntermediateSpring Data JPA

Answer

Add a Pageable parameter to a repository method and Spring Data appends limit/offset plus sorting to the generated query. Controllers can receive Pageable directly: Spring MVC resolves ?page=2&size=20&sort=createdAt,desc automatically (defaults configurable via spring.data.web.pageable.* or @PageableDefault). Page<T> executes an extra count(*) query to report totalElements and totalPages; Slice<T> skips the count and only knows hasNext() (it fetches size+1 rows), which is materially cheaper on large tables, use Page only when the UI truly renders total counts.

Return List<T> with a Pageable when you need neither. The scaling problem: OFFSET forces the database to produce and discard all preceding rows, so page 5,000 of a jobs table walks 100,000 rows to return 20; deep pagination degrades linearly and invites replicas to time out, and concurrent inserts make users see duplicates or gaps between pages. The fix is keyset (seek) pagination: filter on the last seen sort key (where (created_at, id) < (:lastCreatedAt, :lastId) order by created_at desc, id desc limit 20), which is O(page size) with the right composite index and is stable under inserts; the trade-off is no random page jumps, cursor-style navigation only, which is how infinite-scroll feeds at Flipkart-scale listings behave.

Spring Data ships ScrollPosition/Window APIs supporting both offset and keyset scrolling for exactly this. Two more marks of experience: always enforce an upper bound on size (a client sending size=100000 is a self-DoS; cap via spring.data.web.pageable.max-page-size), and remember sorting by an unindexed column turns every page into a filesort.

@GetMapping("/api/v1/jobs")
Slice<JobCard> list(@PageableDefault(size = 20, sort = "createdAt",
                                     direction = Sort.Direction.DESC)
                    Pageable pageable) {
  return jobs.findByActiveTrue(pageable);   // Slice: no count(*) query
}

// Keyset flavour with Spring Data's scroll API:
Window<Job> window = jobRepository.findFirst20ByActiveTrueOrderByCreatedAtDescIdDesc(
    ScrollPosition.keyset());
// next page: pass window.positionAt(window.size() - 1) back in
Q32

Optimistic versus pessimistic locking in JPA: how do @Version and @Lock behave, and how should services handle OptimisticLockingFailureException?

IntermediateSpring Data JPA

Answer

Optimistic locking assumes conflicts are rare. Add a @Version field (int/long/Instant) to the entity; Hibernate includes it in every UPDATE's WHERE clause and increments it. If another transaction changed the row in between, the update matches zero rows and Hibernate throws OptimisticLockException, which Spring translates to ObjectOptimisticLockingFailureException (a subtype of OptimisticLockingFailureException).

No database locks are held while the user thinks, which is why it suits web workflows: two recruiters editing the same job post, second save fails cleanly instead of silently overwriting. Handling: catch it at the service boundary and either retry the whole use case (re-read, re-apply, re-save, bounded attempts, spring-retry's @Retryable(retryFor = OptimisticLockingFailureException.class) is a tidy implementation) or surface a 409 Conflict telling the client to refresh. Never retry blindly around non-idempotent side effects like payment captures.

Pessimistic locking takes real row locks: @Lock(LockModeType.PESSIMISTIC_WRITE) on a repository method emits SELECT ... FOR UPDATE, blocking competing transactions until commit. Use it for hot contended invariants, wallet balance deduction, seat/inventory allocation, where retry storms would be worse than brief blocking.

Set a lock timeout (jakarta.persistence.lock.timeout query hint, or javax-era equivalent on older stacks) so waiters fail fast instead of piling onto the pool, and keep the critical section tiny: lock, mutate, commit. Deadlocks remain possible when two transactions lock rows in opposite orders; consistent lock ordering by primary key is the standard discipline. Interviewers like the summary rule: optimistic for human-speed editing, pessimistic for machine-speed contention on money-like invariants.

@Entity
public class Wallet {
  @Id Long id;
  BigDecimal balance;
  @Version long version;          // optimistic guard on all normal updates
}

public interface WalletRepository extends JpaRepository<Wallet, Long> {
  @Lock(LockModeType.PESSIMISTIC_WRITE)   // SELECT ... FOR UPDATE
  @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout",
                          value = "3000"))
  @Query("select w from Wallet w where w.id = :id")
  Optional<Wallet> findByIdForUpdate(@Param("id") Long id);
}

@Service
class WalletService {
  @Transactional
  public void debit(Long id, BigDecimal amt) {
    Wallet w = wallets.findByIdForUpdate(id).orElseThrow();
    w.debit(amt);                  // lock held only for this short txn
  }
}
Q33

Explain the Spring Security filter chain and write a SecurityFilterChain bean for a stateless JSON API now that WebSecurityConfigurerAdapter is gone.

IntermediateSpring Security

Answer

Spring Security is a servlet filter chain installed ahead of the DispatcherServlet via DelegatingFilterProxy and FilterChainProxy. FilterChainProxy holds one or more SecurityFilterChain instances, each with a request matcher; the first chain whose matcher fits the request handles it, running an ordered list of filters: SecurityContextHolderFilter, CsrfFilter, authentication filters (UsernamePasswordAuthenticationFilter for form login, BearerTokenAuthenticationFilter for JWTs), ExceptionTranslationFilter, and finally AuthorizationFilter, which consults the authorization rules. Authentication results live in the SecurityContextHolder (a ThreadLocal).

Since Spring Security 5.7 deprecated and 6 removed WebSecurityConfigurerAdapter, configuration is a @Bean returning SecurityFilterChain built with the lambda DSL; component-style methods like .csrf().disable() chained returns are gone in 6.1+ in favour of Customizer lambdas (csrf(AbstractHttpConfigurer::disable)). For a stateless API the canonical shape: disable CSRF (it protects cookie-based sessions; a token-in-header API is not CSRF-vulnerable, and you must be able to justify that in the interview, disabling CSRF on a cookie-session app is a real vulnerability), set SessionCreationPolicy.STATELESS so no JSESSIONID is created, permit public endpoints explicitly, require authentication elsewhere, and plug in your token mechanism. Multiple SecurityFilterChain beans with securityMatcher let you give /actuator/** and /api/** different rules.

Useful debugging: logging.level.org.springframework.security=TRACE prints every filter decision, the fastest way to answer 'why is this 403'. Also know authorizeHttpRequests replaced authorizeRequests, backed by AuthorizationManager rather than the old AccessDecisionManager voters.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

  @Bean
  SecurityFilterChain api(HttpSecurity http) throws Exception {
    http
      .securityMatcher("/api/**")
      .csrf(AbstractHttpConfigurer::disable)          // token API, no cookies
      .sessionManagement(s ->
          s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
      .authorizeHttpRequests(auth -> auth
          .requestMatchers("/api/v1/auth/**", "/api/v1/jobs/public/**").permitAll()
          .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
          .anyRequest().authenticated())
      .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()));
    return http.build();
  }
}
Q34

How do you validate JWTs with Spring Security's oauth2ResourceServer, and how do token claims become authorities?

IntermediateSpring Security

Answer

spring-boot-starter-oauth2-resource-server gives you standards-based JWT validation with almost no custom code, which is the correct answer versus the hand-rolled OncePerRequestFilter JWT filters most tutorials teach. Configure the trusted issuer: spring.security.oauth2.resourceserver.jwt.issuer-uri=https://auth.yourco.in/realms/main makes Boot fetch the issuer's OIDC metadata and JWKS, so signature keys rotate without redeploys; alternatively pin jwk-set-uri or, for a symmetric dev setup, provide a JwtDecoder bean via NimbusJwtDecoder.withSecretKey(). BearerTokenAuthenticationFilter extracts the Authorization: Bearer token, JwtDecoder verifies signature, expiry (with clock skew tolerance), and issuer, and the result becomes a JwtAuthenticationToken in the SecurityContext.

Authorities mapping: by default the scope/scp claim maps to SCOPE_xxx authorities. Real systems keep roles in a custom claim, so you register a JwtAuthenticationConverter with a JwtGrantedAuthoritiesConverter (or a lambda) reading, say, realm_access.roles into ROLE_-prefixed authorities so hasRole("ADMIN") works. Add audience validation explicitly, JwtValidators plus a custom OAuth2TokenValidator checking aud, because accepting any audience from a shared issuer is a genuine cross-service token-replay hole and a favourite senior-round probe.

In handlers, access claims via @AuthenticationPrincipal Jwt jwt or the authentication object. Failures return 401 with a WWW-Authenticate header from BearerTokenAuthenticationEntryPoint, not your @ControllerAdvice (filters run before MVC). Opaque tokens use .opaqueToken() with an introspection endpoint instead; know the trade-off, stateless local validation versus instant revocation.

# application.yml
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.goodspace.ai/realms/main
          audiences: api://jobs-service

// Map a custom roles claim to ROLE_* authorities
@Bean
JwtAuthenticationConverter jwtAuthConverter() {
  var roles = new JwtGrantedAuthoritiesConverter();
  roles.setAuthoritiesClaimName("roles");
  roles.setAuthorityPrefix("ROLE_");
  var conv = new JwtAuthenticationConverter();
  conv.setJwtGrantedAuthoritiesConverter(roles);
  return conv;
}

// usage in a handler
@GetMapping("/me")
MeDto me(@AuthenticationPrincipal Jwt jwt) {
  return new MeDto(jwt.getSubject(), jwt.getClaimAsString("email"));
}
Q35

How does method-level security with @PreAuthorize work, and when do you use it instead of URL-based rules?

IntermediateSpring Security

Answer

Enable it with @EnableMethodSecurity (the Security 6 replacement for @EnableGlobalMethodSecurity; prePostEnabled is true by default now). @PreAuthorize evaluates a SpEL expression against the authentication before the method runs, via an AOP interceptor: hasRole('ADMIN'), hasAuthority('SCOPE_jobs.write'), or expressions using method parameters by name, @PreAuthorize("#userId == authentication.name") for ownership checks, and even bean calls: @PreAuthorize("@jobAccess.canEdit(#jobId, authentication)"), which is the pattern for non-trivial rules because the logic lives in a testable @Component instead of a SpEL string. @PostAuthorize runs after and can reference returnObject, useful for 'you may only see your own record' checks on fetched data. @PreFilter/@PostFilter filter collections but iterate element-by-element, so avoid them on large lists. URL rules in the SecurityFilterChain versus method security: URL rules are your perimeter, coarse, fast, applied before MVC; method security is defence in depth at the service layer, protecting logic no matter which controller, scheduler, or Kafka listener invokes it. Mature codebases use both: authorizeHttpRequests for broad areas, @PreAuthorize for object-level ownership.

The gotchas mirror all Spring AOP: self-invocation bypasses checks, and annotations on private methods do nothing, so annotate public service methods. Failures throw AccessDeniedException, becoming 403 via ExceptionTranslationFilter for web calls. Testing is first-class: @WithMockUser(roles = "ADMIN") in slice tests, and @WithSecurityContext for custom principals; asserting that a non-owner gets AccessDeniedException is a unit test, which is precisely why service-layer authorisation beats controller-only checks.

@Configuration
@EnableMethodSecurity
class MethodSecurityConfig {}

@Service
public class JobAdminService {

  @PreAuthorize("hasRole('ADMIN')")
  public void forceCloseJob(long jobId) { /* ... */ }

  // ownership rule delegated to a testable bean
  @PreAuthorize("@jobAccess.canEdit(#jobId, authentication)")
  public void updateJob(long jobId, UpdateJobDto dto) { /* ... */ }
}

@Component("jobAccess")
class JobAccess {
  public boolean canEdit(long jobId, Authentication auth) {
    return jobRepo.existsByIdAndOwnerEmail(jobId, auth.getName());
  }
}
Q36

How should passwords be stored in a Spring application, and what does DelegatingPasswordEncoder's {bcrypt} prefix do?

IntermediateSpring Security

Answer

Passwords are never encrypted (encryption is reversible); they are hashed with an adaptive, deliberately slow, salted algorithm. Spring Security's PasswordEncoder interface has encode() and matches(); the recommended implementations are BCryptPasswordEncoder (default strength 10, tunable; each hash embeds its own random salt, so identical passwords produce different hashes and rainbow tables die), and Argon2PasswordEncoder for new systems wanting memory-hard hashing that resists GPU cracking. Plain SHA-256, even salted, is wrong because it is fast, billions of guesses per second on commodity GPUs; adaptive cost factors let you keep hashing slow as hardware improves.

The factory method PasswordEncoderFactories.createDelegatingPasswordEncoder() returns a DelegatingPasswordEncoder that prefixes every stored hash with its algorithm id, {bcrypt}$2a$10$..., and picks the right verifier per stored value. That prefix is the migration mechanism: a table containing legacy {sha256} hashes and new {bcrypt} hashes verifies both, and you can transparently re-hash on successful login (upgradeEncoding hook via UserDetailsPasswordService) until the legacy entries disappear, which is exactly how you migrate a decade-old Indian enterprise user table without a forced reset. Operational details worth naming: bcrypt truncates input beyond 72 bytes, so cap password length in your DTO validation; raise strength until encode takes roughly 100ms-250ms on production hardware, it is a tunable defence, not a constant; matches() is constant-time-ish by construction, do not compare hashes with equals(); and never log or return the hash. For interviews, the crisp line is: DelegatingPasswordEncoder makes the algorithm a per-record decision, which turns hash migration from a big-bang project into a rolling upgrade.

@Bean
PasswordEncoder passwordEncoder() {
  // {bcrypt} by default, verifies {argon2}, {pbkdf2}, legacy ids too
  return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}

@Service
class RegistrationService {
  private final PasswordEncoder encoder;
  private final UserRepository users;

  RegistrationService(PasswordEncoder e, UserRepository u) {
    this.encoder = e; this.users = u;
  }

  public void register(String email, String rawPassword) {
    // stored as: {bcrypt}$2a$10$N9qo8uLO... (salt embedded in hash)
    users.save(new User(email, encoder.encode(rawPassword)));
  }

  public boolean login(User u, String raw) {
    return encoder.matches(raw, u.passwordHash());  // never equals()
  }
}
Q37

How do @Cacheable, @CachePut, and @CacheEvict behave with a Redis backend, and which caching mistakes cause production incidents?

IntermediateCaching

Answer

Enable with @EnableCaching; Spring installs a caching interceptor (AOP again). @Cacheable("jobs") checks the cache before invoking the method: hit returns the cached value and skips the body entirely; miss executes and stores the result. Keys come from parameters via a SimpleKey by default; customise with key = "#id" or a keyGenerator. condition gates whether caching applies at all; unless = "#result == null" prevents caching empties (or use it to avoid caching negative lookups, depending on your stampede strategy). @CachePut always executes the method and refreshes the entry, right for update paths. @CacheEvict removes entries (allEntries = true nukes the cache; beforeInvocation = true evicts even if the method throws). With spring-boot-starter-data-redis on the classpath and spring.cache.type=redis, RedisCacheManager serialises entries (configure a Jackson-based RedisSerializer; the JDK default serializer breaks on class changes and is unreadable in redis-cli) and, critically, applies TTLs, which the annotation API does not express: set them per cache via RedisCacheConfiguration.entryTtl() in a RedisCacheManagerBuilderCustomizer.

Incident-grade mistakes to recite: no TTL, so stale data persists until manual eviction; caching entities instead of DTOs, dragging lazy proxies into serialisation; self-invocation skipping the interceptor so the cache silently never engages; mutable cached objects being modified by callers (with an in-memory cache like Caffeine, that corrupts the cached copy for everyone); cache stampede on hot key expiry, mitigate with @Cacheable(sync = true) so one thread computes while others wait; and forgetting eviction on every write path, the number one 'why is the UI showing old data' ticket. Also know spring.cache.cache-names plus Caffeine spec strings for local caches.

@Service
public class CompanyService {

  @Cacheable(cacheNames = "company", key = "#id", sync = true)
  public CompanyDto find(long id) { /* DB hit only on miss */ }

  @CachePut(cacheNames = "company", key = "#result.id")
  public CompanyDto update(UpdateCompanyDto dto) { /* refresh entry */ }

  @CacheEvict(cacheNames = "company", key = "#id")
  public void delete(long id) { /* ... */ }
}

@Bean  // per-cache TTLs, Redis backend
RedisCacheManagerBuilderCustomizer ttls() {
  return b -> b.withCacheConfiguration("company",
      RedisCacheConfiguration.defaultCacheConfig()
          .entryTtl(Duration.ofMinutes(30))
          .serializeValuesWith(SerializationPair.fromSerializer(
              new GenericJackson2JsonRedisSerializer())));
}
Q38

What does @Async actually do, which executor runs the work, and how do you handle exceptions and lost context?

IntermediateAsync & Scheduling

Answer

@EnableAsync plus @Async makes a method return immediately while its body runs on another thread, again via proxy, so self-invocation and private methods silently run synchronously, the most common '@Async does nothing' bug. Return void or CompletableFuture<T> (return CompletableFuture.completedFuture(result) or compose real async work). Which threads: Spring Boot auto-configures a ThreadPoolTaskExecutor named applicationTaskExecutor (tunable via spring.task.execution.pool.core-size, max-size, queue-capacity, thread-name-prefix); without Boot's default you can fall into SimpleAsyncTaskExecutor, which spawns an unbounded new thread per call, a production outage generator.

Understand the pool math: tasks queue up to queue-capacity before the pool grows beyond core-size, so a huge queue with small core means bursts silently sit in memory; a bounded queue with CallerRunsPolicy is the standard backpressure choice. Route specific methods to dedicated pools with @Async("reportExecutor") so a flood of PDF generation cannot starve email sending. Exceptions: for CompletableFuture returns they surface when the future completes; for void methods they vanish unless you register an AsyncUncaughtExceptionHandler via AsyncConfigurer, unlogged async failures are a real incident class.

Context loss: SecurityContextHolder, MDC logging context, and any ThreadLocal do not follow the task; fix with a TaskDecorator that copies MDC/SecurityContext onto the worker thread. And repeat the transaction rule: the async method runs outside the caller's transaction; if it needs one, annotate it @Transactional itself, and beware calling it before the outer transaction commits, the row it wants may not be visible yet, which is exactly what @TransactionalEventListener(AFTER_COMMIT) exists to solve.

@Configuration
@EnableAsync
class AsyncConfig {
  @Bean("mailExecutor")
  ThreadPoolTaskExecutor mailExecutor() {
    var ex = new ThreadPoolTaskExecutor();
    ex.setCorePoolSize(4);
    ex.setMaxPoolSize(8);
    ex.setQueueCapacity(200);                       // bounded!
    ex.setThreadNamePrefix("mail-");
    ex.setRejectedExecutionHandler(
        new ThreadPoolExecutor.CallerRunsPolicy()); // backpressure
    ex.setTaskDecorator(runnable -> {               // carry MDC across threads
      Map<String, String> mdc = MDC.getCopyOfContextMap();
      return () -> { if (mdc != null) MDC.setContextMap(mdc);
                     try { runnable.run(); } finally { MDC.clear(); } };
    });
    return ex;
  }
}

@Service
class MailService {
  @Async("mailExecutor")
  public CompletableFuture<Void> sendOfferLetter(long candidateId) {
    /* ... */
    return CompletableFuture.completedFuture(null);
  }
}
Q39

How does @Scheduled work, why do overlapping or multi-instance runs happen, and how do you prevent double execution across pods?

IntermediateAsync & Scheduling

Answer

@EnableScheduling starts a TaskScheduler that invokes @Scheduled methods. Triggers: fixedRate = 60000 fires every minute measured start-to-start; fixedDelay measures from completion to next start (so slow runs cannot pile up); cron = "0 30 9 * * MON-FRI" uses six fields (seconds first, unlike Unix cron, a detail interviewers check) with zone = "Asia/Kolkata" for IST-anchored business schedules; initialDelay staggers boot-time storms. The default scheduler has one thread, so two @Scheduled methods share it: a long job delays every other schedule silently.

Fix with spring.task.scheduling.pool.size or a custom ThreadPoolTaskScheduler. fixedRate still never overlaps itself on a single instance (the scheduler skips concurrent invocation of the same task by default), but the deeper production issue is horizontal scaling: three pods each run the 9:30 settlement job, triple-charging or triple-mailing. In-JVM scheduling has no cluster awareness. Standard fixes: ShedLock (@SchedulerLock(name = "settlementJob", lockAtMostFor = "10m") backed by a JDBC/Redis lock table) makes exactly one instance win per tick and is the lightweight default; Quartz with its JDBC JobStore when you need persistent, misfire-aware, dynamically-managed jobs; or move the trigger out of the app entirely, a Kubernetes CronJob hitting an internal endpoint, or an event from a scheduler service. lockAtMostFor must exceed worst-case runtime or a second pod starts mid-run; lockAtLeastFor guards against clock skew double-fires.

Also worth saying: schedule bodies should be small orchestrations that enqueue idempotent work, because 'the cron did half the batch then crashed' is only survivable when re-running is safe. Boot 3 exposes scheduled tasks at /actuator/scheduledtasks for verification.

@Component
public class SettlementJobs {

  // 09:30 IST, weekdays; seconds field comes FIRST
  @Scheduled(cron = "0 30 9 * * MON-FRI", zone = "Asia/Kolkata")
  @SchedulerLock(name = "dailySettlement",
                 lockAtMostFor = "15m", lockAtLeastFor = "1m")
  public void dailySettlement() {
    // enqueue idempotent per-merchant tasks; keep this method thin
  }

  @Scheduled(fixedDelay = 30_000, initialDelay = 10_000)
  public void reconcileStuckPayments() { /* completion-to-start spacing */ }
}

# application.yml
# spring.task.scheduling.pool.size: 4
Q40

How do Spring application events work, and what problem does @TransactionalEventListener(phase = AFTER_COMMIT) solve?

IntermediateEvents

Answer

Publish any object through ApplicationEventPublisher.publishEvent(new OrderPlacedEvent(orderId)); every @EventListener method whose parameter type matches receives it. By default this is synchronous and in-thread: listeners run inside the publisher's call stack, share its transaction, and a throwing listener propagates the exception back into the publisher, all three facts surprise people. Add @Async to a listener (with @EnableAsync) for fire-and-forget, order multiple listeners with @Order, and filter with condition SpEL.

Events are an in-process decoupling tool, OrderService should not know that placing an order triggers email, loyalty points, and a search-index update, but they are not durable messaging: no retries, no persistence, lost on crash; Kafka/RabbitMQ remain the cross-service answer. The killer feature is @TransactionalEventListener. Problem: a listener sends a confirmation email for a row the transaction has not committed yet; the transaction then rolls back, and the customer has an email for an order that does not exist.

Or the listener (async) queries for the new row and cannot see it. @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT), the default phase, buffers the event and delivers it only after the surrounding transaction commits successfully; AFTER_ROLLBACK and AFTER_COMPLETION cover the other outcomes, BEFORE_COMMIT runs inside just before commit. Two sharp edges: if no transaction is active the event is dropped unless fallbackExecution = true; and an AFTER_COMMIT listener runs with the connection's transaction completed, so writes it performs need @Transactional(propagation = REQUIRES_NEW) or they join a stale context. This buffering is also the in-JVM half of the outbox pattern, which makes it a bridge topic to microservice rounds.

public record OrderPlacedEvent(long orderId, String email) {}

@Service
public class OrderService {
  private final ApplicationEventPublisher events;
  public OrderService(ApplicationEventPublisher events) { this.events = events; }

  @Transactional
  public void place(CreateOrder cmd) {
    Order o = repo.save(Order.from(cmd));
    events.publishEvent(new OrderPlacedEvent(o.getId(), cmd.email()));
    // event delivered only if this txn commits
  }
}

@Component
class OrderMailer {
  @Async
  @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
  public void onPlaced(OrderPlacedEvent e) {
    mailer.sendConfirmation(e.email(), e.orderId()); // row is committed & visible
  }
}
Q41

@SpringBootTest versus @WebMvcTest versus @DataJpaTest: what does each load, and why do test suites get slow?

IntermediateTesting

Answer

@SpringBootTest boots the full ApplicationContext, every bean, auto-configuration, and optionally a real server (webEnvironment = RANDOM_PORT with TestRestTemplate or WebTestClient for true end-to-end HTTP). Use it sparingly for wiring-level integration tests. Slice annotations load only a vertical: @WebMvcTest(JobController.class) starts the MVC layer, controllers, @ControllerAdvice, converters, Spring Security filters, but no services or repositories; you mock collaborators and drive requests with MockMvc. @DataJpaTest loads JPA repositories, entities, and a transactional test harness, each test rolls back by default, and by default swaps your datasource for an embedded H2, which you should usually disable with @AutoConfigureTestDatabase(replace = Replace.NONE) plus Testcontainers, because H2's SQL dialect quietly diverges from MySQL/Postgres (different functions, constraint behaviour), letting tests pass on queries that fail in production.

Other slices exist: @JsonTest, @RestClientTest, @WebFluxTest, @JdbcTest. Why suites crawl: Spring caches contexts across test classes keyed by configuration (the context cache), so a suite with consistent configuration boots very few contexts; every @MockitoBean set, @ActiveProfiles change, custom properties, or @DirtiesContext creates a new cache key and another full boot, and thirty distinct configurations at 10 seconds each is a five-minute tax before a single assertion runs. Disciplines that fix it: standardise a shared test configuration (a common abstract base class or meta-annotation), consolidate mock sets, treat @DirtiesContext as a code smell, and keep the pyramid honest, plain JUnit and Mockito unit tests for logic (no Spring at all, which constructor injection makes trivial), slices for the web/persistence contracts, and a handful of @SpringBootTest end-to-end paths.

@WebMvcTest(JobController.class)
class JobControllerTest {

  @Autowired MockMvc mvc;
  @MockitoBean JobService jobs;   // replaces the bean in this slice

  @Test
  void returns404WhenMissing() throws Exception {
    when(jobs.find(42L)).thenThrow(new JobNotFoundException(42L));

    mvc.perform(get("/api/v1/jobs/42"))
       .andExpect(status().isNotFound())
       .andExpect(jsonPath("$.title").value("Job not found"));
  }
}

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class CandidateRepositoryTest { /* runs against Testcontainers DB */ }
Q42

What replaced @MockBean, and how do you assert JSON contracts precisely with MockMvc?

IntermediateTesting

Answer

Spring Boot 3.4 deprecated @MockBean and @SpyBean in favour of @MockitoBean and @MockitoSpyBean, which live in Spring Framework 6.2 (org.springframework.test.context.bean.override.mockito) rather than Boot; the mechanism was generalised into a bean-override framework (@TestBean for factory-method overrides is part of the same family). Behaviour is familiar: @MockitoBean replaces (or adds) a bean with a Mockito mock for that test's context, and, as before, each distinct combination of overridden beans forks a new context cache entry, so consolidate them. In interviews, knowing the rename plus the cache implication signals you track the ecosystem, many candidates still write @MockBean without knowing it warns on 3.4+.

On MockMvc: build requests with MockMvcRequestBuilders (get/post/put), set content and contentType(MediaType.APPLICATION_JSON), and assert with andExpect chains: status().isCreated(), header().string("Location", ...), and jsonPath("$.items[0].id").value(7) for structural checks. For whole-payload comparison, JSONAssert via content().json(expected, strict) beats string equality because it ignores key order. Security integrates cleanly: @WithMockUser(roles = "ADMIN") or the SecurityMockMvcRequestPostProcessors (post(...).with(jwt().jwt(j -> j.claim("roles", List.of("ADMIN"))))) let you test 401/403 paths without a token server, and csrf() is required on mutating requests when CSRF protection is active in the tested chain.

Boot 3.4 also added MockMvcTester, an AssertJ-flavoured wrapper over MockMvc, worth naming as the modern alternative. The philosophy interviewers reward: slice tests assert the HTTP contract (status, headers, JSON shape, error body) and validation behaviour, while business logic stays in plain unit tests; if your controller test mocks five services and asserts one integer, the logic is in the wrong layer.

@WebMvcTest(controllers = ApplicationController.class)
class ApplyEndpointTest {

  @Autowired MockMvc mvc;
  @MockitoBean ApplyService applyService;   // Boot 3.4+ replacement for @MockBean

  @Test
  void rejectsUnauthenticated() throws Exception {
    mvc.perform(post("/api/v1/jobs/7/apply"))
       .andExpect(status().isUnauthorized());
  }

  @Test
  void appliesWithJwt() throws Exception {
    mvc.perform(post("/api/v1/jobs/7/apply")
            .with(jwt().jwt(j -> j.subject("cand-91")))
            .contentType(MediaType.APPLICATION_JSON)
            .content("{\"coverNote\":\"hi\"}"))
       .andExpect(status().isCreated())
       .andExpect(jsonPath("$.applicationId").isNumber());
  }
}
Q43

How does Testcontainers integrate with Spring Boot, and what does @ServiceConnection remove from your test config?

IntermediateTesting

Answer

Testcontainers spins up real infrastructure, Postgres, MySQL, Redis, Kafka, in Docker containers per test run, killing the 'works on H2, dies on MySQL' class of bugs. Classic wiring needed three pieces: a @Container-annotated static container field, @Testcontainers on the class, and a @DynamicPropertySource method copying container.getJdbcUrl()/username/password into Spring properties. Spring Boot 3.1 collapsed that: annotate the container bean or field with @ServiceConnection and Boot derives the connection details automatically, it knows PostgreSQLContainer maps to spring.datasource.*, RedisContainer (or a GenericContainer with the right image) to spring.data.redis.*, KafkaContainer to spring.kafka.bootstrap-servers, via ConnectionDetails abstractions introduced for exactly this.

No property plumbing at all. Patterns that keep suites fast: declare containers as static (shared across test methods) and reuse one container across classes via a shared abstract base class or singleton-container pattern, container startup is seconds, so per-test containers are prohibitive; enable Testcontainers reuse (testcontainers.reuse.enable=true in ~/.testcontainers.properties plus withReuse(true)) on developer machines to skip even that. Boot 3.1 also added 'development-time services': run the app locally against containers by putting them in a TestApplication class and executing ./gradlew bootTestRun (or the equivalent test main), so local dev needs no installed database at all.

On CI, the runner needs Docker (Docker-in-Docker or a mounted socket); Indian enterprise CI on restricted runners sometimes cannot provide it, in which case teams fall back to service containers in GitHub Actions or a shared test database, know the trade-off. Close with the test-pyramid placement: @DataJpaTest plus @ServiceConnection Postgres is the sweet spot for repository tests, real dialect, rolled-back transactions, negligible boilerplate.

@TestConfiguration(proxyBeanMethods = false)
class TestcontainersConfig {

  @Bean
  @ServiceConnection   // Boot maps this to spring.datasource.* automatically
  PostgreSQLContainer<?> postgres() {
    return new PostgreSQLContainer<>("postgres:16-alpine");
  }

  @Bean
  @ServiceConnection(name = "redis")
  GenericContainer<?> redis() {
    return new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
  }
}

@SpringBootTest
@Import(TestcontainersConfig.class)
class CheckoutFlowIT {
  // real Postgres + real Redis, zero property plumbing
}
Q44

How do you size and monitor a HikariCP pool, and what does 'Connection is not available, request timed out' actually tell you?

IntermediateData Access & Performance

Answer

HikariCP is Boot's default connection pool. Key properties: spring.datasource.hikari.maximum-pool-size (default 10), minimum-idle, connection-timeout (default 30s, how long a borrower waits before SQLTransientConnectionException: 'Connection is not available, request timed out after 30000ms'), max-lifetime (retire connections before the DB or an intermediate LB kills them; keep it below MySQL's wait_timeout or you collect dead connections), idle-timeout, and leak-detection-threshold, which logs a stack trace for any connection held longer than the threshold, the fastest way to find code paths that borrow and never return. Sizing: bigger is not better.

The pool's job is to queue work in the app rather than thrash the database; the classic formula (cores * 2 + effective spindle count) lands most services at 10-20 connections, and the database's max_connections divided across all pods is the real ceiling, twenty pods with maximum-pool-size 50 asking Postgres for 1,000 connections is a self-inflicted outage. Now the error message: it means all connections were busy for the full connection-timeout. That is a symptom with three usual causes, and diagnosing which is the interview: (1) leaks, code holding connections (leak detection finds it); (2) long transactions or OSIV holding connections across slow external calls, the transaction-shape problem; (3) genuine throughput ceiling, the pool is correctly sized but queries are slow (fix the queries or add replicas).

Monitor via Micrometer: hikaricp.connections.active/idle/pending and hikaricp.connections.acquire timings ship automatically through Actuator; a sustained pending count is your early-warning signal. Pair connection-timeout with sane query timeouts (spring.jpa.properties.jakarta.persistence.query.timeout or JDBC socket timeouts) so one slow query cannot serially occupy the pool.

# application.yml: deliberate pool posture for one service pod
spring:
  datasource:
    hikari:
      maximum-pool-size: 15        # pods * this < DB max_connections
      minimum-idle: 5
      connection-timeout: 3000     # fail fast; don't queue 30s under brownout
      max-lifetime: 1500000        # 25m, below MySQL wait_timeout
      leak-detection-threshold: 20000   # stack-trace any 20s+ borrow

# Watch: /actuator/metrics/hikaricp.connections.pending
# Sustained pending > 0  ->  leak, long txns/OSIV, or real capacity limit
Q45

How do you implement a custom Bean Validation constraint with a ConstraintValidator that needs a Spring bean?

IntermediateValidation

Answer

Two pieces: an annotation and a validator. The annotation is meta-annotated with @Constraint(validatedBy = YourValidator.class), carries the standard message/groups/payload attributes, and declares @Target and @Retention. The validator implements ConstraintValidator<YourAnnotation, FieldType> with initialize() (read annotation attributes) and isValid(value, context).

Convention: return true for null and let @NotNull handle presence separately, so the constraint composes instead of duplicating null checks. The part that elevates the answer: in a Spring application, Hibernate Validator is wired with a SpringConstraintValidatorFactory, so your ConstraintValidator is instantiated as a Spring bean and can constructor-inject repositories or clients, which enables database-backed rules like 'GSTIN must be unique' or 'pincode must exist'. That power comes with caveats interviewers want you to volunteer: validators run on every validation pass, so a DB-hitting validator on a hot endpoint needs caching or reconsideration; uniqueness checks at validation time race with concurrent inserts, so the database unique constraint remains the source of truth, catch DataIntegrityViolationException and map it to the same error shape; and heavy validators make @Valid latency part of your p99.

Use context.disableDefaultConstraintViolation() plus buildConstraintViolationWithTemplate() to attach violations to specific fields, essential for class-level constraints like @PasswordsMatch that compare two fields. Class-level constraints target the DTO type itself. Finally, custom constraints work identically on method validation (@Validated on the class validating @RequestParam) and cascade with @Valid on nested objects; messages resolve through the standard MessageInterpolator so they localise via messages.properties like built-ins.

@Target({ ElementType.FIELD, ElementType.PARAMETER })
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = GstinValidator.class)
public @interface ValidGstin {
  String message() default "invalid GSTIN";
  Class<?>[] groups() default {};
  Class<? extends Payload>[] payload() default {};
}

public class GstinValidator implements ConstraintValidator<ValidGstin, String> {
  private static final Pattern SHAPE =
      Pattern.compile("^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$");

  private final StateCodeRegistry states;      // injected: it's a Spring bean
  public GstinValidator(StateCodeRegistry states) { this.states = states; }

  @Override
  public boolean isValid(String v, ConstraintValidatorContext ctx) {
    if (v == null) return true;                // compose with @NotNull
    return SHAPE.matcher(v).matches()
        && states.exists(v.substring(0, 2));
  }
}
Q46

In what order does Spring Boot resolve configuration properties, and how should secrets reach the application in production?

IntermediateConfiguration

Answer

Boot builds an ordered list of PropertySource objects; the first source containing a key wins. The order you should be able to sketch, highest priority first: command-line arguments (--server.port=9000), Java system properties (-Dserver.port), OS environment variables (SERVER_PORT, via relaxed binding), profile-specific files outside the jar (config/application-prod.yml next to the jar beats the packaged one), profile-specific files inside the jar, application.yml outside the jar, application.yml inside the jar, @PropertySource-declared files, and finally defaults set via SpringApplication.setDefaultProperties. Devtools and test-level @TestPropertySource/@DynamicPropertySource sit above most of these in their contexts.

Practical consequences: an env var always beats packaged YAML, which is the entire twelve-factor deployment story on Kubernetes, same image, per-environment env vars; and 'why is my property ignored' is almost always a higher-priority source shadowing yours, which you can prove by inspecting /actuator/env, it shows every source in order and which one won (guard that endpoint; it reveals secrets unless sanitised, and Boot 3 requires explicit configuration to show unsanitised values). Secrets: never in git, never in the image. Options in rough order of maturity: environment variables injected by the platform (Kubernetes Secrets, ECS task definitions); mounted-file secrets consumed via spring.config.import=configtree:/run/secrets/, which turns a directory of files into properties (the volume-mount pattern); Spring Cloud Vault or AWS Secrets Manager integrations pulling at startup with spring.config.import=vault:// or aws-secretsmanager:; and per-key rotation strategies where the client refreshes without restart. Interviewers also like hearing that spring.config.import fails fast when a required source is missing, unless marked optional:, another deliberate boot-time guard.

# Kubernetes-style: packaged defaults, env overrides, mounted secrets
# application.yml (in jar)
spring:
  config:
    import: "optional:configtree:/run/secrets/"
db:
  url: jdbc:postgresql://localhost:5432/dev

# Deployment env (wins over the jar):
#   DB_URL=jdbc:postgresql://prod-rds:5432/app
# Mounted file /run/secrets/db.password -> property db.password

Key Points

  • CLI args > system props > env vars > external files > packaged files
  • Same image everywhere; environments differ only in injected sources
  • /actuator/env shows the winning source (sanitised); use it to debug shadowing
  • Secrets via env/mounted configtree/Vault or cloud secret managers, never git
Q47

How do you publish custom business metrics with Micrometer, and what do http.server.requests percentiles require?

IntermediateObservability

Answer

Micrometer is Spring's vendor-neutral metrics facade, SLF4J for metrics. Inject MeterRegistry anywhere and create meters: Counter (monotonic totals, orders_placed_total), Timer (latency plus throughput; use registry.timer(...).record(runnable) or Timer.Sample around a block), Gauge (a value observed on scrape, queue depth, must reference the object weakly, and gauges on collections must reference a live object or they silently report NaN), and DistributionSummary (sizes/amounts). Tags make metrics queryable, timer("payment.charge", "provider", "upi", "outcome", "success"), but every distinct tag-value combination materialises a new time series, so tagging by userId or orderId is a cardinality explosion that can take down your Prometheus; tag only by low-cardinality dimensions and keep IDs in traces/logs.

Annotations @Timed and @Counted work on beans via registered aspects (TimedAspect bean required), with the usual proxy caveats. Out of the box, Boot instruments HTTP server latency as http.server.requests tagged by uri (the template, not the raw path, precisely to bound cardinality), status, method, and exception; JVM memory and GC; Hikari; and RestClient/WebClient calls as http.client.requests. Percentiles need explicit opt-in because histograms cost memory: set management.metrics.distribution.percentiles-histogram.http.server.requests=true so Prometheus can compute quantiles across pods via histogram_quantile (server-side aggregatable), rather than publishing pre-computed non-aggregatable percentiles.

Export to Prometheus via the /actuator/prometheus endpoint (micrometer-registry-prometheus on the classpath), or OTLP to a collector for stacks like SigNoz, which several Indian teams run self-hosted. Common-tags (application, region) belong in a MeterRegistryCustomizer so every metric carries deployment identity. The closing interview line: metrics for aggregates and alerting, traces for individual requests, logs for detail; Micrometer now covers the first two with Observation.

@Service
public class ChargeService {
  private final MeterRegistry metrics;
  public ChargeService(MeterRegistry metrics) { this.metrics = metrics; }

  public ChargeResult charge(Order o, String provider) {
    return metrics.timer("payment.charge", "provider", provider)
        .record(() -> {
          ChargeResult r = gateway.charge(o);
          metrics.counter("payment.outcome",
              "provider", provider, "status", r.status()).increment();
          return r;
        });
  }
}

# application.yml: aggregatable latency histograms
management:
  metrics:
    distribution:
      percentiles-histogram:
        http.server.requests: true
Q48

Why does Spring Boot reject circular bean dependencies at startup, and what are the honest ways to break a cycle?

IntermediateContainer

Answer

A cycle, OrderService needs PaymentService which needs OrderService, cannot be satisfied with constructor injection: neither constructor can run first. Boot fails startup with a clear 'The dependencies of some of the beans in the application context form a cycle' report drawing the loop. Historically, cycles involving setter/field injection could be 'resolved' through Spring's early-reference mechanism (the container exposes a half-initialised bean via an early singleton reference so the other side can be built), but Spring Boot 2.6 flipped the default to prohibit circular references entirely, because early references hand out beans before their initialisation completes and interact badly with AOP proxies, one side can capture the raw bean while the world sees the proxy.

The escape hatch spring.main.allow-circular-references=true exists and is the wrong answer in an interview except as 'a temporary migration flag'. Honest fixes, in preference order: (1) refactor, a cycle almost always means a missing third concept; extract the shared piece into a new bean both depend on (OrderPaymentCoordinator, or move the offending method), and interviewers explicitly want to hear 'the cycle is a design smell' before any mechanism; (2) @Lazy on one injection point, which injects a proxy that resolves the target on first use, breaking construction-time ordering, acceptable when the cyclic call path is genuinely rare; (3) ObjectProvider<PaymentService>, making the dependency lookup explicit and deferred; (4) restructure around events, publish OrderPlacedEvent instead of calling back into the other service, which removes the compile-time edge entirely and often matches the domain better; (5) setter injection as a last resort, reintroducing mutability. Also name the diagnostic: the startup report, or ./mvnw dependency-free tools like ArchUnit rules asserting package-level acyclicity so cycles never re-enter the codebase.

// Cycle: OrderService <-> PaymentService
// Fix (1): extract the shared concern
@Service
class OrderService {
  private final PaymentPort payments;          // interface, no back-edge
  OrderService(PaymentPort payments) { this.payments = payments; }
}

// Fix (2): defer one edge when refactoring isn't feasible today
@Service
class PaymentService {
  private final ObjectProvider<OrderService> orders;  // resolved on use
  PaymentService(ObjectProvider<OrderService> orders) { this.orders = orders; }

  void onRefund(long orderId) {
    orders.getObject().markRefunded(orderId);
  }
}

# NOT a fix, a migration flag only:
# spring.main.allow-circular-references: true
Q49

BeanFactoryPostProcessor versus BeanPostProcessor: which framework features are built on each, and when would you write your own?

AdvancedContainer Internals

Answer

They hook different phases. BeanFactoryPostProcessor runs after bean definitions are loaded but before any bean is instantiated; it operates on metadata, the BeanDefinition objects, not on instances. PropertySourcesPlaceholderConfigurer (resolving ${...} in definitions) and ConfigurationClassPostProcessor (the machine that processes @Configuration, @ComponentScan, @Import, and registers @Bean methods as definitions) are the canonical examples; auto-configuration itself rides on this phase.

Write one when you need to rewrite wiring wholesale: registering beans dynamically from external metadata (via its subtype BeanDefinitionRegistryPostProcessor), forcing all beans matching a pattern to be lazy, or redirecting a property across every definition. BeanPostProcessor runs per instance, around initialisation: postProcessBeforeInitialization and postProcessAfterInitialization for every bean the container creates. This is where most Spring 'magic' physically happens: AutowiredAnnotationBeanPostProcessor injects @Autowired members, CommonAnnotationBeanPostProcessor handles @PostConstruct/@PreDestroy, and AbstractAutoProxyCreator subclasses (AnnotationAwareAspectJAutoProxyCreator, plus the infrastructure advisors behind @Transactional, @Cacheable, @Async) return AOP proxies from postProcessAfterInitialization, replacing the raw instance in the container.

Write your own to implement custom annotations: scanning beans for @KafkaHandler-style methods and registering endpoints, wrapping beans implementing some interface with a metrics decorator, or vetoing misconfigured beans at startup. Sharp edges that mark real experience: BeanPostProcessor beans and everything they depend on are instantiated extremely early, before regular singletons, so injecting normal business beans into one can trigger 'bean is not eligible for getting processed by all BeanPostProcessors' warnings, meaning those early-created beans silently miss proxying (their @Transactional stops working); keep post-processors dependency-light, use ObjectProvider for lookups, and declare them via static @Bean methods in configuration classes so the config class itself is not instantiated early.

// Wrap every bean implementing RateLimited with a limiter proxy
@Component
public class RateLimitPostProcessor implements BeanPostProcessor {

  @Override
  public Object postProcessAfterInitialization(Object bean, String name) {
    if (!(bean instanceof RateLimited limited)) return bean;
    return Proxy.newProxyInstance(
        bean.getClass().getClassLoader(),
        bean.getClass().getInterfaces(),
        (proxy, method, args) -> {
          limiter.acquire(name);                 // gate every call
          return method.invoke(bean, args);
        });
  }
}

// Definition-phase counterpart: make all repositories lazy
static class LazyRepos implements BeanFactoryPostProcessor {
  public void postProcessBeanFactory(ConfigurableListableBeanFactory bf) {
    for (String n : bf.getBeanDefinitionNames())
      if (n.endsWith("Repository")) bf.getBeanDefinition(n).setLazyInit(true);
  }
}
Q50

How would you build a custom Spring Boot starter with its own auto-configuration for an internal library?

AdvancedSpring Boot Internals

Answer

The convention is two modules. First, an autoconfigure module containing the @AutoConfiguration classes, the @ConfigurationProperties types, and optional dependencies; second, a thin starter module (yourco-audit-spring-boot-starter) whose POM just aggregates the autoconfigure module plus required libraries. Registration: list your auto-configuration classes, one per line, in META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports (the Boot 2.7+ mechanism replacing the old spring.factories key, which was removed for auto-configuration in Boot 3).

Annotate each class with @AutoConfiguration (implies proxyBeanMethods = false) and guard everything the way Boot itself does: @ConditionalOnClass so the config backs off when the library is absent, @ConditionalOnProperty(prefix = "yourco.audit", name = "enabled", havingValue = "true", matchIfMissing = true) for a kill switch, and, non-negotiably, @ConditionalOnMissingBean on every @Bean so consuming teams can override any piece by declaring their own. Order against other auto-configurations with @AutoConfigureAfter/@AutoConfigureBefore (for example after DataSourceAutoConfiguration if you need the DataSource). Bind settings via @EnableConfigurationProperties, ship the spring-boot-configuration-processor so IDEs autocomplete yourco.audit.* keys, and test with ApplicationContextRunner, Boot's dedicated harness for asserting 'context contains my bean when the class is present and property set; backs off when the user defines their own'.

This is a genuinely common senior task in Indian platform teams: wrapping company-standard auditing, Kafka conventions, or auth into a starter so forty services get consistent behaviour from one dependency line. The interview-winning nuance: auto-configurations are not component-scanned (imports file only), user configuration is processed first so user beans win, and keep starters free of transitive opinions (no logging implementations) to avoid classpath fights.

// yourco-audit-autoconfigure:
// META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
//   com.yourco.audit.AuditAutoConfiguration

@AutoConfiguration(after = DataSourceAutoConfiguration.class)
@ConditionalOnClass(AuditTrail.class)
@EnableConfigurationProperties(AuditProps.class)
public class AuditAutoConfiguration {

  @Bean
  @ConditionalOnMissingBean            // consumer's own bean always wins
  @ConditionalOnProperty(prefix = "yourco.audit", name = "enabled",
                         matchIfMissing = true)
  AuditTrail auditTrail(DataSource ds, AuditProps props) {
    return new JdbcAuditTrail(ds, props.table());
  }
}

// Test with ApplicationContextRunner:
new ApplicationContextRunner()
    .withConfiguration(AutoConfigurations.of(AuditAutoConfiguration.class))
    .withPropertyValues("yourco.audit.enabled=true")
    .run(ctx -> assertThat(ctx).hasSingleBean(AuditTrail.class));
Q51

What does spring.threads.virtual.enabled=true change in a Boot 3.2+ service, and what is thread pinning?

AdvancedConcurrency & Performance

Answer

On Java 21+, that single property switches Boot's request handling onto virtual threads: Tomcat executes each request on a new virtual thread instead of a bounded platform-thread pool, and Boot also moves its async/scheduling defaults (applicationTaskExecutor, @Scheduled) to virtual-thread-per-task executors. Virtual threads are cheap, JVM-scheduled threads that unmount from their carrier platform thread when they block on I/O, so 10,000 concurrent requests waiting on a slow downstream no longer require 10,000 OS threads, the thread-per-request model suddenly scales like async code while remaining plain, debuggable, blocking Java. Practical consequences: the server.tomcat.threads.max ceiling stops being your concurrency limit for I/O-bound work; latency under high fan-out improves without rewriting to WebFlux; and for many teams the honest conclusion is that virtual threads deliver most of reactive's throughput benefit at a fraction of the cognitive cost, know this trade-off cold, it is 2026's favourite architecture question.

What does not change: CPU-bound work gains nothing (carriers are still limited to core count), and shared-resource limits move elsewhere, most importantly the connection pool: unbounded request concurrency against maximum-pool-size 15 turns Hikari into the queue, so pools and rate limits become your explicit backpressure. Pinning: when a virtual thread blocks inside a synchronized block or a native call, it cannot unmount and pins its carrier; enough simultaneous pinning starves all carriers. Java 24 (JEP 491) removed the synchronized pinning penalty, but on Java 21 you detect it with -Djdk.tracePinnedThreads=full and fix hot paths by replacing synchronized with ReentrantLock, older library versions (JDBC drivers, connection pools, Jackson internals historically) were the usual offenders, and upgrading them is part of enabling virtual threads responsibly. Avoid ThreadLocal-heavy caching designs, millions of short-lived threads make them expensive.

# application.yml (Java 21+, Boot 3.2+)
spring:
  threads:
    virtual:
      enabled: true

# Detect pinning on Java 21 while load testing:
#   java -Djdk.tracePinnedThreads=full -jar app.jar
# Output names the frame holding a monitor during blocking I/O.

// Hot-path fix for pinning-prone code on Java 21:
private final ReentrantLock lock = new ReentrantLock();
void refreshToken() {
  lock.lock();               // virtual-thread friendly, unlike synchronized
  try { /* blocking HTTP call */ } finally { lock.unlock(); }
}
Q52

When is Spring WebFlux the right choice over MVC in 2026, and what breaks when blocking code sneaks into a reactive pipeline?

AdvancedReactive

Answer

WebFlux runs on an event-loop model (Netty by default): a handful of threads process many connections, and handlers return Mono<T>/Flux<T> pipelines that describe work instead of performing it. Its genuine wins: very high concurrency with bounded memory, streaming semantics (SSE, chunked infinite streams), end-to-end backpressure via Reactive Streams, and integration with reactive drivers, R2DBC for SQL, reactive Mongo/Redis/Kafka clients. Its costs are equally real: everything in the call path must be non-blocking or the model collapses; stack traces become operator soup (mitigate with Hooks.onOperatorDebug in dev, checkpoint(), or the reactor-tools debug agent); and JPA does not exist here, R2DBC lacks an ORM of Hibernate's maturity, so rich relational domains lose the most productive part of the Spring data stack.

The failure mode to explain: a blocking JDBC call or RestTemplate inside a flatMap occupies an event-loop thread; with only 2x cores loop threads, a few dozen concurrent blocked calls freeze the entire server, symptoms are cliff-edge latency collapse rather than graceful degradation. Escape hatches: publishOn/subscribeOn(Schedulers.boundedElastic()) to shunt unavoidable blocking work onto an elastic pool, and BlockHound in tests to fail the build when anything blocks on a loop thread. The 2026 decision framework interviewers want: with virtual threads making blocking MVC scale for I/O-bound concurrency, WebFlux's remaining sweet spots are streaming-first APIs, gateway/edge services (Spring Cloud Gateway is WebFlux-based), extreme-connection-count workloads (chat, feeds, SSE fan-out), and stacks already invested in reactive drivers; for a typical relational CRUD-plus-integrations service, MVC on virtual threads wins on debuggability, hiring, and JPA. Mixing models is legitimate: MVC controllers can return Flux for SSE endpoints, and WebClient works fine from MVC.

@RestController
public class QuoteController {

  private final ReactiveQuoteRepo quotes;   // R2DBC-backed

  // SSE stream: one of WebFlux's genuine sweet spots
  @GetMapping(value = "/quotes/stream",
              produces = MediaType.TEXT_EVENT_STREAM_VALUE)
  Flux<Quote> stream(@RequestParam String symbol) {
    return quotes.streamBySymbol(symbol)
        .sample(Duration.ofMillis(200))      // backpressure-aware throttle
        .timeout(Duration.ofSeconds(30));
  }

  // Unavoidable blocking call, quarantined off the event loop:
  Mono<Pan> verifyPan(String pan) {
    return Mono.fromCallable(() -> legacyPanClient.verify(pan)) // blocking
        .subscribeOn(Schedulers.boundedElastic());
  }
}
Q53

How does Spring Boot's AOT engine make GraalVM native images work, and what breaks reflection-heavy code at native runtime?

AdvancedNative & AOT

Answer

GraalVM native-image compiles ahead-of-time under a closed-world assumption: anything not reachable at build time does not exist at runtime. Spring's runtime model, classpath scanning, reflective bean instantiation, dynamic proxies, resource loading, violates that everywhere, so Spring Boot 3's AOT engine runs at build time: it evaluates your configuration (conditions get fixed to the build-time outcome), generates plain-Java bean registration code replacing runtime reflection, generates proxy classes ahead of time, and emits GraalVM reachability metadata (hints) for the reflection, resources, and serialisation the app still needs. Build via the Native Build Tools: ./gradlew nativeCompile or ./mvnw -Pnative native:compile, or a container in one step with bootBuildImage using Paketo buildpacks.

The payoff: startup in tens of milliseconds and a fraction of the RSS memory, transformative for scale-to-zero serverless, CLI tools, and dense Kubernetes packing; the costs: multi-minute builds, no JIT (peak throughput can trail a warmed JVM, though profile-guided optimisation narrows it), and the closed world. What breaks: any reflection the framework could not see, Class.forName on computed names, Jackson serialising types only reached dynamically, hand-rolled Proxy.newProxyInstance, ServiceLoader tricks, some libraries' internals, failing at runtime with ClassNotFoundException, missing-reflection-configuration errors, or silently empty JSON. Fixes: @RegisterReflectionForBinding(MyDto.class) for serialisation targets, a RuntimeHintsRegistrar registering reflection/resource/proxy hints programmatically (@ImportRuntimeHints), the shared reachability-metadata repository that covers popular libraries, and the tracing agent (-agentlib:native-image-agent) to record needed metadata from a test run.

Also know the constraints AOT imposes even on JVM deploys of AOT-processed apps: bean conditions are frozen at build time, so profile-dependent bean graphs must be decided by then. Honest closing note: for long-running high-throughput services, the JVM with CDS and tiered compilation often remains the better trade; native shines where cold start and memory dominate.

// Register reflection metadata the AOT engine can't infer
@Configuration
@ImportRuntimeHints(PartnerHints.class)
@RegisterReflectionForBinding({ PartnerWebhookDto.class })
class PartnerConfig {}

class PartnerHints implements RuntimeHintsRegistrar {
  @Override
  public void registerHints(RuntimeHints hints, ClassLoader cl) {
    hints.resources().registerPattern("partner-schemas/*.json");
    hints.reflection().registerType(
        LegacyPartnerClient.class,
        MemberCategory.INVOKE_DECLARED_METHODS,
        MemberCategory.DECLARED_FIELDS);
  }
}

# Build:
#   ./mvnw -Pnative native:compile        (local toolchain)
#   ./gradlew bootBuildImage              (buildpacks, container output)
Q54

Why do teams avoid two-phase commit between a database and Kafka, and how does the transactional outbox pattern work in Spring?

AdvancedMicroservices & Messaging

Answer

The dual-write problem: a service commits an order to Postgres and publishes OrderPlaced to Kafka. Any interleaving of failures leaves the two stores disagreeing, DB committed but publish failed (downstream never learns), or published but DB rolled back (downstream acts on a ghost order). Wrapping both in one XA/2PC transaction is theoretically possible but avoided in practice: Kafka does not participate in XA, JTA coordinators add heavyweight infrastructure and blocking prepare phases, cloud-managed brokers rarely support it, and 2PC couples availability of both systems, precisely what event-driven architecture tries to escape.

The outbox pattern solves it with one transaction in one store: inside the same DB transaction that writes the order, insert a row into an outbox table (id, aggregate_id, event_type, payload JSON, created_at). Atomicity is now guaranteed by the database. A separate relay publishes outbox rows to Kafka: either a Spring @Scheduled poller selecting unpublished rows (with SKIP LOCKED for multi-pod safety), publishing, then marking/deleting, or log-based change data capture with Debezium tailing the WAL/binlog and emitting rows to Kafka without polling load.

Delivery becomes at-least-once, so consumers must be idempotent, dedupe on event id or use upsert semantics; ordering per aggregate comes from partitioning by aggregate_id. In Spring the write path composes naturally with @TransactionalEventListener: the service publishes an in-JVM event, a BEFORE_COMMIT listener persists the outbox row in the same transaction, and the relay handles Kafka; libraries like Spring Modulith ship exactly this 'event publication registry' out of the box, worth naming. Contrast with sagas for multi-service workflows: outbox guarantees reliable publication of facts; sagas (choreography via events, or orchestration via a state machine) manage distributed business consistency with compensating actions. Senior rounds at payment companies in India lean on this exact question set.

@Service
public class OrderService {
  @Transactional
  public void place(CreateOrder cmd) {
    Order o = orders.save(Order.from(cmd));
    outbox.save(OutboxRow.of(              // SAME transaction = atomic
        o.getId(), "OrderPlaced",
        json.write(new OrderPlaced(o.getId(), o.total()))));
  }
}

@Component
class OutboxRelay {
  @Scheduled(fixedDelay = 500)
  @Transactional
  public void drain() {
    // FOR UPDATE SKIP LOCKED keeps multiple pods from double-publishing
    List<OutboxRow> batch = outbox.lockNextBatch(100);
    for (OutboxRow row : batch) {
      kafka.send("orders", row.aggregateId().toString(), row.payload());
      row.markPublished();               // at-least-once; consumers dedupe
    }
  }
}
Q55

How do you wire Resilience4j circuit breakers, retries, and bulkheads into a Spring Boot service, and in what order do they apply?

AdvancedResilience

Answer

Resilience4j is the standard resilience library for Spring since Hystrix went into maintenance. With resilience4j-spring-boot3 on the classpath, configure instances in YAML and apply them with annotations (AOP-based, so all proxy caveats apply, self-invocation bypasses them). @CircuitBreaker(name = "gst", fallbackMethod = "cached") tracks a sliding window of outcomes; when failureRate exceeds failure-rate-threshold (or slow calls exceed slow-call-rate-threshold with slow-call-duration-threshold, the often-forgotten half, a hung dependency is worse than a failing one), the breaker OPENs and calls fail immediately with CallNotPermittedException, protecting your threads and the struggling dependency; after wait-duration-in-open-state it goes HALF_OPEN, admits permitted-number-of-calls-in-half-open-state probes, and closes on success. @Retry(name = "gst") retries with exponential backoff and jitter (enable-randomized-wait), and must be restricted to idempotent operations and transient exceptions via retry-exceptions/ignore-exceptions, retrying a payment capture is how you double-charge, say this unprompted. @Bulkhead caps concurrent calls per dependency (semaphore or thread-pool flavour) so one slow downstream cannot consume every Tomcat thread; @RateLimiter and @TimeLimiter (reactive/CompletableFuture-based timeouts) round out the set. Order matters and is configurable; the default aspect order applies Retry outermost, then CircuitBreaker, then RateLimiter, then TimeLimiter, then Bulkhead innermost, meaning each retry attempt is individually recorded by the breaker; flip breaker outside retry (resilience4j.retry.retryAspectOrder properties) and a single user action can no longer hammer an open breaker with backoff storms, be ready to reason about both arrangements.

Fallback methods must match the signature plus the exception parameter, and fallbacks should degrade honestly (cached data, queued-for-later, explicit 503) rather than fabricate success. Everything emits Micrometer metrics (resilience4j.circuitbreaker.state and call metrics) and Actuator exposes /actuator/circuitbreakers, alert on state transitions, not just error rates.

# application.yml
resilience4j:
  circuitbreaker:
    instances:
      gst:
        sliding-window-size: 50
        failure-rate-threshold: 50
        slow-call-duration-threshold: 2s
        slow-call-rate-threshold: 80
        wait-duration-in-open-state: 30s
        permitted-number-of-calls-in-half-open-state: 5
  retry:
    instances:
      gst:
        max-attempts: 3
        wait-duration: 200ms
        enable-exponential-backoff: true
        retry-exceptions: [java.io.IOException]

@Service
public class GstLookupService {
  @CircuitBreaker(name = "gst", fallbackMethod = "fromCache")
  @Retry(name = "gst")
  public GstDetails lookup(String gstin) {
    return gstClient.get().uri("/v1/gstin/{g}", gstin)
        .retrieve().body(GstDetails.class);
  }

  GstDetails fromCache(String gstin, Throwable t) {
    return cache.lastKnown(gstin)
        .orElseThrow(() -> new GstTemporarilyUnavailable(gstin));
  }
}
Q56

How does @KafkaListener consume messages, and how do you configure error handling with retries and a dead-letter topic?

AdvancedMicroservices & Messaging

Answer

spring-kafka's @KafkaListener(topics = "orders", groupId = "billing") registers the method with a listener container that polls Kafka and dispatches records; concurrency on the container (or spring.kafka.listener.concurrency) creates that many consumer threads, bounded usefully by the topic's partition count, since a partition is consumed by at most one consumer in a group. Offsets: the default is container-managed acknowledgment where offsets commit after the listener returns successfully (AckMode.BATCH/RECORD); switch to MANUAL ack modes and an Acknowledgment parameter when you need explicit control. Delivery is at-least-once in any sane configuration, so idempotent consumers are mandatory, dedupe on a business key or event id.

Deserialisation: configure JsonDeserializer with trusted packages (spring.kafka.consumer.properties.spring.json.trusted.packages), and always wrap deserialisers in ErrorHandlingDeserializer; otherwise a single un-deserialisable record ('poison pill') throws before your code runs and the container loops on it forever, one of the classic Kafka production incidents. Error handling: the modern mechanism is DefaultErrorHandler (replacing the old SeekToCurrentErrorHandler) with a BackOff, new DefaultErrorHandler(recoverer, new ExponentialBackOff(...)), which retries the failed record by re-seeking, then invokes the recoverer. Pair it with DeadLetterPublishingRecoverer to publish exhausted records to a dead-letter topic (convention: orders.DLT, same partition), preserving headers with the exception and original offset for forensics; classify with addNotRetryableExceptions(ValidationException.class) so permanent failures skip straight to the DLT instead of burning retries.

Blocking retries stall the partition, so for long backoffs use spring-kafka's non-blocking retry topics (@RetryableTopic) which hop records through orders-retry-1000, orders-retry-5000 topics. Transactions: KafkaTemplate supports exactly-once-v2 producer transactions, but consumer-side idempotency remains the practical backbone. Also know @DltHandler methods, and that rebalance storms from slow listeners (exceeding max.poll.interval.ms) masquerade as duplicate processing.

@Configuration
class KafkaErrorConfig {
  @Bean
  DefaultErrorHandler errorHandler(KafkaTemplate<Object, Object> template) {
    var recoverer = new DeadLetterPublishingRecoverer(template); // -> orders.DLT
    var backoff = new ExponentialBackOffWithMaxRetries(4);
    backoff.setInitialInterval(500);
    backoff.setMultiplier(2.0);
    var handler = new DefaultErrorHandler(recoverer, backoff);
    handler.addNotRetryableExceptions(ValidationException.class);
    return handler;
  }
}

@Component
class BillingConsumer {
  @KafkaListener(topics = "orders", groupId = "billing", concurrency = "3")
  public void onOrder(OrderPlaced event,
                      @Header(KafkaHeaders.RECEIVED_PARTITION) int partition) {
    if (processed.exists(event.eventId())) return;   // idempotency guard
    billing.invoice(event);
  }

  @DltHandler
  public void dead(OrderPlaced event) {
    alerting.page("billing DLT", event.eventId());
  }
}
Q57

A Spring Boot service takes 45 seconds to start and 2 GB of RAM. Where do you look, and which Boot features cut both?

AdvancedPerformance

Answer

Measure before touching anything. Startup: the Boot startup actuator endpoint (/actuator/startup, enabled by wiring a BufferingApplicationStartup into SpringApplication) produces a step-by-step timeline of bean instantiation; alternatively async-profiler on the boot phase, or simply logging.level.org.springframework.boot.autoconfigure=DEBUG to spot conditions and slow auto-configurations. Common startup thieves: eager schema validation or Flyway migrations against distant databases, classpath scanning across a monolithic package tree, beans doing network I/O in @PostConstruct (cache warms, S3 lists), Hibernate metadata for hundreds of entities, and fat classpaths pulling dozens of auto-configurations you never use (audit with /actuator/conditions and spring.autoconfigure.exclude).

Levers, cheapest first: spring.main.lazy-initialization=true defers non-essential bean creation to first use, big wins but moves failures to request time, so keep critical beans eager via @Lazy(false) and rely on readiness probes; move @PostConstruct work to ApplicationRunner-triggered async warmup; trim starters. JVM-level: Class Data Sharing, Boot 3.3 added first-class CDS support, run the extracted jar with a training run to produce an archive (java -Djarmode=tools -jar app.jar extract, then run with -XX:SharedArchiveFile), commonly cutting startup 30-40%; Project CRaC checkpoint/restore for sub-second restore where infrastructure allows; and GraalVM native for the extreme case. Memory: 2 GB is usually unexamined defaults, right-size -Xmx to observed live set (watch jvm.memory.used after full GC via Micrometer), remember container awareness (MaxRAMPercentage against cgroup limits), account for Metaspace plus thread stacks (200 Tomcat threads x 1MB matters), pick a collector fitting the goal (G1 default; ZGC for low pause on large heaps), and hunt duplicate caches, oversized Hikari pools, and Hibernate second-level caches nobody tuned. The narrative interviewers reward is method: profile, attribute, fix the top item, re-measure, rather than reciting flags.

// Enable the startup timeline endpoint
public static void main(String[] args) {
  SpringApplication app = new SpringApplication(Application.class);
  app.setApplicationStartup(new BufferingApplicationStartup(2048));
  app.run(args);
}
// -> GET /actuator/startup : ranked bean-instantiation timeline

# Quick wins, application.yml:
spring:
  main:
    lazy-initialization: true      # defer non-critical beans
  jpa:
    hibernate:
      ddl-auto: none               # no schema diffing at boot

# CDS (Boot 3.3+):
#   java -Djarmode=tools -jar app.jar extract --destination extracted
#   cd extracted && java -XX:ArchiveClassesAtExit=app.jsa -jar app.jar (training)
#   java -XX:SharedArchiveFile=app.jsa -jar app.jar   (fast subsequent boots)
Q58

How does distributed tracing work in Spring Boot 3 with Micrometer Tracing, and how do trace ids reach your logs and cross service boundaries?

AdvancedObservability

Answer

Spring Cloud Sleuth died with Boot 2; Boot 3 replaced it with Micrometer Tracing, a facade over a tracer bridge, micrometer-tracing-bridge-brave (Zipkin's Brave) or micrometer-tracing-bridge-otel (the OpenTelemetry SDK), plus an exporter such as opentelemetry-exporter-otlp to ship spans to Tempo, Jaeger, SigNoz, or any OTLP collector (management.otlp.tracing.endpoint). The engine underneath is the Observation API: framework instrumentation (MVC server requests, RestClient/WebClient, spring-kafka, R2DBC) creates Observations, and registered handlers turn each observation into both a Timer metric and a trace span, one instrumentation, two signals, which is the architectural point to articulate. Context propagation across services uses W3C Trace Context headers (traceparent) by default, injected automatically into RestClient/WebClient calls and read on the server side, so a checkout request fans out across four services and reassembles as one trace; B3 headers remain configurable for legacy Zipkin estates, and Kafka propagation rides message headers.

Logs join via MDC: the tracing bridge puts traceId and spanId into the logging context, and Boot's logging.pattern.correlation (with logging.include-application-name and friends) renders them in every line, so you can pivot from a slow span to its exact log lines; with structured logging (Boot 3.4 added native JSON logging via logging.structured.format.console=ecs or logstash) the ids become queryable fields. Production concerns that separate senior answers: sampling, management.tracing.sampling.probability=0.1 keeps overhead and storage sane while tail-based sampling at the collector catches rare errors; async boundaries, context must be propagated onto @Async executors (ContextPropagatingTaskDecorator or context-propagation library) or spans orphan; custom spans via Observation.createNotStarted("settlement.batch", registry).observe(runnable) or the @Observed annotation with its aspect; and baggage (business ids carried across services) configured explicitly, never carrying PII in headers.

# build.gradle (Boot 3.x)
# implementation 'io.micrometer:micrometer-tracing-bridge-otel'
# implementation 'io.opentelemetry:opentelemetry-exporter-otlp'

# application.yml
management:
  tracing:
    sampling:
      probability: 0.1
  otlp:
    tracing:
      endpoint: http://otel-collector:4318/v1/traces
logging:
  pattern:
    correlation: "[%X{traceId:-},%X{spanId:-}] "

// Custom span around a business operation
@Observed(name = "settlement.batch",
          contextualName = "daily-settlement")
public void settleMerchant(long merchantId) { /* ... */ }
Q59

Beyond authentication: which Spring-specific vulnerabilities should a security review of a Boot service look for?

AdvancedSecurity Hardening

Answer

A checklist grounded in real Spring CVEs and audit findings. (1) Mass assignment through data binding: binding request input directly onto entities or rich command objects lets attackers set fields you never exposed (the Spring4Shell CVE-2022-22965 exploited ClassLoader traversal through exactly this binder machinery). Mitigate by binding onto purpose-built DTOs with only intended fields, and where classic binding is unavoidable, set allowed fields via @InitBinder with setAllowedFields; Spring 6.1 added Jakarta-era guidance and DataBinder improvements, but DTO discipline is the durable fix. (2) Actuator exposure: /actuator/env, /heapdump, and /loggers reachable from the internet is a recurring Indian-bug-bounty staple; separate management port, explicit include list, and Security rules via EndpointRequest. (3) SpEL injection: never feed user input into SpelExpressionParser or into annotations' expression strings built dynamically; several CVEs (including the CVE-2022-22963 Spring Cloud Function RCE) were exactly this class. (4) Deserialisation: Jackson polymorphic typing (enableDefaultTyping-era patterns, @JsonTypeInfo on Object fields) enables gadget-chain RCE; keep polymorphism allow-listed via PolymorphicTypeValidator, and treat Java native serialisation of untrusted data as forbidden. (5) Path traversal on file endpoints and static resource resolvers (multiple CVEs in resource handling; also functional-web PathPatterns quirks): canonicalise and allow-list paths. (6) CORS misconfiguration: reflecting arbitrary origins with credentials. (7) Open redirects via redirect: view names built from parameters. (8) Missing method-level authorisation on internal service methods reachable from multiple entry points. Process controls: dependency scanning (OWASP Dependency-Check, Snyk, or GitHub Dependabot) wired into CI because Boot BOM upgrades deliver most fixes; fast Boot patch adoption; security headers via Spring Security defaults (they are good, do not disable frame options or HSTS casually); and secrets scanning, committed credentials remain the most common real-world finding in audits. Framing the answer as 'binder, actuator, SpEL, deserialisation, then process' shows a reviewer's mind, not a listicle memory.

// Mass-assignment guard where classic binding must stay
@Controller
class ProfileController {
  @InitBinder
  void bindingRules(WebDataBinder binder) {
    binder.setAllowedFields("displayName", "headline", "city");
    // everything else (role, verified, balance...) cannot be bound
  }
}

// Jackson polymorphism, allow-listed instead of wide open
BasicPolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
    .allowIfSubType("com.goodspace.events.")
    .build();
ObjectMapper mapper = JsonMapper.builder()
    .activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.NON_FINAL)
    .build();

Key Points

  • Mass assignment: bind to narrow DTOs; setAllowedFields when binding legacy-style
  • Actuator: separate port, explicit exposure include list, Security rules
  • Never evaluate user input as SpEL; allow-list Jackson polymorphism
  • CI dependency scanning + fast Boot patch cadence delivers most CVE fixes
Q60

Which changes across recent Spring Framework and Boot releases actually matter in interviews, and what should you say about Boot 4 and Framework 7?

AdvancedVersions & Migration

Answer

Organise by release line, because interviewers probe whether you have lived the upgrades. Boot 3.0 / Framework 6.0 (2022): Java 17 baseline, jakarta.* namespace, GraalVM native support via AOT, Micrometer Tracing replacing Sleuth, ProblemDetail, HTTP interfaces (@HttpExchange), declarative Security via SecurityFilterChain only. Boot 3.1: Testcontainers @ServiceConnection and development-time containers, SSL bundles (spring.ssl.bundle.*) with reloadable certs, Docker Compose integration (spring-boot-docker-compose auto-starts services in dev).

Boot 3.2 / Framework 6.1: virtual threads via spring.threads.virtual.enabled on Java 21, RestClient as the modern blocking HTTP client, JdbcClient as its JDBC analogue, initial CRaC support, observability polish. Boot 3.3: CDS support for faster startup, SBOM actuator endpoint (supply-chain security, increasingly asked after the ecosystem's CVE years), Micrometer @SpanTag, service-connection additions. Boot 3.4 / Framework 6.2: structured logging out of the box (logging.structured.format.console=ecs|logstash), @MockitoBean/@MockitoSpyBean replacing @MockBean, MockMvcTester (AssertJ-style MockMvc), bean-override framework (@TestBean), improved @Fallback beans and background bean initialisation.

On the next major line: Spring Framework 7 and Boot 4 (the 2025-era generation; verify exact status against spring.io rather than asserting from memory) headline with JSpecify null-safety annotations across the codebase, an API-versioning abstraction in Spring MVC/WebFlux, Jakarta EE 11 alignment, deeper AOT/Leyden-oriented startup work, and removal of long-deprecated APIs, plus Boot modularisation efforts. The honest, senior way to answer version questions: name the features you have actually used, state baselines precisely (Boot 3 = Java 17 floor, virtual threads = Java 21), and for the newest line describe direction rather than bluffing minor-version trivia; interviewers punish confident wrongness far harder than 'that shipped after my last upgrade, here is what I know it targets'.

Key Points

  • 3.0: Java 17, jakarta.*, AOT/native, Micrometer Tracing, ProblemDetail
  • 3.2: virtual threads, RestClient, JdbcClient (Framework 6.1)
  • 3.4: structured logging, @MockitoBean replaces @MockBean (Framework 6.2)
  • Framework 7 / Boot 4 direction: JSpecify null-safety, API versioning, EE 11

Companies Hiring Spring

Flipkart
PhonePe
Razorpay
Walmart Global Tech
JPMorgan Chase
Goldman Sachs
Infosys
TCS

Salary Insights

Average in India
₹8-26 LPA

Frequently Asked Questions

What salary can a Spring developer expect in India in 2026?

The broad band is ₹8-26 LPA. Service companies (TCS, Infosys, Wipro, Cognizant) typically pay ₹6-12 LPA for 2-5 years of Java plus Spring Boot experience. Product companies and fintech (Flipkart, PhonePe, Razorpay, CRED) pay ₹18-35 LPA for strong mid-level engineers, and global capability centres of investment banks (Goldman Sachs, JPMorgan, Morgan Stanley in Bengaluru and Mumbai) sit in a similar or higher range with bonuses. What moves you to the top of the band is not more annotations; it is transactions, JPA performance tuning, Kafka, Kubernetes deployment experience, and the ability to reason about production failures. Staff-level engineers who own microservice architectures cross ₹40 LPA at several of these employers.

How long does it take to prepare for a Spring interview if I already know Java?

With solid core Java (collections, generics, threads, streams), 4-6 weeks of focused preparation covers a mid-level interview: one week on the container (DI, scopes, lifecycle, proxies), one on Spring Boot and configuration, one on Spring Data JPA including the N+1 and locking questions, one on Spring Security's current filter-chain model, and the rest on transactions, testing, and one real project you can defend line by line. If you are coming from zero Spring, budget 3 months and build something non-trivial, an API with JWT auth, Postgres, Redis caching, and Testcontainers tests, because interviewers at product companies probe what you built, not what you watched. Freshers targeting service companies can prepare the basics list in 3-4 weeks.

What do interviewers expect from freshers versus experienced Spring developers?

Freshers are tested on core Java first, then Spring fundamentals: what DI buys you, @SpringBootApplication, REST controllers, @Transactional basics, and one small project. Nobody expects a fresher to explain CGLIB internals, but confusing @Component with @Bean is disqualifying. At 3-5 years, the bar jumps to the proxy model and its self-invocation traps, transaction propagation, JPA performance (N+1, lazy loading, locking), the SecurityFilterChain style, and test slices; you should also have a production war story about connection pools or a memory issue. At 7+ years, expect architecture rounds: outbox patterns, Kafka error handling, resilience, observability, migration experience (Boot 2 to 3, javax to jakarta), and trade-off questions like virtual threads versus WebFlux where the reasoning matters more than the conclusion.

Is Spring still worth learning in 2026 given Node.js, Go, and Python?

Yes, and the Indian market data is unambiguous: Java plus Spring remains the largest single backend hiring pool in the country, spanning banks, insurers, fintech, e-commerce, and every major GCC. The framework has also modernised aggressively, virtual threads removed the old 'Java does not scale like Node' argument, native images answer the cold-start criticism, and Boot's developer experience now rivals any ecosystem. Go wins for small high-performance infrastructure tools and Node for TypeScript-everywhere teams, but for large transactional systems with big teams, Spring's transaction management, JPA, Security, and twenty years of operational knowledge remain unmatched. Pragmatically: Spring gives you the widest set of employers in India, and the concepts (DI, AOP, transactional boundaries) transfer to every other framework you will ever touch.

Should I learn Spring Framework first or jump straight into Spring Boot?

Start with Boot, but deliberately learn the Framework concepts underneath it as you go. Building your first REST API with Spring Initializr on day one keeps motivation high, and Boot is what every job actually uses. The mistake is stopping there: candidates who only know 'add the starter and it works' fail the moment an interviewer asks why a bean was not created or what proxy sits behind @Transactional. So for each Boot feature you use, dig one level down: auto-configuration to conditions, @Transactional to AOP proxies, application.yml to the Environment and property sources. You do not need the XML era or standalone Framework projects; you do need to be able to explain what Boot is automating.

How does Spring compare with NestJS and .NET for a backend career in India?

All three share the same mental model, dependency injection, decorators or annotations, modular structure, so skills transfer well. The differences are market shape. Spring has the deepest and widest Indian demand: banks, GCCs, fintech, and enterprises, with the highest ceiling at investment-bank pay scales. .NET demand is strong but concentrated in specific GCCs and Microsoft-stack enterprises. NestJS demand is newer and skews toward startups that want TypeScript across the stack; salaries at the top are comparable, but the number of open roles is smaller. If you optimise for optionality and large-company offers in India, Spring is the safest primary bet, and learning NestJS later takes weeks precisely because it borrowed Spring's architecture.

Introduction

Two decades after Rod Johnson shipped the first IoC container, Spring is still the framework that runs Indian fintech, banking, and e-commerce at scale. Flipkart's order pipelines, PhonePe's payment switches, and the trading systems at Goldman Sachs and JPMorgan in Bengaluru all sit on Spring Boot services. The modern stack looks very different from the XML era: Spring Framework 6 and Boot 3 require Java 17, the javax namespace is gone in favour of jakarta, virtual threads land with a single property, and GraalVM native images are a supported build target. Interviewers in 2026 expect you to know this modern shape, not the spring-servlet.xml world of old tutorials.

Spring interviews in India follow a predictable arc. Rounds one and two probe the container: bean lifecycle, scopes, constructor injection, how @Transactional proxies actually behave, and why self-invocation silently skips them. Then come the production questions: the N+1 select problem, HikariCP pool exhaustion, LazyInitializationException, why open-in-view is a trap, and how the Spring Security filter chain works now that WebSecurityConfigurerAdapter is gone. Senior rounds go after architecture: outbox patterns with Kafka, Resilience4j circuit breakers, Micrometer tracing, and when virtual threads make WebFlux unnecessary. Product companies like Razorpay and Walmart Global Tech push hardest on the failure modes, not the definitions.

This guide contains 60 Spring interview questions, ordered basic to advanced and grouped by topic. Every answer is written for how the framework behaves in production, including the gotchas that only show up under load, and most technical questions carry a runnable, modern Java code example using current Boot 3-era APIs. Work through the basic set to lock down container fundamentals, then spend most of your preparation time on the intermediate transaction, JPA, and security questions, because that middle band is where Indian interviewers decide between a standard offer and a strong one.

Ready to practice Spring interviews?

Don't just read, practice these Spring questions live with an AI interviewer that asks follow-ups and scores your answers.

AI-powered practice
Instant feedback
Free to start
Start Free Mock Interview