← Home
Spring / Spring Boot

Top 9 Senior Interview Questions

IoC · AOP · Transactions · Auto-configuration · Security · Actuator

Questions

  1. Spring IoC and Dependency Injection — types and tradeoffs
  2. How @Transactional works — pitfalls and failure modes
  3. Spring AOP — how it works, use cases
  4. Spring Boot auto-configuration internals
  5. Bean lifecycle — @PostConstruct, @PreDestroy, scopes
  6. Spring Security filter chain
  7. @ConfigurationProperties vs @Value
  8. Graceful shutdown and health probes
  9. Spring Boot 3.x — @HttpExchange, ProblemDetail, virtual thread config
Q1 · Core
Explain Spring IoC and Dependency Injection. Which injection type do you prefer and why?
🎙 Model answer (60 sec)
"IoC means the framework manages object creation and lifecycle — you declare what you need, the container provides it. DI is the mechanism: dependencies are injected rather than created inside the class. I always prefer constructor injection. It makes all required dependencies explicit and visible in one place, the object is fully initialized the moment it's constructed, and dependencies can be final — which is thread-safe and prevents accidental reassignment. It also makes the class testable without Spring at all — just call the constructor with mocks. Field injection with @Autowired hides dependencies and makes it impossible to create the object without a Spring container. The one time I use setter injection is for optional dependencies."

IoC concept

The framework creates and manages object lifecycles. You declare what you need; the container provides it. DI is the mechanism — dependencies are injected rather than created by the class itself.

Three injection types

// 1. Constructor injection — PREFERRED
@Service
public class PaymentService {
    private final AccountRepository repo;   // final — immutable, testable
    private final FraudChecker fraud;

    public PaymentService(AccountRepository repo, FraudChecker fraud) {
        this.repo = repo;
        this.fraud = fraud;
    }
}

// 2. Setter injection — for optional dependencies only
@Autowired(required = false)
public void setMetricsCollector(MetricsCollector collector) { ... }

// 3. Field injection — avoid. Hides dependencies, can't use final, not testable without Spring
@Autowired private AccountRepository repo; // BAD

Why constructor injection wins

  • Dependencies are explicit — visible in the constructor signature.
  • Fields can be final → immutable bean, thread-safe.
  • Circular dependency fails fast at startup, not at runtime.
  • Unit tests don't need Spring: just new PaymentService(mockRepo, mockFraud).
Interview drill-down — expect these follow-ups:
L1 → "CGLIB vs JDK proxy — which does Spring use and when?"
Spring defaults to CGLIB for @Configuration classes and any bean where proxy-target-class=true. JDK proxy requires the bean to implement an interface — Spring uses it when an interface is present and proxy-target-class=false. CGLIB subclasses the concrete class at runtime. The practical difference: CGLIB can proxy classes without interfaces; JDK proxy cannot. Lombok's @AllArgsConstructor can break CGLIB proxying if there's no no-arg constructor — add @NoArgsConstructor or configure Spring to use CGLIB proxy mode explicitly.
L2 → "How does @Lazy resolve circular dependency at startup?"
@Lazy injects a CGLIB proxy immediately (cheap, no initialization), deferring real bean creation until first method call. The circular dependency is broken: bean A gets a proxy for B, B gets a proxy for A, both initialize successfully. When A first calls B.method(), the proxy creates the real B. Use sparingly — it hides design problems and makes startup order opaque. The better fix is always to refactor.
L3 → "Prototype bean injected into a singleton — what are the three ways to get a fresh instance each call?"
(1) ObjectFactory: inject ObjectFactory<MyPrototype>, call factory.getObject() each time. (2) Provider (JSR-330): inject Provider<MyPrototype>, call provider.get(). (3) @Lookup method injection: declare an abstract method in the singleton annotated @Lookup — Spring overrides it to return a new prototype instance. ObjectFactory/Provider are more explicit and testable; @Lookup requires making the class abstract or non-final.
Trap answer to avoid: "Circular dependencies are fine — Spring handles them automatically." — Spring handles setter/field injection circles but constructor injection circles fail at startup (correct behavior). And "auto-handling" circles via @Lazy hides design problems. Interviewers want to hear you recognize circles as a design smell, not a framework feature to exploit.
Q2 · Transactions
How does @Transactional work internally? What are the most common pitfalls?
🎙 Model answer (60 sec)
"@Transactional works via a proxy. Spring wraps your bean in a proxy — JDK dynamic proxy if it implements an interface, CGLIB subclass otherwise. When an external caller invokes a @Transactional method, the proxy intercepts the call, opens a transaction, calls your method, then commits or rolls back. Three pitfalls I always watch for: one, self-invocation — calling a @Transactional method from within the same class bypasses the proxy, so no transaction is created. Two, checked exceptions don't trigger rollback by default — only unchecked. Use rollbackFor = Exception.class if you need otherwise. Three, propagation traps — REQUIRES_NEW suspends the outer transaction and opens a new one with a different database connection, so they can't see each other's uncommitted writes and can deadlock if they touch the same rows."

Proxy mechanics

  • Spring wraps the bean in a proxy (JDK Dynamic Proxy if interface exists, CGLIB otherwise).
  • External call to @Transactional method → proxy intercepts → opens transaction → calls real method → commits or rolls back.
  • this.method() inside the same class bypasses the proxy entirely — no transaction.

