Spring Boot: Interview Questions
A comprehensive collection of Spring Boot interview questions organized by difficulty level โ from scenario-based to advanced and expert topics. Covers auto-configuration, microservices, performance, security, and production concerns.
Level I โ Core Conceptsโ
Q1: What is Spring Boot and how does it differ from Spring Framework?โ
Spring Boot is an opinionated framework built on top of Spring Framework that simplifies application setup and development. Key differences:
| Aspect | Spring Framework | Spring Boot |
|---|---|---|
| Configuration | Manual (XML/Java) | Auto-configuration |
| Server | External WAR deployment | Embedded server (fat JAR) |
| Dependencies | Manual version management | Starter POMs |
| Boilerplate | Significant | Minimal |
| Production features | Manual setup | Actuator included |
Spring Boot does not replace Spring โ it removes the friction of using it.
Q2: What does @SpringBootApplication do?โ
It is a convenience annotation combining three annotations:
@SpringBootConfigurationโ Marks the class as a configuration source@EnableAutoConfigurationโ Enables Spring Boot's auto-configuration mechanism@ComponentScanโ Scans the current package and all sub-packages for Spring components
Q3: What are Spring Boot Starters?โ
Starters are curated dependency descriptors that bundle compatible libraries for a specific purpose. Instead of manually specifying dozens of dependencies with compatible versions, you add a single starter.
Examples:
spring-boot-starter-webโ Web apps with Spring MVC + embedded Tomcatspring-boot-starter-data-jpaโ JPA with Hibernatespring-boot-starter-securityโ Spring Securityspring-boot-starter-testโ Testing with JUnit 5, Mockito, AssertJ
Q4: How does Spring Boot Auto-Configuration work?โ
Spring Boot reads META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports from all JARs on the classpath. Each listed class uses conditional annotations (@ConditionalOnClass, @ConditionalOnMissingBean, etc.) to decide whether to register beans. If a JDBC driver is on the classpath, a DataSource is auto-configured. If you define your own DataSource bean, auto-configuration backs off.
Q5: What is the purpose of Spring Boot Actuator?โ
Actuator provides production-ready features:
| Endpoint | Purpose |
|---|---|
/health | Application health checks |
/metrics | JVM, HTTP, custom metrics |
/env | Environment properties |
/beans | All registered beans |
/loggers | View/change log levels at runtime |
/info | Application info |
It also integrates with monitoring systems like Prometheus, Grafana, and Datadog.
Q6: What is the difference between application.properties and application.yml?โ
Both serve the same purpose โ externalized configuration. The difference is syntax:
# application.properties
server.port=8080
spring.datasource.url=jdbc:mysql://localhost:3306/mydb
# application.yml
server:
port: 8080
spring:
datasource:
url: jdbc:mysql://localhost:3306/mydb
YAML is more readable for nested properties. Both support profiles (application-{profile}.properties/yml).
Level II โ Practical Usageโ
Q7: How do you handle different environments in Spring Boot?โ
Use Spring Profiles:
- Create profile-specific config files:
application-dev.yml,application-prod.yml - Activate via command line:
-Dspring.profiles.active=prod - Or via environment variable:
SPRING_PROFILES_ACTIVE=prod
Beans can also be profile-specific:
@Configuration
@Profile("production")
public class ProdConfig { ... }
Q8: How do you implement exception handling in Spring Boot REST APIs?โ
Use @RestControllerAdvice for global exception handling:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(ResourceNotFoundException ex) {
return ResponseEntity.status(404)
.body(new ErrorResponse(404, ex.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
List<String> errors = ex.getBindingResult().getFieldErrors().stream()
.map(e -> e.getField() + ": " + e.getDefaultMessage())
.toList();
return ResponseEntity.badRequest()
.body(new ErrorResponse(400, "Validation failed", errors));
}
}
Q9: What is the difference between @Controller and @RestController?โ
| Annotation | Returns | Response Body |
|---|---|---|
@Controller | View name (resolved by ViewResolver) | Requires @ResponseBody on each method |
@RestController | Direct response body (JSON/XML) | @ResponseBody applied automatically |
@RestController = @Controller + @ResponseBody
Q10: How does Spring Boot handle database migrations?โ
Spring Boot auto-configures Flyway or Liquibase when they're on the classpath:
- Flyway: SQL-based migrations in
db/migration/(V1__Create_table.sql) - Liquibase: XML/YAML changelogs
Migrations run automatically at startup, ensuring the database schema matches the application version.
Q11: What is @ConfigurationProperties and when should you use it?โ
@ConfigurationProperties binds a group of external properties to a Java object:
@ConfigurationProperties(prefix = "app.mail")
public class MailProperties {
private String host;
private int port;
private String from;
// getters and setters
}
Use it instead of multiple @Value annotations when you have related configuration properties. It supports:
- Type-safe binding
- Relaxed naming (
mail-host,MAIL_HOST,mailHost) - Validation with
@Validated
Level III โ Scenario-Basedโ
Q12: Your Spring Boot application starts slowly. How do you diagnose and fix it?โ
Diagnosis:
- Enable startup analysis:
--debugflag orspring.main.lazy-initialization=true - Use Spring Boot's startup tracing (
ApplicationStartupwithBufferingApplicationStartup) - Check auto-configuration report for unnecessary configurations
- Profile with JFR (Java Flight Recorder) or async-profiler
Common fixes:
| Cause | Fix |
|---|---|
| Too many auto-configurations | Exclude unnecessary ones with @SpringBootApplication(exclude = ...) |
| Classpath scanning too broad | Narrow @ComponentScan to specific packages |
| Eager initialization of all beans | Use spring.main.lazy-initialization=true for dev |
| Flyway migrations on large DB | Baseline migrations, optimize SQL |
| Hibernate DDL auto | Set spring.jpa.hibernate.ddl-auto=none in production |
Q13: You have a REST API that sometimes returns 500 errors under load. How do you investigate?โ
Step-by-step approach:
- Check logs โ Look for stack traces, OOM errors, connection pool exhaustion
- Monitor metrics โ Check
/actuator/metricsforhttp.server.requests,hikaricp.connections.active - Check connection pool โ Is
hikari.maximum-pool-sizetoo small? - Check thread pool โ Are all Tomcat threads busy? (
server.tomcat.threads.max) - Look for N+1 queries โ Enable Hibernate statistics:
hibernate.generate_statistics=true - Check for memory leaks โ Monitor heap usage with Actuator or JVisualVM
- Enable circuit breaker โ Use Resilience4j to prevent cascading failures to downstream services
Q14: How would you secure a REST API that exposes both public and protected endpoints?โ
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**", "/actuator/health").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
}
Key considerations:
- Use JWT for stateless authentication
- Apply method-level security with
@PreAuthorizefor fine-grained control - Use CORS configuration for frontend/API separation
- Disable CSRF for stateless APIs (no session cookies)
- Rate-limit sensitive endpoints
Q15: Your application needs to call three external APIs and aggregate results. How do you design this?โ
Approach 1: Parallel execution with CompletableFuture
@Service
public class AggregationService {
@Async
public CompletableFuture<UserData> fetchUserData(String userId) {
return CompletableFuture.completedFuture(userClient.getUser(userId));
}
@Async
public CompletableFuture<List<Order>> fetchOrders(String userId) {
return CompletableFuture.completedFuture(orderClient.getOrders(userId));
}
@Async
public CompletableFuture<PaymentInfo> fetchPayments(String userId) {
return CompletableFuture.completedFuture(paymentClient.getPayments(userId));
}
public AggregatedResponse aggregate(String userId) {
CompletableFuture<UserData> userFuture = fetchUserData(userId);
CompletableFuture<List<Order>> ordersFuture = fetchOrders(userId);
CompletableFuture<PaymentInfo> paymentsFuture = fetchPayments(userId);
CompletableFuture.allOf(userFuture, ordersFuture, paymentsFuture).join();
return new AggregatedResponse(
userFuture.join(), ordersFuture.join(), paymentsFuture.join()
);
}
}
Approach 2: Reactive with WebClient
public Mono<AggregatedResponse> aggregate(String userId) {
Mono<UserData> user = webClient.get().uri("/users/{id}", userId).retrieve().bodyToMono(UserData.class);
Mono<List<Order>> orders = webClient.get().uri("/orders?userId={id}", userId).retrieve().bodyToFlux(Order.class).collectList();
Mono<PaymentInfo> payments = webClient.get().uri("/payments?userId={id}", userId).retrieve().bodyToMono(PaymentInfo.class);
return Mono.zip(user, orders, payments)
.map(tuple -> new AggregatedResponse(tuple.getT1(), tuple.getT2(), tuple.getT3()));
}
Add Resilience4j circuit breakers and timeouts for each external call:
@CircuitBreaker(name = "userService", fallbackMethod = "fallbackUser")
public UserData fetchUser(String userId) { ... }
Q16: How would you implement pagination and sorting for a large dataset?โ
@GetMapping("/products")
public Page<ProductDTO> getProducts(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "name") String sortBy,
@RequestParam(defaultValue = "asc") String direction) {
Sort sort = direction.equalsIgnoreCase("asc")
? Sort.by(sortBy).ascending()
: Sort.by(sortBy).descending();
Pageable pageable = PageRequest.of(page, size, sort);
return productRepository.findAll(pageable).map(this::toDTO);
}
Key considerations:
- Always limit
sizewith a maximum (e.g., 100) to prevent huge queries - Use database-level pagination (Spring Data does this automatically)
- Return
Page<T>for total count orSlice<T>for better performance when total isn't needed - Add index on the sort column
Q17: You need to process a CSV file with millions of rows. How do you do this in Spring Boot?โ
Use Spring Batch:
@Configuration
public class CsvBatchConfig {
@Bean
public Job importJob(JobRepository jobRepository, Step step) {
return new JobBuilder("csvImport", jobRepository)
.start(step)
.build();
}
@Bean
public Step step(JobRepository jobRepository, PlatformTransactionManager txManager) {
return new StepBuilder("processStep", jobRepository)
.<InputRecord, OutputRecord>chunk(1000, txManager)
.reader(csvReader())
.processor(recordProcessor())
.writer(databaseWriter())
.faultTolerant()
.skipLimit(100)
.skip(ParseException.class)
.build();
}
}
Why Spring Batch?
- Chunk-based processing (configurable batch size)
- Built-in restart/retry/skip capabilities
- Transaction management per chunk
- Job monitoring and execution history
Level IV โ Advancedโ
Q18: How does Spring Boot's auto-configuration ordering work?โ
Auto-configuration classes are loaded in a specific order:
- Classes listed first in
AutoConfiguration.importshave lower priority @AutoConfigureOrdersets explicit priority@AutoConfigureBefore/@AutoConfigureAfterdefine relative ordering@ConditionalOnMissingBeanensures user-defined beans take precedence
Practical implication: If you define a DataSource bean in your @Configuration class, DataSourceAutoConfiguration detects it via @ConditionalOnMissingBean and skips its own DataSource creation.
Q19: Explain the @Conditional family of annotations in depth.โ
These annotations control whether a bean or configuration class is registered:
| Annotation | Condition |
|---|---|
@ConditionalOnClass | Class is on the classpath |
@ConditionalOnMissingClass | Class is NOT on the classpath |
@ConditionalOnBean | Bean exists in the context |
@ConditionalOnMissingBean | Bean does NOT exist in the context |
@ConditionalOnProperty | Property has a specific value |
@ConditionalOnResource | Resource exists on the classpath |
@ConditionalOnWebApplication | App is a web application |
@ConditionalOnExpression | SpEL expression is true |
Custom conditional:
public class OnKubernetesCondition implements Condition {
@Override
public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
return System.getenv("KUBERNETES_SERVICE_HOST") != null;
}
}
@Configuration
@Conditional(OnKubernetesCondition.class)
public class KubernetesConfig { ... }
Q20: What is the difference between @Transactional propagation levels?โ
| Propagation | Behavior |
|---|---|
REQUIRED (default) | Join existing transaction or create new one |
REQUIRES_NEW | Always create a new transaction, suspend existing |
NESTED | Execute within a nested transaction (savepoint) |
SUPPORTS | Use existing transaction if available, otherwise non-transactional |
NOT_SUPPORTED | Execute non-transactionally, suspend existing |
MANDATORY | Must run within an existing transaction, throw exception if none |
NEVER | Must NOT run within a transaction, throw exception if one exists |
Common pitfall: Calling a @Transactional method from within the same class bypasses the proxy, so the transaction annotation has no effect. Always call from another bean.
Q21: How do you implement multi-tenancy in Spring Boot?โ
Three strategies:
| Strategy | Isolation | Complexity | Use Case |
|---|---|---|---|
| Separate Database | Highest | High | Regulatory compliance |
| Shared Database, Separate Schema | Medium | Medium | Moderate isolation needs |
| Shared Database, Shared Schema | Lowest | Low | SaaS with many tenants |
Shared schema with discriminator column:
@Entity
@FilterDef(name = "tenantFilter", parameters = @ParamDef(name = "tenantId", type = String.class))
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
public class Order {
@Column(name = "tenant_id")
private String tenantId;
}
Tenant resolution via interceptor:
@Component
public class TenantInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) {
String tenantId = request.getHeader("X-Tenant-ID");
TenantContext.setCurrentTenant(tenantId);
return true;
}
}
Q22: How does Spring Boot handle bean lifecycle and destruction?โ
Full bean lifecycle:
1. Instantiation (constructor)
2. Populate properties (dependency injection)
3. BeanNameAware.setBeanName()
4. BeanFactoryAware.setBeanFactory()
5. ApplicationContextAware.setApplicationContext()
6. BeanPostProcessor.postProcessBeforeInitialization()
7. @PostConstruct
8. InitializingBean.afterPropertiesSet()
9. Custom init-method
10. BeanPostProcessor.postProcessAfterInitialization()
--- Bean is ready for use ---
11. @PreDestroy
12. DisposableBean.destroy()
13. Custom destroy-method
Q23: How do you implement API versioning in Spring Boot?โ
Four approaches:
// 1. URI versioning
@GetMapping("/api/v1/users")
public List<UserV1> getUsersV1() { ... }
@GetMapping("/api/v2/users")
public List<UserV2> getUsersV2() { ... }
// 2. Header versioning
@GetMapping(value = "/api/users", headers = "X-API-VERSION=1")
public List<UserV1> getUsersV1() { ... }
// 3. Parameter versioning
@GetMapping(value = "/api/users", params = "version=1")
public List<UserV1> getUsersV1() { ... }
// 4. Content negotiation (Accept header)
@GetMapping(value = "/api/users", produces = "application/vnd.myapp.v1+json")
public List<UserV1> getUsersV1() { ... }
| Approach | Pros | Cons |
|---|---|---|
| URI | Simple, cacheable | URL pollution |
| Header | Clean URLs | Not visible in browser |
| Parameter | Simple | Can clutter query strings |
| Content negotiation | RESTful | Complex for clients |
Q24: How do you implement rate limiting in Spring Boot?โ
Using Bucket4j with Spring Boot:
@Component
public class RateLimitFilter extends OncePerRequestFilter {
private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String clientIp = request.getRemoteAddr();
Bucket bucket = buckets.computeIfAbsent(clientIp, this::createBucket);
if (bucket.tryConsume(1)) {
chain.doFilter(request, response);
} else {
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.getWriter().write("Rate limit exceeded");
}
}
private Bucket createBucket(String key) {
return Bucket.builder()
.addLimit(Bandwidth.classic(100, Refill.intervally(100, Duration.ofMinutes(1))))
.build();
}
}
Using Spring Cloud Gateway (for API Gateway):
spring:
cloud:
gateway:
routes:
- id: rate-limited-route
uri: lb://my-service
predicates:
- Path=/api/**
filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 10
redis-rate-limiter.burstCapacity: 20
Level V โ Expertโ
Q25: How would you design a Spring Boot application for 10,000+ concurrent users?โ
Architecture considerations:
- Stateless design โ No server-side sessions, use JWT
- Horizontal scaling โ Multiple instances behind a load balancer
- Connection pooling โ Tune HikariCP (
maximumPoolSizebased onconnections = cores * 2 + disk_spindles) - Caching layers โ Redis for distributed caching, Caffeine for local hot cache
- Async processing โ Offload heavy work to message queues (Kafka, RabbitMQ)
- Database optimization โ Read replicas, connection pooling, indexed queries
- CDN โ Static assets via CDN
- Circuit breakers โ Resilience4j for downstream service failures
Thread pool configuration:
server:
tomcat:
threads:
max: 400
min-spare: 50
max-connections: 10000
accept-count: 200
Or switch to reactive:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
WebFlux uses event-loop model โ can handle thousands of concurrent connections with few threads.
Q26: Explain Spring Boot's class loading mechanism for fat JARs.โ
Spring Boot includes a custom class loader (LaunchedURLClassLoader) that can read nested JARs:
my-app.jar
โโโ BOOT-INF/classes/ โ App classes (higher priority)
โโโ BOOT-INF/lib/ โ Dependency JARs (loaded as nested JARs)
โโโ org/springframework/boot/loader/
โโโ JarLauncher โ Entry point
Boot sequence:
- JVM starts
JarLauncher.main()(fromMANIFEST.MF) JarLaunchercreatesLaunchedURLClassLoaderthat understands nested JAR format- Loads
BOOT-INF/classes/first, thenBOOT-INF/lib/*.jar - Delegates to your
@SpringBootApplicationclass
This is why you can't run java -cp my-app.jar com.example.MyApp โ the custom launcher is required.
Q27: How do you implement the saga pattern for distributed transactions in Spring Boot?โ
A: Avoid blocking distributed transaction protocols (like 2PC). Instead, decompose cross-service workflows into a sequence of local transactions using the Saga Pattern. You can implement this in Spring Boot using:
- Choreography-based Sagas (Event-Driven): Services listen to and publish events (e.g., via Spring Boot Kafka binders) to trigger subsequent steps.
- Orchestration-based Sagas (Central Coordinator): A dedicated controller bean coordinates calls to other services and schedules compensating actions if any steps fail.
For complete structural code examples of both orchestration and choreography sagas, idempotency handling, and the compensating transaction playbook in Spring Boot, see the dedicated Saga Pattern Guide.
Q28: How do you implement CQRS in Spring Boot?โ
Command Query Responsibility Segregation separates read and write models:
// Command side โ writes
@Service
public class OrderCommandService {
private final OrderRepository writeRepo;
private final ApplicationEventPublisher events;
@Transactional
public void createOrder(CreateOrderCommand command) {
Order order = Order.create(command);
writeRepo.save(order);
events.publishEvent(new OrderCreatedEvent(order));
}
}
// Query side โ reads (can use different data model, even different database)
@Service
public class OrderQueryService {
private final OrderReadRepository readRepo; // Optimized for queries
public OrderView findOrder(String orderId) {
return readRepo.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
}
public Page<OrderSummary> searchOrders(OrderSearchCriteria criteria, Pageable pageable) {
return readRepo.search(criteria, pageable);
}
}
// Event handler syncs read model
@Component
public class OrderProjection {
@EventListener
@Async
public void on(OrderCreatedEvent event) {
// Update read-optimized view/table
readRepo.save(OrderView.from(event));
}
}
Q29: How do you handle zero-downtime deployments with Spring Boot?โ
Strategies:
- Rolling deployment โ Replace instances one at a time behind a load balancer
- Blue-green deployment โ Run two environments, switch traffic atomically
- Canary deployment โ Route small percentage of traffic to new version
Spring Boot requirements:
# Graceful shutdown
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
# Health check for readiness
management:
endpoint:
health:
probes:
enabled: true
health:
livenessstate:
enabled: true
readinessstate:
enabled: true
Database migration compatibility:
- Migrations must be backward-compatible (old code runs with new schema)
- Add columns (nullable or with defaults), never remove in the same release
- Use expand-and-contract pattern for breaking schema changes
Q30: How do you build a custom Spring Boot Starter?โ
Step-by-step:
- Create the autoconfigure module:
@AutoConfiguration
@ConditionalOnClass(NotificationService.class)
@EnableConfigurationProperties(NotificationProperties.class)
public class NotificationAutoConfiguration {
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(prefix = "notification", name = "enabled", havingValue = "true", matchIfMissing = true)
public NotificationService notificationService(NotificationProperties props) {
return new NotificationService(props.getProvider(), props.getApiKey());
}
}
- Create the properties class:
@ConfigurationProperties(prefix = "notification")
public class NotificationProperties {
private boolean enabled = true;
private String provider = "email";
private String apiKey;
// getters and setters
}
- Register in
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports:
com.example.notification.NotificationAutoConfiguration
- Create the starter POM (depends on autoconfigure module + actual library)
Users then simply add your starter and configure via application.yml:
notification:
enabled: true
provider: slack
api-key: ${SLACK_API_KEY}
Rapid-Fire Questionsโ
Q31: What is spring.jpa.open-in-view and should you disable it?โ
OSIV (Open Session in View) keeps the Hibernate session open through the entire HTTP request, allowing lazy-loaded entities to be fetched in the view layer. Disable it (false) because it holds database connections longer than necessary and hides N+1 query issues. Fetch everything you need in the service layer instead.
Q32: What is the difference between @Component, @Service, @Repository, and @Controller?โ
All are specializations of @Component and register beans via component scanning. The differences are semantic:
| Annotation | Semantic Meaning | Extra Behavior |
|---|---|---|
@Component | Generic bean | None |
@Service | Business logic | None (documentation only) |
@Repository | Data access layer | Exception translation (SQL โ Spring exceptions) |
@Controller | Web controller | Request mapping support |
Q33: How does Spring Boot decide which embedded server to use?โ
It checks the classpath in this order:
- If Tomcat classes are present โ Tomcat (default, included in
starter-web) - If Jetty classes are present โ Jetty
- If Undertow classes are present โ Undertow
If multiple are present, Tomcat wins unless explicitly excluded.
Q34: What happens if two beans of the same type exist?โ
Spring throws NoUniqueBeanDefinitionException. Resolve with:
@Primaryโ marks one bean as the default@Qualifier("beanName")โ specifies exactly which bean to inject@ConditionalOnMissingBeanโ prevents creation if one already exists
Q35: What is the difference between CommandLineRunner and ApplicationRunner?โ
Both run after the application context is ready:
| Interface | Argument Type | Use Case |
|---|---|---|
CommandLineRunner | String... args (raw) | Simple CLI arg processing |
ApplicationRunner | ApplicationArguments (parsed) | Named/optional arg support |
Q36: How do you test a Spring Boot application?โ
| Annotation | What It Does |
|---|---|
@SpringBootTest | Loads full application context |
@WebMvcTest | Loads only web layer (controllers) |
@DataJpaTest | Loads only JPA components (repositories) |
@MockBean | Replaces a bean with a Mockito mock |
@TestPropertySource | Override properties for tests |
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@Test
void shouldReturnUser() throws Exception {
when(userService.findById(1L)).thenReturn(new User(1L, "John"));
mockMvc.perform(get("/api/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.name").value("John"));
}
}
Q37: What is Spring Boot's relaxed binding?โ
Spring Boot maps properties using relaxed rules:
| Format | Example |
|---|---|
| Kebab | my-app.api-key |
| Camel | myApp.apiKey |
| Underscore | my_app.api_key |
| Upper case | MY_APP_API_KEY |
All map to the same Java field apiKey. This is useful because environment variables use UPPER_SNAKE_CASE while YAML uses kebab-case.
Q38: How do you implement health checks for downstream dependencies?โ
@Component
public class DatabaseHealthIndicator implements HealthIndicator {
private final DataSource dataSource;
@Override
public Health health() {
try (Connection conn = dataSource.getConnection()) {
return Health.up()
.withDetail("database", "reachable")
.build();
} catch (SQLException e) {
return Health.down()
.withDetail("database", "unreachable")
.withException(e)
.build();
}
}
}
Spring Boot includes built-in health indicators for common dependencies (DB, Redis, Elasticsearch, etc.) that activate automatically when those dependencies are on the classpath.
Level VI โ Expert (Senior Interview Questions)โ
Q39: Why does calling @Transactional method from within the same class not start a transaction?โ
Spring implements @Transactional using AOP proxies. When code outside the class calls service.placeOrder(), it hits the proxy, which opens a transaction. But when placeOrder() internally calls this.sendConfirmation(), it bypasses the proxy โ calling the raw object directly. The proxy never intercepts the call, so @Transactional on sendConfirmation() is silently ignored.
Fix: Extract to a separate bean, or inject self to force the call through the proxy.
// This correctly forces the call through the proxy
@Service
public class OrderService {
@Autowired private OrderService self; // inject proxy of itself
public void placeOrder(Order order) {
self.sendConfirmation(order); // goes through proxy โ
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void sendConfirmation(Order order) { ... }
}
Q40: What is the difference between BeanPostProcessor and BeanFactoryPostProcessor? When would you use each?โ
BeanFactoryPostProcessor | BeanPostProcessor | |
|---|---|---|
| Runs | Before any beans are instantiated | After each bean instance is created |
| Modifies | Bean definitions (metadata) | Bean instances |
| Example use | Resolve ${...} placeholders | Wrap beans in AOP proxies |
| Risk | Calling getBean() causes premature init | Must be defined as early as possible |
PropertySourcesPlaceholderConfigurer is a BeanFactoryPostProcessor โ it resolves properties before any bean is created. Spring's AOP mechanism uses BeanPostProcessor to replace beans with proxies after creation.
Q41: What are the trade-offs of virtual threads (Project Loom) in Spring Boot 3.2+?โ
spring:
threads:
virtual:
enabled: true
Benefits: Thousands of concurrent requests with far fewer OS threads; existing blocking code works as-is; no refactoring to reactive.
Trade-offs and pitfalls:
synchronizedblocks pin the carrier OS thread โ blocks the entire JVM thread-by-thread advantage. Migrate toReentrantLock.- Third-party libraries (HikariCP, Redis clients) with internal
synchronizedcan pin threads โ monitor with JFR. - No benefit for CPU-bound work โ only I/O-bound operations gain.
- Thread-local caching assumptions may break at scale (99,999 virtual threads can share thread-locals wastefully).
Q42: What constraints does GraalVM Native Image impose on Spring Boot applications?โ
GraalVM Native Image compiles the app at build time, eliminating the JVM at runtime. This imposes:
| Constraint | Why | Spring Boot Mitigation |
|---|---|---|
| No runtime classpath scanning | Reflection must be registered at build time | @RegisterReflectionForBinding, reflect-config.json |
| No dynamic proxy generation at runtime | CGLIB proxies pre-generated at build time | Spring AOT processes these |
| No lazy initialization | All beans must be resolvable at build time | Spring AOT hints track them |
| Slower build time | Everything compiled upfront | GraalVM build plugins |
Build:
./mvnw -Pnative spring-boot:build-image # Cloud-native buildpacks + GraalVM
When to use: Serverless functions, CLI tools, or anything requiring fast startup + low memory. Not ideal for large monoliths with heavy ORM usage.
Q43: How does @Scheduled behave in a multi-instance deployment, and how do you fix it?โ
By default, @Scheduled runs on every JVM instance. In a 5-node cluster running a @Scheduled job at midnight, all 5 nodes execute it simultaneously โ causing data duplication, double-sends, or conflicts.
Fix with ShedLock:
@Scheduled(cron = "0 0 0 * * *")
@SchedulerLock(name = "midnightJob", lockAtLeastFor = "5m", lockAtMostFor = "30m")
public void midnightJob() { ... } // Only 1 node acquires the DB lock
Alternative: Use a dedicated scheduler (Quartz clustered, AWS EventBridge, Kubernetes CronJob) for critical single-execution jobs.
Q44: Explain how Spring Boot's auto-configuration ordering works. How do you control it?โ
Auto-configuration classes are all loaded from AutoConfiguration.imports. Their ordering is controlled by:
@AutoConfigureOrder(n)โ explicit numeric priority (lower = earlier)@AutoConfigureAfter(X.class)โ load after specific auto-config@AutoConfigureBefore(X.class)โ load before specific auto-config@ConditionalOnMissingBeanโ user-defined beans take precedence; auto-config backs off
Practical example: Writing a custom starter that should run after DataSourceAutoConfiguration but before JpaAutoConfiguration:
@AutoConfiguration
@AutoConfigureAfter(DataSourceAutoConfiguration.class)
@AutoConfigureBefore(JpaAutoConfiguration.class)
public class MyCustomDatabaseConfig {
@Bean
@ConditionalOnMissingBean
public CustomDatabaseInterceptor interceptor(DataSource ds) { ... }
}
Q45: How do you propagate SecurityContext to @Async threads? What pitfalls exist?โ
By default, SecurityContextHolder is ThreadLocal โ Spring Security context lost on @Async execution. Three solutions:
DelegatingSecurityContextAsyncTaskExecutor(recommended) โ wraps the thread pool, copies context per taskMODE_INHERITABLETHREADLOCALโ uses Java'sInheritableThreadLocal, but reused pooled threads carry stale context- Explicit pass-through โ pass
Authenticationas a method parameter
The cleanest production pattern:
@Override
public Executor getAsyncExecutor() {
return new DelegatingSecurityContextAsyncTaskExecutor(
new ThreadPoolTaskExecutor() {{ setCorePoolSize(10); initialize(); }}
);
}
Q46: Design a custom HealthIndicator for a business-critical dependency. What should it check?โ
@Component
public class PaymentGatewayHealthIndicator implements HealthIndicator {
private final PaymentGatewayClient client;
@Override
public Health health() {
try {
// โ
Check actual connectivity (ping endpoint, not just config)
GatewayStatus status = client.ping();
if (status.isUp()) {
return Health.up()
.withDetail("gateway", status.getVersion())
.withDetail("latencyMs", status.getLatencyMs())
.build();
}
return Health.down()
.withDetail("reason", status.getErrorMessage())
.build();
} catch (Exception e) {
return Health.down(e)
.withDetail("gateway", "unreachable")
.build();
}
}
}
What a strong health check verifies:
- Network reachability (not just config existence)
- Authentication success (not just connectivity)
- Response < timeout threshold
- Exposed via
/actuator/health/paymentGateway
Configuration:
management:
endpoint:
health:
show-details: when-authorized
group:
readiness:
include: db, paymentGateway, redis
Q47: In what order do Spring AOP aspects execute when a service method has @PreAuthorize, @Transactional, @Cacheable, and a custom logging aspect?โ
Order is controlled by @Order on each aspect (lower = outermost proxy). Spring's built-in aspect orders:
@PreAuthorize / Method Security โ [custom aspects] โ @Cacheable โ @Transactional โ @Validated
(outermost) (innermost)
In practice, the default execution order for a service method call is:
External call
โ [1] Spring Security MethodSecurityInterceptor (@PreAuthorize) โ throws 403 before any work
โ [2] Custom LoggingAspect @Order(2) โ MDC setup, start timer
โ [3] @Cacheable aspect โ return cached value or continue
โ [4] @Transactional aspect (Integer.MAX_VALUE - 1) โ open transaction
โ target method executes
โ commit or rollback
โ write to cache on success
โ stop timer, clear MDC
โ security check done
Critical implication: If the logging aspect is outside the transaction aspect, a log entry written in @AfterReturning will appear even if the transaction rolled back โ because the log happened outside the transaction boundary. Always use @Order explicitly on custom aspects.
Q48: When would you choose a custom @Aspect over Spring Security's @PreAuthorize for access control?โ
| Scenario | Use @PreAuthorize | Use Custom @Aspect |
|---|---|---|
| Role/permission check | โ Standard, concise | Overkill |
| Row-level access (check owner) | โ
SpEL #entity.ownerId == principal.id | If complex logic needed |
| Multi-system access check (external API, DB, cache) | โ SpEL can't call remote | โ Full Java logic |
| Audit trail required | โ No built-in persistence | โ Can persist to DB in aspect |
| Complex conditional logic | Cumbersome in SpEL | โ Readable Java code |
Custom audit security aspect pattern:
@Around("@annotation(sensitiveOp)")
public Object auditSensitiveOperation(ProceedingJoinPoint pjp, SensitiveOperation sensitiveOp) throws Throwable {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
// Log who, what, when โ BEFORE calling the method
auditRepo.save(AuditEntry.builder()
.user(auth.getName())
.action(sensitiveOp.action())
.resource(pjp.getArgs()[0].toString())
.build());
return pjp.proceed();
}
Note: If you save the audit entry inside the same transaction as the business operation and the transaction rolls back, the audit log is ALSO lost. Fix: use @Transactional(propagation = REQUIRES_NEW) inside the aspect to save audit in a separate transaction.
Q49: MDC context is lost in @Async methods. How do you fix it?โ
MDC uses ThreadLocal storage. When @Async spawns a new thread from the executor pool, the new thread has NO MDC context โ the trace ID, request ID, and user info are all lost.
Fix 1: TaskDecorator on the async executor:
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Override
public Executor getAsyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(50);
// TaskDecorator: wrap each task with MDC propagation
executor.setTaskDecorator(runnable -> {
Map<String, String> contextMap = MDC.getCopyOfContextMap();
return () -> {
try {
if (contextMap != null) MDC.setContextMap(contextMap);
runnable.run();
} finally {
MDC.clear(); // Always clean up
}
};
});
executor.initialize();
return executor;
}
}
Fix 2: SecurityContext propagation for @Async + Spring Security:
// Spring Security's SecurityContext is also ThreadLocal
// Fix: configure DelegatingSecurityContextExecutor
@Bean
public Executor asyncExecutor() {
Executor delegate = new ThreadPoolTaskExecutor();
// Wraps each task to copy SecurityContext as well as MDC
return new DelegatingSecurityContextExecutor(delegate,
SecurityContextHolder.getContext());
}
Without this fix, Authentication is null inside @Async methods โ causing NullPointerException when calling SecurityContextHolder.getContext().getAuthentication().
Q50: @CacheEvict runs but the cache still returns stale data after a failed transaction. Why?โ
Root cause: @CacheEvict defaults to beforeInvocation = false โ meaning it runs after the method returns. If the method is also @Transactional, the cache is evicted, but the transaction later rolls back โ the database still has the old data โ the cache is empty โ next request refills cache from DB with rolled-back (old) value. Now cache is consistent again.
But the real problem occurs when:
@CacheEvict(value = "products", key = "#product.id")
@Transactional
public void updateProduct(Product product) {
productRepository.save(product);
// If an exception here causes rollback...
// cache was already evicted (the eviction ran after save but before rollback committed)
// Next cache fetch gets OLD data from DB โ cache filled with stale data
}
Fix options:
// Option 1: beforeInvocation = true โ evict before any logic runs
// Safer: cache is evicted regardless of transaction outcome
@CacheEvict(value = "products", key = "#product.id", beforeInvocation = true)
@Transactional
public void updateProduct(Product product) { ... }
// Option 2: Use a TransactionSynchronizationAdapter to evict AFTER commit
@Transactional
public void updateProduct(Product product) {
productRepository.save(product);
Long id = product.getId();
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronizationAdapter() {
@Override
public void afterCommit() {
cacheManager.getCache("products").evict(id); // Only evicts on TX commit
}
});
}
Option 2 is the correct production approach โ cache only evicted when the database transaction successfully commits.
Q51: Design a custom @Auditable annotation and AOP aspect for production use. What edge cases must you handle?โ
Production-worthy implementation:
// Annotation
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Auditable {
String action(); // e.g., "USER_LOGIN", "ORDER_CANCEL"
boolean captureArgs() default false; // careful with PII
boolean captureResult() default false;
}
// Aspect
@Aspect
@Component
@Order(2) // After security, before transaction (so audit is in same TX as business op)
public class AuditableAspect {
private final AuditService auditService;
@Around("@annotation(auditable)")
public Object audit(ProceedingJoinPoint pjp, Auditable auditable) throws Throwable {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = (auth != null) ? auth.getName() : "anonymous";
AuditRecord record = AuditRecord.builder()
.action(auditable.action())
.username(username)
.traceId(MDC.get("traceId"))
.method(pjp.getSignature().toShortString())
.args(auditable.captureArgs() ? sanitize(pjp.getArgs()) : null)
.timestamp(Instant.now())
.build();
try {
Object result = pjp.proceed();
record.setOutcome("SUCCESS");
if (auditable.captureResult()) record.setResult(sanitize(result));
return result;
} catch (AccessDeniedException e) {
record.setOutcome("ACCESS_DENIED");
throw e;
} catch (Exception e) {
record.setOutcome("FAILURE: " + e.getClass().getSimpleName());
throw e;
} finally {
// Save ALWAYS โ success or failure โ in a separate transaction to avoid rollback
auditService.saveAsync(record);
}
}
}
Edge cases to handle in production:
| Edge case | Problem | Solution |
|---|---|---|
| Transaction rollback | Audit entry lost | Save audit in separate REQUIRES_NEW transaction or async |
| PII in method args | GDPR violation | captureArgs = false by default; sanitize() removes sensitive fields |
| High throughput | Audit DB write is on critical path | async save via @Async or Kafka |
| Null authentication | Pre-auth or system task | Null check, default to "system" user |
| Exception from audit | Breaks business operation | Wrap audit save in try-catch; don't let audit fail business logic |
Q31: How do you enable Virtual Threads in Spring Boot 3.2+ and what is the "Carrier Thread Pinning" trap?โ
How to Enable: In Spring Boot 3.2+ with Java 21, simply add this configuration property:
spring:
threads:
virtual:
enabled: true
This automatically configures Tomcat, Jetty, and default task executors (like the @Async executor) to run tasks on Virtual Threads instead of traditional Platform Threads.
The "Carrier Thread Pinning" Trap:
- Virtual Threads run on top of actual OS-level Platform Threads (called Carrier Threads).
- When a virtual thread encounters a blocking operation (like I/O or sleep), it yields control, allowing the carrier thread to execute other virtual threads.
- However, if the virtual thread blocks inside a
synchronizedblock or method, or calls native code, it is "pinned" to the carrier thread. The carrier thread cannot be released, blocking all other virtual threads waiting on that platform thread. - How to prevent: Avoid using
synchronizedblocks inside hot paths or blocking operations. Replace them withjava.util.concurrent.locks.ReentrantLock, which allows virtual threads to yield control correctly when blocking.
Q32: What is the default rollback behavior of Spring's @Transactional for checked vs. unchecked exceptions, and how do you customize it?โ
Default Behavior:
- Spring's transactional system only rolls back transactions automatically for Unchecked Exceptions (subclasses of
RuntimeExceptionandErrorlikeNullPointerExceptionorIllegalArgumentException). - Transactions do not roll back automatically for Checked Exceptions (subclasses of
Exceptionthat require atry-catchorthrowsdeclaration, likeIOExceptionorSQLException). The transaction will still commit even if the checked exception propagates out of the method.
How to Customize:
To force Spring to roll back the transaction when a checked exception occurs, use the rollbackFor attribute:
@Transactional(rollbackFor = Exception.class)
public void processPayment() throws IOException {
// Both checked and unchecked exceptions will trigger a rollback
}
You can also prevent rollbacks for specific runtime exceptions using noRollbackFor.
Q33: Why does calling a method annotated with @Transactional or @Async from another method in the same class fail to create a transaction or run asynchronously?โ
The Cause: Spring applies behavior like transactions and asynchronous execution by wrapping your beans in Proxy Objects (usually CGLIB proxies).
- When an external client calls a method on your bean, it actually invokes it on the proxy wrapper. The proxy starts the transaction or offloads the task to a thread pool before delegating the call to your actual bean.
- When you invoke a method internally from another method within the same class (e.g.
this.doSomething()), the call bypasses the proxy entirely and executes directly on the target object. Consequently, any annotations on the called method are completely ignored.
The Solutions:
- Separate Beans (Recommended): Move the annotated method to a dedicated collaborator class.
- Self-Injection: Injected the service into itself (using
@Lazyto avoid circular dependency errors) and call the method on the injected self-reference. AopContext.currentProxy(): Call((MyService) AopContext.currentProxy()).myMethod(), which requires@EnableAspectJAutoProxy(exposeProxy = true).
Advanced Editorial Pass: Interview Readiness with Production Depthโ
How to Level Up Answersโ
- Move from definitions to operational consequences and failure recovery paths.
- Explain not only what a feature does, but what can break under scale.
- Use concrete stories: incident, diagnosis, mitigation, and long-term fix.
Signals of a Strong Answerโ
- Clear trade-offs (latency, consistency, complexity, cost).
- Explicit boundaries between framework behavior and team-owned logic.
- Measurable outcomes and observability strategy.
Practice Drillsโ
- Re-answer each question with one real failure mode and one mitigation.
- Add metrics you would monitor before and after the design choice.
- Time-box explanations to 90 seconds while preserving technical depth.