Top 40 Spring Boot Interview Questions & Answers
Interview Prep
These 40 questions cover the most frequently asked Spring Boot topics across all experience levels. Each answer is concise and interview-ready, with code snippets where they add clarity.
Core Concepts
Q1: What is Spring Boot and why use it?
Answer: Spring Boot is an opinionated framework built on top of Spring Framework that eliminates boilerplate configuration and lets you ship production-ready applications in minutes rather than days.
Why it exists: The Spring Framework is powerful but notoriously configuration-heavy. Setting up a web application required XML configs, servlet mappings, dependency version management, and external server deployment. Spring Boot solves this "configuration fatigue" by making sensible defaults the norm.
How it works internally:
- Auto-configuration scans the classpath and registers beans automatically (e.g., sees
HikariCPjar, configures aDataSource). - Embedded servers (Tomcat/Jetty/Undertow) are packaged inside the JAR — no WAR deployment.
- Starter dependencies bundle compatible library versions under a tested BOM.
- Actuator provides health checks, metrics, and env inspection out of the box.
When to use: Any new Spring-based project. The only reason NOT to use it is if you are maintaining a legacy Spring project that cannot upgrade.
Common follow-up: "Is Spring Boot a replacement for Spring?" No — it is a layer on top that makes Spring easier to configure. All Spring concepts (DI, AOP, transactions) still apply.
Key gotchas:
- Auto-configuration can feel like "magic" — learn to use
--debugor/actuator/conditionsto understand what is being configured. - The main class must sit at the root package, otherwise
@ComponentScanmisses your beans.
Q2: What is the difference between Spring and Spring Boot?
Answer: Spring is the foundational IoC/DI framework; Spring Boot is an opinionated wrapper that removes Spring's configuration overhead while keeping all its power.
Why the distinction matters in interviews: Interviewers want to know you understand that Spring Boot does not replace Spring — it automates what you would otherwise configure manually.
| Aspect | Spring Framework | Spring Boot |
|---|---|---|
| Configuration | Requires manual XML or Java config | Auto-configuration out of the box |
| Server | Needs external server (Tomcat, JBoss) | Embedded server included |
| Dependencies | Manually manage compatible versions | Starters provide curated dependency sets |
| Setup time | Significant boilerplate | Minimal — start coding immediately |
| Production features | Build your own | Actuator, DevTools built in |
| Opinionation | Flexible but verbose | Convention over configuration |
How to think about it: Spring gives you Lego bricks. Spring Boot gives you a pre-built house you can customize.
When you would still use raw Spring: Embedded library development, extremely constrained environments, or when you need fine-grained control over every bean without any auto-magic.
Key gotcha: Spring Boot auto-configures only when you have not defined your own bean. If something is not wiring correctly, check if auto-configuration backed off because it detected your custom bean.
Q3: How does auto-configuration work in Spring Boot?
Answer: Auto-configuration is the mechanism that automatically registers beans based on what jars are on your classpath and what beans you have already defined — essentially, Spring Boot "guesses" what you need and configures it for you.
Why it exists: Without it, you would need 50+ lines of configuration just to set up a DataSource, EntityManagerFactory, and TransactionManager. Auto-configuration does it in zero lines.
How it works step by step:
@EnableAutoConfiguration(bundled in@SpringBootApplication) triggers the process.- Spring Boot reads
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports(3.x) orMETA-INF/spring.factories(2.x) to discover ~150 configuration classes. - Each class uses
@Conditionalannotations to decide whether to activate:@ConditionalOnClass— "only if this class is on classpath"@ConditionalOnMissingBean— "only if user hasn't defined their own"@ConditionalOnProperty— "only if this property is set"
- User-defined beans always win — define your own
DataSourceand the auto-configured one backs off.
Debugging auto-configuration:
- Run with
--debugflag to see the conditions evaluation report - Visit
/actuator/conditionsat runtime - Use
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})to disable specific auto-configs
Key gotcha: Auto-configuration ordering matters. If your custom config runs too late, you might get the auto-configured bean instead. Use @AutoConfigureBefore / @AutoConfigureAfter in library code.
Q4: What does @SpringBootApplication combine?
Answer: @SpringBootApplication is a convenience meta-annotation that combines three annotations into one, reducing the boilerplate on your main class.
The three annotations:
@SpringBootConfiguration // Marks this as a configuration class (specialization of @Configuration)
@EnableAutoConfiguration // Enables Spring Boot auto-configuration
@ComponentScan // Scans the current package and sub-packages for @Component classes
Why this matters: These three annotations together establish the foundation of a Spring Boot app: (1) this class is a config source, (2) auto-configure everything you can, (3) find all my beans starting from this package downward.
When to customize:
- Exclude specific auto-configs:
@SpringBootApplication(exclude = {SecurityAutoConfiguration.class}) - Change scan base:
@SpringBootApplication(scanBasePackages = "com.myapp")
Common follow-up: "Why must the main class be in the root package?" Because @ComponentScan defaults to scanning from the annotated class's package downward. If your main class is in com.app but services are in com.services, they will not be found.
Key gotcha: Having multiple @SpringBootApplication classes causes confusion. Your app should have exactly one, placed at the root package.
Q5: What are Spring Boot starters?
Answer: Starters are curated, pre-tested sets of dependencies bundled under a single artifact that you include in your pom.xml or build.gradle — one dependency gives you everything needed for a capability.
Why they exist: Before starters, setting up Spring MVC required manually adding Spring Web, Jackson, Tomcat, validation, and ensuring all versions were compatible. One mismatch meant hours of debugging NoSuchMethodError.
How they work: Each starter pulls in a specific set of transitive dependencies managed by the Spring Boot BOM (Bill of Materials). No code — just dependency coordination.
Key starters:
spring-boot-starter-web— Spring MVC, embedded Tomcat, Jackson, validationspring-boot-starter-data-jpa— Spring Data JPA, Hibernate, HikariCPspring-boot-starter-security— Spring Security, authentication/authorizationspring-boot-starter-test— JUnit 5, Mockito, AssertJ, Spring Testspring-boot-starter-actuator— Health checks, metrics, monitoring
Naming convention: spring-boot-starter-* for official starters. Third-party starters use *-spring-boot-starter (e.g., mybatis-spring-boot-starter).
Common follow-up: "How do you create a custom starter?" Create a *-spring-boot-starter module with an auto-configuration class, a META-INF/spring/...AutoConfiguration.imports file, and optionally a *-spring-boot-starter-autoconfigure module.
Key gotcha: Including a starter you do not need can trigger unwanted auto-configuration. For example, including spring-boot-starter-data-jpa without a DataSource configured will fail at startup.
Q6: How does the embedded server work, and can you switch it?
Answer: Spring Boot packages an embedded servlet container inside your JAR, so your application runs as a self-contained Java process with java -jar app.jar — no external server installation or WAR deployment required.
Why this matters: It aligns with modern deployment (Docker containers, Kubernetes). One JAR = one deployable unit, version-controlled and reproducible.
How it works internally:
spring-boot-starter-webincludesspring-boot-starter-tomcatby default.- Auto-configuration creates a
TomcatServletWebServerFactorybean. - On startup, Spring Boot launches the embedded server, registers your
DispatcherServlet, and begins accepting requests.
Switching servers:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-undertow</artifactId>
</dependency>
When to switch: Undertow has lower memory footprint (good for reactive/microservices). Jetty offers better HTTP/2 and WebSocket support. Tomcat is the safest default.
Key gotcha: If you are building a reactive app with WebFlux, Netty is the default (not a servlet container at all). Do not mix servlet and reactive stacks in the same application.
Configuration & Profiles
Q7: application.properties vs application.yml — which should you use?
Answer: Both serve the same purpose — externalizing application configuration. The choice is largely team preference, but YAML has won the popularity contest in modern Spring Boot projects.
Why YAML is more popular: Spring Boot configs tend to be deeply nested (spring.datasource.hikari.maximum-pool-size). YAML's hierarchical format eliminates prefix repetition.
| Feature | .properties | .yml |
|---|---|---|
| Syntax | key=value flat pairs | Hierarchical indentation-based |
| Readability | Verbose for nested keys | Cleaner for deep nesting |
| Multi-doc | Not supported | Supports --- separator for multiple profiles |
| Lists | server.ports[0]=8080 | Natural list syntax with - |
| Gotcha | None significant | Indentation errors cause silent failures |
How Spring resolves them: Both are loaded by the same PropertySource mechanism. If both exist, .properties takes precedence for the same key.
When to use .properties: Simple apps, legacy teams familiar with the format, or when you want zero risk of indentation bugs.
Key gotcha: YAML is whitespace-sensitive. A single tab character (instead of spaces) will silently break parsing without a clear error message. Use an IDE with YAML validation.
Q8: How do Spring Boot profiles work?
Answer: Profiles are a mechanism to segregate configuration and beans by environment, so the same codebase behaves differently in dev, staging, and production without code changes.
Why they exist: You need different database URLs, logging levels, feature flags, and credentials per environment. Profiles make this first-class rather than relying on external scripts.
How they work:
- File-based:
application-{profile}.ymlfiles override the defaultapplication.yml. E.g.,application-prod.ymlsets production DataSource. - Activation:
spring.profiles.active=devvia properties, JVM arg (-Dspring.profiles.active=dev), env variable, or programmatically. - Bean-level:
@Profile("dev")on a bean class or method restricts it to that environment.
Profile groups (Spring Boot 2.4+):
Activating prod now activates all three sub-profiles.
When to use: Every project with more than one environment (i.e., every real project).
Common follow-up: "What is the profile precedence order?" Profile-specific files override default. If multiple profiles are active, the last one wins for conflicting keys.
Key gotcha: Secrets should NOT live in profile files committed to Git. Use environment variables, Vault, or Spring Cloud Config for credentials. Profiles are for structural differences (URLs, pool sizes), not secrets.
Q9: How does @ConfigurationProperties work?
Answer: @ConfigurationProperties binds a group of external configuration properties to a strongly-typed Java object, giving you type safety, validation, and IDE auto-completion instead of scattered @Value annotations.
Why it exists: Using @Value("${app.mail.host}") everywhere is fragile — typos are not caught until runtime, you get no auto-completion, and related properties are scattered across classes.
How it works:
@ConfigurationProperties(prefix = "app.mail")
@Validated
public class MailProperties {
@NotEmpty private String host;
private int port = 587;
private String username;
// getters and setters (or use constructor binding)
}
Enable with @EnableConfigurationProperties(MailProperties.class) or annotate with @Component.
Constructor binding (Spring Boot 2.2+): Makes config objects immutable — no setters needed:
@ConfigurationProperties(prefix = "app.mail")
public record MailProperties(String host, int port, String username) {}
When to use: Any time you have 3+ related config properties. It is the standard approach for library/starter configuration.
Common follow-up: "How is this different from @Value?" @Value is for single properties; @ConfigurationProperties is for groups. @ConfigurationProperties supports relaxed binding (app.mail-host maps to mailHost), validation, and metadata generation for IDE support.
Key gotcha: If validation fails, the application will not start — which is actually a good thing (fail fast). But make sure test profiles provide valid values.
Bean Lifecycle & Dependency Injection
Q10: Describe the Spring bean lifecycle.
Answer: The Spring bean lifecycle is the sequence of steps from instantiation to destruction that every Spring-managed bean goes through, with multiple extension points where you can hook in custom logic.
Why this matters: Understanding the lifecycle helps you debug initialization issues, implement custom startup/shutdown logic, and understand when proxies (AOP, transactions) get applied.
The lifecycle sequence:
- Instantiation — Bean created via constructor (or factory method).
- Populate properties — Dependencies injected (setter/field injection).
- Aware interfaces —
BeanNameAware,BeanFactoryAware,ApplicationContextAwarecalled. - BeanPostProcessor.postProcessBeforeInitialization() — Applied to ALL beans (e.g.,
@PostConstructhandling). - Initialization —
@PostConstruct/InitializingBean.afterPropertiesSet()/ custominit-method. - BeanPostProcessor.postProcessAfterInitialization() — Proxies created here (AOP,
@Transactional,@Async). - Bean is ready for use.
- Destruction —
@PreDestroy/DisposableBean.destroy()/ customdestroy-methodon context shutdown.
When to use lifecycle hooks:
@PostConstruct— Validate configuration, warm caches, register listeners@PreDestroy— Close connections, flush buffers, deregister from service discovery
Common follow-up: "When are proxies created?" In step 6 — which is why calling a @Transactional method from within @PostConstruct might not work as expected (the proxy may not be fully wired yet).
Key gotcha: Prototype-scoped beans do NOT get @PreDestroy called — Spring does not manage their full lifecycle after creation.
Q11: What are the bean scopes in Spring?
Answer: Bean scope determines how many instances of a bean Spring creates and how long they live. The default is singleton — one instance shared across the entire application context.
Why this matters: Choosing the wrong scope causes subtle bugs like shared mutable state in singletons or unexpected garbage collection in prototypes.
| Scope | Description | Lifecycle |
|---|---|---|
| singleton (default) | One instance per Spring IoC container | Application lifetime |
| prototype | New instance every time the bean is requested | Caller manages |
| request | One instance per HTTP request (web only) | Request lifetime |
| session | One instance per HTTP session (web only) | Session lifetime |
| application | One instance per ServletContext | App lifetime |
| websocket | One instance per WebSocket session | WebSocket lifetime |
When to use non-singleton: Prototype for stateful objects (builders, command objects). Request scope for request-scoped data (current user context). Session scope for shopping carts or wizard state.
The classic pitfall: Injecting a prototype bean into a singleton gives you a single instance of the prototype forever. The singleton holds one reference and never asks for a new one.
Solutions:
@Autowired ObjectProvider<PrototypeBean> provider;
PrototypeBean fresh = provider.getObject(); // new instance each call
// Or use @Lookup
@Lookup
public abstract PrototypeBean createPrototype();
Key gotcha: @Scope("prototype") beans do not participate in @PreDestroy callbacks — Spring hands them off and forgets about them.
Q12: What is the difference between @Component, @Service, @Repository, and @Controller?
Answer: All four are stereotype annotations detected by component scanning. They all register the class as a Spring bean. The difference is semantic clarity and, in one case, additional behavior.
Why multiple annotations exist: Code readability and layered architecture. When you see @Repository, you instantly know it is a data access class. It also enables targeted AOP pointcuts.
The hierarchy:
- @Component — Generic Spring-managed bean. Base annotation.
- @Service — Business/service layer. No extra behavior beyond semantics.
- @Repository — Persistence layer. Adds automatic exception translation — converts JDBC/JPA/Hibernate exceptions into Spring's
DataAccessExceptionhierarchy. This makes your service layer database-agnostic. - @Controller — Web layer. Enables
@RequestMappingand view resolution.@RestController=@Controller+@ResponseBodyon every method.
When to use what: Follow your architectural layers. If a class does not fit neatly into service/repo/controller, use @Component. For configuration, use @Configuration.
Common follow-up: "Can you use @Component everywhere instead?" Yes, it compiles and works. But you lose the @Repository exception translation, your AOP pointcuts become less precise, and code reviewers will flag it.
Key gotcha: @Repository exception translation only works if you have a PersistenceExceptionTranslationPostProcessor bean — which Spring Boot auto-configures. In plain Spring, you need to register it manually.
Q13: What are the types of dependency injection, and which is preferred?
Answer: Constructor injection is the preferred approach in modern Spring — it enforces immutability, makes dependencies explicit, and eliminates the possibility of partially constructed beans.
The three types:
// 1. Constructor injection (PREFERRED)
@Service
public class OrderService {
private final PaymentGateway gateway;
private final InventoryService inventory;
public OrderService(PaymentGateway gateway, InventoryService inventory) {
this.gateway = gateway;
this.inventory = inventory;
}
}
// 2. Setter injection — for optional dependencies
@Autowired(required = false)
public void setNotificationService(NotificationService svc) { this.svc = svc; }
// 3. Field injection — avoid in production code
@Autowired private PaymentGateway gateway; // untestable without reflection
Why constructor injection wins:
- Fields can be
final(immutable, thread-safe) - Dependencies are explicit in the constructor signature
- The bean cannot exist in an invalid state (all deps provided at creation)
- Easy to test — just pass mocks to the constructor, no reflection needed
@Autowiredis optional when there is a single constructor (Spring 4.3+)
When setter injection is acceptable: Truly optional dependencies, or breaking circular dependencies (as a workaround).
Key gotcha: Field injection makes your classes tightly coupled to the Spring container. You cannot instantiate them in a plain unit test without @InjectMocks or reflection.
Q14: How do @Qualifier and @Primary resolve ambiguity?
Answer: When multiple beans of the same type exist in the container, Spring throws NoUniqueBeanDefinitionException. @Primary and @Qualifier are the two mechanisms to resolve this ambiguity.
Why this happens: You have two DataSource beans (primary and reporting), or two implementations of a NotificationService interface (email and SMS).
How they work:
- @Primary — Marks one bean as the default choice. Used at the declaration site.
- @Qualifier("name") — Explicitly selects a specific bean at the injection point.
@Bean @Primary
public DataSource primaryDataSource() { ... }
@Bean @Qualifier("reporting")
public DataSource reportingDataSource() { ... }
// Uses primary (default)
@Autowired DataSource ds;
// Uses reporting (qualifier overrides primary)
@Autowired @Qualifier("reporting") DataSource reportingDs;
Precedence: @Qualifier > @Primary > bean name matching.
When to use which: Use @Primary when 90% of injection points want the same bean. Use @Qualifier when you need explicit selection at specific injection points.
Common follow-up: "What about custom qualifiers?" You can create your own qualifier annotation for type-safe selection without string-based names:
Key gotcha: If you rename a @Qualifier string and miss one injection point, you get a runtime error. Custom qualifier annotations prevent this.
Q15: What is a circular dependency and how do you resolve it?
Answer: A circular dependency occurs when bean A depends on bean B, and bean B depends on bean A (directly or transitively), creating an unresolvable creation loop.
Why this is a problem: With constructor injection, Spring cannot instantiate either bean — it needs B to create A, but needs A to create B. You get BeanCurrentlyInCreationException.
How to detect: Spring Boot 2.6+ disables circular references by default, so you will get a clear error at startup listing the cycle.
Solutions (best to worst):
- Redesign — Extract the shared logic into a third bean that both depend on. This is almost always the correct answer because a circular dependency signals a design flaw.
- Use
@Lazyon one constructor parameter — Spring injects a proxy that resolves lazily: - Use events — Replace direct calls with
ApplicationEventPublisher, decoupling the two beans. - Switch one dependency to setter injection — Allows partial construction.
When it is acceptable: Rarely. In legacy code migrations, @Lazy is a pragmatic short-term fix.
Key gotcha: spring.main.allow-circular-references=true re-enables the old behavior, but this is a band-aid. Every circular dependency is tech debt — the app becomes harder to reason about, test, and maintain.
Transactions & AOP
Q16: How does @Transactional work under the hood?
Answer: @Transactional uses AOP proxies to wrap your bean in transaction management logic — the proxy begins a transaction before your method executes, and commits or rolls back afterward based on the outcome.
Why it exists: Without it, you would manually call connection.setAutoCommit(false), connection.commit(), and handle rollback in every service method. @Transactional declaratively handles all this.
How it works step by step:
- Spring creates a proxy (CGLIB by default) around your bean at startup.
- When a
@Transactionalmethod is called externally, the proxy intercepts. - The proxy obtains a connection from the pool and begins a transaction.
- Your method executes.
- On success — proxy commits. On RuntimeException — proxy rolls back.
Key attributes:
- propagation —
REQUIRED(join or create),REQUIRES_NEW(always new, suspend current),NESTED(savepoint),MANDATORY,SUPPORTS,NOT_SUPPORTED,NEVER - isolation —
READ_COMMITTED(most common),REPEATABLE_READ,SERIALIZABLE - rollbackFor — By default, only unchecked exceptions trigger rollback. Use
rollbackFor = Exception.classto include checked exceptions. - readOnly — Hints to Hibernate to skip dirty checking and to the DB to use read replicas.
- timeout — Seconds before transaction timeout.
The #1 interview pitfall: Calling a @Transactional method from within the same class bypasses the proxy — the transaction is NOT applied. This is called "self-invocation."
public class OrderService {
public void process() {
this.save(); // NO TRANSACTION — direct call, not through proxy
}
@Transactional
public void save() { ... }
}
Fix: Inject the bean into itself (self-injection), use TransactionTemplate programmatically, or refactor into a separate bean.
Q17: Explain Spring AOP concepts: aspect, advice, pointcut, join point.
Answer: Spring AOP (Aspect-Oriented Programming) lets you extract cross-cutting concerns (logging, security, caching, transactions) into reusable modules called aspects, rather than scattering them across your business code.
Why it exists: Without AOP, you would have logging/security/metrics code duplicated in every service method. AOP separates "what to do" (advice) from "where to do it" (pointcut).
The four core concepts:
- Aspect — A class that encapsulates a cross-cutting concern. Annotated with
@Aspect. - Join Point — A point during execution where an aspect can plug in. In Spring AOP, this is always a method execution (not field access or constructor — that requires full AspectJ).
- Pointcut — An expression that matches join points:
@Pointcut("execution(* com.app.service.*.*(..))"). - Advice — The code that runs at a join point:
@Before— runs before the method@After— runs after (regardless of outcome)@AfterReturning— runs after successful return (access return value)@AfterThrowing— runs after an exception (access exception)@Around— wraps the method entirely (most powerful, controls whether method even executes)
@Aspect @Component
public class PerformanceAspect {
@Around("@annotation(Timed)")
public Object measureTime(ProceedingJoinPoint pjp) throws Throwable {
long start = System.nanoTime();
Object result = pjp.proceed();
log.info("{} took {}ms", pjp.getSignature(), (System.nanoTime() - start) / 1_000_000);
return result;
}
}
How Spring AOP differs from AspectJ: Spring uses runtime proxies (not bytecode weaving). This means it only works on Spring-managed beans, only on public methods, and only on external calls (not self-invocation).
Key gotcha: @Around advice MUST call pjp.proceed() or the target method never executes. Forgetting this silently swallows the method call.
Security
Q18: Describe the Spring Security authentication flow.
Answer: Spring Security is a filter-based framework that intercepts every HTTP request before it reaches your controller, authenticating the user and authorizing access through a chain of servlet filters.
Why filter-based: Filters run before Spring MVC dispatching, so unauthenticated requests never reach your business code. This is the security-in-depth principle.
The authentication flow (form/HTTP Basic):
- Request hits the Security Filter Chain (a chain of
jakarta.servlet.Filterimplementations). UsernamePasswordAuthenticationFilterextracts credentials and creates an unauthenticatedAuthenticationtoken.- Token is passed to AuthenticationManager (typically
ProviderManager). - ProviderManager delegates to one or more AuthenticationProviders.
- The provider uses UserDetailsService to load the user and PasswordEncoder to verify the password.
- On success: a fully populated
Authentication(with authorities) is stored in SecurityContextHolder (ThreadLocal by default). - Subsequent requests:
SecurityContextPersistenceFilterrestores the context from the session (stateful) or re-authenticates (stateless/JWT).
When to customize which part:
- Custom user store? Implement
UserDetailsService - Custom auth logic (LDAP, OAuth)? Implement
AuthenticationProvider - Custom filter (JWT)? Add a
OncePerRequestFilter
Common follow-up: "What is SecurityContextHolder?" It is a ThreadLocal holder for the Authentication object. After authentication, any code in the request can call SecurityContextHolder.getContext().getAuthentication() to get the current user.
Key gotcha: In async/reactive scenarios, ThreadLocal does not propagate. You need SecurityContextHolder.setStrategyName(MODE_INHERITABLETHREADLOCAL) or use DelegatingSecurityContextExecutor.
Q19: How do you implement JWT authentication with Spring Security?
Answer: JWT (JSON Web Token) authentication replaces server-side sessions with a stateless token — the server validates the token signature on each request instead of looking up a session store.
Why JWT: Stateless authentication scales horizontally (no sticky sessions, no session replication). Perfect for microservices and SPAs.
The implementation pattern:
- Disable sessions —
sessionManagement(sm -> sm.sessionCreationPolicy(STATELESS)) - Login endpoint (
/auth/login) — Verify credentials withAuthenticationManager, then generate a signed JWT containing subject, roles, and expiry. - JWT filter — A
OncePerRequestFilterplaced beforeUsernamePasswordAuthenticationFilter:- Extract
Authorization: Bearer <token>header - Validate signature and expiry
- Parse claims into a
UsernamePasswordAuthenticationToken - Set it in
SecurityContextHolder
- Extract
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http.csrf(csrf -> csrf.disable())
.sessionManagement(sm -> sm.sessionCreationPolicy(STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**").permitAll()
.anyRequest().authenticated())
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
Best practices:
- Short-lived access tokens (15 min) + refresh tokens (7 days)
- Never put sensitive data in JWT payload (it is Base64, not encrypted)
- Use asymmetric keys (RS256) in microservices so services verify without sharing the secret
- Store refresh tokens server-side (DB/Redis) for revocation capability
Key gotcha: JWTs cannot be revoked once issued (they are self-contained). If a user's permissions change, the old token remains valid until it expires. Solutions: short TTL, token blacklist, or version-based invalidation.
Exception Handling & Validation
Q20: How does global exception handling work in Spring Boot?
Answer: @RestControllerAdvice provides a centralized place to handle exceptions across all controllers, eliminating the need for try-catch blocks in every controller method and ensuring consistent error responses.
Why it exists: Without centralized handling, every controller duplicates error-response logic, formats vary between endpoints, and stack traces leak to clients.
How it works:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleNotFound(ResourceNotFoundException ex) {
return new ErrorResponse("NOT_FOUND", ex.getMessage(), Instant.now());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> errors = ex.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage));
return new ErrorResponse("VALIDATION_FAILED", errors.toString(), Instant.now());
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ErrorResponse handleAll(Exception ex) {
log.error("Unhandled exception", ex);
return new ErrorResponse("INTERNAL_ERROR", "Something went wrong", Instant.now());
}
}
Mechanism: Spring MVC's ExceptionHandlerExceptionResolver catches exceptions thrown from controllers, matches them to @ExceptionHandler methods by exception type (most specific wins), and invokes the handler.
When to use: Every production Spring Boot application should have one. Scope it with @RestControllerAdvice(basePackages = "com.app.api") if needed.
Common follow-up: "What about Spring Boot 3's Problem Details (RFC 7807)?" Spring Boot 3 adds native support — set spring.mvc.problemdetails.enabled=true and extend ResponseEntityExceptionHandler for automatic RFC 7807 responses.
Key gotcha: The catch-all Exception.class handler should always log the full stack trace server-side but return a generic message to the client. Never leak internal details in production error responses.
Q21: How do you validate request bodies in Spring Boot?
Answer: Spring Boot integrates with Bean Validation (JSR 380 / Hibernate Validator) to declaratively validate incoming request data using annotations — invalid requests are rejected before reaching your business logic.
Why it exists: Validation is a cross-cutting concern. Without declarative validation, you write manual if-else checks in every controller method, which is error-prone and verbose.
How it works:
// 1. Define constraints on the DTO
public class CreateUserRequest {
@NotBlank(message = "Name is required")
private String name;
@Email(message = "Invalid email format")
private String email;
@Min(value = 18, message = "Must be at least 18")
@Max(150)
private int age;
@Pattern(regexp = "^\\+?[1-9]\\d{9,14}$", message = "Invalid phone")
private String phone;
}
// 2. Trigger validation in controller
@PostMapping("/users")
public ResponseEntity<?> create(@Valid @RequestBody CreateUserRequest req) { ... }
@Valid vs @Validated:
- @Valid (JSR 380) — Triggers validation, throws
MethodArgumentNotValidException. - @Validated (Spring) — Supports validation groups for different rules on create vs update.
public interface OnCreate {}
public interface OnUpdate {}
@NotNull(groups = OnUpdate.class)
private Long id;
@PostMapping public void create(@Validated(OnCreate.class) @RequestBody Req req) { ... }
@PutMapping public void update(@Validated(OnUpdate.class) @RequestBody Req req) { ... }
Custom validators: Implement ConstraintValidator<AnnotationType, FieldType> for cross-field or business rule validation (e.g., @UniqueEmail).
Key gotcha: @Valid on @RequestParam or @PathVariable does not work without @Validated on the controller class. Also, nested objects need @Valid on the field to trigger cascading validation.
Spring Data JPA
Q22: What are Spring Data JPA repositories?
Answer: Spring Data JPA repositories are interfaces that provide full CRUD and query functionality without writing any implementation code — Spring generates the implementation at runtime using JDK dynamic proxies.
Why they exist: Traditional DAO classes are 80% boilerplate. Every entity needs save(), findById(), findAll(), delete() — all doing the same thing with different types. Spring Data eliminates this repetition.
How it works:
- You define an interface extending
JpaRepository<Entity, IdType>. - At startup, Spring scans for repository interfaces and generates proxy implementations.
- Method names are parsed into JPQL queries automatically (derived queries).
public interface UserRepository extends JpaRepository<User, Long> {
List<User> findByLastNameOrderByFirstNameAsc(String lastName);
Optional<User> findByEmail(String email);
boolean existsByUsername(String username);
long countByStatus(Status status);
List<User> findByAgeBetween(int min, int max);
}
Repository hierarchy:
Repository— Marker interfaceCrudRepository— Basic CRUD (save,findById,delete,count)PagingAndSortingRepository— AddsfindAll(Pageable)andfindAll(Sort)JpaRepository— Addsflush,saveAndFlush,deleteInBatch,getOne
When to use which: Start with JpaRepository for most cases. Use CrudRepository when you want to restrict the available operations.
Key gotcha: Derived query method names can get unwieldy (findByDepartmentNameAndStatusAndSalaryGreaterThanOrderByHireDateDesc). Switch to @Query when the method name exceeds readability limits.
Q23: @Query — JPQL vs native queries?
Answer: @Query lets you define custom queries on repository methods — either in JPQL (database-agnostic, operates on entities) or native SQL (database-specific, operates on tables).
Why you need both: Derived query methods handle simple cases, but complex joins, subqueries, window functions, or database-specific syntax require explicit queries.
How to use:
// JPQL — database-agnostic, uses entity/field names
@Query("SELECT u FROM User u WHERE u.email = :email")
Optional<User> findByEmailJpql(@Param("email") String email);
// Native SQL — database-specific, uses table/column names
@Query(value = "SELECT * FROM users WHERE email = :email", nativeQuery = true)
Optional<User> findByEmailNative(@Param("email") String email);
// Modification queries (requires @Modifying)
@Modifying(clearAutomatically = true)
@Query("UPDATE User u SET u.active = false WHERE u.lastLogin < :date")
int deactivateInactiveUsers(@Param("date") LocalDate date);
When to use JPQL: Default choice. Portable, validates at startup, supports entity graph navigation.
When to use native: Window functions (ROW_NUMBER()), JSON operators (->>), full-text search (@@), CTEs, database-specific hints.
Common follow-up: "What does @Modifying do?" It tells Spring Data this is not a SELECT — it is an INSERT/UPDATE/DELETE. Without it, Spring assumes a select query and fails. Add clearAutomatically = true to evict stale entities from the persistence context after the update.
Key gotcha: Native queries bypass Hibernate's entity tracking. If you update via native SQL, the persistence context still has stale cached entities. Use clearAutomatically = true or manually call entityManager.clear().
Q24: How do pagination and sorting work?
Answer: Spring Data JPA provides pagination as a first-class feature — pass a Pageable parameter to any repository method and get back a Page<T> with content, total count, and navigation metadata.
Why it matters: Without pagination, querying a table with millions of rows returns everything into memory. Pagination keeps responses bounded and APIs responsive.
How it works:
// Repository method
Page<User> findByStatus(Status status, Pageable pageable);
// Service usage
Pageable pageable = PageRequest.of(0, 20, Sort.by("createdAt").descending());
Page<User> page = userRepository.findByStatus(Status.ACTIVE, pageable);
page.getContent(); // List<User> for this page
page.getTotalElements(); // Total matching records
page.getTotalPages(); // Total pages
page.hasNext(); // Navigation
REST controller integration: Spring resolves Pageable directly from query params:
When to use Slice vs Page: Page executes a COUNT query (expensive on large tables). Slice only knows if there is a "next" page — more efficient for infinite scroll UIs.
Common follow-up: "What about keyset pagination?" For large datasets, offset-based pagination degrades (the DB still scans skipped rows). Keyset pagination uses a WHERE clause on the last seen ID — O(1) regardless of offset.
Key gotcha: The COUNT query for Page can be a performance killer. Use @Query(countQuery = "...") to optimize it, or switch to Slice<T> when you do not need total count.
Q25: What is the N+1 problem and how do you solve it?
Answer: The N+1 problem is when loading N parent entities causes N additional queries to lazily fetch their associated children — turning 1 query into N+1, devastating performance at scale.
Why it happens: JPA defaults @OneToMany to LAZY loading. When you iterate over parents and access their children, each access triggers a separate SELECT. Loading 100 orders with lazy items fires 1 + 100 = 101 queries.
How to detect: Enable SQL logging (spring.jpa.show-sql=true or logging.level.org.hibernate.SQL=DEBUG) and watch for repeated SELECT patterns. Tools like Hibernate Statistics or P6Spy also help.
Solutions (from most to least common):
-
JOIN FETCH (JPQL):
-
@EntityGraph (declarative):
-
@BatchSize (Hibernate-specific):
-
Subselect fetch mode:
When to use which: JOIN FETCH for known use cases. @EntityGraph for flexibility across multiple repository methods. @BatchSize as a global safety net.
Key gotcha: JOIN FETCH with pagination (Pageable) causes Hibernate to fetch ALL results in memory and paginate in Java (with a warning: "firstResult/maxResults specified with collection fetch"). Solution: use a two-query approach — first query IDs with pagination, then fetch entities with JOIN FETCH by IDs.
Q26: Lazy vs Eager loading — when to use which?
Answer: Lazy loading defers association fetching until first access; eager loading fetches immediately with the parent. The universal best practice is to default everything to LAZY and selectively fetch eagerly where needed.
Why LAZY is the default recommendation:
- You rarely need all associations for every use case
- Eager loading creates a "snowball effect" — loading one entity pulls in its entire object graph
- You cannot un-eager something, but you can always eagerly fetch a lazy association via JOIN FETCH
Defaults:
@OneToMany,@ManyToMany— LAZY (collection, could be large)@ManyToOne,@OneToOne— EAGER (single row, cheap... in theory)
Best practice: Override the @ManyToOne and @OneToOne defaults to LAZY:
Then eagerly fetch only when needed:
@EntityGraph(attributePaths = {"department", "manager"})
Optional<Employee> findWithDetailsById(Long id);
The LazyInitializationException: Occurs when accessing a lazy association outside a Hibernate session (after the transaction/session is closed).
Fixes:
- Ensure
@Transactionalspans the entire operation that needs the data - Use DTOs — project only needed fields in the query
- Use
@EntityGraphor JOIN FETCH to pre-load what you need - Avoid
Open Session in View(anti-pattern that keeps sessions open in the controller layer)
Key gotcha: @OneToOne with LAZY only works correctly when the association is non-optional and on the side that owns the FK. Otherwise Hibernate must query to determine if the value is null, effectively making it eager.
Caching
Q27: How does Spring Boot caching work?
Answer: Spring Boot's caching abstraction lets you cache method return values with annotations — the first call executes the method and stores the result; subsequent calls with the same key return the cached value without executing the method.
Why it exists: Caching is a cross-cutting concern. Without abstraction, you would write Redis/Caffeine-specific code in every service method. Spring's abstraction lets you switch providers without changing business code.
How it works:
- Add
@EnableCachingto a configuration class. - Annotate methods with cache annotations.
- Spring creates a proxy that checks the cache before invoking the method.
@Cacheable(value = "users", key = "#id") // Cache on first call
public User findById(Long id) { ... }
@CachePut(value = "users", key = "#user.id") // Always execute, update cache
public User update(User user) { ... }
@CacheEvict(value = "users", key = "#id") // Remove from cache
public void delete(Long id) { ... }
@CacheEvict(value = "users", allEntries = true) // Clear entire cache
public void evictAll() { ... }
Cache provider auto-detection (priority order): Caffeine > Redis > EhCache > Hazelcast > ConcurrentHashMap (fallback).
When to use: Read-heavy data that changes infrequently (user profiles, product catalogs, configuration lookups). Not for rapidly changing data.
Common follow-up: "How do you handle cache invalidation in distributed systems?" Use Redis or Hazelcast as the cache provider — all instances share the same cache. For event-driven invalidation, publish cache eviction events via messaging.
Key gotchas:
- Same-class method calls bypass the cache proxy (same as
@Transactional). @Cacheablewithnullreturn values — by default, null is cached. Useunless = "#result == null"to prevent this.- Cache key collisions — ensure your key strategy is unique across method signatures.
Actuator & Monitoring
Q28: What does Spring Boot Actuator provide?
Answer: Actuator exposes production-ready operational endpoints for health checking, metrics collection, environment inspection, and runtime management — everything ops teams need to monitor and manage a running application.
Why it exists: Production applications need observability. Without Actuator, you build custom health endpoints, metrics exporters, and log-level changers from scratch for every service.
Key endpoints:
| Endpoint | Purpose |
|---|---|
/actuator/health | Application health (DB, disk, custom checks) |
/actuator/info | Build info, git commit, custom metadata |
/actuator/metrics | Micrometer metrics (JVM, HTTP, custom) |
/actuator/env | All configuration properties (sanitized) |
/actuator/beans | All registered beans and their dependencies |
/actuator/loggers | View and change log levels at runtime |
/actuator/threaddump | JVM thread dump |
/actuator/prometheus | Prometheus-format metrics export |
/actuator/heapdump | JVM heap dump (downloadable) |
Configuration:
management:
endpoints:
web:
exposure:
include: health, info, metrics, prometheus
endpoint:
health:
show-details: when_authorized
How it integrates with monitoring: Actuator + Micrometer + Prometheus + Grafana is the standard observability stack. Actuator exposes metrics, Prometheus scrapes them, Grafana visualizes.
When to use: Every production Spring Boot application. No exceptions.
Key gotcha: By default only health and info are exposed over HTTP. Sensitive endpoints like env, heapdump, and shutdown must be secured or restricted to internal networks. Never expose all endpoints without authentication in production.
Q29: How do you create a custom health indicator?
Answer: A custom health indicator lets you add application-specific health checks (external service availability, queue depth, license expiry) to the /actuator/health endpoint.
Why you need custom indicators: The built-in checks cover DB, disk, and Redis. But your app likely depends on Elasticsearch, a payment gateway, a third-party API, or an internal microservice — those need custom checks.
How to implement:
@Component
public class SearchEngineHealthIndicator implements HealthIndicator {
private final RestClient searchClient;
@Override
public Health health() {
try {
SearchClusterInfo info = searchClient.clusterHealth();
if ("green".equals(info.status())) {
return Health.up()
.withDetail("cluster", info.name())
.withDetail("nodes", info.nodeCount())
.build();
}
return Health.degraded()
.withDetail("status", info.status())
.build();
} catch (Exception e) {
return Health.down()
.withDetail("error", e.getMessage())
.build();
}
}
}
How it works: Spring auto-detects any bean implementing HealthIndicator and includes it in the composite health check at /actuator/health. The bean name minus "HealthIndicator" suffix becomes the component name (e.g., searchEngine).
When to create one: For every critical external dependency your service cannot function without. Use health groups to separate liveness from readiness checks:
management:
endpoint:
health:
group:
readiness:
include: db, searchEngine
liveness:
include: ping
Key gotcha: Health checks should be fast (< 1 second) and lightweight. Do not perform heavy queries or full integration tests. Use timeouts and circuit breakers within health checks to prevent cascading failures.
Async & Scheduling
Q30: How does @Async work in Spring Boot?
Answer: @Async makes a method execute in a separate thread from a thread pool, returning immediately to the caller — turning synchronous calls into asynchronous ones without managing threads manually.
Why it exists: Some operations (email sending, report generation, notification dispatch) should not block the HTTP request thread. @Async offloads them without you managing ExecutorService directly.
How it works:
@EnableAsyncon a config class activates async support.- Spring creates a proxy around
@Asyncmethods. - When called, the proxy submits the method to a
TaskExecutorinstead of executing synchronously.
@Async("reportExecutor")
public CompletableFuture<Report> generateReport(Long userId) {
Report report = heavyComputation(userId); // runs in pool thread
return CompletableFuture.completedFuture(report);
}
Configuring the thread pool (critical):
@Bean("reportExecutor")
public TaskExecutor reportExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("report-");
executor.setRejectedExecutionHandler(new CallerRunsPolicy());
return executor;
}
Return types: void (fire-and-forget) or CompletableFuture<T> / Future<T> (caller can check result later).
Key gotchas:
- Must be called from a different bean — same-class calls bypass the proxy (identical to
@Transactional). - The default
SimpleAsyncTaskExecutorcreates a new thread per task with no pooling — always define a customThreadPoolTaskExecutor. - Exceptions in
voidasync methods are silently swallowed unless you configure anAsyncUncaughtExceptionHandler. SecurityContextdoes not propagate to async threads by default — useDelegatingSecurityContextAsyncTaskExecutor.
Q31: How does scheduling work with @Scheduled?
Answer: @Scheduled lets you define methods that execute periodically or on a cron schedule — perfect for background maintenance tasks, polling, and batch jobs without requiring a full-blown scheduler like Quartz.
Why it exists: Nearly every application has recurring tasks: cache cleanup, report generation, health checks, dead-letter reprocessing. @Scheduled handles these with zero infrastructure.
How it works:
@EnableScheduling // on a config class
@Scheduled(fixedRate = 5000) // Every 5 seconds regardless of execution time
public void pollForUpdates() { ... }
@Scheduled(fixedDelay = 10000) // 10 seconds after PREVIOUS execution finishes
public void processQueue() { ... }
@Scheduled(cron = "0 0 2 * * MON-FRI") // 2 AM on weekdays
public void nightlyCleanup() { ... }
@Scheduled(fixedRateString = "${app.poll.interval}") // Externalized
public void configurablePoll() { ... }
Cron format: second minute hour day-of-month month day-of-week
Critical configuration: By default, ALL @Scheduled methods share a single thread. If one task blocks, all others are delayed.
When to use vs Quartz: @Scheduled is perfect for single-instance tasks. For distributed scheduling (cluster-aware, persistent, retry), use Quartz or a distributed scheduler.
Common follow-up: "How do you prevent duplicate execution in multiple instances?" Use ShedLock — it acquires a lock in a shared store (DB/Redis) before executing, ensuring only one instance runs the task.
Key gotcha: fixedRate can cause task overlap if execution takes longer than the interval. Use fixedDelay for tasks where overlap is dangerous (e.g., processing a queue).
Events
Q32: How do Spring Events work?
Answer: Spring Events provide a publish-subscribe mechanism for decoupling components within a single application — publishers emit events without knowing who handles them, and listeners react independently.
Why they exist: Direct method calls between services create tight coupling. Events invert this: the order service publishes "order placed" without knowing about email, inventory, analytics, or loyalty services.
How it works:
// 1. Define event (any POJO — no need to extend ApplicationEvent since Spring 4.2)
public record OrderPlacedEvent(Long orderId, BigDecimal amount, String customerEmail) {}
// 2. Publish
@Service
public class OrderService {
@Autowired private ApplicationEventPublisher publisher;
@Transactional
public void placeOrder(Order order) {
orderRepo.save(order);
publisher.publishEvent(new OrderPlacedEvent(order.getId(), order.getTotal(), order.getEmail()));
}
}
// 3. Listen
@Component
public class NotificationListener {
@EventListener
public void onOrderPlaced(OrderPlacedEvent event) {
sendEmail(event.customerEmail(), event.orderId());
}
}
// 4. Transactional listener — fires only AFTER the transaction commits
@TransactionalEventListener(phase = AFTER_COMMIT)
public void afterOrderCommitted(OrderPlacedEvent event) {
sendToAnalytics(event); // Only if the order actually persisted
}
Synchronous by default: The listener runs in the same thread as the publisher. Combine with @Async for fire-and-forget:
When to use: Decoupling side effects (notifications, audit logs, cache invalidation) from the primary business logic. For cross-service events, graduate to a message broker (Kafka, RabbitMQ).
Key gotcha: @TransactionalEventListener with AFTER_COMMIT does NOT run if there is no active transaction when the event is published. Also, if the listener throws an exception, it is logged but does not affect the publisher — which can cause silent failures.
Reactive Spring
Q33: What is the difference between Spring MVC and Spring WebFlux?
Answer: Spring MVC is the traditional synchronous, thread-per-request web framework; Spring WebFlux is its non-blocking, reactive counterpart designed for high-concurrency scenarios with minimal threads.
Why WebFlux exists: The thread-per-request model breaks down when you have thousands of concurrent connections with high I/O latency (waiting for slow microservices, databases, external APIs). You run out of threads while they sit idle waiting for responses.
| Aspect | Spring MVC | Spring WebFlux |
|---|---|---|
| Programming model | Synchronous, blocking | Asynchronous, non-blocking |
| Threading | Thread-per-request (200 threads = 200 concurrent) | Event loop (4 threads can handle thousands) |
| Server | Servlet-based (Tomcat, Jetty) | Netty (default), or Servlet 3.1+ async |
| Return types | Plain objects, ResponseEntity | Mono<T>, Flux<T> |
| Best for | CRUD apps with blocking JDBC | High-concurrency, streaming, gateway/proxy |
| Stack | Servlet API, blocking I/O | Reactive Streams (Project Reactor) |
| Learning curve | Lower | Higher — requires reactive thinking |
When to choose WebFlux: API gateways, real-time streaming (SSE, WebSocket), orchestrating many downstream service calls, applications with 10K+ concurrent connections.
When to stick with MVC: Traditional CRUD with JDBC/JPA (blocking), team not trained in reactive, simpler debugging and stack traces.
Common follow-up: "Can you mix them?" Not in the same application. You choose one stack. However, you can use WebClient (reactive HTTP client) in an MVC app for non-blocking outbound calls.
Key gotcha: A single blocking call in a WebFlux pipeline (e.g., JDBC query, Thread.sleep()) blocks the event loop thread and degrades the entire application. You must use non-blocking I/O end-to-end (R2DBC for DB, reactive Redis, WebClient for HTTP).
Q34: What are Mono and Flux?
Answer: Mono and Flux are the two reactive types from Project Reactor — they represent asynchronous sequences of 0-to-1 and 0-to-N elements respectively, and form the foundation of Spring WebFlux's reactive programming model.
Why two types: Having separate types for "at most one result" vs "multiple results" makes API contracts clearer and enables optimizations. Mono<User> tells you this returns one user; Flux<User> tells you it could be a stream.
- Mono<T> — 0 or 1 element. Analogous to
Optional<T>orCompletableFuture<T>. - Flux<T> — 0 to N elements. Analogous to a reactive
Stream<T>that can be infinite.
@GetMapping("/users/{id}")
public Mono<User> getUser(@PathVariable Long id) {
return userRepository.findById(id); // single result
}
@GetMapping(value = "/users/stream", produces = TEXT_EVENT_STREAM_VALUE)
public Flux<User> streamUsers() {
return userRepository.findAll(); // streamed to client as SSE
}
// Composing reactive pipelines
public Mono<OrderSummary> createOrder(OrderRequest req) {
return userService.findById(req.userId())
.flatMap(user -> inventoryService.reserve(req.items()))
.flatMap(reservation -> paymentService.charge(user, req.total()))
.map(payment -> new OrderSummary(payment.id(), "CONFIRMED"));
}
Key operators: map, flatMap, filter, zip, merge, concat, retry, onErrorResume, switchIfEmpty, timeout, cache.
Critical principle: Both are lazy — nothing happens until something subscribes. In WebFlux, the framework subscribes for you. In non-web contexts, you must call .subscribe() or .block().
Key gotcha: Never call .block() on a Mono/Flux in a WebFlux application — it defeats the purpose and can deadlock the event loop. .block() is only for bridging reactive code in traditional MVC or test code.
REST API Design
Q35: What are REST API best practices in Spring Boot?
Answer: REST API design is about creating intuitive, consistent, and maintainable interfaces. Spring Boot provides the tools; you provide the discipline.
Core principles:
- Nouns for resources —
/api/orders, not/api/getOrdersor/api/createOrder. - HTTP methods convey action —
GET(read),POST(create),PUT(full replace),PATCH(partial update),DELETE(remove). - Correct status codes —
201 Created(with Location header),204 No Content(successful DELETE),400 Bad Request,404 Not Found,409 Conflict,422 Unprocessable Entity. - Consistent error responses — Standardized body with
code,message,timestamp,details. - Pagination — Return metadata (
totalElements,page,size,totalPages) alongside content. - Use DTOs — Never expose JPA entities directly. Entities leak internal structure, bypass lazy loading boundaries, and create coupling between DB schema and API contract.
- Versioning — Plan for it from day one (URI path is simplest).
- HATEOAS — Include links to related resources for discoverability (optional but RESTful).
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {
@PostMapping
public ResponseEntity<OrderResponse> create(@Valid @RequestBody CreateOrderRequest req) {
OrderResponse order = orderService.create(req);
URI location = URI.create("/api/v1/orders/" + order.id());
return ResponseEntity.created(location).body(order);
}
}
Common follow-up: "DTO vs Entity — why bother?" Entities expose database internals, create infinite recursion with bidirectional relationships, leak lazy-loading proxies in JSON, and make API evolution impossible without DB migration.
Key gotcha: Returning 200 OK for everything is a common anti-pattern. Clients rely on status codes for flow control (retry on 503, redirect on 301, show form errors on 422).
Q36: What are the common API versioning strategies?
Answer: API versioning ensures existing clients continue working when you make breaking changes. The strategy you choose determines how clients specify which version they want.
Why you need versioning: APIs are contracts. Once a client depends on a response shape, you cannot change it without breaking them. Versioning lets you evolve while maintaining backward compatibility.
| Strategy | Example | Pros | Cons |
|---|---|---|---|
| URI path | /api/v1/users | Simple, visible, easy to route, cache-friendly | URL pollution, hard to sunset |
| Query parameter | /api/users?version=1 | Easy to default | Easy to overlook, caching issues |
| Custom header | X-API-Version: 1 | Clean URLs | Not visible in browser, harder to test |
| Content negotiation | Accept: application/vnd.myapp.v1+json | Most RESTful, standard | Complex client configuration |
URI path versioning is the most widely used in practice (GitHub, Stripe, Google all use it) because of its simplicity and visibility.
Implementation in Spring Boot:
@RestController
@RequestMapping("/api/v1/users")
public class UserControllerV1 { ... }
@RestController
@RequestMapping("/api/v2/users")
public class UserControllerV2 { ... }
When you need a new version: Only for breaking changes (removing fields, changing types, restructuring response). Adding new fields is backward-compatible and does not need a version bump.
Common follow-up: "How do you deprecate a version?" Announce a timeline (6-12 months), add Sunset and Deprecation headers to responses, monitor usage metrics, and eventually return 410 Gone.
Key gotcha: Do not version too eagerly. Every version doubles your maintenance burden. Use additive changes (new optional fields) to avoid versioning as long as possible.
Testing
Q37: What testing annotations does Spring Boot provide?
Answer: Spring Boot provides test "slices" — annotations that load only a subset of the application context relevant to the layer you are testing, making tests faster and more focused than loading the entire application.
Why slices matter: A full @SpringBootTest loads every bean, database connection, and external integration. For testing a single controller, that is overkill and slow. Slice tests load only what you need.
| Annotation | Scope | What it loads | Use for |
|---|---|---|---|
@SpringBootTest | Full integration | Entire application context | End-to-end, smoke tests |
@WebMvcTest | Controller layer | MVC infra only (no services, no DB) | Controller logic, request/response |
@DataJpaTest | Repository layer | JPA + embedded DB, no web | Query correctness, repo methods |
@WebFluxTest | Reactive controllers | WebFlux infrastructure | Reactive endpoint testing |
@JsonTest | Serialization | Jackson/Gson auto-config | JSON ser/deser edge cases |
@RestClientTest | REST clients | MockRestServiceServer | Outbound HTTP call testing |
Practical usage:
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mockMvc;
@MockBean OrderService orderService;
@Test
void shouldReturn404WhenNotFound() throws Exception {
when(orderService.findById(1L)).thenReturn(Optional.empty());
mockMvc.perform(get("/api/orders/1"))
.andExpect(status().isNotFound());
}
}
Testing pyramid: Many unit tests (no Spring) > some slice tests > few full integration tests.
Common follow-up: "When do you use @SpringBootTest?" For testing the wiring between layers, startup behavior, or full request-to-database flows. Use webEnvironment = RANDOM_PORT with TestRestTemplate for true HTTP tests.
Key gotcha: @DataJpaTest uses an embedded H2 by default, which can mask database-specific behavior. Use @AutoConfigureTestDatabase(replace = NONE) + Testcontainers for realistic DB testing.
Q38: What is the difference between @MockBean and @Mock?
Answer: @Mock creates a standalone Mockito mock for pure unit tests; @MockBean creates a mock and registers it in the Spring application context, replacing the real bean — used in slice and integration tests.
Why the distinction matters: Choosing the wrong one either slows your test unnecessarily (using Spring when you don't need it) or causes confusing failures (mock not injected into the context).
| Aspect | @Mock (Mockito) | @MockBean (Spring Boot) |
|---|---|---|
| Context | No Spring context | Requires Spring context |
| Speed | Milliseconds | Seconds (context startup) |
| Replaces bean | No | Yes (in the context) |
| Use with | @ExtendWith(MockitoExtension.class) | @WebMvcTest, @SpringBootTest |
// Unit test — fast, no Spring context
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock PaymentGateway gateway;
@Mock OrderRepository repo;
@InjectMocks OrderService service;
@Test
void shouldProcessPayment() {
when(gateway.charge(any())).thenReturn(success());
service.placeOrder(order);
verify(gateway).charge(order.getTotal());
}
}
// Slice test — needs Spring context for MVC infrastructure
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@MockBean OrderService orderService; // replaces real bean
@Autowired MockMvc mockMvc;
}
When to use which:
@Mock— Testing business logic in isolation (service, utility classes). Preferred.@MockBean— Testing controllers (need MockMvc), or when the test requires Spring wiring (security filters, validation, serialization).
Common follow-up (Spring Boot 3.4+): @MockitoBean and @MockitoSpyBean are the new names — @MockBean is deprecated in favor of these to avoid confusion with the Mockito @Mock annotation.
Key gotcha: Every unique combination of @MockBean declarations creates a new application context (contexts are cached by their bean definitions). Overusing @MockBean with different configurations defeats context caching and makes your test suite slow.
Docker & Deployment
Q39: How do you containerize a Spring Boot application?
Answer: Spring Boot applications are containerized as executable JARs inside Docker images, with multi-stage builds being the standard approach for optimal image size and security.
Why containerize: Consistency across environments (dev laptop = CI = production), easy horizontal scaling, infrastructure-as-code, and compatibility with orchestrators (Kubernetes, ECS).
Multi-stage Dockerfile (standard approach):
# Build stage
FROM eclipse-temurin:21-jdk AS build
WORKDIR /app
COPY . .
RUN ./mvnw package -DskipTests
# Runtime stage — smaller base image, no build tools
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
Layered JARs (Spring Boot 2.3+) — better caching:
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract
# Dependencies change rarely — cached layers
COPY --from=build /app/dependencies/ ./
COPY --from=build /app/spring-boot-loader/ ./
COPY --from=build /app/snapshot-dependencies/ ./
COPY --from=build /app/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
Buildpacks (zero Dockerfile):
Production best practices:
- Use non-root user in the container
- Set JVM memory limits (
-XX:MaxRAMPercentage=75.0) - Use health check:
HEALTHCHECK CMD curl -f http://localhost:8080/actuator/health - Pin base image versions (not
latest)
Key gotcha: JVM inside Docker does not know the container memory limits by default (older JVMs). Use Java 17+ which respects cgroup limits automatically, or explicitly set -XX:MaxRAMPercentage.
Spring Boot 3 & Jakarta EE
Q40: What are the key changes in Spring Boot 3?
Answer: Spring Boot 3 is the most significant major version upgrade — it raises the Java baseline to 17, migrates the entire ecosystem from javax.* to jakarta.*, and adds first-class support for native compilation and observability.
Why it is a big deal: The javax to jakarta namespace change affects every import in your codebase. Combined with the Java 17 baseline, it forces a coordinated upgrade of your entire dependency stack.
| Change | Detail | Impact |
|---|---|---|
| Java 17+ baseline | Minimum Java version raised from 8/11 to 17 | Records, sealed classes, pattern matching available |
| Jakarta EE 9+ | javax.* -> jakarta.* package namespace | Every servlet, JPA, validation import changes |
| Spring Framework 6 | Underlying framework major version | New APIs, removed deprecations |
| Native compilation | First-class GraalVM native image via Spring AOT | Sub-second startup, reduced memory |
| Observability | Built-in Micrometer Observation API | Unified metrics + distributed tracing |
| HTTP interfaces | Declarative HTTP clients (like Feign but native) | @HttpExchange on interfaces |
| Problem Details | RFC 7807 error responses | Standardized error format |
| Virtual threads | Java 21 virtual thread support (3.2+) | Thread-per-request at WebFlux scale |
Migration essentials:
- Upgrade to Java 17+ (or 21 for virtual threads)
- Replace
javax.*withjakarta.*— use OpenRewrite to automate - Replace
WebSecurityConfigurerAdapterwithSecurityFilterChainbeans - Update third-party libs to Jakarta-compatible versions
- Test thoroughly — subtle behavior changes in security, property binding
Common follow-up: "What is Spring AOT and how does it enable native images?" Spring AOT (Ahead-of-Time) processing analyzes your application at build time, pre-computes bean definitions, and generates reflection hints — eliminating the runtime classpath scanning that GraalVM cannot handle.
Key gotcha: Not all libraries support native compilation. Anything relying heavily on runtime reflection (some ORMs, serialization libs) needs explicit GraalVM hints. Check compatibility before committing to native.
Further Reading
For deeper dives into each topic, explore the dedicated pages in the Spring & Microservices section of this site.