Pitfall 1 — self-invocation

@Service
public class OrderService {
    public void processBatch(List<Order> orders) {
        orders.forEach(this::processOne); // BUG: 'this' bypasses proxy!
    }

    @Transactional
    public void processOne(Order o) { ... }
}

// Fix: extract to a separate bean, OR inject self:
@Autowired private OrderService self;
orders.forEach(self::processOne); // goes through proxy

Pitfall 2 — checked exceptions don't roll back

@Transactional  // default: rollback on RuntimeException only
public void transfer() throws BusinessException {
    debit();
    credit();
    throw new BusinessException("limit exceeded"); // does NOT roll back!
}

// Fix:
@Transactional(rollbackFor = BusinessException.class)

Pitfall 3 — REQUIRES_NEW deadlock

@Transactional
public void outer() {
    repo.lockRow(id);  // holds DB lock
    inner();           // opens NEW connection — same row, deadlock!
}

@Transactional(propagation = REQUIRES_NEW)
public void inner() {
    repo.readRow(id);  // waits for lock held by outer → deadlock
}

REQUIRES_NEW suspends the outer transaction and borrows a new DB connection. Two connections from the same thread fighting for the same row = deadlock.

Read-only optimisation

@Transactional(readOnly = true)
public List<Account> getAccounts() { ... }
// Hibernate skips dirty checking, can route to read replica, skips flush.
// Real performance impact at scale — not just documentation.
Interview drill-down — expect these follow-ups:
L1 → "@Transactional on an interface method vs the implementation — what breaks?"
If Spring uses a CGLIB proxy (which it does for Spring Boot auto-detected classes), annotations on interfaces are NOT recognized — the proxy is based on the concrete class. The annotation must be on the implementation class or the implementing method. With JDK proxy (interface-based), the interface annotation works. The safe rule: always put @Transactional on the implementation to be proxy-type-agnostic.
L2 → "REQUIRES_NEW — when is it actually the right choice despite the deadlock risk?"
For audit logging: you want to save an audit record even if the main transaction rolls back. By saving the audit in REQUIRES_NEW, the audit record commits independently. Another case: sending notifications in a new transaction so notification failures don't roll back the main business transaction. The key: REQUIRES_NEW operations must never access the same rows as the outer transaction, or you risk the deadlock described in the gotcha.
L3 → "readOnly=true — does it actually prevent writes at the DB level?"
No. readOnly=true is a hint to Spring and the JDBC driver — it doesn't add a DB-level constraint. What it actually does: (1) Hibernate skips dirty-checking (no FlushType.AUTO), saving CPU on large entity graphs. (2) Spring DataSourceTransactionManager may set connection.setReadOnly(true) — some JDBC drivers route to read replicas. (3) Some JPA providers (EclipseLink) set the isolation level to SERIALIZABLE for true read-only semantics. It's an optimization hint, not a safety guard.
Trap answer to avoid: "@Transactional guarantees atomicity." — @Transactional on a Spring service method only works if called through the proxy. Self-invocation, checked exceptions, and propagation=REQUIRES_NEW with overlapping rows can all silently break the guarantee. The annotation is a powerful tool that requires understanding its proxy mechanics to use correctly.
Q3 · AOP
What is Spring AOP? How does it work and what are the practical use cases?
🎙 Model answer (60 sec)
"Spring AOP lets you apply cross-cutting concerns — logging, security, metrics, retries — to methods without touching the business logic. It works via the same proxy mechanism as @Transactional: Spring wraps the target bean, and an Around advice intercepts matching method calls. The key constraint: it only works on Spring-managed bean method calls from outside the bean — self-invocation bypasses the proxy. Practical uses I rely on: @Transactional and @Cacheable are both AOP under the hood; I also use it for audit logging (log who called what with what arguments) and timing metrics on service methods. The trap to avoid is thinking Spring AOP equals AspectJ — Spring uses AspectJ annotations but proxy-based interception, not AspectJ's bytecode weaving. You can't intercept private methods, constructors, or calls within the same class."

Core concepts

  • Aspect: module containing cross-cutting logic.
  • Pointcut: expression that matches join points (which methods to intercept).
  • Advice: the code to run — Before, After, Around, AfterReturning, AfterThrowing.
  • Join point: specific execution point (method call in Spring AOP).

Spring AOP uses proxies (not bytecode weaving like AspectJ). This means it only intercepts Spring-managed bean method calls — not constructor calls, field access, or private methods.

Around advice example — execution time logging

@Aspect
@Component
public class PerformanceAspect {

    @Around("@annotation(Monitored)")
    public Object measure(ProceedingJoinPoint pjp) throws Throwable {
        long start = System.currentTimeMillis();
        try {
            return pjp.proceed();
        } finally {
            long elapsed = System.currentTimeMillis() - start;
            log.info("{} took {}ms", pjp.getSignature().getName(), elapsed);
        }
    }
}

Practical use cases

  • Audit logging: log who called what with what parameters — without polluting service code.
  • Security checks: @PreAuthorize in Spring Security is AOP under the hood.
  • Performance monitoring: method-level latency tracking for SLAs.
  • Retry logic: @Retryable from Spring Retry is AOP.
  • Caching: @Cacheable is AOP — intercepts, checks cache, calls method only on miss.
Limitation: Self-invocation doesn't work (same as @Transactional) — the proxy isn't involved. Also, AOP applies only to Spring-managed beans.
Interview drill-down — expect these follow-ups:
L1 → "What happens when two @Around advices target the same method? Which runs first?"
Order is determined by @Order annotation on the @Aspect class. Lower number = higher priority = outermost advice. Without @Order, order is undefined (depends on class loading). For Spring's built-in aspects: @Transactional has order Integer.MAX_VALUE-4, @Async has Integer.MAX_VALUE-1 — meaning transaction wraps retry which wraps the actual call. If you add your own @Around without @Order, it may run inside or outside @Transactional — specify @Order to be explicit.
L2 → "Can AOP be used to add @Transactional behavior dynamically at runtime?"
Yes — @Transactional IS AOP. You could write your own @Around advice that calls TransactionTemplate.execute() around the join point. This is exactly what Spring's TransactionInterceptor does. The key insight: all of Spring's declarative features (@Transactional, @Cacheable, @Async, @Retryable) are implemented as @Aspect with @Around advice. Understanding AOP means understanding all of these simultaneously.
L3 → "AspectJ load-time weaving vs Spring AOP — when would you actually use LTW?"
When you need to intercept code that Spring can't proxy: constructors, static methods, private methods, or code in third-party libraries. Example: you want to add tracing to every new object creation (constructor weaving). Or you want to intercept a static utility method in a library you don't own. Spring AOP can't do these — they require AspectJ's bytecode manipulation at load time. The trade-off: significant build/runtime complexity. Only justified for framework-level cross-cutting concerns that truly can't be addressed by proxy-based AOP.
Trap answer to avoid: "Spring AOP and AspectJ are the same thing." — Spring AOP uses AspectJ's annotation syntax (@Aspect, @Around, @Pointcut) but NOT AspectJ's weaving engine. Spring AOP is proxy-based and limited to Spring-managed bean method calls. AspectJ is a full bytecode manipulation framework. Using AspectJ annotations with Spring doesn't give you AspectJ capabilities — unless you add the AspectJ weaver.
Q4 · Spring Boot Internals
How does Spring Boot auto-configuration work under the hood?
🎙 Model answer (60 sec)
"@SpringBootApplication includes @EnableAutoConfiguration, which imports AutoConfigurationImportSelector. At startup, this reads a list of hundreds of auto-configuration classes from a file in the spring-boot-autoconfigure jar. Each class is annotated with @Conditional variants — @ConditionalOnClass means 'only configure this if this class is on the classpath', @ConditionalOnMissingBean means 'only configure this if I haven't already defined this bean'. So when you add spring-boot-starter-data-jpa to the classpath, DataSource and JPA auto-configuration activates because the required classes are present. As soon as you define your own DataSource bean, those auto-configs back off. You override the defaults by providing your own beans, not by turning off the framework."

The startup sequence

  1. @SpringBootApplication = @Configuration + @EnableAutoConfiguration + @ComponentScan.
  2. @EnableAutoConfiguration imports AutoConfigurationImportSelector.
  3. Selector reads META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports (Boot 3+) — a list of hundreds of auto-config classes.
  4. Each class is evaluated with @Conditional annotations — only matching ones are loaded.

Conditional annotations

@AutoConfiguration
@ConditionalOnClass(DataSource.class)            // JDBC on classpath?
@ConditionalOnProperty("spring.datasource.url")  // URL configured?
public class DataSourceAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean  // user didn't define their own DataSource?
    public DataSource dataSource(DataSourceProperties props) {
        return new HikariDataSource(...);
    }
}
  • @ConditionalOnMissingBean: your bean overrides the default. Auto-configs run after user @Configuration classes, so your beans are already registered when evaluated.
  • @ConditionalOnClass: add a dependency to your POM → behaviour changes automatically.

How to debug auto-configuration

# Run with --debug flag to see what matched and what didn't:
java -jar app.jar --debug

# Or check the conditions report via Actuator:
GET /actuator/conditions

Excluding an auto-config

@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class })
// Or in properties:
spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
Interview drill-down — expect these follow-ups:
L1 → "Two DataSource beans — Spring registers one via auto-config, you define another. Which wins?"
Yours wins. @ConditionalOnMissingBean means the auto-config bean is only registered if no DataSource bean exists in the context. Spring's auto-config runs after @Configuration classes are processed — by the time DataSourceAutoConfiguration evaluates its condition, your DataSource is already registered, so it skips. This "user beans override defaults" principle applies to almost every Spring Boot auto-configuration.
L2 → "How does @ConditionalOnProperty work with multi-value conditions?"
@ConditionalOnProperty checks a single property by default. For complex conditions, combine annotations: @Conditional(OnMyCustomCondition.class) where OnMyCustomCondition implements Condition and reads the environment programmatically. Alternatively, use @Profile for environment-based switching. A common pattern: @ConditionalOnProperty("feature.x.enabled") on a @Bean to create a feature-flag-driven configuration without any code changes.
L3 → "You added a dependency to pom.xml and behavior changed unexpectedly. How do you diagnose it?"
Run --debug or check /actuator/conditions. The conditions report shows every auto-config class, whether it was applied, and why (MATCHED) or why not (NO_MATCH — with the specific condition that failed). Find the unexpected auto-config that was triggered by your new dependency's @ConditionalOnClass. Then either exclude it (@SpringBootApplication(exclude=...)) or configure it to behave as expected. This is the standard auto-config debugging workflow.
Trap answer to avoid: "Spring Boot magic — I don't need to understand how it works." — Every senior interview at a company using Spring will probe auto-configuration internals. Not knowing @Conditional, the conditions evaluation order, or how to override defaults signals you've only used Spring as a black box — which is a risk for maintaining complex Spring applications.
Q5 · Bean Lifecycle
Explain the Spring Bean lifecycle. When do you use @PostConstruct and @PreDestroy?
🎙 Model answer (60 sec)
"The lifecycle is: constructor → dependencies injected → @PostConstruct → bean is ready → @PreDestroy on shutdown. I use @PostConstruct for initialization logic that needs the injected dependencies to exist — things you can't do in the constructor because the fields aren't set yet, like warming a cache or validating config. @PreDestroy is for cleanup: closing connections, flushing buffers, deregistering from a registry. The senior-level thing to know: if your @PostConstruct logic depends on OTHER beans also being fully initialized — not just your own dependencies — you need SmartInitializingSingleton instead, because its afterSingletonsInstantiated() method fires after every singleton in the context is fully initialized."

Lifecycle order

Constructor
  → @Autowired dependencies injected
  → @PostConstruct
  → InitializingBean.afterPropertiesSet()
  → @Bean(initMethod)
  → ApplicationReadyEvent / ApplicationRunner
  ─── SERVING TRAFFIC ───
  → @PreDestroy
  → DisposableBean.destroy()
  → @Bean(destroyMethod)

@PostConstruct use cases

@PostConstruct
public void init() {
    // Validate required config (fail fast before serving traffic)
    if (props.getApiKey().isBlank()) throw new IllegalStateException("API key required");

    // Warm up caches on startup
    cache.load(configRepo.findAll());
}

@PreDestroy use cases

@PreDestroy
public void shutdown() {
    // Flush in-flight Kafka messages before JVM exits
    producer.flush();
    producer.close();

    // Drain in-progress jobs
    executor.shutdown();
    executor.awaitTermination(30, TimeUnit.SECONDS);
}

SmartInitializingSingleton — the senior-level hook

@PostConstruct runs per-bean, as soon as that bean's dependencies are injected. But sometimes you need to run logic after all singleton beans are fully initialized — for example, registering beans that need to discover each other, or validating cross-bean configuration.

The correct hook is SmartInitializingSingleton.afterSingletonsInstantiated() — Spring calls it on every bean that implements the interface, after all singleton beans have been created and all @PostConstruct methods have run:

@Component
public class StrategyRegistry implements SmartInitializingSingleton {

    @Autowired
    private List<PaymentStrategy> strategies;  // all strategy beans injected

    @Override
    public void afterSingletonsInstantiated() {
        // Safe to iterate — all beans fully initialized by here
        strategies.forEach(s -> log.info("Registered: {}", s.getClass().getSimpleName()));
        // Validate that at least one strategy is available
        if (strategies.isEmpty()) throw new IllegalStateException("No payment strategies registered");
    }
}

The trap: doing this in @PostConstruct fails because the injected List<PaymentStrategy> may not be fully populated yet — some strategy beans may not have been created when this bean's @PostConstruct fires. SmartInitializingSingleton is the correct answer because Spring guarantees all singletons exist before calling it.

Bean scopes

ScopeInstancesUse for
singleton1 per contextStateless services (default)
prototypeNew per injectionStateful objects (report generators)
request1 per HTTP requestWeb-scoped state
session1 per HTTP sessionUser session data
Pitfall: Injecting a prototype bean into a singleton gives you effectively a singleton — the singleton holds the same prototype instance forever. Use ObjectFactory<T> or @Lookup to get a fresh instance each time.
Interview drill-down — expect these follow-ups:
L1 → "@PostConstruct vs @Bean(initMethod) — when would you use one over the other?"
@PostConstruct is on the class itself — the class "knows" it needs initialization. Good for internal setup (validating properties, populating internal caches). @Bean(initMethod) is on the @Bean factory method — the caller decides when to run initialization. Good for third-party classes you don't control (you can't add @PostConstruct to a JDBC driver). Also, @PostConstruct is JSR-250 (standard Java) — @Bean attributes are Spring-specific. Prefer @PostConstruct for your own classes; @Bean(initMethod) for external ones.
L2 → "ApplicationRunner vs CommandLineRunner — what's the difference?"
CommandLineRunner receives String[] args (raw command line arguments). ApplicationRunner receives ApplicationArguments (structured — can parse --key=value options). Both run after the full ApplicationContext is initialized and the embedded server is started. For most use cases they're interchangeable. Prefer ApplicationRunner if you need to parse named arguments. Use @Order if multiple runners must run in sequence.
L3 → "In @PreDestroy, you flush Kafka and await executor termination. What if the JVM gets a SIGKILL instead of SIGTERM?"
SIGKILL is non-interceptable — the JVM is terminated immediately. @PreDestroy doesn't run. This is why distributed systems must be designed for unclean shutdown: Kafka producers should use idempotent=true so unflushed messages can be safely retried on restart. Executors should use outbox patterns so in-progress work is persisted before execution. @PreDestroy is a best-effort cleanup, not a durability guarantee. Plan for the SIGKILL case.
Trap answer to avoid: "I use @PostConstruct to initialize everything at startup." — Expensive initialization in @PostConstruct (loading gigabytes of data, making dozens of HTTP calls) delays the Spring context startup and fails liveness probes. Use @PostConstruct for fast validation; move slow initialization to ApplicationRunner after the server is up, or to lazy-loading on first request.
Q6 · Security
How does Spring Security work? Describe the filter chain and JWT integration.
🎙 Model answer (60 sec)
"Spring Security is a chain of servlet filters registered as a single DelegatingFilterProxy. Every HTTP request passes through them in order — CORS, CSRF, authentication, authorization. For JWT, I add a custom OncePerRequestFilter early in the chain: it reads the Authorization header, validates the token signature and expiration, and if valid, constructs a UsernamePasswordAuthenticationToken and puts it in the SecurityContextHolder. From that point on, the rest of the filters and your controllers see an authenticated principal. The JWT filter is added with addFilterBefore(jwtFilter, BearerTokenAuthenticationFilter.class). Two things I always get asked: stateless REST APIs should call sessionManagement().sessionCreationPolicy(STATELESS) — don't create a session that will be ignored anyway. And method-level security with @PreAuthorize uses SpEL against the Authentication object in the context."

Filter chain

Spring Security is a chain of Filters, registered as a DelegatingFilterProxy. Every HTTP request passes through the chain in order.

FilterPurpose
SecurityContextPersistenceFilterLoads/saves SecurityContext
CorsFilterCORS preflight handling
CsrfFilterCSRF token validation
UsernamePasswordAuthenticationFilterForm login
BearerTokenAuthenticationFilterJWT / OAuth2 bearer token
AuthorizationFilterRole/permission check
ExceptionTranslationFilterConverts security exceptions to HTTP responses

Modern SecurityFilterChain config

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    return http
        .csrf(csrf -> csrf.disable())           // stateless API — no CSRF needed
        .sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/api/public/**").permitAll()
            .requestMatchers("/api/admin/**").hasRole("ADMIN")
            .anyRequest().authenticated()
        )
        .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
        .build();
}

JWT flow

  1. Client sends Authorization: Bearer <token>.
  2. BearerTokenAuthenticationFilter extracts the token.
  3. JwtDecoder validates signature + expiry.
  4. Claims mapped to Authentication object → stored in SecurityContextHolder.
  5. Downstream code uses @PreAuthorize("hasRole('ADMIN')") or SecurityContextHolder.getContext().getAuthentication().
Interview drill-down — expect these follow-ups:
L1 → "JWT is stateless — what's the implication for logout/token revocation?"
You can't revoke a JWT without a server-side store. Once issued, it's valid until expiry. Common mitigations: short expiry (15 minutes) with refresh tokens; maintain a token blocklist in Redis (negate the stateless benefit but practical for high-security flows); rotate the signing secret (invalidates all tokens — nuclear option for security incidents). OAuth2's token introspection endpoint is the proper solution: clients validate against the auth server, which can mark tokens as revoked.
L2 → "Spring Security's SecurityContextHolder — is it thread-safe with virtual threads?"
SecurityContextHolder uses ThreadLocal by default (MODE_THREADLOCAL). Virtual threads still have ThreadLocal support. But if a request uses @Async or CompletableFuture, the new thread starts with empty SecurityContext — same propagation problem as MDC. Fix: SecurityContextHolder.setStrategyName(MODE_INHERITABLETHREADLOCAL) propagates to child threads. Or use Spring Security's DelegatingSecurityContextExecutor wrapper on your thread pools to explicitly propagate the SecurityContext.
L3 → "Method-level security with @PreAuthorize — how does it differ from HTTP-level requestMatchers?"
requestMatchers in SecurityFilterChain acts at the HTTP layer — it can only enforce based on URL patterns and HTTP method. @PreAuthorize acts at the method level via AOP — it can access method arguments and Spring Security's SpEL context for fine-grained logic: @PreAuthorize("hasRole('ADMIN') or #userId == authentication.name"). Use HTTP-level for coarse-grained route protection; @PreAuthorize for data-level security where the same endpoint serves different users with different permission scopes.
Trap answer to avoid: "I disable CSRF protection for REST APIs." — Correct for stateless APIs (no session, Bearer tokens not in cookies), but wrong to do without understanding why. CSRF attacks require the victim to be authenticated via cookie. If your API uses cookie-based auth, CSRF is still a real threat. If it's Bearer token auth with no cookies, CSRF is irrelevant. The answer must include the reasoning, not just the flag.
Q7 · Configuration
@ConfigurationProperties vs @Value — what's the difference and when do you use each?
🎙 Model answer (60 sec)
"@Value injects a single property directly into a field — quick for one-offs but doesn't scale. @ConfigurationProperties binds an entire namespace of properties to a typed POJO — you get type safety, IDE autocomplete with the metadata processor, validation via @Validated and @NotNull, and all related config grouped in one class. My rule: if a component needs more than two config values, use @ConfigurationProperties. The other benefit: @ConfigurationProperties supports relaxed binding — 'payment.gateway-url', 'PAYMENT_GATEWAY_URL', and 'payment.gatewayUrl' all bind to the same field. @Value requires an exact match. For tests, @ConfigurationProperties classes are trivially testable with @ConfigurationPropertiesTest without loading the full Spring context."

@Value — one property at a time

@Value("${payment.gateway.url}")     private String url;
@Value("${payment.gateway.timeout:5000}") private int timeout; // with default

@ConfigurationProperties — typed, grouped, validated

@ConfigurationProperties(prefix = "payment.gateway")
@Validated
public class GatewayProperties {
    @NotBlank private String url;
    @Min(100) private int timeout = 5000;
    @Min(1)   private int retries = 3;
    private Map<String, String> headers = new HashMap<>();
}

Comparison

Feature@Value@ConfigurationProperties
Type safetyNoYes — validated at startup
GroupingScatteredOne class per config group
Relaxed bindingNoYes — PAYMENT_GATEWAY_URL = payment.gateway.url
Maps/ListsDifficultNative support
IDE autocompleteNoYes (with metadata processor)
Rule: Use @Value for one-off injections. Use @ConfigurationProperties for anything with 3+ related properties or where validation matters.

Property resolution priority

1. Command line args       --server.port=9090
2. OS env vars             SERVER_PORT=9090
3. application-{profile}.yml
4. application.yml
→ Secrets go in env vars / Vault, never in yml files committed to git.
Interview drill-down — expect these follow-ups:
L1 → "@ConfigurationProperties relaxed binding — what exactly does 'relaxed' mean?"
Spring Boot normalizes property names when binding @ConfigurationProperties. All of these bind to the same field: payment.gateway.url (kebab-case), paymentGatewayUrl (camelCase), PAYMENT_GATEWAY_URL (SCREAMING_SNAKE — for env vars), payment_gateway_url (underscore). The canonical form is kebab-case in .yml files. This matters for Kubernetes deployments where env vars must be SCREAMING_SNAKE_CASE — they automatically bind to @ConfigurationProperties without any mapping code.
L2 → "Kubernetes Secrets as env vars — what's the security risk and the better approach?"
Kubernetes Secrets encoded as env vars are visible in process listings, pod descriptions, and often in logs (Spring prints env vars at startup at DEBUG level). Better approach: mount Secrets as files (volume mounts), read them via spring.config.import=file:/etc/secrets/. Even better: use a secrets manager (HashiCorp Vault, AWS Secrets Manager) with Spring Cloud Vault — secrets are fetched at runtime, rotatable without pod restarts, and never stored in Kubernetes etcd at all.
L3 → "How do you reload @ConfigurationProperties at runtime without restarting the service?"
Three approaches: (1) Spring Cloud Config with @RefreshScope — call /actuator/refresh endpoint to reload all @RefreshScope beans and rebind @ConfigurationProperties. (2) Spring Cloud Config with automatic refresh via Spring Cloud Bus (Kafka/RabbitMQ) — push a refresh event to all instances simultaneously. (3) Watch file changes — use @Value combined with a FileWatcher that programmatically updates bean fields. Note: @ConfigurationProperties beans are NOT @RefreshScope by default — you must add @RefreshScope explicitly.
Trap answer to avoid: "I use @Value everywhere — it's simpler." — @Value with SpEL in strings is type-unsafe, not validated at startup, doesn't support relaxed binding, and scatters configuration across the codebase. For a service with 15+ config properties, @Value becomes unmaintainable. The one-liner simplicity is deceptive.
Q8 · Production
How do you implement graceful shutdown and health probes in Spring Boot?
🎙 Model answer (60 sec)
"Graceful shutdown is two lines of config: server.shutdown=graceful and a timeout-per-shutdown-phase. On SIGTERM, Spring stops accepting new requests, waits for in-flight requests to finish within the timeout, then shuts down. Without this, every rolling deploy drops in-flight requests. For health probes, Spring Actuator provides separate endpoints: /actuator/health/liveness for 'is the process alive' and /actuator/health/readiness for 'is it ready to serve traffic'. Kubernetes restarts on failed liveness, and stops routing on failed readiness — they serve different purposes and must not be mixed. One production detail that catches people: Kubernetes removes a pod from the load balancer and sends SIGTERM simultaneously. The preStop hook adds a few seconds of sleep before shutdown starts, giving the load balancer time to drain existing connections."

Graceful shutdown

# application.yml
server:
  shutdown: graceful                      # stop accepting new requests
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s       # max wait for in-flight

On SIGTERM: Spring stops accepting new requests, waits for in-flight requests to complete (up to timeout), then shuts down. For Kafka consumers, implement SmartLifecycle to stop consuming before the timeout.

Health probes for Kubernetes

# application.yml
management:
  endpoint:
    health:
      probes:
        enabled: true       # enables /actuator/health/liveness and /readiness
  health:
    livenessState:
      enabled: true
    readinessState:
      enabled: true
  • Liveness /actuator/health/liveness: "Is the process alive?" Fail → Kubernetes restarts the pod.
  • Readiness /actuator/health/readiness: "Can it serve traffic?" Fail → removed from load balancer (used during startup and graceful shutdown).

Custom health indicator

@Component
public class KafkaHealthIndicator implements HealthIndicator {
    @Override
    public Health health() {
        try {
            kafkaAdmin.describeCluster();
            return Health.up().withDetail("broker", "reachable").build();
        } catch (Exception e) {
            return Health.down().withDetail("error", e.getMessage()).build();
        }
    }
}
Tip: Don't include slow external dependency checks in your readiness probe — if the dependency is down, that doesn't mean you should be pulled from the load balancer. Keep probes fast (<1s).
Interview drill-down — expect these follow-ups:
L1 → "graceful shutdown timeout is 30s. What happens to requests that take 31 seconds to complete?"
Spring forcibly stops the executor after the timeout — the request's thread is interrupted. The client receives a connection reset or 502 (if behind a load balancer). The partial work that was done may be committed or rolled back depending on where in the transaction the interrupt happened. For long-running operations (file uploads, batch processing), either increase timeout-per-shutdown-phase, or implement a pre-shutdown hook that routes these operations to a retry queue before SIGTERM arrives.
L2 → "Slow liveness probe — /actuator/health takes 800ms. What are the causes?"
Liveness probe calling /actuator/health and including DB/Redis health indicators. Under load, DB connections are busy — the health check waits for a connection from HikariCP pool. Fix: (1) Split liveness to /actuator/health/liveness (JVM-only, no deps). (2) If health checks must include DB, cache the health result for 5s — don't make a DB query on every probe. (3) Set a dedicated connection in HikariCP for health checks that can't be starved by application traffic.
L3 → "What is the preStop hook in Kubernetes and why do you need it even with graceful shutdown?"
Even with graceful shutdown, there's a race: Kubernetes removes the pod from the load balancer AND sends SIGTERM simultaneously. Requests in-flight with the old connection from the load balancer may arrive at the pod AFTER SIGTERM — and be rejected. The preStop hook adds a sleep before SIGTERM processing, giving the load balancer time to drain: lifecycle.preStop.exec.command: ["sleep", "5"]. This 5-second buffer ensures the load balancer finishes routing existing connections before the JVM starts rejecting new ones.
Trap answer to avoid: "I set spring.lifecycle.timeout-per-shutdown-phase=0 so the service shuts down instantly." — Zero timeout means in-flight requests are dropped immediately on SIGTERM. This causes 502 errors visible to users during every deploy. Graceful shutdown exists precisely to drain in-flight requests — setting timeout to zero defeats its purpose entirely.
Q9 · Spring Boot 3.x
What are the most important new features in Spring Boot 3.x? How do @HttpExchange, ProblemDetail, and virtual thread support change how you write services?
🎙 Model answer (60 sec)
"Spring Boot 3 is a significant release — it requires Java 17+, migrates to Jakarta EE 9 (the javax.* → jakarta.* rename), and introduces three things I use constantly. First, @HttpExchange gives us Feign-style declarative HTTP clients built into Spring — no extra dependency. Second, ProblemDetail standardizes error responses per RFC 9457, so all our APIs return consistent JSON errors automatically. Third, virtual thread support is now first-class — one property enables virtual threads for the entire Tomcat servlet layer. The migration cost is real (the jakarta rename touches every import), but the result is a leaner, more modern stack."

@HttpExchange — declarative HTTP clients (no Feign needed)

Spring Boot 3.0 introduced @HttpExchange, a native alternative to OpenFeign. You define an interface with HTTP annotations; Spring generates the implementation backed by RestClient or WebClient.

// 1. Define the HTTP interface
@HttpExchange(url = "/users", accept = "application/json")
public interface UserServiceClient {

    @GetExchange("/{id}")
    UserDto getUser(@PathVariable Long id);

    @PostExchange
    UserDto createUser(@RequestBody CreateUserRequest request);

    @DeleteExchange("/{id}")
    void deleteUser(@PathVariable Long id);
}

// 2. Register the client as a bean
@Bean
public UserServiceClient userServiceClient() {
    RestClient restClient = RestClient.builder()
        .baseUrl("https://user-service.internal")
        .build();
    RestClientAdapter adapter = RestClientAdapter.create(restClient);
    HttpServiceProxyFactory factory = HttpServiceProxyFactory
        .builderFor(adapter).build();
    return factory.createClient(UserServiceClient.class);
}

// 3. Inject and use — looks identical to Feign
@Service
public class OrderService {
    private final UserServiceClient userClient;

    public Order placeOrder(Long userId, OrderRequest req) {
        UserDto user = userClient.getUser(userId);  // blocking, works with virtual threads
        // ...
    }
}
RestClient vs WebClient: Back the interface with RestClient for blocking/virtual-thread services, WebClient for reactive services. RestClient (Spring 6.1) is the successor to RestTemplate — same blocking model, modern fluent API.

ProblemDetail — RFC 9457 standardized error responses

Before Spring Boot 3, every team invented their own error response JSON shape. Spring Boot 3 implements RFC 9457 (Problem Details for HTTP APIs) via ProblemDetail — a standard JSON structure that API consumers can rely on.

// Enable RFC 9457 problem detail format globally
spring.mvc.problemdetails.enabled=true  # Spring Boot 3.x

// Spring now returns this JSON for 4xx/5xx by default:
// {
//   "type": "about:blank",
//   "title": "Bad Request",
//   "status": 400,
//   "detail": "Failed to read request",
//   "instance": "/api/orders"
// }

// Custom exception → ProblemDetail in @ControllerAdvice
@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handleOrderNotFound(OrderNotFoundException ex) {
    ProblemDetail problem = ProblemDetail
        .forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    problem.setTitle("Order Not Found");
    problem.setType(URI.create("https://api.example.com/errors/order-not-found"));
    problem.setProperty("orderId", ex.getOrderId());  // custom extension fields
    return problem;
}

// Produces:
// {
//   "type": "https://api.example.com/errors/order-not-found",
//   "title": "Order Not Found",
//   "status": 404,
//   "detail": "No order with id 42",
//   "instance": "/api/orders/42",
//   "orderId": 42
// }

Virtual thread configuration — one property

# application.yml — enable virtual threads for Tomcat + async tasks
spring:
  threads:
    virtual:
      enabled: true   # Spring Boot 3.2+ — configures Tomcat + @Async executor

# What this does:
# - Tomcat uses virtual threads per request (replaces thread pool)
# - @Async methods run on virtual threads
# - Spring Security and @Transactional context propagation work correctly
# - No code changes needed — drop-in replacement

Before this property existed (Spring Boot 3.0–3.1), you needed a manual TomcatProtocolHandlerCustomizer bean. Spring Boot 3.2 made it a single config property.

Migration: javax.* → jakarta.* (the biggest Boot 3 pain point)

Spring Boot 3 requires Jakarta EE 9+. Every javax.* import becomes jakarta.*. This is a mechanical rename but touches hundreds of files in a real project.

// Spring Boot 2.x
import javax.persistence.Entity;
import javax.validation.constraints.NotNull;
import javax.servlet.http.HttpServletRequest;

// Spring Boot 3.x
import jakarta.persistence.Entity;
import jakarta.validation.constraints.NotNull;
import jakarta.servlet.http.HttpServletRequest;

// Migration tool: OpenRewrite recipe handles 90% automatically
// ./mvnw rewrite:run -Drewrite.recipeArtifactCoordinates=... \
//   -Drewrite.activeRecipes=org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_2
OpenRewrite (free, open-source) automates the Jakarta migration, Spring Security config lambda DSL migration, and more. Mention it by name — interviewers are impressed when candidates know the migration tooling.
Interview drill-down — expect these follow-ups:
L1 → "How do you add retry and timeout to an @HttpExchange client?"
Configure the underlying RestClient before wrapping it. For timeout: set connectTimeout and readTimeout on the ClientHttpRequestFactory. For retry: wrap with a Spring Retry interceptor or use Resilience4j's RestClientCustomizer to add a circuit breaker / retry decorator around the client. Unlike Feign which has built-in Ribbon/Retry configuration, @HttpExchange is explicit — you compose the behaviour yourself on the underlying client. This is by design: the annotation layer handles the HTTP contract; resilience is a cross-cutting concern added to the HTTP client layer.
L2 → "A service returns a non-standard error JSON body. How do you map it to a typed exception with @HttpExchange?"
Implement a custom ResponseErrorHandler on the RestClient (or ExchangeFilterFunction for WebClient). In the error handler: read the response body, deserialize into your error DTO, and throw a typed exception. The @HttpExchange proxy will propagate the exception to your calling code. This is the same pattern as Feign's ErrorDecoder — just applied at the RestClient layer instead.
L3 → "You have a Spring Boot 2.7 service with 600 classes. How do you plan and execute a migration to Spring Boot 3.2?"
Staged approach: (1) First upgrade to latest Spring Boot 2.7 and Java 17 — resolve any Java 17 incompatibilities without the Jakarta rename. (2) Run OpenRewrite's UpgradeSpringBoot_3_0 recipe — handles Jakarta rename, Spring Security lambda DSL migration, @SpringBootTest(webEnvironment) changes. Commit the automated changes. (3) Fix what OpenRewrite couldn't: third-party libraries that haven't published Jakarta-compatible versions (common blockers: older Swagger, MapStruct, Hibernate Validator). (4) Run the full test suite — expect failures from Security config changes (the method chaining DSL changed significantly). (5) Upgrade to Boot 3.2 for virtual thread support and RestClient. Never try Boot 2 → Boot 3 in one giant PR. The staged approach isolates what broke and why.
Trap answer to avoid: "Spring Boot 3 is just a namespace change." — The jakarta rename is mechanical, but Boot 3 also drops support for Java 8/11, changes the Spring Security configuration DSL (method chaining → lambda DSL), upgrades to Hibernate 6 (query syntax changes, different type mappings), and requires Jakarta EE 9-compatible third-party libraries. Many projects have blocking third-party library dependencies. Calling it "just a rename" signals you haven't done a real migration.