Spring Interview Questions | JiQuest

add

#

Spring

Spring & Spring Boot interview preparation

Spring & Spring Boot: 132 interview-ready questions with working code answers.

Practice Core DI, Boot auto-configuration, Security, Data JPA, MVC/REST, Testing, Cloud/microservices, Batch, WebFlux, Caching, AOP, Kafka/RabbitMQ, and deployment -- with real code, diagrams for the trickiest scenarios, and the reasoning interviewers actually probe for.

132Questions
14Categories
100%Runnable code
HTTP request Security filterchain @Scheduled /@KafkaListener DispatcherServlet HandlerMapping routes to the matched @Controller Service layer @Transactional business logic Repository Data JPA / R2DBC every hop passes through a dynamic proxy -- that's how @Transactional and security enforce themselves

What makes a strong Spring answer?

Interviewers are checking whether you understand how Spring actually wires and proxies your beans -- not just whether you remember annotation names.

Why, not just howExplain the mechanism behind an annotation (proxying, condition evaluation, bean lifecycle), not just where to paste it.
Know the failure modesSelf-invocation skipping AOP, N+1 queries, lazy-init masking startup errors -- these are what separates senior answers.
Right tool for the layerMVC vs WebFlux, JPA vs JDBC vs R2DBC, Kafka vs RabbitMQ -- pick based on the actual constraint, not familiarity.
Production-shapedGraceful shutdown, readiness vs liveness, observability -- a correct answer that ignores deployment reality is incomplete.
LayerReach for it whenWatch out for
Core DI & Boot auto-configWiring beans, environment-specific config, conditional startup behavior.Self-invocation bypassing proxies; lazy-init hiding a broken bean until first use.
Data access (JPA / JDBC / R2DBC)JPA for object graphs and productivity; JDBC/native SQL for tuned queries; R2DBC only inside a fully reactive stack.N+1 queries, LazyInitializationException, mixing blocking JPA into a reactive chain.
Web (MVC vs WebFlux)MVC by default; WebFlux for high-concurrency I/O-bound or streaming workloads.Adopting WebFlux without a fully non-blocking stack gains nothing but complexity.
Messaging (Kafka / RabbitMQ)Kafka for event streaming and replay; RabbitMQ for flexible routing and task queues.At-least-once delivery means every consumer must be idempotent.
Cloud / microservicesService discovery, circuit breakers, and sagas once a single deployable no longer fits.Distributed 2PC across services; trusting the network boundary instead of validating tokens per-service.

Categories

Questions and answers

Every question has a real, working code answer using modern Spring Boot 3.x / Spring 6.x / Spring Security 6.x idioms. Diagrams appear next to the questions where a picture explains the scenario faster than prose.

Spring Boot Fundamentals & Configuration

1. How would you configure a Spring Boot application to connect to multiple databases? Describe the steps and considerations.

Define two separate DataSource configurations, each with its own @ConfigurationProperties prefix, EntityManagerFactory, and TransactionManager. Mark one pair @Primary so Spring can resolve ambiguity when autowiring, and split @EnableJpaRepositories by base package so each repository group binds to the correct EntityManagerFactory. Keep entities for each database in separate packages to make the repository scanning boundaries unambiguous.

@Configuration
public class PrimaryDbConfig {

    @Primary
    @Bean
    @ConfigurationProperties("app.datasource.primary")
    public DataSourceProperties primaryDataSourceProperties() {
        return new DataSourceProperties();
    }

    @Primary
    @Bean
    public DataSource primaryDataSource(
            @Qualifier("primaryDataSourceProperties") DataSourceProperties props) {
        return props.initializeDataSourceBuilder().build();
    }
}

@Configuration
@EnableJpaRepositories(
        basePackages = "com.example.reporting.repository",
        entityManagerFactoryRef = "reportingEntityManagerFactory",
        transactionManagerRef = "reportingTransactionManager")
public class ReportingDbConfig {

    @Bean
    @ConfigurationProperties("app.datasource.reporting")
    public DataSourceProperties reportingDataSourceProperties() {
        return new DataSourceProperties();
    }

    @Bean
    public DataSource reportingDataSource(
            @Qualifier("reportingDataSourceProperties") DataSourceProperties props) {
        return props.initializeDataSourceBuilder().build();
    }

    @Bean
    public LocalContainerEntityManagerFactoryBean reportingEntityManagerFactory(
            EntityManagerFactoryBuilder builder,
            @Qualifier("reportingDataSource") DataSource dataSource) {
        return builder.dataSource(dataSource)
                .packages("com.example.reporting.entity")
                .persistenceUnit("reporting")
                .build();
    }

    @Bean
    public PlatformTransactionManager reportingTransactionManager(
            @Qualifier("reportingEntityManagerFactory") LocalContainerEntityManagerFactoryBean emf) {
        return new JpaTransactionManager(emf.getObject());
    }
}
Gotcha Spring Boot auto-configures a single DataSource by default; as soon as you declare a second one you must exclude DataSourceAutoConfiguration or explicitly wire every bean yourself, otherwise the context fails with an ambiguous-bean error.

2. Explain how you would set up a Spring Boot application to handle high availability and load balancing.

Run multiple stateless instances behind a load balancer (an L7 proxy, cloud ALB, or Kubernetes Service), each exposing a health endpoint the balancer polls before routing traffic. Externalize session state to Redis or a database so any instance can serve any request, and make outbound calls to other services resilient with client-side load balancing and circuit breakers via Spring Cloud LoadBalancer and Resilience4j. Configure graceful shutdown so in-flight requests finish before an instance is removed during a rolling deploy.

@Configuration
@LoadBalancerClient(name = "inventory-service")
public class InventoryClientConfig {

    @Bean
    @LoadBalanced
    public RestClient.Builder loadBalancedRestClientBuilder() {
        return RestClient.builder();
    }
}

@Service
public class InventoryClient {

    private final RestClient restClient;

    public InventoryClient(RestClient.Builder builder) {
        this.restClient = builder.baseUrl("http://inventory-service").build();
    }

    @CircuitBreaker(name = "inventory", fallbackMethod = "fallbackStock")
    public StockLevel getStock(String sku) {
        return restClient.get()
                .uri("/stock/{sku}", sku)
                .retrieve()
                .body(StockLevel.class);
    }

    private StockLevel fallbackStock(String sku, Throwable ex) {
        return StockLevel.unknown(sku);
    }
}
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=25s
management.endpoint.health.probes.enabled=true
Tip Enabling server.shutdown=graceful alongside Kubernetes readiness probes is what actually prevents dropped connections during rolling restarts — the load balancer deregisters the pod before it stops accepting new work.

3. Describe how you would implement a custom health check in a Spring Boot application and why it might be necessary.

Implement HealthIndicator (or extend AbstractHealthIndicator) to report on dependencies the built-in checks don't cover, such as a third-party API, a message broker, or business-critical cache. Spring Boot registers it automatically under Actuator's /actuator/health aggregate, naming it after the bean minus the HealthIndicator suffix. Custom checks matter because generic "the JVM is up" health isn't the same as "the app can actually do its job" — orchestrators need the latter to make restart/routing decisions.

@Component
public class PaymentGatewayHealthIndicator implements HealthIndicator {

    private final PaymentGatewayClient client;

    public PaymentGatewayHealthIndicator(PaymentGatewayClient client) {
        this.client = client;
    }

    @Override
    public Health health() {
        try {
            boolean reachable = client.ping();
            if (reachable) {
                return Health.up()
                        .withDetail("gateway", "reachable")
                        .build();
            }
            return Health.down()
                    .withDetail("gateway", "unreachable")
                    .build();
        } catch (Exception ex) {
            return Health.down(ex).build();
        }
    }
}
Gotcha A slow or blocking health check runs on every probe hit and can pile up threads under load; keep it fast (use a cached status or a short timeout) rather than making a live call on every request.

4. How do you handle application configuration for different environments (development, testing, production) in Spring Boot?

Use Spring Profiles: a base application.yml holds shared defaults, and profile-specific files (application-dev.yml, application-test.yml, application-prod.yml) override only what differs. The active profile is selected at runtime via SPRING_PROFILES_ACTIVE so the same build artifact is promoted unchanged across environments. @Profile can additionally gate beans that should only exist in certain environments, like a mock client in dev versus a real one in prod.

# application.yml
spring:
  application:
    name: order-service

---
spring:
  config:
    activate:
      on-profile: prod
datasource:
  url: jdbc:postgresql://prod-db:5432/orders
logging:
  level:
    root: WARN
@Service
@Profile("!prod")
public class MockNotificationClient implements NotificationClient {
    public void send(String to, String message) {
        System.out.println("Mock send to " + to + ": " + message);
    }
}
Tip Never bake credentials into application-prod.yml; keep it to structural overrides and inject secrets separately so the profile file itself is safe to commit.

5. What are the best practices for managing application properties and secrets in a Spring Boot application?

Bind related properties to strongly-typed, validated @ConfigurationProperties classes instead of scattering @Value lookups, and layer configuration sources by precedence (command-line args > env vars > profile files > defaults) so ops can override without a rebuild. Never commit real secrets — pull them at runtime from a vault (AWS Secrets Manager, HashiCorp Vault, Kubernetes Secrets) via Spring Cloud Config or environment injection. Validate configuration at startup with @Validated so missing or malformed values fail fast instead of surfacing as a runtime NPE.

@ConfigurationProperties(prefix = "app.mail")
@Validated
public record MailProperties(
        @NotBlank String host,
        @Min(1) @Max(65535) int port,
        @NotBlank String username) {
}

@Configuration
@EnableConfigurationProperties(MailProperties.class)
public class MailConfig {

    @Bean
    public JavaMailSender mailSender(MailProperties props, MailPassword password) {
        JavaMailSenderImpl sender = new JavaMailSenderImpl();
        sender.setHost(props.host());
        sender.setPort(props.port());
        sender.setUsername(props.username());
        sender.setPassword(password.value());
        return sender;
    }
}
Gotcha application.yml committed to git is a permanent leak surface even after deletion, since it lives in history — secrets belong in a vault or env vars injected at deploy time, never in the repo.

6. Explain how you would use Spring Boot's Actuator to monitor application metrics and perform diagnostics.

Add spring-boot-starter-actuator and expose the endpoints you need — health, metrics, prometheus, threaddump, heapdump — over a management port separate from the app port. Metrics flow through Micrometer, so registering a MeterRegistry-backed counter or timer automatically surfaces under /actuator/metrics and can be scraped by Prometheus for dashboards and alerts. For live diagnostics, threaddump and heapdump let you inspect a stuck or leaking instance without attaching a remote debugger.

@Component
public class OrderMetrics {

    private final Counter ordersPlaced;
    private final Timer checkoutTimer;

    public OrderMetrics(MeterRegistry registry) {
        this.ordersPlaced = Counter.builder("orders.placed")
                .description("Number of orders placed")
                .register(registry);
        this.checkoutTimer = Timer.builder("orders.checkout.duration")
                .register(registry);
    }

    public void recordOrder(Runnable checkout) {
        checkoutTimer.record(checkout);
        ordersPlaced.increment();
    }
}
management.endpoints.web.exposure.include=health,info,metrics,prometheus
management.endpoint.health.show-details=when-authorized
management.server.port=8081
Tip Running Actuator on a separate management.server.port lets you keep diagnostic endpoints off the public network entirely, exposing them only inside the cluster.

7. How do you handle logging in a Spring Boot application, and how would you configure different log levels for various environments?

Spring Boot uses Logback by default via the SLF4J facade, so code depends only on org.slf4j.Logger and stays implementation-agnostic. Set base levels per package in application.yml and let profile-specific files override them — verbose DEBUG in dev, lean WARN/INFO in prod — or use a logback-spring.xml with <springProfile> blocks for more control like separate appenders per environment. In production, prefer structured JSON output shipped to a log aggregator over plain text files on disk.

@RestController
public class OrderController {

    private static final Logger log = LoggerFactory.getLogger(OrderController.class);

    @PostMapping("/orders")
    public OrderResponse create(@RequestBody OrderRequest request) {
        log.debug("Received order request: {}", request);
        OrderResponse response = process(request);
        log.info("Order {} created", response.orderId());
        return response;
    }
}
<configuration>
    <include resource="org/springframework/boot/logging/logback/base.xml"/>

    <springProfile name="dev">
        <logger name="com.example" level="DEBUG"/>
    </springProfile>

    <springProfile name="prod">
        <logger name="com.example" level="INFO"/>
        <logger name="org.hibernate.SQL" level="WARN"/>
    </springProfile>
</configuration>
Gotcha Logging full request/response bodies at DEBUG is invaluable locally but a compliance risk in prod if that level ever gets flipped on accidentally — scrub or exclude PII fields before they reach the logger.

8. Describe how you would implement internationalization (i18n) in a Spring Boot application.

Store translated strings in messages.properties plus locale variants (messages_fr.properties, messages_de.properties) loaded through a MessageSource bean, then resolve the request's locale with a LocaleResolver — typically AcceptHeaderLocaleResolver for APIs or a cookie/session-based resolver for web apps with a manual switcher. Inject MessageSource wherever user-facing text is produced (controllers, validation messages, exception handlers) instead of hardcoding strings.

@Configuration
public class LocaleConfig {

    @Bean
    public LocaleResolver localeResolver() {
        AcceptHeaderLocaleResolver resolver = new AcceptHeaderLocaleResolver();
        resolver.setDefaultLocale(Locale.ENGLISH);
        resolver.setSupportedLocales(List.of(Locale.ENGLISH, Locale.FRENCH, Locale.GERMAN));
        return resolver;
    }

    @Bean
    public MessageSource messageSource() {
        ReloadableResourceBundleMessageSource source = new ReloadableResourceBundleMessageSource();
        source.setBasename("classpath:i18n/messages");
        source.setDefaultEncoding("UTF-8");
        return source;
    }
}

@RestController
public class GreetingController {

    private final MessageSource messages;

    public GreetingController(MessageSource messages) {
        this.messages = messages;
    }

    @GetMapping("/greeting")
    public String greet(Locale locale) {
        return messages.getMessage("greeting.welcome", null, locale);
    }
}
Tip Bind validation annotations to message keys (e.g. @NotBlank(message = "{user.name.required}")) so field-level error text is translated the same way as everything else, instead of leaking hardcoded English into API error responses.

Spring Security

1. How would you secure a REST API in a Spring Boot application using Spring Security? Describe the steps for both authentication and authorization.

In Spring Security 6.x you define a SecurityFilterChain bean instead of extending WebSecurityConfigurerAdapter. Authentication is typically handled with stateless JWT bearer tokens validated by a resource-server filter, while authorization is expressed declaratively with authorizeHttpRequests() using path and role matchers. Sessions are disabled since REST APIs are stateless, and CSRF protection is turned off for token-based APIs since there's no browser-managed session cookie to forge.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}
Tip Order matchers from most specific to least specific — the first matching rule wins, so a broad anyRequest() must always come last.

2. Explain how to configure OAuth2 authentication and authorization for a Spring Boot application.

Spring Boot supports OAuth2 in two complementary roles: a client that delegates login to an external provider (Google, Okta, Keycloak) via spring-boot-starter-oauth2-client, and a resource server that validates incoming access tokens via spring-boot-starter-oauth2-resource-server. Configuration is largely declarative through application.yml registration properties plus a SecurityFilterChain that wires oauth2Login() and/or oauth2ResourceServer(). The token itself (JWT or opaque) carries the authenticated principal and scopes, which Spring Security maps into granted authorities.

@Bean
public SecurityFilterChain oauth2FilterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/login/**", "/error").permitAll()
            .anyRequest().authenticated()
        )
        .oauth2Login(login -> login.loginPage("/login"))
        .oauth2ResourceServer(oauth2 -> oauth2
            .jwt(jwt -> jwt.jwtAuthenticationConverter(customJwtConverter()))
        );
    return http.build();
}

// application.yml
// spring:
//   security:
//     oauth2:
//       client:
//         registration:
//           okta:
//             client-id: ${OKTA_CLIENT_ID}
//             client-secret: ${OKTA_CLIENT_SECRET}
//             scope: openid, profile, email
//       resourceserver:
//         jwt:
//           issuer-uri: https://your-tenant.okta.com/oauth2/default
Pitfall Mixing up oauth2Login (this app is the client logging users in) with oauth2ResourceServer (this app validates tokens issued elsewhere) leads to misconfigured filter chains — many services need only the latter.

3. Describe a scenario where you would use Spring Security's method-level security annotations and how you would implement them.

Method-level security shines when authorization logic depends on more than the URL — for example, letting a user edit only their own order, not just any authenticated user hitting /orders/{id}. Annotations like @PreAuthorize and @PostAuthorize let you express that business rule directly on the service method using SpEL, referencing method arguments or the return value. This keeps authorization close to the domain logic rather than scattered across controller-level path rules.

@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
}

@Service
public class OrderService {

    private final OrderRepository orderRepository;

    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @PreAuthorize("hasRole('ADMIN') or #order.ownerUsername == authentication.name")
    public void updateOrder(Order order) {
        orderRepository.save(order);
    }

    @PostAuthorize("returnObject.ownerUsername == authentication.name")
    public Order getOrder(Long id) {
        return orderRepository.findById(id).orElseThrow();
    }
}
Tip @EnableMethodSecurity replaces the older @EnableGlobalMethodSecurity and enables @PreAuthorize/@PostAuthorize by default without extra flags.

4. How would you handle user role management and permissions in a Spring Boot application using Spring Security?

Roles and permissions are usually modeled as entities in a database — a user has one or more roles, and each role maps to a set of granted authorities. A custom UserDetailsService loads the user and converts their roles/permissions into GrantedAuthority objects at authentication time, which then drive both authorizeHttpRequests() checks and method-level annotations. Prefixing role names with ROLE_ lets hasRole() work, while raw authority strings support finer-grained permissions via hasAuthority().

@Service
public class DomainUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    public DomainUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) {
        AppUser user = userRepository.findByUsername(username)
            .orElseThrow(() -> new UsernameNotFoundException("No user: " + username));

        Set<GrantedAuthority> authorities = user.getRoles().stream()
            .flatMap(role -> Stream.concat(
                Stream.of(new SimpleGrantedAuthority("ROLE_" + role.getName())),
                role.getPermissions().stream().map(p -> new SimpleGrantedAuthority(p.getName()))
            ))
            .collect(Collectors.toSet());

        return new User(user.getUsername(), user.getPasswordHash(), authorities);
    }
}
Pitfall Forgetting the ROLE_ prefix is a classic bug — hasRole("ADMIN") internally checks for authority ROLE_ADMIN, so authorities stored without the prefix will silently fail authorization.

5. Explain how you would implement single sign-on (SSO) in a Spring Boot application.

SSO is most commonly implemented by delegating authentication to an external identity provider using either the OpenID Connect (OIDC) flow via oauth2Login(), or SAML2 via saml2Login() for enterprise IdPs like ADFS or Okta. The Spring Boot app never handles credentials directly; it redirects to the IdP, receives back an ID token or SAML assertion, and Spring Security builds an OidcUser or Saml2AuthenticatedPrincipal from it. Multiple applications sharing the same IdP session then get SSO for free because the browser already holds a valid session with the identity provider.

@Bean
public SecurityFilterChain ssoFilterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/", "/public/**").permitAll()
            .anyRequest().authenticated()
        )
        .oauth2Login(Customizer.withDefaults())
        .logout(logout -> logout.logoutSuccessUrl("/"));
    return http.build();
}

// application.yml
// spring:
//   security:
//     oauth2:
//       client:
//         provider:
//           corp-idp:
//             issuer-uri: https://sso.corp.example.com/realms/main
//         registration:
//           corp-idp:
//             provider: corp-idp
//             client-id: ${SSO_CLIENT_ID}
//             client-secret: ${SSO_CLIENT_SECRET}
//             scope: openid, profile, email
Tip True SSO logout requires back-channel or front-channel logout propagation to the IdP — simply clearing the local session leaves the user still logged in at the identity provider.

6. What are some common vulnerabilities in web applications, and how does Spring Security help mitigate them?

Common vulnerabilities include CSRF, XSS, session fixation, clickjacking, and insecure password storage. Spring Security addresses these largely out of the box: CSRF tokens are enabled by default for browser sessions, security headers like X-Frame-Options and Content-Security-Policy guard against clickjacking, session fixation protection regenerates the session ID on login, and PasswordEncoder implementations like BCrypt prevent plaintext credential storage. XSS mitigation is partly the framework's job (templating engines escape output) but Spring Security's header support and CSP configuration reinforce it.

@Bean
public SecurityFilterChain webFilterChain(HttpSecurity http) throws Exception {
    http
        .headers(headers -> headers
            .frameOptions(frame -> frame.sameOrigin())
            .contentSecurityPolicy(csp -> csp.policyDirectives("default-src 'self'"))
        )
        .sessionManagement(sm -> sm
            .sessionFixation(SessionManagementConfigurer.SessionFixationConfigurer::migrateSession)
        )
        .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
        .formLogin(Customizer.withDefaults());
    return http.build();
}
Tip Spring Security's default CSRF, session-fixation, and header protections are all opt-out, not opt-in — disabling them (e.g. for "convenience" during development) is the most common way this hardening gets accidentally lost.

7. How would you handle security for a microservices architecture with Spring Boot and Spring Security?

In microservices, authentication is usually centralized at an API gateway or via a shared identity provider, with each downstream service acting as a stateless OAuth2 resource server that validates a propagated JWT independently rather than re-authenticating users. Service-to-service calls typically use the client-credentials grant or an internal mTLS boundary, and every service applies its own fine-grained authorizeHttpRequests()/method-security rules based on scopes or roles embedded in the token. This avoids a shared session store and keeps each service's trust decision local and stateless.

@Bean
public SecurityFilterChain resourceServerFilterChain(HttpSecurity http) throws Exception {
    http
        .csrf(AbstractHttpConfigurer::disable)
        .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/actuator/health").permitAll()
            .requestMatchers("/internal/**").hasAuthority("SCOPE_service.internal")
            .anyRequest().authenticated()
        )
        .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt
            .jwtAuthenticationConverter(scopeAuthoritiesConverter())
        ));
    return http.build();
}

@Bean
public JwtAuthenticationConverter scopeAuthoritiesConverter() {
    JwtGrantedAuthoritiesConverter authoritiesConverter = new JwtGrantedAuthoritiesConverter();
    authoritiesConverter.setAuthorityPrefix("SCOPE_");
    JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
    converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
    return converter;
}
Pitfall Trusting an internal network boundary alone ("it's behind the gateway, so it's safe") without validating the JWT at each service leaves lateral movement wide open if any single service is compromised.

8. Describe how to use Spring Security to protect a web application from common attacks such as CSRF and XSS.

For CSRF, Spring Security's synchronizer-token pattern is enabled by default for stateful, cookie-based sessions and should stay on for any form-based or session-authenticated web app; it only needs disabling for stateless token-based APIs with no ambient credentials. For XSS, Spring Security itself doesn't sanitize output, but it complements templating-engine auto-escaping (Thymeleaf, JSP) with response headers — a strict Content-Security-Policy and X-Content-Type-Options: nosniff significantly reduce the blast radius of an injected script even if output escaping is missed somewhere.

@Bean
public SecurityFilterChain webAppFilterChain(HttpSecurity http) throws Exception {
    http
        .csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
        .headers(headers -> headers
            .contentSecurityPolicy(csp -> csp.policyDirectives(
                "default-src 'self'; script-src 'self'; object-src 'none'"))
            .contentTypeOptions(Customizer.withDefaults())
        )
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/css/**", "/js/**").permitAll()
            .anyRequest().authenticated()
        )
        .formLogin(Customizer.withDefaults());
    return http.build();
}
Tip CookieCsrfTokenRepository.withHttpOnlyFalse() is the standard choice for JavaScript-driven SPAs that need to read the CSRF cookie and echo it back in a request header on each mutating call.

Spring Transaction Management

1. Describe a scenario where you would use the REQUIRES_NEW propagation type in a Spring Boot application.

Use REQUIRES_NEW when a piece of work must be committed independently of the outer transaction's outcome — the classic example is audit logging. If a business operation fails and rolls back, you still want a record that the attempt happened, so the audit write runs in its own physical transaction with its own connection.

Outer TX main business logic orderRepository.save(order) throws exception → ROLLBACK (order save undone) calls Inner TX (REQUIRES_NEW) auditLogService.logOrderAttempt() independent commit — audit log saved Even if Outer TX rolls back, the Inner TX has already committed independently.
@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final AuditLogService auditLogService;

    public OrderService(OrderRepository orderRepository, AuditLogService auditLogService) {
        this.orderRepository = orderRepository;
        this.auditLogService = auditLogService;
    }

    @Transactional
    public void placeOrder(Order order) {
        orderRepository.save(order);
        auditLogService.logOrderAttempt(order.getId(), "ORDER_PLACED");
        if (order.getAmount() > 100_000) {
            throw new IllegalStateException("Amount exceeds fraud threshold");
        }
    }
}

@Service
public class AuditLogService {

    private final AuditLogRepository auditLogRepository;

    public AuditLogService(AuditLogRepository auditLogRepository) {
        this.auditLogRepository = auditLogRepository;
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logOrderAttempt(Long orderId, String action) {
        auditLogRepository.save(new AuditLog(orderId, action, Instant.now()));
    }
}
REQUIRES_NEW suspends the caller's transaction and opens a brand-new one on a separate connection, so the audit write commits immediately and cannot be undone by the outer rollback. This costs an extra connection/commit round-trip, so reserve it for cases where independent durability genuinely matters.

2. How do you handle transactions in a Spring Boot application that needs to interact with multiple data sources?

Each DataSource needs its own PlatformTransactionManager, and you pick which one a method uses via @Transactional("beanName"). Two locally-scoped transactions committed separately are not atomic as a pair, so if you truly need all-or-nothing behavior across both databases you must reach for JTA (Atomikos/Narayana) or, better, an eventual-consistency pattern like outbox/saga.

@Configuration
public class MultiDataSourceConfig {

    @Bean
    @Primary
    @ConfigurationProperties("app.datasource.primary")
    public DataSource primaryDataSource() {
        return DataSourceBuilder.create().build();
    }

    @Bean
    @ConfigurationProperties("app.datasource.secondary")
    public DataSource secondaryDataSource() {
        return DataSourceBuilder.create().build();
    }

    @Bean
    @Primary
    public PlatformTransactionManager primaryTransactionManager(
            @Qualifier("primaryDataSource") DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }

    @Bean
    public PlatformTransactionManager secondaryTransactionManager(
            @Qualifier("secondaryDataSource") DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }
}

@Service
public class MultiDbReportingService {

    @Transactional("primaryTransactionManager")
    public void writeOrderToPrimary(Order order) {
        // uses primaryDataSource
    }

    @Transactional("secondaryTransactionManager")
    public void writeMetricToSecondary(Metric metric) {
        // uses secondaryDataSource, separate local transaction
    }
}
Calling both methods from a wrapping @Transactional method does NOT make them atomic together — each still commits/rolls back independently against its own transaction manager. For real cross-database atomicity use a JTA transaction manager; otherwise design for idempotent retries or a saga/outbox instead.

3. Explain how you would implement and configure transaction management in a Spring Boot application that uses a combination of JPA and JDBC.

As long as JPA (via EntityManagerFactory) and plain JDBC (via JdbcTemplate) share the exact same underlying DataSource, a single JpaTransactionManager can coordinate both. Configuring setDataSource() on the JpaTransactionManager lets it expose the active JPA connection to JDBC-based code, so a single @Transactional boundary covers repository saves and raw SQL updates together.

@Configuration
public class JpaJdbcTxConfig {

    @Bean
    public JpaTransactionManager transactionManager(
            EntityManagerFactory entityManagerFactory, DataSource dataSource) {
        JpaTransactionManager transactionManager = new JpaTransactionManager(entityManagerFactory);
        transactionManager.setDataSource(dataSource);
        return transactionManager;
    }
}

@Service
public class HybridUserService {

    private final UserRepository userRepository; // Spring Data JPA
    private final JdbcTemplate jdbcTemplate;      // plain JDBC, same DataSource

    public HybridUserService(UserRepository userRepository, JdbcTemplate jdbcTemplate) {
        this.userRepository = userRepository;
        this.jdbcTemplate = jdbcTemplate;
    }

    @Transactional
    public void createUserWithAudit(User user) {
        userRepository.save(user);
        jdbcTemplate.update(
            "INSERT INTO audit_log(user_id, action, created_at) VALUES (?, ?, NOW())",
            user.getId(), "USER_CREATED");
    }
}
Spring Boot autoconfigures this correctly out of the box when there is a single DataSource bean — you rarely need to define the JpaTransactionManager yourself. It only becomes a manual step once you introduce multiple data sources.

4. How would you handle transaction management in a Spring Boot application that involves a distributed transaction across microservices?

True two-phase commit across services is generally avoided in microservice architectures because it couples availability across independently deployed services. Instead, use the Saga pattern: each service commits a local transaction and, in the same local transaction, writes an outbox event; a relay process then publishes that event so other services react and, if something fails downstream, compensating actions undo prior steps.

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final OutboxEventRepository outboxEventRepository;

    public OrderService(OrderRepository orderRepository, OutboxEventRepository outboxEventRepository) {
        this.orderRepository = orderRepository;
        this.outboxEventRepository = outboxEventRepository;
    }

    @Transactional
    public void createOrder(Order order) {
        orderRepository.save(order);
        outboxEventRepository.save(
            new OutboxEvent("ORDER_CREATED", order.getId(), order.toPayloadJson()));
    }
}

@Component
public class OutboxRelay {

    private final OutboxEventRepository outboxEventRepository;
    private final KafkaTemplate<String, String> kafkaTemplate;

    public OutboxRelay(OutboxEventRepository outboxEventRepository,
                        KafkaTemplate<String, String> kafkaTemplate) {
        this.outboxEventRepository = outboxEventRepository;
        this.kafkaTemplate = kafkaTemplate;
    }

    @Scheduled(fixedDelay = 2000)
    @Transactional
    public void relayPendingEvents() {
        for (OutboxEvent event : outboxEventRepository.findUnpublished()) {
            kafkaTemplate.send("orders.events", event.getAggregateId().toString(), event.getPayload());
            event.markPublished();
        }
    }
}
Do not attempt JTA-style 2PC across service boundaries owned by different databases/teams — it does not scale and creates tight availability coupling. Pair the outbox pattern with idempotent consumers and explicit compensating transactions for failure recovery.

5. Describe how to use @Transactional to ensure data consistency in a Spring Boot application with complex business logic.

For multi-step business logic, apply @Transactional at the service method that represents the whole use case, use rollbackFor to make sure checked or custom exceptions trigger a rollback (Spring only rolls back on unchecked exceptions by default), and keep validation checks before any mutating repository calls so a failed invariant aborts cleanly.

@Service
public class LoanApprovalService {

    private final LoanRepository loanRepository;
    private final AccountRepository accountRepository;
    private final NotificationService notificationService;

    public LoanApprovalService(LoanRepository loanRepository, AccountRepository accountRepository,
                                NotificationService notificationService) {
        this.loanRepository = loanRepository;
        this.accountRepository = accountRepository;
        this.notificationService = notificationService;
    }

    @Transactional(rollbackFor = InsufficientFundsException.class)
    public void approveLoan(Long loanId) {
        Loan loan = loanRepository.findById(loanId)
            .orElseThrow(() -> new IllegalArgumentException("Loan not found"));
        Account account = accountRepository.findById(loan.getAccountId())
            .orElseThrow(() -> new IllegalArgumentException("Account not found"));

        if (account.getBalance() < loan.getMinimumReserve()) {
            throw new InsufficientFundsException("Reserve requirement not met");
        }

        account.setBalance(account.getBalance() - loan.getMinimumReserve());
        loan.setStatus(LoanStatus.APPROVED);

        accountRepository.save(account);
        loanRepository.save(loan);
        notificationService.sendApprovalNotice(loan);
    }
}
@Transactional relies on a Spring AOP proxy, so calling an @Transactional method from another method in the SAME class (self-invocation) bypasses the proxy entirely and runs without transactional semantics. Move such calls to a separate collaborator bean, or inject a self-reference proxy if you must keep it in one class.

6. How would you test transaction management in a Spring Boot application to ensure proper rollback and commit behavior?

Write integration tests with @SpringBootTest against a real (or Testcontainers) database, trigger the failure path, and assert nothing persisted. Because a test-level @Transactional rolls everything back after the test regardless of what the code did, verify actual commits — especially from REQUIRES_NEW methods — by reading the data back in a fresh, independent transaction rather than relying on the enclosing test transaction's rollback.

@SpringBootTest
class OrderServiceTransactionTest {

    @Autowired
    private OrderService orderService;
    @Autowired
    private OrderRepository orderRepository;
    @Autowired
    private AuditLogRepository auditLogRepository;
    @Autowired
    private PlatformTransactionManager transactionManager;

    @Test
    void placeOrder_rollsBackOrderButKeepsAuditLog_whenFraudThresholdExceeded() {
        Order order = new Order(null, 250_000.0);

        assertThrows(IllegalStateException.class, () -> orderService.placeOrder(order));

        TransactionTemplate verifyInNewTx = new TransactionTemplate(transactionManager);
        verifyInNewTx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);

        verifyInNewTx.execute(status -> {
            assertEquals(0, orderRepository.count());
            assertEquals(1, auditLogRepository.count());
            return null;
        });
    }
}
Use @DataJpaTest with @AutoConfigureTestDatabase(replace = Replace.NONE) plus Testcontainers when you need production-like transaction/locking behavior — the default in-memory H2 can mask isolation-level bugs that only show up on the real database engine.

7. What strategies would you use to optimize transaction performance in a Spring Boot application?

Keep transactions as short-lived as possible: do validation and I/O-bound work like HTTP/API calls outside the transactional boundary, mark pure read operations as @Transactional(readOnly = true) so the persistence provider can skip dirty checking, and batch writes instead of issuing them one row at a time.

@Service
public class OrderQueryService {

    private final OrderRepository orderRepository;
    private final JdbcTemplate jdbcTemplate;

    public OrderQueryService(OrderRepository orderRepository, JdbcTemplate jdbcTemplate) {
        this.orderRepository = orderRepository;
        this.jdbcTemplate = jdbcTemplate;
    }

    @Transactional(readOnly = true)
    public List<OrderSummary> getOrderSummaries(Long customerId) {
        return orderRepository.findSummariesByCustomerId(customerId);
    }

    @Transactional
    public void bulkUpdateStatus(List<Long> orderIds, OrderStatus status) {
        jdbcTemplate.batchUpdate(
            "UPDATE orders SET status = ? WHERE id = ?",
            orderIds.stream()
                .map(id -> new Object[] { status.name(), id })
                .toList());
    }
}
Also tune HikariCP pool size to match actual DB concurrency, avoid holding a transaction open across a JSON/HTTP call to another service, and default to the lowest isolation level that still guarantees correctness — READ_COMMITTED is usually sufficient and far cheaper than SERIALIZABLE.

8. How does Spring Boot handle transaction management in a batch processing scenario?

Spring Batch manages transactions per chunk rather than per item: a Step configured with a chunk size wraps the read-process-write cycle for that chunk in a single transaction driven by the configured PlatformTransactionManager. If an item fails, only the current chunk rolls back (or is skipped per policy) — earlier committed chunks remain intact, and the JobRepository tracks execution metadata in its own separate transactions.

@Configuration
public class OrderProcessingJobConfig {

    @Bean
    public Step processOrdersStep(JobRepository jobRepository,
                                   PlatformTransactionManager transactionManager,
                                   ItemReader<Order> orderReader,
                                   ItemProcessor<Order, Order> orderProcessor,
                                   ItemWriter<Order> orderWriter) {
        return new StepBuilder("processOrdersStep", jobRepository)
            .<Order, Order>chunk(100, transactionManager)
            .reader(orderReader)
            .processor(orderProcessor)
            .writer(orderWriter)
            .faultTolerant()
            .skip(ValidationException.class)
            .skipLimit(50)
            .build();
    }

    @Bean
    public Job processOrdersJob(JobRepository jobRepository, Step processOrdersStep) {
        return new JobBuilder("processOrdersJob", jobRepository)
            .start(processOrdersStep)
            .build();
    }
}
Choose the chunk size deliberately: too small increases commit overhead, too large increases the amount of reprocessing work lost on a failure. Combine skip/retry policies with a chunk-sized transaction boundary to balance throughput against rollback cost.

Spring Data JPA

1. How would you handle a situation where a Spring Data JPA repository method needs to perform complex queries that are not supported by the JPA query methods?

For queries that go beyond derived query methods, use the @Query annotation with JPQL or native SQL, or reach for the JPA Criteria API / Specification when the query needs to be built dynamically based on optional filters. JpaSpecificationExecutor is the cleanest option because it composes predicates type-safely and keeps the repository interface declarative. For very complex or reporting-style queries, a custom repository implementation or QueryDSL is often a better fit.

public interface OrderRepository extends JpaRepository<Order, Long>,
        JpaSpecificationExecutor<Order> {
}

public class OrderSpecifications {
    public static Specification<Order> hasStatus(String status) {
        return (root, query, cb) ->
                status == null ? null : cb.equal(root.get("status"), status);
    }

    public static Specification<Order> placedAfter(LocalDate date) {
        return (root, query, cb) ->
                date == null ? null : cb.greaterThan(root.get("placedDate"), date);
    }
}

// Usage
Specification<Order> spec = Specification.where(hasStatus("SHIPPED"))
        .and(placedAfter(LocalDate.now().minusDays(30)));
List<Order> orders = orderRepository.findAll(spec);
Prefer Specification over building JPQL strings by hand when filters are optional — it avoids a combinatorial explosion of query-method names.

2. Describe how you would implement pagination and sorting in a Spring Boot application using Spring Data JPA.

Spring Data JPA repositories can accept a Pageable parameter, which encapsulates page number, page size, and a Sort. Passing it into a findAll(Pageable) or a derived query method returns a Page<T> with the content plus metadata like total pages and total elements. In a REST controller, Pageable can be bound directly from request parameters using @PageableDefault.

public interface ProductRepository extends JpaRepository<Product, Long> {
    Page<Product> findByCategory(String category, Pageable pageable);
}

@GetMapping("/products")
public Page<Product> getProducts(
        @RequestParam String category,
        @PageableDefault(size = 20, sort = "price") Pageable pageable) {
    return productRepository.findByCategory(category, pageable);
}
Sorting on unindexed columns for large tables can be expensive; add a database index on any column exposed for sorting via the API.

3. How do you handle optimistic locking and pessimistic locking in a Spring Data JPA application?

Optimistic locking is implemented by adding a @Version field to the entity; Hibernate checks the version on update and throws OptimisticLockException if another transaction modified the row first, so it works well for low-contention scenarios without holding database locks. Pessimistic locking acquires a database-level lock immediately by annotating a repository method with @Lock(LockModeType.PESSIMISTIC_WRITE), which is better when contention is high and you want to block other transactions outright.

@Entity
public class Account {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private BigDecimal balance;

    @Version
    private Long version;
}

public interface AccountRepository extends JpaRepository<Account, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select a from Account a where a.id = :id")
    Optional<Account> findByIdForUpdate(@Param("id") Long id);
}
Optimistic locking failures must be caught (e.g. ObjectOptimisticLockingFailureException) and retried or surfaced to the user — silently ignoring them leads to lost updates.

4. Explain how you would use custom repository implementations in Spring Data JPA to extend functionality beyond the standard repository methods.

Define a custom "fragment" interface with the extra methods, provide an implementation class whose name matches the interface name with an Impl suffix, and have the main repository interface extend both JpaRepository and the fragment interface. Spring Data automatically detects the implementation and merges it into the composed repository proxy, so custom logic (bulk operations, EntityManager-based queries, calls to legacy DAOs, etc.) can coexist with the generated methods.

public interface CustomOrderRepository {
    void archiveOldOrders(LocalDate cutoff);
}

public class CustomOrderRepositoryImpl implements CustomOrderRepository {

    @PersistenceContext
    private EntityManager entityManager;

    @Override
    public void archiveOldOrders(LocalDate cutoff) {
        entityManager.createQuery(
                "update Order o set o.archived = true where o.placedDate < :cutoff")
                .setParameter("cutoff", cutoff)
                .executeUpdate();
    }
}

public interface OrderRepository extends JpaRepository<Order, Long>, CustomOrderRepository {
}
Bulk JPQL updates like this bypass the persistence context, so entities already loaded in memory won't reflect the change until refreshed.

5. Describe a scenario where you would use the @Query annotation with native SQL queries in a Spring Data JPA repository.

Native queries are useful when you need database-specific features JPQL can't express — window functions, full-text search, CTEs, or vendor-specific hints — or when tuning a hot-path query for performance. Set nativeQuery = true and, for paginated native queries, supply a matching countQuery so Spring Data can compute total pages correctly.

public interface EmployeeRepository extends JpaRepository<Employee, Long> {

    @Query(value = "SELECT * FROM employees e " +
            "WHERE e.department = :dept " +
            "ORDER BY e.salary DESC " +
            "LIMIT 10",
            nativeQuery = true)
    List<Employee> findTopEarners(@Param("dept") String dept);

    @Query(value = "SELECT * FROM employees WHERE department = :dept",
            countQuery = "SELECT count(*) FROM employees WHERE department = :dept",
            nativeQuery = true)
    Page<Employee> findByDepartmentNative(@Param("dept") String dept, Pageable pageable);
}
Native queries return the raw column set, so entity fields not selected (or renamed) can silently come back null — keep them in sync with schema changes.

6. How would you manage entity relationships (e.g., one-to-many, many-to-many) in Spring Data JPA? Provide an example of how to configure these relationships.

One-to-many relationships are typically modeled bidirectionally with @ManyToOne owning the foreign key on the child side and @OneToMany(mappedBy = ...) on the parent, using helper methods to keep both sides in sync. Many-to-many relationships use @ManyToMany with an explicit @JoinTable, or are modeled as two one-to-many relations through a dedicated join entity when extra columns (like a timestamp) are needed on the association itself.

@Entity
public class Author {
    @Id @GeneratedValue
    private Long id;

    @OneToMany(mappedBy = "author", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Book> books = new ArrayList<>();

    public void addBook(Book book) {
        books.add(book);
        book.setAuthor(this);
    }
}

@Entity
public class Book {
    @Id @GeneratedValue
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "author_id")
    private Author author;

    @ManyToMany
    @JoinTable(name = "book_tag",
            joinColumns = @JoinColumn(name = "book_id"),
            inverseJoinColumns = @JoinColumn(name = "tag_id"))
    private Set<Tag> tags = new HashSet<>();
}
Default fetch type for @OneToMany/@ManyToMany is lazy, which is usually correct — but accessing the collection outside an open transaction/session throws LazyInitializationException.

7. What are some best practices for handling large data sets and performance tuning in Spring Data JPA applications?

Always paginate rather than loading full tables, use interface-based or DTO projections to fetch only the columns you need, and use @EntityGraph or explicit fetch joins to avoid N+1 query problems when related entities are needed. For bulk writes, batch inserts/updates (hibernate.jdbc.batch_size) and periodically flush and clear the EntityManager to avoid unbounded memory growth, and mark read-only transactions with @Transactional(readOnly = true) so Hibernate can skip dirty checking.

public interface OrderRepository extends JpaRepository<Order, Long> {

    @EntityGraph(attributePaths = {"items", "customer"})
    List<Order> findByStatus(String status);

    interface OrderSummary {
        Long getId();
        BigDecimal getTotal();
    }

    List<OrderSummary> findProjectedByStatus(String status);
}

@Transactional
public void bulkInsert(List<Order> orders) {
    int batchSize = 50;
    for (int i = 0; i < orders.size(); i++) {
        entityManager.persist(orders.get(i));
        if (i % batchSize == 0 && i > 0) {
            entityManager.flush();
            entityManager.clear();
        }
    }
}
Watch the Hibernate SQL log (or a tool like p6spy) for repeated single-row SELECTs after a collection fetch — that's the classic N+1 symptom, fixed with a fetch join or @EntityGraph.

8. How would you configure a Spring Data JPA repository to support multi-tenancy in a Spring Boot application?

Repositories themselves stay tenant-agnostic; multi-tenancy is configured at the Hibernate/datasource layer, typically as schema-per-tenant or discriminator-column-per-tenant. Implement a CurrentTenantIdentifierResolver that reads the tenant id from request context (e.g. a header or JWT claim stored in a ThreadLocal), and a MultiTenantConnectionProvider that switches schemas per connection; wire both into Hibernate through JPA properties.

@Component
public class TenantContext {
    private static final ThreadLocal<String> CURRENT_TENANT = new ThreadLocal<>();

    public static void setTenant(String tenantId) { CURRENT_TENANT.set(tenantId); }
    public static String getTenant() { return CURRENT_TENANT.get(); }
    public static void clear() { CURRENT_TENANT.remove(); }
}

public class TenantIdentifierResolver implements CurrentTenantIdentifierResolver<String> {
    @Override
    public String resolveCurrentTenantIdentifier() {
        String tenant = TenantContext.getTenant();
        return tenant != null ? tenant : "public";
    }

    @Override
    public boolean validateExistingCurrentSessions() { return true; }
}

// application.properties
// spring.jpa.properties.hibernate.multiTenancy=SCHEMA
// spring.jpa.properties.hibernate.tenant_identifier_resolver=com.example.TenantIdentifierResolver
// spring.jpa.properties.hibernate.multi_tenant_connection_provider=com.example.SchemaMultiTenantConnectionProvider
A Filter or interceptor that sets the tenant on the way in and calls TenantContext.clear() in a finally block is essential — otherwise thread-pool reuse can leak a tenant id into the next request.

Spring Core & Dependency Injection

1. Constructor vs. field vs. setter injection — which should you use and why?

Field injection (@Autowired directly on a field) is the most compact but hides required dependencies, prevents making fields final, and forces you to use reflection or a Spring test context just to unit test the class. Setter injection allows optional or reconfigurable dependencies but leaves a window where the object exists in a partially-initialized state. Constructor injection makes dependencies explicit and immutable, lets the object be fully valid the moment it's constructed, and fails fast at context startup if a required bean is missing.

@Service
public class OrderService {

    private final PaymentGateway paymentGateway;
    private final InventoryClient inventoryClient;

    // Since Spring 4.3, @Autowired is optional here because
    // this is the only constructor.
    public OrderService(PaymentGateway paymentGateway,
                         InventoryClient inventoryClient) {
        this.paymentGateway = paymentGateway;
        this.inventoryClient = inventoryClient;
    }
}
Prefer constructor injection for required dependencies and reserve setter injection for truly optional, reconfigurable collaborators. Plain unit tests can call new OrderService(mockGateway, mockClient) with no Spring context at all.

2. Two beans implement the same interface — how do you resolve @Autowired ambiguity?

When more than one bean matches an injection point's type, Spring throws NoUniqueBeanDefinitionException unless the ambiguity is resolved. @Primary marks one candidate as the default to use whenever a specific one isn't requested. @Qualifier lets the injection point name exactly which bean it wants, and a @Qualifier at the injection site always wins over a bean marked @Primary.

public interface NotificationSender {
    void send(String message);
}

@Service("emailSender")
@Primary
public class EmailNotificationSender implements NotificationSender {
    public void send(String message) { /* ... */ }
}

@Service("smsSender")
public class SmsNotificationSender implements NotificationSender {
    public void send(String message) { /* ... */ }
}

@Service
public class AlertService {
    private final NotificationSender sender;

    public AlertService(@Qualifier("smsSender") NotificationSender sender) {
        this.sender = sender;
    }
}
Only mark one bean per type as @Primary. Two @Primary beans of the same type reintroduces the exact ambiguity you were trying to avoid.

3. What bean scopes does Spring provide, and how do you define a custom one?

singleton (default) creates one shared instance per container; prototype creates a new instance on every injection/lookup and Spring does not manage its full destruction lifecycle. request and session (web-aware contexts only) tie a bean's lifetime to an HTTP request or session and typically need a scoped proxy so a singleton-scoped bean can hold a reference to them safely. Custom scopes are added by implementing org.springframework.beans.factory.config.Scope and registering it with ConfigurableBeanFactory.registerScope.

@Component
@Scope(BeanDefinition.SCOPE_PROTOTYPE)
public class ReportGenerator { /* new instance per request */ }

@Component
@Scope(value = WebApplicationContext.SCOPE_SESSION,
       proxyMode = ScopedProxyMode.TARGET_CLASS)
public class ShoppingCart { /* one per HTTP session */ }

// Registering a custom scope, e.g. "thread"
@Configuration
public class CustomScopeConfig {
    @Bean
    static CustomScopeConfigurer customScopeConfigurer() {
        CustomScopeConfigurer configurer = new CustomScopeConfigurer();
        configurer.addScope("thread", new SimpleThreadScope());
        return configurer;
    }
}
Prototype beans are handed off and forgotten — Spring calls initialization callbacks but never @PreDestroy/destroy() on them. If cleanup is needed, manage the lifecycle yourself or use a DisposableBeanAdapter pattern.

4. How does Spring detect a circular dependency between two beans, and how do you fix it?

While constructing a bean, Spring tracks it in a "beans currently in creation" set. If constructing bean A requires bean B, and constructing B requires A again, Spring detects A already in that set and throws BeanCurrentlyInCreationException (wrapped in a BeanCreationException) rather than looping forever. The cleanest fix is to refactor the shared behavior into a third bean so neither depends on the other; a quicker tactical fix is breaking the cycle with setter/field injection or @Lazy so one side receives a lazy proxy instead of the fully-constructed bean.

@Service
public class ServiceA {
    private final ServiceB serviceB;

    public ServiceA(@Lazy ServiceB serviceB) {
        this.serviceB = serviceB; // injected as a lazy proxy
    }
}

@Service
public class ServiceB {
    private final ServiceA serviceA;

    public ServiceB(ServiceA serviceA) {
        this.serviceA = serviceA;
    }
}
Since Spring Boot 2.6, circular references are disabled by default (spring.main.allow-circular-references=false), so the app now fails fast at startup instead of silently working around the cycle. Treat a circular dependency as a design smell to fix, not just a flag to flip.

5. When should you use @Bean instead of @Component?

@Component (and its specializations @Service, @Repository, @Controller) is a class-level annotation picked up by component scanning — use it for classes you own and control. @Bean is a method-level annotation inside a @Configuration class, used when you need to register a third-party class you can't annotate, apply custom construction logic, or produce several differently-configured beans of the same type.

@Configuration
public class HttpClientConfig {

    @Bean
    public RestTemplate restTemplate(RestTemplateBuilder builder) {
        return builder
                .connectTimeout(Duration.ofSeconds(3))
                .readTimeout(Duration.ofSeconds(5))
                .build();
    }
}
Inside a @Configuration class, calling one @Bean method from another (e.g. restTemplate(builder())) is safe — the CGLIB-enhanced configuration class intercepts the call and returns the existing singleton instead of creating a new one.

6. What's the difference between ApplicationContext and BeanFactory, and when does it matter?

BeanFactory is the root DI container interface — a lean, lazy-loading bean registry. ApplicationContext extends it and adds the features real applications rely on: event publishing (ApplicationEventPublisher), internationalization (MessageSource), environment/property abstraction, automatic registration of BeanPostProcessors and BeanFactoryPostProcessors, and eager pre-instantiation of singleton beans at startup so misconfiguration fails immediately rather than on first use.

public static void main(String[] args) {
    ConfigurableApplicationContext context =
            SpringApplication.run(MyApp.class, args);

    PricingEngine engine = context.getBean(PricingEngine.class);
}
In practice, every Spring Boot application uses ApplicationContext. Working with a bare BeanFactory is reserved for extremely memory-constrained environments where eager singleton instantiation and the extra features aren't affordable.

7. What's the difference between @PostConstruct/@PreDestroy, InitializingBean/DisposableBean, and BeanPostProcessor — and what order do they run in?

@PostConstruct/@PreDestroy are JSR-250 annotations, framework-agnostic and the generally recommended choice. InitializingBean/DisposableBean are Spring-specific interfaces that couple your class to the framework but avoid reflection lookups. BeanPostProcessor is a container-level hook that runs around every bean's initialization — it's the mechanism Spring itself uses to implement things like @Autowired processing and AOP proxy creation. During startup, postProcessBeforeInitialization runs, then @PostConstruct, then afterPropertiesSet(), then postProcessAfterInitialization; on shutdown, @PreDestroy runs before destroy().

Instantiate (constructor call) Populate properties (DI) BeanPostProcessor .before @PostConstruct / InitializingBean BeanPostProcessor .after Bean ready @PreDestroy / DisposableBean on shutdown later, on container close
@Component
public class CacheWarmer implements InitializingBean, DisposableBean {

    @PostConstruct
    public void postConstruct() {
        System.out.println("1. @PostConstruct");
    }

    @Override
    public void afterPropertiesSet() {
        System.out.println("2. InitializingBean#afterPropertiesSet");
    }

    @PreDestroy
    public void preDestroy() {
        System.out.println("@PreDestroy");
    }

    @Override
    public void destroy() {
        System.out.println("DisposableBean#destroy");
    }
}

@Component
public class LoggingBeanPostProcessor implements BeanPostProcessor {

    @Override
    public Object postProcessBeforeInitialization(Object bean, String beanName) {
        return bean; // runs before @PostConstruct / afterPropertiesSet
    }

    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) {
        return bean; // runs after init methods, e.g. where AOP proxies get created
    }
}
Prefer @PostConstruct/@PreDestroy for application code since they don't couple your class to Spring interfaces. Reach for BeanPostProcessor only when you need to intercept or augment every bean in the context, not just one.

8. How do @Conditional, @ConditionalOnProperty, and @Profile control bean creation?

@Profile registers a bean only when a named profile (e.g. dev, prod) is active. @ConditionalOnProperty registers a bean based on a configuration property's presence or value, which is how many Spring Boot auto-configurations turn features on and off. @Conditional is the general-purpose mechanism underlying both — you implement the Condition interface for fully custom logic evaluated against the bean factory and environment at context-load time.

@Bean
@Profile("dev")
public DataSource devDataSource() {
    return new EmbeddedDatabaseBuilder().setType(EmbeddedDatabaseType.H2).build();
}

@Bean
@ConditionalOnProperty(name = "feature.new-search.enabled", havingValue = "true")
public SearchService newSearchService() {
    return new ElasticSearchService();
}

@Bean
@Conditional(OnCloudPlatformCondition.class)
public MetricsPublisher metricsPublisher() {
    return new CloudMetricsPublisher();
}

public class OnCloudPlatformCondition implements Condition {
    @Override
    public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
        return context.getEnvironment().containsProperty("VCAP_APPLICATION");
    }
}
Forgetting to activate a profile (spring.profiles.active) in a test context is a common source of "no qualifying bean" failures — the bean definition simply never gets registered.

9. How do you inject all beans implementing an interface as an ordered list?

Spring auto-injects a List<T> (or Map<String, T>) of every bean matching type T when that's the declared injection type — no special annotation is required beyond the usual constructor injection. Ordering is controlled with @Order (or by implementing Ordered); Spring sorts the collection using AnnotationAwareOrderComparator, lowest value first, with unordered beans sorted last.

public interface DiscountRule {
    BigDecimal apply(BigDecimal price);
}

@Component
@Order(1)
public class LoyaltyDiscountRule implements DiscountRule {
    public BigDecimal apply(BigDecimal price) { return price.multiply(new BigDecimal("0.95")); }
}

@Component
@Order(2)
public class SeasonalDiscountRule implements DiscountRule {
    public BigDecimal apply(BigDecimal price) { return price.multiply(new BigDecimal("0.90")); }
}

@Service
public class PricingEngine {
    private final List<DiscountRule> rules;

    public PricingEngine(List<DiscountRule> rules) {
        this.rules = rules; // already sorted: Loyalty, then Seasonal
    }
}
Use @Order values with gaps (10, 20, 30) rather than 1, 2, 3 — it leaves room to insert a rule between two existing ones later without renumbering everything.

10. What does @Lazy do, and what pitfalls come with lazy bean initialization?

@Lazy defers a singleton bean's creation until it's first requested instead of at context startup, useful for expensive-to-construct beans that are only sometimes needed, or for breaking circular dependencies. It can be applied per-bean, per-injection-point, or globally via spring.main.lazy-initialization=true. The trade-off is that Spring's default fail-fast behavior — validating all singleton wiring at startup — is weakened: a misconfigured bean may not surface an error until the first real request hits it in production.

@Component
@Lazy
public class ReportingEngine {
    public ReportingEngine() {
        // expensive setup: loads templates, warms caches, etc.
    }
}

@RestController
public class AdminController {
    private final ReportingEngine reportingEngine;

    // @Lazy here injects a proxy; the real bean is created on first use
    public AdminController(@Lazy ReportingEngine reportingEngine) {
        this.reportingEngine = reportingEngine;
    }
}
Avoid flipping global lazy initialization on just to "fix" a circular dependency or speed up startup metrics — it can mask configuration errors until real traffic triggers them. Use targeted @Lazy on specific beans and keep startup fail-fast checks (health/readiness probes, integration tests) in place.

Spring Boot Auto-Configuration & Starters

1. What does @SpringBootApplication actually do, and how does @EnableAutoConfiguration decide which beans to register?

@SpringBootApplication is a meta-annotation that bundles @SpringBootConfiguration (a specialized @Configuration), @ComponentScan, and @EnableAutoConfiguration. @ComponentScan finds your own @Component/@Service/@Repository/@Controller classes starting at the package of the annotated class. @EnableAutoConfiguration triggers ImportAutoConfigConfiguration/AutoConfigurationImportSelector, a deferred ImportSelector that runs after regular component scanning and programmatically imports a curated list of @Configuration classes, each guarded by @Conditional checks so only the ones relevant to your classpath and properties actually register beans.

@SpringBootApplication
public class ShippingServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(ShippingServiceApplication.class, args);
    }
}

// Equivalent to:
@SpringBootConfiguration
@ComponentScan
@EnableAutoConfiguration
public class ShippingServiceApplicationExpanded { }
AutoConfigurationImportSelector implements DeferredImportSelector, so it always processes candidate imports after all other @Import and @ComponentScan-discovered configurations are registered, which is why auto-configured beans can safely back off in favor of your own.

2. How do you build a custom Spring Boot starter, and why is it conventionally split into an "autoconfigure" module and a "starter" module?

The autoconfigure module contains the actual @Configuration classes, @ConfigurationProperties classes, and the conditional logic, with only optional/provided dependencies on the libraries it configures. The starter module is typically an empty POM/build.gradle whose sole purpose is to declare the runtime dependencies (the autoconfigure module plus the library itself) so consumers get everything with one dependency line. This separation lets other projects reuse the autoconfigure logic without pulling in your specific starter's dependency set.

// acme-spring-boot-autoconfigure module
@AutoConfiguration
@ConditionalOnClass(AcmeClient.class)
@EnableConfigurationProperties(AcmeProperties.class)
public class AcmeAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean
    public AcmeClient acmeClient(AcmeProperties properties) {
        return new AcmeClient(properties.getApiKey(), properties.getEndpoint());
    }
}

@ConfigurationProperties(prefix = "acme")
public class AcmeProperties {
    private String apiKey;
    private String endpoint = "https://api.acme.io";
    // getters/setters
}
Register the configuration class in META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports inside the autoconfigure module, not the starter module — the starter module should contain no Java code at all.

3. How do you exclude a specific auto-configuration class, and when would you use the annotation form versus the properties form?

You can exclude via the exclude/excludeName attribute on @SpringBootApplication or @EnableAutoConfiguration when the class is on the compile classpath, or via the spring.autoconfigure.exclude property when you need it to vary per environment (e.g. disable it only in a test profile) or when the class isn't compile-time visible. The property form is additive with the annotation form — both are unioned before auto-configuration classes are filtered out.

@SpringBootApplication(exclude = { DataSourceAutoConfiguration.class })
public class ReportingServiceApplication { }
# application-test.yml
spring:
  autoconfigure:
    exclude:
      - org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration
Excluding an auto-configuration doesn't just remove its default beans — it also skips any @ConditionalOnBean checks elsewhere that depend on it, which can silently disable downstream auto-configurations too.

4. How does the AutoConfiguration.imports mechanism in Spring Boot 2.7+/3.x replace spring.factories, and how is ordering determined now?

Auto-configuration classes used to be listed under the org.springframework.boot.autoconfigure.EnableAutoConfiguration key in META-INF/spring.factories. Since Boot 2.7, they're listed one-per-line in META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports, a plain text file, and classes are annotated with @AutoConfiguration instead of plain @Configuration. Ordering is no longer purely file-order-based: it's driven by @AutoConfigureBefore/@AutoConfigureAfter/@AutoConfigureOrder metadata plus the declaration order within the imports file as a tiebreaker, and Boot pre-computes this metadata at build time into an auto-configuration metadata properties file for faster startup.

# src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
com.acme.autoconfigure.AcmeAutoConfiguration
com.acme.autoconfigure.AcmeWebMvcAutoConfiguration
@AutoConfiguration(after = DataSourceAutoConfiguration.class)
public class AcmeAutoConfiguration { }
spring.factories entries under the EnableAutoConfiguration key still work for backward compatibility but are deprecated; new starters should use the imports file, and @Configuration-only classes (no ordering/conditions) can simply switch to @AutoConfiguration.

5. How do you write a conditional auto-configuration class using @ConditionalOnClass, @ConditionalOnMissingBean, and @ConditionalOnProperty together?

@ConditionalOnClass gates the whole configuration on a library being present on the classpath, so it's cheap to evaluate and avoids ClassNotFoundException. @ConditionalOnProperty lets users opt out or opt in via configuration without touching code, typically with a sensible default via matchIfMissing. @ConditionalOnMissingBean is applied at the bean-method level, not the class level, so a user-supplied bean of the same type always wins and Boot's bean simply backs off.

@AutoConfiguration
@ConditionalOnClass(CacheManager.class)
@ConditionalOnProperty(prefix = "acme.cache", name = "enabled", matchIfMissing = true)
@EnableConfigurationProperties(AcmeCacheProperties.class)
public class AcmeCacheAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean
    public CacheManager cacheManager(AcmeCacheProperties properties) {
        CaffeineCacheManager manager = new CaffeineCacheManager();
        manager.setCacheSpecification(properties.getSpec());
        return manager;
    }
}
Put @ConditionalOnMissingBean on the @Bean method, not on the @AutoConfiguration class — putting it on the class checks for the bean before any of the class's own beans exist, which trivially always passes and defeats the purpose.

6. How can you customize an auto-configured bean (e.g. Jackson's ObjectMapper or a DataSource) without disabling Boot's defaults entirely?

Rather than defining your own competing @Bean (which fully replaces Boot's, forcing you to reimplement everything it wired up), bind a @ConfigurationProperties class to expose the knobs Boot already respects, or implement a customizer callback interface that Boot auto-detects and applies to the bean it's about to create. This preserves all the sensible defaults while letting you tweak just the parts you care about.

@Component
public class AcmeJacksonCustomizer implements Jackson2ObjectMapperBuilderCustomizer {

    @Override
    public void customize(Jackson2ObjectMapperBuilder builder) {
        builder.simpleDateFormat("yyyy-MM-dd")
               .serializationInclusion(JsonInclude.Include.NON_NULL);
    }
}
spring:
  datasource:
    hikari:
      maximum-pool-size: 20
      connection-timeout: 3000
Most Boot auto-configurations expose a *Customizer interface (Jackson2ObjectMapperBuilderCustizer, RestTemplateCustomizer, WebServerFactoryCustomizer) precisely so you can hook into bean creation instead of fighting @ConditionalOnMissingBean by redefining the whole bean.

7. An auto-configuration you expected to run didn't apply — how do you debug it using the condition evaluation report?

Run the app with --debug (or set debug=true/DEBUG env var) to have Boot print the ConditionEvaluationReport at startup, which lists every auto-configuration class under "Positive matches" and "Negative matches" along with the exact condition that passed or failed. For programmatic inspection (e.g. in a test), you can pull the same report from the ApplicationContext via ConditionEvaluationReport.get(...).

java -jar app.jar --debug
# or
./gradlew bootRun --args='--debug'
Negative matches:
-----------------
   DataSourceAutoConfiguration:
      Did not match:
         - @ConditionalOnClass did not find required class 'javax.sql.DataSource' (OnClassCondition)
actuator's /actuator/conditions endpoint exposes the same report over HTTP in a running app — enable it with management.endpoints.web.exposure.include=conditions when you can't restart with --debug in a deployed environment.

8. How do you control the order in which two auto-configuration classes are processed, and why does it matter?

Ordering matters because a later configuration's @ConditionalOnBean or @ConditionalOnMissingBean checks depend on which beans already exist at evaluation time — get the order wrong and a condition can be evaluated before the bean it's checking for has been registered. @AutoConfigureBefore/@AutoConfigureAfter express relative ordering against specific named auto-configuration classes, while @AutoConfigureOrder sets a general priority (lower values run first, similar to @Order) independent of any specific class.

@AutoConfiguration
@AutoConfigureAfter(DataSourceAutoConfiguration.class)
@AutoConfigureBefore(HibernateJpaAutoConfiguration.class)
@ConditionalOnBean(DataSource.class)
public class AcmeAuditingAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean
    public AuditLogger auditLogger(DataSource dataSource) {
        return new JdbcAuditLogger(dataSource);
    }
}
@AutoConfigureBefore/After only reorder auto-configuration classes relative to each other — they have no effect on your own regular @Configuration classes, and they cannot be used to force ordering against classes outside the auto-configuration import mechanism.

Spring MVC & REST APIs

1. What is the difference between @RestController and @Controller, and what does @ResponseBody actually change?

@Controller marks a class as an MVC controller whose methods, by default, return a logical view name that gets resolved to a template. @ResponseBody tells Spring to skip view resolution entirely and instead serialize the return value straight into the HTTP response body using an HttpMessageConverter. @RestController is simply a meta-annotation that combines @Controller and @ResponseBody at the class level, so every handler method behaves as if annotated with @ResponseBody.

// Traditional MVC controller - returns a view name
@Controller
public class PageController {

    @GetMapping("/orders/{id}")
    public String orderPage(@PathVariable Long id, Model model) {
        model.addAttribute("order", orderService.findById(id));
        return "order-detail"; // resolved to a template (e.g. Thymeleaf)
    }

    @GetMapping("/orders/{id}/summary")
    @ResponseBody
    public OrderDto orderSummaryJson(@PathVariable Long id) {
        return orderService.toDto(orderService.findById(id)); // written as JSON
    }
}

// REST controller - every method behaves like it has @ResponseBody
@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @GetMapping("/{id}")
    public OrderDto getOrder(@PathVariable Long id) {
        return orderService.toDto(orderService.findById(id));
    }
}
Mixing both in one app is fine: keep page-rendering controllers as @Controller and expose your JSON/XML API surface with @RestController, rather than sprinkling @ResponseBody on individual methods.

2. How does Spring MVC handle request/response bodies and content negotiation with the Accept header?

Spring registers a chain of HttpMessageConverter beans (Jackson for JSON, JAXB for XML, StringHttpMessageConverter, etc.). On the way in, @RequestBody picks the first converter whose supported media type matches the Content-Type header to deserialize the payload. On the way out, the ContentNegotiationManager inspects the Accept header (and optionally a path extension or query parameter) to choose which converter serializes the return value, and 406 Not Acceptable is returned if no converter can produce a compatible type.

@RestController
@RequestMapping("/api/products")
public class ProductController {

    // Content-Type of the incoming request selects the reader
    @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
    public ResponseEntity<ProductDto> create(@RequestBody ProductDto dto) {
        ProductDto saved = productService.save(dto);
        return ResponseEntity.status(HttpStatus.CREATED).body(saved);
    }

    // Accept header selects the writer; both JSON and XML converters must be on the classpath
    @GetMapping(value = "/{id}", produces = { MediaType.APPLICATION_JSON_VALUE, MediaType.APPLICATION_XML_VALUE })
    public ProductDto get(@PathVariable Long id) {
        return productService.findDto(id);
    }
}
Do not enable ContentNegotiationConfigurer.favorParameter() without care - letting clients switch response format via a query string (e.g. ?format=json) can be used to bypass Accept-based access controls and complicates caching.

3. How do you validate request payloads with @Valid/@Validated, and how would you write a custom validation constraint?

@Valid (JSR-380/Bean Validation) triggers cascading validation of an object graph and is typically used on @RequestBody or @ModelAttribute arguments; a failure throws MethodArgumentNotValidException. @Validated is Spring's own annotation - it supports validation groups and, when placed on the class, enables method-level constraint validation on individual @RequestParam/@PathVariable arguments (throwing ConstraintViolationException instead). A custom constraint pairs a annotation with a ConstraintValidator implementation.

public record CreateUserRequest(
    @NotBlank @Email String email,
    @NotBlank @Size(min = 8, max = 64) String password,
    @ValidCountryCode String countryCode
) {}

@RestController
@RequestMapping("/api/users")
@Validated
public class UserController {

    @PostMapping
    public ResponseEntity<Void> create(@Valid @RequestBody CreateUserRequest request) {
        userService.register(request);
        return ResponseEntity.status(HttpStatus.CREATED).build();
    }

    @GetMapping
    public List<UserDto> search(@RequestParam @Min(1) int page, @RequestParam @Max(100) int size) {
        return userService.search(page, size);
    }
}

// Custom constraint
@Target({ ElementType.FIELD, ElementType.PARAMETER })
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = CountryCodeValidator.class)
public @interface ValidCountryCode {
    String message() default "must be a valid ISO 3166-1 alpha-2 country code";
    Class[] groups() default {};
    Class[] payload() default {};
}

public class CountryCodeValidator implements ConstraintValidator<ValidCountryCode, String> {
    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        return value != null && Set.of(Locale.getISOCountries()).contains(value.toUpperCase());
    }
}
Method-level validation (@RequestParam/@PathVariable constraints) only works if @Validated is present on the controller class itself - @Valid alone will not trigger it.

4. How do you implement global exception handling with @ControllerAdvice, and what should a consistent error response look like?

@ControllerAdvice combined with @ExceptionHandler centralizes exception-to-HTTP-response mapping so individual controllers stay free of try/catch noise. Spring Boot 3 / Spring 6 ship the RFC 7807 ProblemDetail type, which gives you a standard shape (type, title, status, detail, instance) that you can extend with custom fields. Extending ResponseEntityExceptionHandler lets you override handling for Spring's own exceptions (like MethodArgumentNotValidException) while adding your own handlers for domain exceptions.

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ProblemDetail handleNotFound(ResourceNotFoundException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
        problem.setTitle("Resource not found");
        problem.setProperty("errorCode", "RESOURCE_NOT_FOUND");
        return problem;
    }

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed");
        List<String> fieldErrors = ex.getBindingResult().getFieldErrors().stream()
                .map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
                .toList();
        problem.setProperty("errors", fieldErrors);
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(problem);
    }

    @ExceptionHandler(Exception.class)
    public ProblemDetail handleUnexpected(Exception ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred");
    }
}
Always keep a catch-all handler for generic Exception at the bottom, and never leak stack traces or internal exception messages to clients in the "detail" field.

5. What are the main approaches to versioning REST APIs, and what are their trade-offs?

URI versioning puts the version directly in the path (/api/v1/orders); it is explicit, cache-friendly, and easy to test, but "pollutes" the URI and implies a resource has multiple identities. Header versioning uses a custom header or Accept media type parameter, keeping URIs stable but making the API harder to explore in a browser and less cache-friendly by default. Request-parameter versioning is the simplest to add on but is the easiest to overlook and rarely considered good REST practice for long-term APIs.

// 1) URI-based
@RestController
@RequestMapping("/api/v2/orders")
public class OrderV2Controller {
    @GetMapping("/{id}")
    public OrderV2Dto get(@PathVariable Long id) { return orderService.getV2(id); }
}

// 2) Header-based (custom header)
@RestController
@RequestMapping("/api/orders")
public class OrderController {
    @GetMapping(value = "/{id}", headers = "X-API-Version=2")
    public OrderV2Dto getV2(@PathVariable Long id) { return orderService.getV2(id); }

    @GetMapping(value = "/{id}", headers = "X-API-Version=1")
    public OrderV1Dto getV1(@PathVariable Long id) { return orderService.getV1(id); }
}

// 3) Media-type / Accept-header based
@GetMapping(value = "/{id}", produces = "application/vnd.acme.order.v2+json")
public OrderV2Dto getViaMediaType(@PathVariable Long id) { return orderService.getV2(id); }

// 4) Request-param based
@GetMapping(value = "/{id}", params = "version=2")
public OrderV2Dto getViaParam(@PathVariable Long id) { return orderService.getV2(id); }
Header and media-type versioning can silently break behind CDNs/proxies that key caches only on the URL - if you rely on them, make sure Vary headers and cache keys account for the version dimension.

6. How do you implement HATEOAS in a Spring Boot REST API using Spring HATEOAS?

Spring HATEOAS wraps your DTOs in EntityModel (single resource) or CollectionModel (collections) and attaches Link objects describing related actions or resources, letting clients navigate the API rather than hard-coding URLs. The WebMvcLinkBuilder's linkTo(methodOn(...)) idiom builds links type-safely from controller method signatures instead of string concatenation, so refactors that change a mapping automatically update the generated links.

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @GetMapping("/{id}")
    public EntityModel<OrderDto> getOrder(@PathVariable Long id) {
        OrderDto order = orderService.findDto(id);

        EntityModel<OrderDto> model = EntityModel.of(order);
        model.add(linkTo(methodOn(OrderController.class).getOrder(id)).withSelfRel());
        model.add(linkTo(methodOn(OrderController.class).cancelOrder(id)).withRel("cancel"));
        model.add(linkTo(methodOn(CustomerController.class).getCustomer(order.customerId())).withRel("customer"));
        return model;
    }

    @GetMapping
    public CollectionModel<EntityModel<OrderDto>> getOrders() {
        List<EntityModel<OrderDto>> orders = orderService.findAllDto().stream()
                .map(o -> EntityModel.of(o, linkTo(methodOn(OrderController.class).getOrder(o.id())).withSelfRel()))
                .toList();
        return CollectionModel.of(orders, linkTo(methodOn(OrderController.class).getOrders()).withSelfRel());
    }
}
Add the spring-boot-starter-hateoas dependency and Spring Boot will auto-configure the appropriate HttpMessageConverter (application/hal+json) for EntityModel/CollectionModel responses.

7. How do you handle file upload and download in a REST controller?

File uploads arrive as multipart/form-data and are bound to a MultipartFile parameter, which gives access to the original filename, content type, size, and an InputStream/bytes for storage. Downloads should stream a Resource (or byte array) back with an explicit Content-Type and a Content-Disposition header so browsers know whether to render or save the file, rather than returning a raw byte[] with no headers.

@RestController
@RequestMapping("/api/files")
public class FileController {

    @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public ResponseEntity<String> upload(@RequestParam("file") MultipartFile file) throws IOException {
        if (file.isEmpty()) {
            return ResponseEntity.badRequest().body("File must not be empty");
        }
        String storedName = fileStorageService.store(file.getOriginalFilename(), file.getInputStream());
        return ResponseEntity.status(HttpStatus.CREATED).body(storedName);
    }

    @GetMapping("/{filename}")
    public ResponseEntity<Resource> download(@PathVariable String filename) {
        Resource resource = fileStorageService.loadAsResource(filename);
        String contentType = "application/octet-stream";
        return ResponseEntity.ok()
                .contentType(MediaType.parseMediaType(contentType))
                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + resource.getFilename() + "\"")
                .body(resource);
    }
}
Never trust the client-supplied filename directly for storage paths - sanitize it and resolve it against a fixed base directory to prevent path-traversal (e.g. "../../etc/passwd"), and enforce max file size via spring.servlet.multipart.max-file-size.

8. How do you write a custom HandlerMethodArgumentResolver?

HandlerMethodArgumentResolver lets you inject custom logic to resolve a controller method parameter, such as pulling the authenticated user off the security context based on a marker annotation, instead of repeating that lookup in every method. You implement supportsParameter() to decide which parameters it applies to and resolveArgument() to produce the value, then register the resolver via WebMvcConfigurer.

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface CurrentUser {}

public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(CurrentUser.class)
                && parameter.getParameterType().equals(AppUser.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
            NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth == null || !auth.isAuthenticated()) {
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "No authenticated user");
        }
        return (AppUser) auth.getPrincipal();
    }
}

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new CurrentUserArgumentResolver());
    }
}

@GetMapping("/api/me")
public AppUserDto me(@CurrentUser AppUser user) {
    return AppUserDto.from(user);
}
Keep the resolver narrowly scoped (specific annotation + specific type) in supportsParameter() - an overly broad match can silently shadow Spring's built-in resolvers.

9. What is the difference between a HandlerInterceptor and a Filter, and when should you use each?

A Filter is a Servlet-spec construct that runs in the servlet container before the request even reaches DispatcherServlet, so it has no knowledge of which controller/handler will process the request - good for cross-cutting, framework-agnostic concerns like authentication, CORS, logging, or compression. A HandlerInterceptor runs inside the Spring MVC pipeline, after handler mapping has resolved the target method, giving it access to the HandlerMethod, the Model/View, and hooks before, after, and once the view has been rendered - better suited to MVC-specific concerns like auditing which controller was hit or enriching the model.

// Filter - servlet-level, no knowledge of the Spring handler
@Component
public class RequestLoggingFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
            throws ServletException, IOException {
        long start = System.currentTimeMillis();
        chain.doFilter(req, res);
        log.info("{} {} -> {} in {}ms", req.getMethod(), req.getRequestURI(), res.getStatus(),
                System.currentTimeMillis() - start);
    }
}

// HandlerInterceptor - MVC-level, knows the handler
public class AuditInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        if (handler instanceof HandlerMethod hm) {
            log.info("Dispatching to {}#{}", hm.getBeanType().getSimpleName(), hm.getMethod().getName());
        }
        return true;
    }
}

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new AuditInterceptor()).addPathPatterns("/api/**");
    }
}
Use Filters for anything that must run regardless of framework (security, CORS, gzip) and Interceptors for anything that needs the resolved handler or Spring's Model - don't duplicate the same concern in both layers.

10. How do you configure CORS for a Spring Boot REST API, both globally and per controller?

Global CORS configuration is set once via a WebMvcConfigurer bean and applies consistently across the whole application, which is preferable for most APIs. Per-controller (or per-method) @CrossOrigin annotations override or narrow that global policy for specific endpoints that need different allowed origins or methods. Both ultimately configure the same CorsConfiguration machinery that Spring uses to answer preflight OPTIONS requests.

// Global configuration
@Configuration
public class CorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/api/**")
                .allowedOrigins("https://app.example.com")
                .allowedMethods("GET", "POST", "PUT", "DELETE")
                .allowedHeaders("*")
                .allowCredentials(true)
                .maxAge(3600);
    }
}

// Per-controller override
@RestController
@RequestMapping("/api/public/reports")
@CrossOrigin(origins = "*", methods = { RequestMethod.GET })
public class PublicReportController {

    @GetMapping("/{id}")
    public ReportDto get(@PathVariable Long id) {
        return reportService.findDto(id);
    }
}
Never combine allowedOrigins("*") with allowCredentials(true) - the CORS spec disallows the wildcard origin when credentials are allowed, and browsers will reject the response; list explicit origins instead.

11. How do you implement async request processing in a Spring MVC controller using Callable, DeferredResult, or CompletableFuture?

Returning Callable<T> hands the handler execution off to Spring's configured task executor while the request thread is released back to the container, useful when the work is CPU/IO-bound but synchronous in nature. DeferredResult is used when completion is driven by an external event (a message arriving, a callback firing) rather than by a thread Spring manages for you. CompletableFuture composes naturally with async client calls (WebClient, async DB drivers) and lets you chain transformations before the response is written.

@RestController
@RequestMapping("/api/reports")
public class ReportController {

    // Callable - Spring runs it on the configured AsyncTaskExecutor
    @GetMapping("/{id}/callable")
    public Callable<ReportDto> getViaCallable(@PathVariable Long id) {
        return () -> reportService.generate(id); // blocking work, off the request thread
    }

    // DeferredResult - completed later by some external event/callback
    @GetMapping("/{id}/deferred")
    public DeferredResult<ReportDto> getViaDeferred(@PathVariable Long id) {
        DeferredResult<ReportDto> result = new DeferredResult<>(5000L,
                ReportDto.timeout());
        reportEventBus.onReportReady(id, result::setResult);
        return result;
    }

    // CompletableFuture - composes with other async calls
    @GetMapping("/{id}/future")
    public CompletableFuture<ReportDto> getViaFuture(@PathVariable Long id) {
        return reportService.generateAsync(id)
                .thenApply(this::enrichWithMetadata);
    }
}

@Configuration
public class AsyncConfig implements WebMvcConfigurer {
    @Override
    public void configureAsyncSupport(AsyncSupportConfigurer configurer) {
        configurer.setDefaultTimeout(10_000);
        configurer.setTaskExecutor(applicationTaskExecutor());
    }
}
Always set an explicit timeout (per-DeferredResult or globally via configureAsyncSupport) and provide a fallback/timeout value - an async request left hanging indefinitely will exhaust the servlet container's async request slots.

12. How would you implement basic rate limiting on a REST endpoint?

The cleanest way is a token-bucket algorithm applied per client key (API key, user id, or IP) inside a HandlerInterceptor, so the limiting logic is centralized and reusable across controllers instead of duplicated in each handler. Bucket4j provides a ready-made, thread-safe bucket implementation you configure with a refill rate and capacity; the interceptor just asks the bucket to try to consume a token and returns 429 when it's empty.

@Component
public class RateLimitInterceptor implements HandlerInterceptor {

    private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();

    private Bucket newBucket() {
        Bandwidth limit = Bandwidth.classic(20, Refill.greedy(20, Duration.ofMinutes(1)));
        return Bucket.builder().addLimit(limit).build();
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws IOException {
        String apiKey = request.getHeader("X-API-Key");
        String clientKey = (apiKey != null) ? apiKey : request.getRemoteAddr();
        Bucket bucket = buckets.computeIfAbsent(clientKey, k -> newBucket());

        ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);
        response.addHeader("X-RateLimit-Remaining", String.valueOf(probe.getRemainingTokens()));

        if (probe.isConsumed()) {
            return true;
        }
        long waitSeconds = TimeUnit.NANOSECONDS.toSeconds(probe.getNanosToWaitForRefill());
        response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
        response.addHeader("Retry-After", String.valueOf(waitSeconds));
        return false;
    }
}

@Configuration
public class RateLimitConfig implements WebMvcConfigurer {
    private final RateLimitInterceptor rateLimitInterceptor;
    public RateLimitConfig(RateLimitInterceptor rateLimitInterceptor) {
        this.rateLimitInterceptor = rateLimitInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(rateLimitInterceptor).addPathPatterns("/api/**");
    }
}
An in-memory ConcurrentHashMap of buckets only works for a single instance - behind a load balancer with multiple replicas, each instance enforces its own limit independently, so back this with a shared store (e.g. Bucket4j's Redis/Hazelcast proxy manager) for a correct distributed rate limit.

Spring Boot Testing

1. When would you reach for @SpringBootTest versus a slice test like @WebMvcTest or @DataJpaTest?

@SpringBootTest boots the entire ApplicationContext (all beans, optionally a real embedded server), which is realistic but slow, so it's reserved for true end-to-end integration checks that span multiple layers. Slice annotations like @WebMvcTest, @DataJpaTest, and @JsonTest auto-configure only the beans relevant to one layer (MVC infrastructure, JPA repositories, Jackson) and skip the rest, keeping tests fast and focused. Overusing @SpringBootTest everywhere makes the suite slow and gives you weaker isolation of failures.

// Fast, layer-focused
@WebMvcTest(OrderController.class)
class OrderControllerSliceTest {
    @Autowired MockMvc mockMvc;
    @MockBean OrderService orderService;
    // only web layer beans are loaded
}

// Slow, full context, real interaction across layers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OrderIntegrationTest {
    @Autowired TestRestTemplate restTemplate;
    // entire application context is started
}
Default to slice tests for the majority of your suite; keep @SpringBootTest for a smaller set of true integration scenarios (e.g. checking wiring, security filter chains, or multi-layer flows end to end).

2. What's the practical difference between @MockBean and a plain Mockito @Mock?

@MockBean creates a Mockito mock and registers it in the Spring ApplicationContext, replacing the real bean of that type so it gets injected everywhere the real bean would be — useful inside @SpringBootTest or slice tests where the class under test is resolved by Spring's DI. A plain Mockito @Mock (with @ExtendWith(MockitoExtension.class)) never touches Spring at all; you construct the object under test yourself and wire the mock in manually, so there's no ApplicationContext to start and tests run much faster.

// Spring-aware: mock replaces the bean inside the context
@WebMvcTest(OrderController.class)
class OrderControllerTest {
    @Autowired MockMvc mockMvc;
    @MockBean OrderService orderService; // swapped into the context
}

// Pure Mockito: no Spring context involved
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
    @Mock OrderRepository orderRepository;
    @InjectMocks OrderServiceImpl orderService; // wired manually
}
Each distinct combination of @MockBean overrides forces Spring to cache a separate ApplicationContext; scattering different @MockBean sets across many test classes multiplies context startups and slows the whole build. Since Spring Boot 3.4, prefer the newer @MockitoBean/@MockitoSpyBean annotations, as @MockBean/@SpyBean are deprecated.

3. How do you test a REST controller's status code and JSON response body with MockMvc?

MockMvc lets you dispatch a simulated HTTP request through the Spring MVC stack without starting a real server, then assert on the resulting status, headers, and body. Combine it with jsonPath() to make targeted assertions on fields inside the JSON payload rather than comparing the whole raw string.

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired MockMvc mockMvc;
    @MockBean OrderService orderService;

    @Test
    void returnsOrderAsJson() throws Exception {
        given(orderService.findById(1L))
            .willReturn(new OrderDto(1L, "NEW", 49.99));

        mockMvc.perform(get("/api/orders/1"))
            .andExpect(status().isOk())
            .andExpect(content().contentType(MediaType.APPLICATION_JSON))
            .andExpect(jsonPath("$.id").value(1))
            .andExpect(jsonPath("$.status").value("NEW"))
            .andExpect(jsonPath("$.total").value(49.99));
    }
}
Use andExpect(jsonPath(...)) instead of deserializing the whole response — it keeps the assertion resilient to unrelated fields being added to the DTO later.

4. How do you use Testcontainers to run integration tests against a real Postgres database instead of H2?

Testcontainers spins up a throwaway Docker container running the real database engine for the duration of the test, so your queries, constraints, and dialect-specific SQL are validated against the actual product rather than an in-memory approximation like H2 that can behave subtly differently. @DynamicPropertySource wires the container's randomly assigned JDBC URL, username, and password into the Spring context before it starts.

@Testcontainers
@SpringBootTest
class OrderRepositoryIT {

    @Container
    static PostgreSQLContainer postgres =
        new PostgreSQLContainer<>("postgres:16-alpine");

    @DynamicPropertySource
    static void registerProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired OrderRepository orderRepository;

    @Test
    void savesAndFindsOrder() {
        Order saved = orderRepository.save(new Order("NEW", 49.99));
        assertThat(orderRepository.findById(saved.getId())).isPresent();
    }
}
H2's "Postgres compatibility mode" doesn't catch every dialect quirk (JSONB columns, sequences, certain constraint behaviors); relying on it can let real Postgres-only bugs slip past your test suite.

5. How do you test a Kafka producer and consumer without depending on a live cluster?

spring-kafka-test's @EmbeddedKafka starts an in-process Kafka broker (backed by Zookeeper-less KRaft or the classic embedded broker) for the test's lifetime, so producers and consumers talk to a real broker protocol without external infrastructure. You publish through the actual KafkaTemplate and consume with a test consumer built via KafkaTestUtils, polling until the expected record arrives.

@SpringBootTest
@EmbeddedKafka(partitions = 1, topics = "orders")
class OrderProducerIT {

    @Autowired KafkaTemplate<String, String> kafkaTemplate;
    @Autowired EmbeddedKafkaBroker embeddedKafkaBroker;

    @Test
    void publishesOrderCreatedEvent() {
        Map<String, Object> consumerProps =
            KafkaTestUtils.consumerProps("test-group", "true", embeddedKafkaBroker);
        DefaultKafkaConsumerFactory<String, String> factory =
            new DefaultKafkaConsumerFactory<>(consumerProps);
        Consumer<String, String> consumer = factory.createConsumer();
        embeddedKafkaBroker.consumeFromAnEmbeddedTopic(consumer, "orders");

        kafkaTemplate.send("orders", "order-1", "{\"status\":\"NEW\"}");

        ConsumerRecord<String, String> record =
            KafkaTestUtils.getSingleRecord(consumer, "orders");
        assertThat(record.value()).contains("NEW");
        consumer.close();
    }
}
For larger suites, a Testcontainers KafkaContainer is often preferred over @EmbeddedKafka — it exercises the same wire protocol as production and avoids classpath/version coupling to the embedded broker.

6. What is consumer-driven contract testing, and how does it prevent breaking changes between microservices?

In consumer-driven contract testing, the consumer of an API defines a contract describing the exact requests it will send and the responses it expects; that contract is then used to generate a test the provider must pass in its own build, catching incompatible changes before they reach production. Spring Cloud Contract expresses these as Groovy/YAML contracts that generate MockMvc-based verifier tests on the provider side, while Pact captures interactions recorded by the consumer's own tests and replays them against the provider ("pact verification"), with both approaches decoupling teams from needing full end-to-end environments to catch breakage.

// Provider side: base class the generated contract tests extend
@SpringBootTest
public abstract class ContractVerifierBase {

    @Autowired OrderController orderController;

    @BeforeEach
    void setup() {
        RestAssuredMockMvc.standaloneSetup(orderController);
    }
}
// Spring Cloud Contract Verifier plugin reads contracts under
// src/test/resources/contracts and generates a test class at build time
// (e.g. ShouldReturnOrderTest extends ContractVerifierBase) that fails
// the provider build if the actual response no longer matches the contract.
Run contract verification in CI on every provider build so a breaking API change fails fast, before it reaches a shared staging environment where multiple teams would be blocked simultaneously.

7. How do you test endpoints secured by Spring Security using @WithMockUser or @WithUserDetails?

@WithMockUser populates the SecurityContext with a synthetic Authentication for the duration of a test method, letting you assert how an endpoint behaves for a given username, role, or authority without a real login flow. @WithUserDetails goes further by loading the actual principal through your configured UserDetailsService, which is useful when your authorization logic reads custom fields off the real domain user rather than just roles.

@WebMvcTest(AdminController.class)
class AdminControllerSecurityTest {

    @Autowired MockMvc mockMvc;

    @Test
    @WithMockUser(username = "alice", roles = {"ADMIN"})
    void adminCanAccessDashboard() throws Exception {
        mockMvc.perform(get("/admin/dashboard"))
            .andExpect(status().isOk());
    }

    @Test
    @WithMockUser(roles = {"USER"})
    void regularUserIsForbidden() throws Exception {
        mockMvc.perform(get("/admin/dashboard"))
            .andExpect(status().isForbidden());
    }

    @Test
    @WithUserDetails("alice@example.com")
    void loadsRealPrincipalFromUserDetailsService() throws Exception {
        mockMvc.perform(get("/admin/profile"))
            .andExpect(status().isOk());
    }
}
@WithMockUser bypasses your UserDetailsService entirely, so if authorization decisions depend on data only your real principal carries (e.g. a tenant ID), use @WithUserDetails or a custom @WithSecurityContext annotation instead — otherwise the test can pass while the real login path fails.

8. How do you use @JsonTest to verify a DTO serializes and deserializes correctly?

@JsonTest starts a minimal Spring context containing only Jackson auto-configuration (ObjectMapper, registered modules, any custom serializers/deserializers), and auto-configures a JacksonTester helper for the type you're testing. This lets you assert on the exact JSON shape produced (useful for catching accidental field renames or date-format regressions) as well as verify that incoming JSON maps back to the expected object graph.

@JsonTest
class OrderDtoJsonTest {

    @Autowired JacksonTester<OrderDto> json;

    @Test
    void serializesToExpectedJson() throws Exception {
        OrderDto dto = new OrderDto(1L, "NEW", 49.99);

        assertThat(json.write(dto)).hasJsonPathNumberValue("$.id");
        assertThat(json.write(dto)).extractingJsonPathStringValue("$.status")
            .isEqualTo("NEW");
    }

    @Test
    void deserializesFromJson() throws Exception {
        String content = "{\"id\":1,\"status\":\"NEW\",\"total\":49.99}";

        OrderDto dto = json.parse(content).getObject();
        assertThat(dto.status()).isEqualTo("NEW");
    }
}
@JsonTest picks up any @JsonComponent-annotated custom serializers/deserializers automatically, so it's the right place to test custom date or enum formatting logic in isolation.

9. How do you write a parameterized JUnit 5 test that exercises a service method across many inputs?

@ParameterizedTest runs the same test method once per supplied argument, eliminating copy-pasted test methods that differ only by input and expected output. @ValueSource covers simple single-argument cases, while @MethodSource lets you supply a stream of richer, multi-argument combinations (e.g. via Arguments.of) generated by a factory method.

class DiscountServiceTest {

    private final DiscountService discountService = new DiscountService();

    @ParameterizedTest
    @ValueSource(strings = {"", " ", "INVALID"})
    void rejectsInvalidCouponCodes(String code) {
        assertThat(discountService.isValidCoupon(code)).isFalse();
    }

    @ParameterizedTest
    @MethodSource("orderTotalsAndExpectedDiscounts")
    void appliesTieredDiscount(double total, double expectedDiscount) {
        assertThat(discountService.calculateDiscount(total))
            .isEqualTo(expectedDiscount);
    }

    static Stream<Arguments> orderTotalsAndExpectedDiscounts() {
        return Stream.of(
            Arguments.of(50.0, 0.0),
            Arguments.of(150.0, 15.0),
            Arguments.of(500.0, 75.0)
        );
    }
}
Give parameterized tests a descriptive @ParameterizedTest(name = "...") pattern — the default numeric invocation names make it hard to tell which input failed in a CI report.

10. How do you load a profile-specific configuration for a test, and how does @TestConfiguration differ from a regular @Configuration bean?

@ActiveProfiles activates one or more Spring profiles for a test's ApplicationContext, so profile-gated beans and application-test.yml properties take effect exactly as they would in a "test" environment. @TestConfiguration marks a configuration class as test-only: Spring Boot excludes it from component scanning in the main application context, so it's safe to keep alongside production code without accidentally being picked up outside tests, and it's typically used to swap in fakes for external dependencies.

@SpringBootTest
@ActiveProfiles("test")
class PaymentServiceIT {

    @Autowired PaymentGateway paymentGateway; // resolves to the fake below

    @Test
    void chargesThroughFakeGatewayInTestProfile() {
        PaymentResult result = paymentGateway.charge(49.99);
        assertThat(result.approved()).isTrue();
    }

    @TestConfiguration
    static class TestPaymentConfig {
        @Bean
        PaymentGateway fakePaymentGateway() {
            return amount -> new PaymentResult(true, "TEST-" + amount);
        }
    }
}
Pair @ActiveProfiles("test") with an application-test.yml on the test classpath (e.g. pointing to a test broker URL or disabling a scheduled job) rather than scattering @TestPropertySource overrides across classes.

Spring Cloud & Microservices

1. How does a Spring Boot service register itself with Eureka, and how does another service look it up?

A Eureka server acts as a registry that services announce themselves to on startup and periodically renew via heartbeats; consumers query the registry by logical service name instead of hardcoded host:port pairs. Spring Cloud Netflix Eureka auto-registers any client that has spring-cloud-starter-netflix-eureka-client on the classpath and a configured registry URL, and the DiscoveryClient abstraction lets any service resolve live instances of another.

@SpringBootApplication
@EnableEurekaServer
public class DiscoveryServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(DiscoveryServerApplication.class, args);
    }
}

// order-service application.yml
// spring.application.name: order-service
// eureka.client.service-url.defaultZone: http://localhost:8761/eureka/
// eureka.instance.prefer-ip-address: true

@RestController
public class InstanceController {

    private final DiscoveryClient discoveryClient;

    public InstanceController(DiscoveryClient discoveryClient) {
        this.discoveryClient = discoveryClient;
    }

    @GetMapping("/instances/{service}")
    public List<ServiceInstance> instances(@PathVariable String service) {
        return discoveryClient.getInstances(service);
    }
}
Eureka favors availability over consistency: if a client misses too many heartbeats the server can enter self-preservation mode and stop expiring instances, so a stale registry entry doesn't necessarily mean the instance is down.

2. How does client-side load balancing work with Spring Cloud LoadBalancer?

Instead of a centralized load balancer, each client resolves the target service's instance list from the registry (e.g. Eureka) and picks an instance itself using a pluggable strategy, avoiding a network hop and single point of failure. Spring Cloud LoadBalancer plugs into any @LoadBalanced-annotated HTTP client — RestClient, WebClient, or RestTemplate — so calls to a logical service name like http://payment-service are transparently resolved to a real host.

@Configuration
public class ClientConfig {

    @Bean
    @LoadBalanced
    public RestClient.Builder loadBalancedRestClientBuilder() {
        return RestClient.builder();
    }
}

@Service
public class PaymentClient {

    private final RestClient restClient;

    public PaymentClient(@Qualifier("loadBalancedRestClientBuilder") RestClient.Builder builder) {
        this.restClient = builder.baseUrl("http://payment-service").build();
    }

    public PaymentResponse charge(ChargeRequest request) {
        return restClient.post()
                .uri("/api/payments")
                .body(request)
                .retrieve()
                .body(PaymentResponse.class);
    }
}
The default strategy is round-robin across healthy instances; swap in ZonePreferenceServiceInstanceListSupplier or a weighted supplier when you need zone affinity or canary-style traffic splits.

3. How do you protect a service from a failing downstream dependency using Resilience4j's @CircuitBreaker?

A circuit breaker wraps a call and tracks its failure rate over a sliding window; once failures exceed a configured threshold it "opens" and short-circuits calls to a fallback immediately, rather than letting threads pile up waiting on a dead dependency. After a wait duration it moves to half-open and allows a handful of trial calls through — if they succeed it closes again, if they fail it reopens.

Closed Open Half-Open failure threshold exceeded wait duration elapsed trial call succeeds trial call fails
@Service
public class InventoryService {

    private final RestClient restClient;

    public InventoryService(@Qualifier("loadBalancedRestClientBuilder") RestClient.Builder builder) {
        this.restClient = builder.baseUrl("http://inventory-service").build();
    }

    @CircuitBreaker(name = "inventoryService", fallbackMethod = "fallbackStock")
    public StockResponse checkStock(String sku) {
        return restClient.get().uri("/api/stock/{sku}", sku).retrieve().body(StockResponse.class);
    }

    private StockResponse fallbackStock(String sku, Throwable ex) {
        return new StockResponse(sku, 0, "UNAVAILABLE");
    }
}

// application.yml
// resilience4j.circuitbreaker.instances.inventoryService:
//   sliding-window-size: 10
//   failure-rate-threshold: 50
//   wait-duration-in-open-state: 10s
//   permitted-number-of-calls-in-half-open-state: 3
The fallback method's signature must match the guarded method's parameters plus a trailing Throwable — a mismatch fails silently at startup with no exception thrown until the breaker actually opens.

4. How do you centralize configuration with Spring Cloud Config Server and refresh it at runtime without redeploying?

Config Server exposes property files stored in a Git repo (or Vault, filesystem) over HTTP, keyed by application name and profile, so every microservice pulls its configuration from one versioned source of truth at startup. To pick up changes without a restart, mark the beans that hold externalized values with @RefreshScope and trigger the actuator /actuator/refresh endpoint, which tears down and re-creates just those beans with fresh property values.

@SpringBootApplication
@EnableConfigServer
public class ConfigServerApplication {
    public static void main(String[] args) {
        SpringApplication.run(ConfigServerApplication.class, args);
    }
}

// config-server application.yml
// server.port: 8888
// spring.cloud.config.server.git.uri: https://github.com/example/config-repo
// spring.cloud.config.server.git.default-label: main

// order-service application.yml
// spring.config.import: optional:configserver:http://localhost:8888
// management.endpoints.web.exposure.include: refresh

@RefreshScope
@RestController
public class FeatureController {

    @Value("${feature.discount-enabled:false}")
    private boolean discountEnabled;

    @GetMapping("/feature/discount")
    public boolean discountEnabled() {
        return discountEnabled;
    }
}
Calling /actuator/refresh only refreshes the instance you hit; to broadcast a config change fleet-wide, pair Config Server with Spring Cloud Bus over Kafka or RabbitMQ so every instance refreshes together.

5. How do you trace a single request as it flows across multiple microservices using Micrometer Tracing and Zipkin?

Micrometer Tracing instruments incoming/outgoing HTTP calls, messaging, and scheduled tasks to generate spans, and propagates a trace context (W3C traceparent header) so every service that touches a request contributes spans to the same trace ID. A Zipkin reporter exports finished spans to a Zipkin server, where the whole request's timeline — across every hop — can be visualized to spot latency bottlenecks or failures.

// build.gradle / pom.xml dependencies:
// micrometer-tracing-bridge-brave
// zipkin-reporter-brave

// application.yml
// management.tracing.sampling.probability: 1.0
// management.zipkin.tracing.endpoint: http://localhost:9411/api/v2/spans

@Service
public class OrderService {

    private final Tracer tracer;
    private final RestClient restClient;

    public OrderService(Tracer tracer, @Qualifier("loadBalancedRestClientBuilder") RestClient.Builder builder) {
        this.tracer = tracer;
        this.restClient = builder.baseUrl("http://payment-service").build();
    }

    public void placeOrder(Order order) {
        Span span = tracer.nextSpan().name("charge-payment").start();
        try (Tracer.SpanInScope scope = tracer.withSpan(span)) {
            restClient.post().uri("/api/payments").body(order).retrieve().toBodilessEntity();
        } finally {
            span.end();
        }
    }
}
A sampling probability of 1.0 traces every request, which is fine in development but expensive in production — most teams sample 5-10% and rely on head-based sampling decisions made at the edge service.

6. How does Spring Cloud Gateway route requests to backend services and apply cross-cutting filters?

Spring Cloud Gateway matches incoming requests to routes based on predicates (path, header, method), then forwards them to a destination URI — often lb://service-name so the target is resolved through the load balancer and registry rather than a fixed address. Filters modify the request or response along the way (stripping path prefixes, adding headers, rate limiting) and can be scoped to a single route or applied globally to every route.

@Configuration
public class GatewayRoutes {

    @Bean
    public RouteLocator routes(RouteLocatorBuilder builder) {
        return builder.routes()
                .route("orders", r -> r.path("/api/orders/**")
                        .filters(f -> f.stripPrefix(1)
                                .circuitBreaker(c -> c.setName("ordersCB")
                                        .setFallbackUri("forward:/fallback/orders"))
                                .addRequestHeader("X-Gateway", "edge"))
                        .uri("lb://order-service"))
                .route("payments", r -> r.path("/api/payments/**")
                        .filters(f -> f.stripPrefix(1)
                                .requestRateLimiter(rl -> rl.setRateLimiter(redisRateLimiter())))
                        .uri("lb://payment-service"))
                .build();
    }
}
Global filters (implementing GlobalFilter) run for every route in a defined order, so a misordered auth or logging filter can execute after a route already forwarded the request — always check getOrder() against other filters in the chain.

7. What's the advantage of a declarative REST client like Feign or Spring's HTTP interface clients over hand-written HTTP calls?

Instead of manually building requests with RestClient or WebClient, you declare an interface with annotated methods and the framework generates a working client proxy at runtime — load balancing, retries, and error decoding are wired in by convention. Feign (@FeignClient) has been the traditional choice in Spring Cloud microservices; as of Spring 6 / Boot 3, @HttpExchange-annotated interfaces built on HttpServiceProxyFactory provide the same declarative style without an extra dependency.

@HttpExchange("/api/payments")
public interface PaymentClient {

    @PostExchange
    PaymentResponse charge(@RequestBody ChargeRequest request);

    @GetExchange("/{id}")
    PaymentResponse findById(@PathVariable String id);
}

@Configuration
public class PaymentClientConfig {

    @Bean
    public PaymentClient paymentClient(@Qualifier("loadBalancedRestClientBuilder") RestClient.Builder builder) {
        RestClient restClient = builder.baseUrl("http://payment-service").build();
        HttpServiceProxyFactory factory = HttpServiceProxyFactory
                .builderFor(RestClientAdapter.create(restClient))
                .build();
        return factory.createClient(PaymentClient.class);
    }
}

// Feign equivalent:
// @FeignClient(name = "payment-service")
// public interface PaymentClient {
//     @PostMapping("/api/payments")
//     PaymentResponse charge(@RequestBody ChargeRequest request);
// }

8. How does the bulkhead pattern stop one failing dependency from cascading into unrelated parts of a service?

A bulkhead isolates calls to a dependency into a bounded resource pool — a limited thread pool or a semaphore — so if that dependency slows down or hangs, only the threads assigned to it are exhausted, leaving the rest of the application free to serve other requests. Resilience4j offers both a lightweight semaphore bulkhead and a thread-pool bulkhead that runs the call asynchronously on its own executor.

@Service
public class RecommendationService {

    private final RestClient restClient;

    public RecommendationService(@Qualifier("loadBalancedRestClientBuilder") RestClient.Builder builder) {
        this.restClient = builder.baseUrl("http://recommendation-service").build();
    }

    @Bulkhead(name = "recommendationService", type = Bulkhead.Type.THREADPOOL, fallbackMethod = "fallbackRecs")
    public CompletableFuture<List<Product>> getRecommendations(String userId) {
        return CompletableFuture.supplyAsync(() ->
                restClient.get().uri("/api/recs/{id}", userId).retrieve().body(ProductList.class).products());
    }

    private CompletableFuture<List<Product>> fallbackRecs(String userId, Throwable ex) {
        return CompletableFuture.completedFuture(List.of());
    }
}

// application.yml
// resilience4j.thread-pool-bulkhead.instances.recommendationService:
//   max-thread-pool-size: 10
//   core-thread-pool-size: 5
//   queue-capacity: 20
A bulkhead alone doesn't stop slow calls from occupying threads indefinitely — always pair it with a timeout (Resilience4j TimeLimiter) so a stuck call eventually gets evicted from the pool.

9. How does the Saga pattern keep data consistent across a distributed transaction that spans several microservices?

A saga breaks a distributed transaction into a sequence of local transactions, each owned by one service, and each with a matching compensating action that can undo its effect. If a later step fails, the saga runs the compensations for every already-completed step in reverse order instead of relying on a distributed lock or two-phase commit — sacrificing atomicity for availability, since the system passes through intermediate, eventually-consistent states.

Order Payment Inventory Shipping 1. charge 2. reserve stock 3. schedule ship Refund Payment Release Stock shipment fails compensate in reverse
@Service
public class OrderSagaOrchestrator {

    private final PaymentClient paymentClient;
    private final InventoryClient inventoryClient;
    private final ShippingClient shippingClient;

    public OrderSagaOrchestrator(PaymentClient paymentClient, InventoryClient inventoryClient,
                                  ShippingClient shippingClient) {
        this.paymentClient = paymentClient;
        this.inventoryClient = inventoryClient;
        this.shippingClient = shippingClient;
    }

    public void execute(OrderCreatedEvent event) {
        String paymentId = null;
        String reservationId = null;
        try {
            paymentId = paymentClient.charge(event.orderId(), event.amount());
            reservationId = inventoryClient.reserve(event.orderId(), event.items());
            shippingClient.schedule(event.orderId(), event.address());
        } catch (Exception ex) {
            if (reservationId != null) {
                inventoryClient.release(reservationId);
            }
            if (paymentId != null) {
                paymentClient.refund(paymentId);
            }
            throw new SagaFailedException("Order " + event.orderId() + " rolled back", ex);
        }
    }
}
This example is orchestration-based (one coordinator drives every step). Choreography-based sagas instead have each service publish an event and react to the previous service's event — less coupling, but harder to see the overall flow in one place.

10. How do microservices communicate through publish/subscribe over a message broker instead of direct HTTP calls?

An event-driven microservice publishes a fact about something that happened (e.g. "order created") to a broker topic without knowing or caring who consumes it, and any number of subscribers react independently, each at their own pace. This decouples producer and consumer in time and topology, letting services scale and deploy independently — Spring Cloud Stream provides a broker-agnostic functional programming model on top of Kafka or RabbitMQ.

@Configuration
public class OrderEventConfig {

    @Bean
    public Consumer<OrderCreatedEvent> handleOrderCreated(InventoryService inventoryService) {
        return event -> inventoryService.reserveStock(event.orderId(), event.items());
    }

    @Bean
    public Function<OrderCreatedEvent, StockReservedEvent> reserveAndEmit(InventoryService inventoryService) {
        return event -> inventoryService.reserveAndPublish(event.orderId(), event.items());
    }
}

// application.yml
// spring.cloud.stream.bindings.handleOrderCreated-in-0.destination: order.created
// spring.cloud.stream.bindings.reserveAndEmit-in-0.destination: order.created
// spring.cloud.stream.bindings.reserveAndEmit-out-0.destination: stock.reserved
// spring.cloud.stream.kafka.binder.brokers: localhost:9092
Most brokers only guarantee at-least-once delivery, so consumers must be idempotent (e.g. dedupe on an event ID) — otherwise a redelivered "order created" event can reserve stock twice.

11. What does "contract-first" API design mean, and how do you generate server stubs from an OpenAPI spec in Spring Boot?

Contract-first means the OpenAPI/Swagger YAML document is written and agreed upon before any implementation code, so consumers, the gateway, and the server team can all work in parallel against a stable interface. The openapi-generator-maven-plugin reads that spec at build time and generates the controller interfaces and DTOs, which your @RestController classes then implement — guaranteeing the running code can never silently drift from the published contract.

<plugin>
    <groupId>org.openapitools</groupId>
    <artifactId>openapi-generator-maven-plugin</artifactId>
    <version>7.6.0</version>
    <executions>
        <execution>
            <goals><goal>generate</goal></goals>
            <configuration>
                <inputSpec>${project.basedir}/src/main/resources/api/orders.yaml</inputSpec>
                <generatorName>spring</generatorName>
                <apiPackage>com.example.orders.api</apiPackage>
                <modelPackage>com.example.orders.model</modelPackage>
                <configOptions>
                    <interfaceOnly>true</interfaceOnly>
                    <useSpringBoot3>true</useSpringBoot3>
                </configOptions>
            </configuration>
        </execution>
    </executions>
</plugin>

// generated interface implemented by your controller
@RestController
public class OrdersApiController implements OrdersApi {

    @Override
    public ResponseEntity<OrderDto> getOrder(String orderId) {
        return ResponseEntity.ok(new OrderDto().id(orderId).status("CONFIRMED"));
    }
}

12. How do blue-green and canary deployments reduce the risk of rolling out a new version of a Spring Boot microservice?

Blue-green deployment runs two full, identical environments — the live "blue" version and the newly deployed "green" version — and cuts traffic over to green all at once only after it's verified healthy, giving an instant rollback path by simply pointing traffic back at blue. Canary deployment instead shifts a small percentage of live traffic to the new version and grows that percentage gradually while monitoring error rates and latency, catching regressions before they affect all users.

@Configuration
public class CanaryRoutes {

    @Bean
    public RouteLocator canaryRoutes(RouteLocatorBuilder builder) {
        return builder.routes()
                .route("orders-v1", r -> r.path("/api/orders/**")
                        .and().weight("orders-group", 90)
                        .uri("lb://order-service-v1"))
                .route("orders-v2", r -> r.path("/api/orders/**")
                        .and().weight("orders-group", 10)
                        .uri("lb://order-service-v2"))
                .build();
    }
}

// Kubernetes blue-green cutover: repoint the Service selector
// kubectl patch service order-service -p '{"spec":{"selector":{"version":"green"}}}'
Both strategies are only as safe as the automated rollback trigger behind them — wire canary weight increases (or the blue-green cutover) to real health signals like error rate and p99 latency, not a fixed timer, so a bad release backs itself out automatically.

Spring Batch

1. What are the five core building blocks of a Spring Batch job, and how do they collaborate?

A Job is a container for one or more Steps and represents the entire batch process end to end. Each Step encapsulates an independent phase of work — most commonly a chunk-oriented step wiring together an ItemReader (pulls one item at a time from a source), an ItemProcessor (transforms, enriches, or filters that item by returning null), and an ItemWriter (persists a completed chunk of processed items). The JobRepository sits underneath all of this, persisting metadata about every JobInstance, JobExecution, and StepExecution so progress can be tracked and jobs can be restarted.

@Configuration
public class ImportUserJobConfig {

    @Bean
    public Job importUserJob(JobRepository jobRepository, Step importUserStep) {
        return new JobBuilder("importUserJob", jobRepository)
                .start(importUserStep)
                .build();
    }

    @Bean
    public Step importUserStep(JobRepository jobRepository,
                                PlatformTransactionManager transactionManager,
                                ItemReader<UserCsv> reader,
                                ItemProcessor<UserCsv, User> processor,
                                ItemWriter<User> writer) {
        return new StepBuilder("importUserStep", jobRepository)
                .<UserCsv, User>chunk(100, transactionManager)
                .reader(reader)
                .processor(processor)
                .writer(writer)
                .build();
    }
}
A Job can compose multiple Steps in sequence, in parallel flows, or conditionally (via .next(), .on(...).to(...)) — the reader/processor/writer triad only applies inside chunk-oriented steps; a step can alternatively be a plain Tasklet.

2. How does Spring Batch's chunk-oriented processing model work, and what role does the commit interval play?

Inside a chunk-oriented step, Spring Batch repeatedly calls the ItemReader and passes each item through the ItemProcessor until it has accumulated a "chunk" of items equal to the configured commit interval. Only then does it hand the entire chunk to the ItemWriter in a single transaction and commit — meaning reads and processing happen item-by-item, but writes and commits happen chunk-by-chunk. This aligns transaction boundaries with chunk boundaries, so a failure only rolls back the current chunk rather than the whole step, and the JobRepository records progress after every commit.

Data source Read item Process item Write chunk (chunk full: N items) Commit transaction repeat until chunk size N next chunk
@Bean
public Step chunkStep(JobRepository jobRepository,
                       PlatformTransactionManager transactionManager,
                       ItemReader<Order> reader,
                       ItemProcessor<Order, Invoice> processor,
                       ItemWriter<Invoice> writer) {
    return new StepBuilder("chunkStep", jobRepository)
            .<Order, Invoice>chunk(50, transactionManager) // commit interval = 50
            .reader(reader)
            .processor(processor)
            .writer(writer)
            .build();
}
Picking a commit interval that's too large increases memory use and the cost of a rollback; too small adds transaction/commit overhead per chunk. Tune it based on item size and expected failure rate, not just throughput.

3. How does Spring Batch let a failed job restart from where it left off instead of starting over?

Every commit updates the JobRepository with the current JobExecution/StepExecution status and their ExecutionContext, a persisted key-value bag each step can use to remember progress (for example, a file line offset). When you relaunch the same job with identical JobParameters, Spring Batch finds the existing, non-completed JobInstance and resumes: completed steps are skipped, and a restartable step reopens its ItemReader/ItemWriter via ItemStream.open(), restoring state from the saved ExecutionContext instead of starting from zero.

public class LineOffsetItemReader implements ItemStreamReader<String> {

    private final List<String> lines;
    private int index = 0;

    public LineOffsetItemReader(List<String> lines) {
        this.lines = lines;
    }

    @Override
    public void open(ExecutionContext executionContext) {
        this.index = executionContext.getInt("line.offset", 0);
    }

    @Override
    public String read() {
        return index < lines.size() ? lines.get(index++) : null;
    }

    @Override
    public void update(ExecutionContext executionContext) {
        executionContext.putInt("line.offset", index);
    }

    @Override
    public void close() {
        // release resources
    }
}

// Relaunching with the SAME parameters resumes the existing JobInstance:
JobParameters params = new JobParametersBuilder()
        .addString("input.file", "orders-2026-07.csv")
        .toJobParameters();
jobLauncher.run(importOrdersJob, params);
Restart only works if the JobParameters match exactly — adding a unique timestamp/run-id parameter on every launch (a common trick to force a fresh run) will silently defeat restart because each execution becomes a brand-new JobInstance.

4. How do you make a step tolerate bad records or transient failures without failing the whole batch?

Calling .faultTolerant() on a step builder unlocks skip and retry behavior. A skip policy (.skip(ExceptionType.class).skipLimit(n)) tells the step to log and discard an offending item — encountered during read, process, or write — and keep going, failing the step only once the skip limit is exceeded. A retry policy (.retry(ExceptionType.class).retryLimit(n)) instead re-attempts the same item a set number of times before giving up, which is appropriate for transient errors like a database deadlock rather than permanently malformed data.

@Bean
public Step tolerantStep(JobRepository jobRepository,
                          PlatformTransactionManager transactionManager,
                          ItemReader<OrderCsv> reader,
                          ItemProcessor<OrderCsv, Order> processor,
                          ItemWriter<Order> writer) {
    return new StepBuilder("tolerantStep", jobRepository)
            .<OrderCsv, Order>chunk(20, transactionManager)
            .reader(reader)
            .processor(processor)
            .writer(writer)
            .faultTolerant()
            .skip(FlatFileParseException.class)
            .skipLimit(25)
            .retry(DeadlockLoserDataAccessException.class)
            .retryLimit(3)
            .listener(new SkipListener<OrderCsv, Order>() {
                @Override
                public void onSkipInRead(Throwable t) {
                    log.warn("Skipped malformed line: {}", t.getMessage());
                }
            })
            .build();
}
Always pair skip/retry with a SkipListener (or write skipped records to a dead-letter file/table) — silently dropping bad data without an audit trail makes reconciliation nearly impossible later.

5. How can a Spring Batch step be partitioned to process data in parallel across threads or workers?

Partitioning uses a master/worker model: a Partitioner divides the input domain (e.g., primary-key ranges, file names, or customer regions) into separate ExecutionContexts, one per partition. A PartitionHandlerTaskExecutorPartitionHandler for local multithreading, or a remote-messaging-backed handler for distributing work across separate JVMs — then launches an independent worker Step instance for each partition concurrently, each tracked as its own StepExecution in the JobRepository, and the master step completes once all partitions finish.

public class RangePartitioner implements Partitioner {

    @Override
    public Map<String, ExecutionContext> partition(int gridSize) {
        Map<String, ExecutionContext> partitions = new HashMap<>();
        int rangeSize = 100_000 / gridSize;
        for (int i = 0; i < gridSize; i++) {
            ExecutionContext context = new ExecutionContext();
            context.putInt("minId", i * rangeSize);
            context.putInt("maxId", (i + 1) * rangeSize);
            partitions.put("partition" + i, context);
        }
        return partitions;
    }
}

@Bean
public Step partitionedStep(JobRepository jobRepository,
                             Step workerStep,
                             TaskExecutor taskExecutor) {
    return new StepBuilder("partitionedStep", jobRepository)
            .partitioner("workerStep", new RangePartitioner())
            .step(workerStep)
            .gridSize(4)
            .taskExecutor(taskExecutor)
            .build();
}
Each partition must get its own reader/writer instance — declare them @StepScope so a fresh, stateful bean is created per partition's ExecutionContext. Sharing one stateful ItemReader across threads causes data corruption or duplicate reads.

6. How do you schedule a Spring Batch job to run periodically while preventing overlapping executions?

A method annotated with Spring's @Scheduled(cron = ...) (or a Quartz JobDetail/Trigger invoking a QuartzJobBean) can simply call JobLauncher.run(job, params) on the configured trigger. To stop a slow run from overlapping the next trigger, check JobExplorer.findRunningJobExecutions(jobName) before launching and skip if non-empty; in a multi-instance deployment, add a distributed lock (e.g. ShedLock) around the scheduled method so only one node can launch the job at a time.

@Component
public class NightlyImportScheduler {

    private final JobLauncher jobLauncher;
    private final JobExplorer jobExplorer;
    private final Job nightlyImportJob;

    public NightlyImportScheduler(JobLauncher jobLauncher, JobExplorer jobExplorer, Job nightlyImportJob) {
        this.jobLauncher = jobLauncher;
        this.jobExplorer = jobExplorer;
        this.nightlyImportJob = nightlyImportJob;
    }

    @Scheduled(cron = "0 0 2 * * *")
    public void launchNightlyImport() throws Exception {
        if (!jobExplorer.findRunningJobExecutions("nightlyImportJob").isEmpty()) {
            log.warn("nightlyImportJob is already running, skipping this trigger");
            return;
        }
        JobParameters params = new JobParametersBuilder()
                .addLocalDate("runDate", LocalDate.now())
                .toJobParameters();
        jobLauncher.run(nightlyImportJob, params);
    }
}
Quartz is worth the extra setup over @Scheduled when you need misfire handling, persistent trigger state across restarts, or clustered locking out of the box; for a single-instance app, the JobExplorer check above is usually enough.

7. How do you use JobExecutionListener and StepExecutionListener to monitor batch runs and raise alerts?

JobExecutionListener.beforeJob/afterJob wrap the entire job, giving you a hook to inspect the final BatchStatus/ExitStatus and fire an alert (email, Slack, PagerDuty) on failure. StepExecutionListener.beforeStep/afterStep hook each individual step and are useful for logging read/write/skip counts for dashboards. Both are registered with .listener(...) on the job or step builder, and can be combined with ItemReadListener/ItemWriteListener for finer-grained metrics.

public class AlertingJobExecutionListener implements JobExecutionListener {

    private final AlertService alertService;

    public AlertingJobExecutionListener(AlertService alertService) {
        this.alertService = alertService;
    }

    @Override
    public void afterJob(JobExecution jobExecution) {
        if (jobExecution.getStatus() == BatchStatus.FAILED) {
            alertService.notify("Job " + jobExecution.getJobInstance().getJobName()
                    + " failed: " + jobExecution.getAllFailureExceptions());
        }
    }
}

@Bean
public Job importUserJob(JobRepository jobRepository, Step importUserStep,
                          AlertingJobExecutionListener alertingListener) {
    return new JobBuilder("importUserJob", jobRepository)
            .listener(alertingListener)
            .start(importUserStep)
            .build();
}
Read counts from StepExecution (getReadCount(), getWriteCount(), getSkipCount()) inside afterStep rather than tracking your own counters — Spring Batch already maintains them accurately, including across restarts.

8. How do you read CSV, XML, and JSON files in a batch job using the right ItemReader for each?

For delimited CSV, use FlatFileItemReader with a DelimitedLineTokenizer and a BeanWrapperFieldSetMapper to map columns to fields. For XML, use StaxEventItemReader paired with a marshaller (Jaxb2Marshaller or XStreamMarshaller) that unmarshals each repeating fragment element into an object. For JSON arrays, use JsonItemReader with a JacksonJsonObjectReader to deserialize each array element. All three implement ItemStreamReader, so they plug into the exact same chunk-oriented step without any other configuration changing.

@Bean
@StepScope
public FlatFileItemReader<UserCsv> csvReader(@Value("#{jobParameters['inputFile']}") String path) {
    return new FlatFileItemReaderBuilder<UserCsv>()
            .name("csvReader")
            .resource(new FileSystemResource(path))
            .delimited()
            .names("id", "name", "email")
            .targetType(UserCsv.class)
            .build();
}

@Bean
public StaxEventItemReader<UserXml> xmlReader() {
    Jaxb2Marshaller marshaller = new Jaxb2Marshaller();
    marshaller.setClassesToBeBound(UserXml.class);
    return new StaxEventItemReaderBuilder<UserXml>()
            .name("xmlReader")
            .resource(new ClassPathResource("users.xml"))
            .addFragmentRootElements("user")
            .unmarshaller(marshaller)
            .build();
}

@Bean
public JsonItemReader<UserJson> jsonReader() {
    return new JsonItemReaderBuilder<UserJson>()
            .name("jsonReader")
            .resource(new ClassPathResource("users.json"))
            .jsonObjectReader(new JacksonJsonObjectReader<>(new ObjectMapper(), UserJson.class))
            .build();
}
If you need to read several files of the same format in one step (e.g., orders-*.csv), wrap the delegate reader in a MultiResourceItemReader instead of writing a custom multi-file loop.

Spring WebFlux & Reactive

1. What is the difference between Mono and Flux in Project Reactor, and when would you use each?

Mono<T> represents a stream of zero or one element, while Flux<T> represents a stream of zero to N elements. Use Mono for operations that produce at most one result — findById, save, count — and Flux for anything that streams multiple items, such as findAll, Server-Sent Events, or paginated results. Both implement Publisher and share the same operator vocabulary, so switching between them is mostly a matter of cardinality, not API shape.

public interface UserRepository extends ReactiveCrudRepository<User, Long> {
    Mono<User> findByEmail(String email);
    Flux<User> findByStatus(String status);
}

@RestController
@RequestMapping("/users")
class UserController {

    private final UserRepository repo;

    UserController(UserRepository repo) { this.repo = repo; }

    @GetMapping("/{id}")
    public Mono<User> getUser(@PathVariable Long id) {
        return repo.findById(id);
    }

    @GetMapping
    public Flux<User> listUsers() {
        return repo.findAll();
    }
}
Tip: Mono and Flux share the same operator set — a Flux can be narrowed with .next() or .single(), and a Mono can be widened with .repeat() or .flux() when the cardinality needs to change.

2. When is it actually worth choosing Spring WebFlux over traditional Spring MVC, and when isn't it?

WebFlux pays off under high concurrency with I/O-bound workloads — many slow downstream calls, streaming responses, SSE, or WebSockets — because a small, fixed event-loop thread pool can handle thousands of concurrent connections without one thread per request. If your workload is CPU-bound, depends on blocking JDBC/JPA drivers, or your team lacks reactive debugging experience, plain Spring MVC (optionally on Java 21 virtual threads) often reaches similar scalability with far less cognitive overhead.

// Spring MVC - blocking, one thread per request
@GetMapping("/orders/{id}")
public OrderDto getOrder(@PathVariable Long id) {
    return orderService.findById(id); // blocks calling thread until DB/HTTP returns
}

// Spring WebFlux - non-blocking, thread released while waiting
@GetMapping("/orders/{id}")
public Mono<OrderDto> getOrder(@PathVariable Long id) {
    return orderService.findById(id) // returns immediately, resumes on completion
        .map(this::toDto);
}
Watch out: Adopting WebFlux for a CRUD app backed by JPA gains you nothing — you still block on the JDBC driver, but now you've traded away thread-per-request debuggability and readable stack traces for no real benefit.

3. How does Reactor implement backpressure, and how can a subscriber control the rate of emission from a publisher?

Reactor's Reactive Streams contract lets a Subscriber call request(n) on its Subscription to tell the upstream Publisher exactly how many elements it's ready to receive next — the publisher must never emit more than the outstanding requested amount. When a fast producer and a slow consumer are mismatched, operators like onBackpressureBuffer, onBackpressureDrop, and onBackpressureLatest decide what happens to items that arrive faster than they're requested.

Publisher (fast producer) Buffer / Queue holds pending items Subscriber (slow consumer) request(n) — pulls only as many items as the consumer can handle
Flux.range(1, 1_000_000)
    .onBackpressureBuffer(1000, dropped -> log.warn("Dropped: {}", dropped))
    .publishOn(Schedulers.boundedElastic())
    .subscribe(new BaseSubscriber<Integer>() {
        @Override
        protected void hookOnSubscribe(Subscription subscription) {
            request(10); // pull only 10 items at a time
        }

        @Override
        protected void hookOnNext(Integer value) {
            process(value);
            request(1); // ask for one more once this one is handled
        }
    });
Tip: Flux.create/Flux.generate let you plug in a custom OverflowStrategy (BUFFER, DROP, LATEST, ERROR) when bridging a push-based source into Reactor's request-driven model.

4. Why can't you safely mix R2DBC reactive repositories with blocking JPA repositories in the same reactive chain?

R2DBC drivers are truly non-blocking end-to-end, while JPA/Hibernate always goes through the blocking JDBC API underneath. Calling a JPA repository from inside a reactive pipeline blocks whichever thread is executing at that point — often one of the handful of Netty event-loop threads — which stalls every other in-flight request sharing that thread. If you must call blocking code, isolate it with subscribeOn(Schedulers.boundedElastic()), or better, keep the two paradigms in separate services rather than interleaving them.

// R2DBC - fully non-blocking, safe inside a reactive chain
interface ReactiveOrderRepository extends ReactiveCrudRepository<Order, Long> {
    Flux<Order> findByCustomerId(Long customerId);
}

// Blocking JPA repository - must be isolated on a dedicated pool
Mono<Order> loadLegacyOrder(Long id) {
    return Mono.fromCallable(() -> jpaOrderRepository.findById(id).orElseThrow())
        .subscribeOn(Schedulers.boundedElastic());
}
Watch out: Calling a blocking JPA repository directly inside a WebFlux handler without subscribeOn(boundedElastic()) silently stalls the event-loop threads — the app looks fine under low load and falls over under real traffic.

5. What's the difference between Flux.zip, Flux.merge, and Flux.concat when combining multiple reactive sources?

concat subscribes to sources sequentially, fully draining one before starting the next, so order across sources is preserved but there's no parallelism. merge subscribes to all sources eagerly and interleaves their emissions as they arrive, maximizing throughput but giving up ordering guarantees. zip subscribes to all sources concurrently but pairs up corresponding elements by index, completing as soon as the shortest source completes.

Flux<String> fast = Flux.just("a", "b", "c").delayElements(Duration.ofMillis(10));
Flux<String> slow = Flux.just("x", "y", "z").delayElements(Duration.ofMillis(50));

// concat: sequential, preserves order, waits for "fast" to complete first
Flux.concat(fast, slow).subscribe(System.out::println);

// merge: interleaved as items arrive, no guaranteed order
Flux.merge(fast, slow).subscribe(System.out::println);

// zip: pairs elements by index, completes when the shortest source completes
Flux.zip(fast, slow, (f, s) -> f + "-" + s).subscribe(System.out::println);
Tip: Reach for concat when sequencing matters, merge for maximum throughput across independent sources, and zip when you need to correlate matching elements pairwise.

6. How do onErrorResume, onErrorReturn, and onErrorMap differ when handling errors in a reactive pipeline?

onErrorReturn substitutes a static fallback value on error, with no further reactive logic involved. onErrorResume lets you supply a fallback Publisher, so recovery can itself be asynchronous — retry from a cache, call another service, and so on. onErrorMap doesn't recover at all; it just translates one exception type into another while still propagating an error downstream, which is useful for wrapping low-level exceptions into domain-specific ones.

Mono<User> user = userRepository.findById(id)
    .onErrorMap(DataAccessException.class, ex -> new UserLookupException(id, ex))
    .onErrorResume(UserLookupException.class, ex -> userCache.findById(id))
    .onErrorReturn(TimeoutException.class, User.anonymous());
Tip: All three accept a predicate or exception-type filter as their first argument, so you can chain several handlers for different exception types instead of writing one catch-all.

7. How do you make reactive HTTP calls with WebClient, and how should you handle downstream 4xx/5xx responses?

WebClient is Spring's non-blocking HTTP client and the reactive replacement for RestTemplate. By default, retrieve().bodyToMono(...) throws a generic WebClientResponseException on any 4xx/5xx; use onStatus (or exchangeToMono for full control) to inspect the response and map it to a meaningful domain exception, and combine that with retryWhen for transient upstream failures.

WebClient client = WebClient.builder()
    .baseUrl("https://api.example.com")
    .build();

Mono<InvoiceDto> invoice = client.get()
    .uri("/invoices/{id}", invoiceId)
    .retrieve()
    .onStatus(HttpStatusCode::is4xxClientError,
        response -> response.bodyToMono(String.class)
            .map(body -> new InvoiceNotFoundException(invoiceId, body)))
    .onStatus(HttpStatusCode::is5xxServerError,
        response -> Mono.error(new UpstreamServiceException(invoiceId)))
    .bodyToMono(InvoiceDto.class)
    .retryWhen(Retry.backoff(3, Duration.ofMillis(200))
        .filter(ex -> ex instanceof UpstreamServiceException));
Watch out: Without an onStatus handler, callers end up catching a generic HTTP exception and parsing status codes deep in business logic instead of handling a meaningful domain-specific error.

8. How do you test reactive Flux/Mono pipelines with StepVerifier, including timing and errors?

StepVerifier subscribes to a Publisher and lets you declaratively assert emitted values, completion, or errors — expectNext, expectError, verifyComplete. For pipelines that use delayElements or Mono.delay, withVirtualTime lets you fast-forward simulated time with thenAwait instead of actually waiting, keeping tests fast and deterministic.

@Test
void filtersAndDelaysEmission() {
    Flux<Integer> source = Flux.just(1, 2, 3, 4, 5)
        .delayElements(Duration.ofSeconds(1))
        .filter(n -> n % 2 == 0);

    StepVerifier.withVirtualTime(() -> source)
        .expectSubscription()
        .thenAwait(Duration.ofSeconds(5))
        .expectNext(2, 4)
        .verifyComplete();
}
Tip: Prefer StepVerifier.withVirtualTime over real Thread.sleep-based tests for anything using delayed or timed operators — it keeps the suite fast without sacrificing coverage of timing behavior.

9. What's the difference between subscribeOn and publishOn in Reactor, and how do they affect which thread executes each operator?

subscribeOn determines the thread used to subscribe to the source and run its emission logic; its position in the chain doesn't matter, and if it appears more than once, only the instance closest to the source has any effect. publishOn switches the execution context for every operator downstream of where it's placed, and can be used multiple times in a single chain to hop threads at different stages — for example, off-loading blocking I/O, then a CPU-bound transform, then a final logging step.

Flux.range(1, 5)
    .subscribeOn(Schedulers.boundedElastic()) // affects where the source itself runs
    .map(this::expensiveBlockingLookup)
    .publishOn(Schedulers.parallel())          // switches thread for everything downstream
    .map(this::cpuIntensiveTransform)
    .publishOn(Schedulers.single())            // another switch, e.g. for ordered logging
    .doOnNext(v -> log.info("result: {}", v))
    .subscribe();

10. What's a common pitfall of accidentally introducing a blocking call inside a reactive chain, and how can you detect and avoid it?

Calling blocking APIs — JDBC, Thread.sleep, a blocking HTTP client, a synchronized lock, or even .block() itself — inside an operator like map or flatMap ties up one of the small number of Netty event-loop threads, causing latency spikes and timeouts across unrelated requests sharing that thread. Reactor's BlockHound agent detects this by instrumenting known blocking calls and failing fast when one executes on a non-blocking thread, which is far more reliable than hoping to spot it under load testing; the fix is to isolate blocking work with Mono.fromCallable(...).subscribeOn(Schedulers.boundedElastic()).

// Detect accidental blocking calls in tests/dev
@BeforeAll
static void installBlockHound() {
    BlockHound.install();
}

// The bug: blocking call runs directly on a Netty event-loop thread
Mono<String> broken = Mono.fromSupplier(() -> restTemplate.getForObject(url, String.class));

// The fix: isolate the blocking call on boundedElastic
Mono<String> fixed = Mono.fromCallable(() -> restTemplate.getForObject(url, String.class))
    .subscribeOn(Schedulers.boundedElastic());
Watch out: BlockHound only catches blocking calls at test time if your tests actually exercise that code path on a non-blocking scheduler — it won't help if the blocking call only runs in a rarely-tested error branch.

Caching & AOP

1. Enabling caching with @EnableCaching and @Cacheable on a service method

Spring's cache abstraction is turned on by adding @EnableCaching to a configuration class, which activates the infrastructure that intercepts annotated method calls. Once enabled, @Cacheable on a method tells Spring to check the named cache before running the method body, returning the cached value on a hit and storing the result on a miss.

@Configuration
@EnableCaching
public class CacheConfig {
    @Bean
    public CacheManager cacheManager() {
        return new ConcurrentMapCacheManager("products");
    }
}

@Service
public class ProductService {

    @Cacheable(value = "products", key = "#id")
    public Product findById(Long id) {
        System.out.println("Fetching from DB: " + id);
        return productRepository.findById(id)
                .orElseThrow(() -> new ProductNotFoundException(id));
    }
}
Without @EnableCaching, the @Cacheable, @CacheEvict, and @CachePut annotations are silently ignored — no error is thrown, the method just runs uncached every time.

2. Cache eviction strategies with @CacheEvict and updating cache with @CachePut

@CacheEvict removes one or all entries from a cache, typically called on delete or update operations so stale data isn't served afterward. @CachePut always executes the method and writes its return value into the cache, which is useful when you want to refresh a cache entry without skipping the underlying logic (unlike @Cacheable, which skips execution on a hit).

@Service
public class ProductService {

    @CachePut(value = "products", key = "#product.id")
    public Product update(Product product) {
        return productRepository.save(product);
    }

    @CacheEvict(value = "products", key = "#id")
    public void delete(Long id) {
        productRepository.deleteById(id);
    }

    @CacheEvict(value = "products", allEntries = true)
    public void clearAll() {
        // useful after a bulk import or nightly reload
    }
}
Set beforeInvocation = true on @CacheEvict if you want the entry removed even when the method throws an exception; by default eviction happens after successful completion.

3. Configuring Redis as a distributed cache backend for Spring's cache abstraction

Adding spring-boot-starter-data-redis and setting spring.cache.type=redis lets Spring Boot autoconfigure a RedisCacheManager, so existing @Cacheable/@CacheEvict code works unchanged against a shared, distributed cache instead of an in-memory one. A custom RedisCacheConfiguration bean lets you control TTL and serialization per cache name.

@Configuration
public class RedisCacheConfig {

    @Bean
    public RedisCacheConfiguration cacheConfiguration() {
        return RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(10))
                .disableCachingNullValues()
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new GenericJackson2JsonRedisSerializer()));
    }

    @Bean
    public RedisCacheManagerBuilderCustomizer redisCacheManagerBuilderCustomizer() {
        return builder -> builder.withCacheConfiguration("products",
                RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofHours(1)));
    }
}
spring:
  data:
    redis:
      host: localhost
      port: 6379
  cache:
    type: redis
Always configure a JSON (or other explicit) serializer for Redis values — the default JDK serialization is brittle across class changes and produces unreadable binary keys in Redis.

4. Cache key generation strategies (default SpEL key vs custom KeyGenerator)

Without an explicit key, Spring's default SimpleKeyGenerator builds a key from all method arguments (or SimpleKey.EMPTY for no-arg methods), which works for simple cases but can produce ambiguous keys when signatures overlap. You can override this per-method with a SpEL key expression, or globally by supplying a custom KeyGenerator bean.

@Component
public class CustomKeyGenerator implements KeyGenerator {
    @Override
    public Object generate(Object target, Method method, Object... params) {
        return target.getClass().getSimpleName() + "_" + method.getName()
                + "_" + StringUtils.arrayToCommaDelimitedString(params);
    }
}

@Service
public class ProductService {

    @Cacheable(value = "products", keyGenerator = "customKeyGenerator")
    public Product findByCategoryAndStatus(String category, String status) {
        return productRepository.findByCategoryAndStatus(category, status);
    }

    @Cacheable(value = "products", key = "#category + '_' + #status")
    public List<Product> search(String category, String status) {
        return productRepository.search(category, status);
    }
}
You cannot set both key and keyGenerator on the same annotation — Spring throws an IllegalStateException at startup if both are specified.

5. Handling cache stampede / thundering herd on a popular cache key

A cache stampede happens when a hot key expires and many concurrent requests all miss at once, hammering the database simultaneously. Spring's @Cacheable mitigates this on synchronous, single-JVM caches with sync = true, which serializes concurrent misses for the same key so only one thread computes the value while others wait; for distributed setups, pair short TTLs with jittered expiry or a distributed lock.

@Service
public class PricingService {

    @Cacheable(value = "featuredProducts", sync = true)
    public List<Product> getFeaturedProducts() {
        // only one thread executes this on a cache miss;
        // others block until the value is populated
        return productRepository.findFeatured();
    }
}
// distributed alternative: staggered TTL to avoid synchronized expiry across keys
long baseTtlSeconds = 600;
long jitter = ThreadLocalRandom.current().nextLong(0, 60);
redisTemplate.opsForValue().set(key, value, Duration.ofSeconds(baseTtlSeconds + jitter));
sync = true only protects against concurrent misses within a single JVM; it does not prevent multiple app instances from all missing Redis at the same moment, so a distributed lock or lease pattern is needed at scale.

6. Conditional caching based on method arguments (condition/unless attributes on @Cacheable)

condition is a SpEL expression evaluated before the method runs — if it's false, caching is skipped entirely (as if the annotation weren't there). unless is evaluated after the method returns using the result (#result), letting you cache selectively even though the method still executed; it's ideal for skipping caching of null, empty, or error-like results.

@Service
public class ProductService {

    @Cacheable(value = "products", key = "#id",
               condition = "#id > 0",
               unless = "#result == null || #result.stock == 0")
    public Product findById(Long id) {
        return productRepository.findById(id).orElse(null);
    }
}
Use condition to avoid caching based on inputs (e.g., skip caching for admin users or negative IDs), and unless to avoid caching based on the outcome (e.g., don't cache empty results or errors).

7. Core AOP concepts: aspect, join point, advice, pointcut — how they relate

A join point is a point in program execution where code could run (Spring AOP supports only method execution join points). A pointcut is an expression that selects which join points to match. Advice is the code that runs at a matched join point (before, after, around, etc.), and an aspect is a module that bundles one or more pointcut-advice pairs into a single reusable concern.

@Aspect
@Component
public class TimingAspect {

    // pointcut: selects join points — all methods in service package
    @Pointcut("execution(* com.example.service.*.*(..))")
    public void serviceLayer() {}

    // advice: the "around" logic that runs at the matched join point
    @Around("serviceLayer()")
    public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.currentTimeMillis();
        Object result = joinPoint.proceed();
        long elapsed = System.currentTimeMillis() - start;
        System.out.println(joinPoint.getSignature() + " took " + elapsed + "ms");
        return result;
    }
}
Think of it as: the pointcut answers "where?", the advice answers "what runs, and when relative to the target method?", the join point is the actual matched execution instance, and the aspect is the class that ties them together.

8. Implementing a logging aspect with @Around advice

@Around is the most powerful advice type because it wraps the entire method invocation, giving you control over whether, when, and with what arguments the target method actually runs. Calling joinPoint.proceed() invokes the real method; you can log before and after it, measure timing, modify arguments, or even suppress the call and return a different value.

@Aspect
@Component
@Slf4j
public class LoggingAspect {

    @Around("@annotation(com.example.annotation.Loggable)")
    public Object logMethod(ProceedingJoinPoint joinPoint) throws Throwable {
        String method = joinPoint.getSignature().toShortString();
        log.info("Entering {} with args={}", method, joinPoint.getArgs());
        try {
            Object result = joinPoint.proceed();
            log.info("Exiting {} with result={}", method, result);
            return result;
        } catch (Throwable ex) {
            log.error("Exception in {}: {}", method, ex.getMessage());
            throw ex;
        }
    }
}
Forgetting to call joinPoint.proceed() — or not returning its result — silently breaks the target method: it never executes, or callers get null back instead of the real return value.

9. AOP proxy types: JDK dynamic proxy vs CGLIB — when Spring picks each

Spring AOP creates proxies at runtime to weave advice around bean methods. If the target bean implements at least one interface, Spring defaults to a JDK dynamic proxy implementing that interface; if the bean has no interface (a concrete class only), Spring falls back to a CGLIB subclass proxy. Spring Boot 2+ actually defaults spring.aop.proxy-target-class=true, meaning CGLIB is used by default even when interfaces exist, unless you override it.

spring:
  aop:
    # true (default in Spring Boot): always use CGLIB subclassing
    # false: use JDK dynamic proxies when the bean implements an interface
    proxy-target-class: false
public interface OrderService {
    Order placeOrder(OrderRequest request);
}

@Service
public class OrderServiceImpl implements OrderService {
    // with proxy-target-class=false, Spring proxies via the OrderService interface (JDK proxy)
    // with proxy-target-class=true (Spring Boot default), Spring subclasses OrderServiceImpl (CGLIB)
    @Override
    public Order placeOrder(OrderRequest request) { ... }
}
CGLIB proxies subclass the target, so the class and its advised methods must not be final, and the bean needs a reachable no-arg or CGLIB-usable constructor — final classes/methods silently prevent proxying or throw at startup.

10. Common pitfall: self-invocation not triggering the proxy/advice

Spring AOP advice only fires when a call passes through the proxy Spring created around the bean. Calling another @Transactional or @Cacheable method on this from inside the same class bypasses the proxy entirely — it's a plain Java method call on the raw object, so no advice, no transaction, no caching.

@Service
public class OrderService {

    public void placeOrder(Order order) {
        // BUG: calling "this.updateInventory(...)" invokes the raw method directly,
        // skipping the proxy — @Transactional on updateInventory is silently ignored
        updateInventory(order);
    }

    @Transactional
    public void updateInventory(Order order) {
        inventoryRepository.decrement(order.getItems());
    }
}
// fix: inject a self-reference (or split into a separate bean) so the call goes through the proxy
@Service
public class OrderService {

    @Autowired
    private OrderService self; // proxy-aware self-injection

    public void placeOrder(Order order) {
        self.updateInventory(order);
    }

    @Transactional
    public void updateInventory(Order order) {
        inventoryRepository.decrement(order.getItems());
    }
}
This is one of the most common Spring AOP mistakes: internal ("self") method calls never go through the CGLIB/JDK proxy, so @Transactional, @Cacheable, @Async, and any custom aspect advice are all silently skipped.

11. Using AOP for cross-cutting concerns like auditing changes to an entity

AOP is well suited to auditing because it lets you capture "who changed what, when" without scattering logging calls through every service method. A custom annotation plus an @Around or @AfterReturning aspect can intercept save/update calls, inspect the entity and method arguments, and persist an audit record independently of business logic.

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Audited {
    String action();
}

@Aspect
@Component
@RequiredArgsConstructor
public class AuditAspect {

    private final AuditLogRepository auditLogRepository;

    @AfterReturning(pointcut = "@annotation(audited)", returning = "result")
    public void audit(JoinPoint joinPoint, Audited audited, Object result) {
        String user = SecurityContextHolder.getContext().getAuthentication().getName();
        auditLogRepository.save(new AuditLog(audited.action(), user, result, Instant.now()));
    }
}

@Service
public class ProductService {

    @Audited(action = "PRODUCT_UPDATED")
    public Product update(Product product) {
        return productRepository.save(product);
    }
}
Keeping auditing in an aspect (rather than inline in each service method) means adding audit coverage to a new method is a one-line annotation, and the auditing logic itself stays in one testable place.

12. Combining multiple aspects on the same join point and controlling execution order with @Order

When several aspects match the same join point (e.g., security, logging, and transaction management all wrapping the same method), Spring needs a deterministic order to nest their advice. Annotating each aspect class with @Order (lower values run first / are outermost on entry) controls this nesting explicitly, rather than relying on unpredictable declaration order.

@Aspect
@Component
@Order(1) // outermost: runs first on the way in, last on the way out
public class SecurityAspect {

    @Around("execution(* com.example.service.*.*(..))")
    public Object checkAccess(ProceedingJoinPoint joinPoint) throws Throwable {
        System.out.println("Checking access...");
        return joinPoint.proceed();
    }
}

@Aspect
@Component
@Order(2) // runs after SecurityAspect, closer to the target method
public class LoggingAspect {

    @Around("execution(* com.example.service.*.*(..))")
    public Object logCall(ProceedingJoinPoint joinPoint) throws Throwable {
        System.out.println("Logging call...");
        return joinPoint.proceed();
    }
}
Picture nested rings: the aspect with the lowest @Order value forms the outermost ring, entering first and exiting last, while higher-order aspects sit closer to the actual target method invocation.

Messaging: Kafka & RabbitMQ

1. How do you produce and consume messages using Spring Kafka's KafkaTemplate and @KafkaListener?

KafkaTemplate wraps a configured Kafka Producer and exposes convenience methods like send(topic, key, value) for publishing, returning a CompletableFuture you can use to confirm delivery. @KafkaListener declares a method that Spring binds to a ConcurrentMessageListenerContainer, which polls the broker and dispatches records to your method automatically. Both are auto-configured by Spring Boot from spring.kafka.* properties, so you only need to supply serializers/deserializers and topic names.

@Service
public class OrderEventProducer {

    private final KafkaTemplate<String, OrderEvent> kafkaTemplate;

    public OrderEventProducer(KafkaTemplate<String, OrderEvent> kafkaTemplate) {
        this.kafkaTemplate = kafkaTemplate;
    }

    public void publish(OrderEvent event) {
        kafkaTemplate.send("orders", event.getOrderId(), event);
    }
}

@Component
public class OrderEventConsumer {

    @KafkaListener(topics = "orders", groupId = "order-service")
    public void onMessage(OrderEvent event) {
        System.out.println("Received order: " + event.getOrderId());
    }
}
Use a JsonSerializer/JsonDeserializer pair (or Avro) with matching type mappings on both sides so producer and consumer agree on the payload shape.

2. How do you guarantee message ordering in Kafka when a topic has multiple partitions?

Kafka only guarantees ordering within a single partition, not across an entire topic. By sending messages with a consistent partition key — such as an account or order ID — Kafka's default partitioner hashes that key to the same partition every time, and since one partition is consumed by exactly one consumer thread within a group, all events for that key are processed in send order. Choosing a key with enough cardinality also keeps load evenly spread across partitions.

@Service
public class PaymentEventProducer {

    private final KafkaTemplate<String, PaymentEvent> kafkaTemplate;

    public PaymentEventProducer(KafkaTemplate<String, PaymentEvent> kafkaTemplate) {
        this.kafkaTemplate = kafkaTemplate;
    }

    public void publish(PaymentEvent event) {
        // Same key (accountId) -> same partition -> strict per-account ordering
        kafkaTemplate.send("payments", event.getAccountId(), event);
    }
}
Changing the partition count later reshuffles the key-to-partition mapping, which can break existing ordering guarantees — plan partition counts up front.

3. How do you handle consumer failures with retry and a dead-letter topic in Spring Kafka?

Spring Kafka's non-blocking retry support (@RetryableTopic) republishes a failed record to an internal retry topic with a backoff delay instead of blocking the partition, then redelivers it to the same listener. Once the configured attempt count is exhausted, the record is routed to a dead-letter topic where it can be inspected or reprocessed manually via a @DltHandler method.

Producer Main Topic orders-0..N Consumer Retry Topic backoff x N fail retry attempts > N Dead-Letter Topic
@Component
public class ShipmentEventConsumer {

    @RetryableTopic(
        attempts = "4",
        backoff = @Backoff(delay = 1000, multiplier = 2.0),
        dltTopicSuffix = "-dlt",
        autoCreateTopics = "true")
    @KafkaListener(topics = "shipments", groupId = "shipment-service")
    public void onMessage(ShipmentEvent event) {
        shipmentService.process(event);
    }

    @DltHandler
    public void onDltMessage(ShipmentEvent event,
                              @Header(KafkaHeaders.EXCEPTION_MESSAGE) String error) {
        deadLetterAuditService.record(event, error);
    }
}
Retry topics multiply partition count and consumer load — monitor the DLQ actively, since messages landing there need a human or a replay job to resolve.

4. How do you build an idempotent Kafka consumer that avoids processing duplicate messages?

At-least-once delivery means every consumer must tolerate redelivery, so idempotency is enforced by recording a unique message identifier in a dedup table with a unique constraint before running business logic, inside the same database transaction. If the insert fails with a constraint violation, the message was already processed and can be safely skipped. This is more reliable than a "check then act" lookup, which is vulnerable to race conditions under concurrent redelivery.

@Entity
@Table(name = "processed_message",
       uniqueConstraints = @UniqueConstraint(columnNames = "message_id"))
public class ProcessedMessage {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "message_id", nullable = false, unique = true)
    private String messageId;

    private Instant processedAt;
}

@Service
public class PaymentEventHandler {

    private final ProcessedMessageRepository processedMessageRepository;
    private final PaymentService paymentService;

    public PaymentEventHandler(ProcessedMessageRepository processedMessageRepository,
                                PaymentService paymentService) {
        this.processedMessageRepository = processedMessageRepository;
        this.paymentService = paymentService;
    }

    @Transactional
    public void handle(PaymentEvent event) {
        try {
            processedMessageRepository.save(new ProcessedMessage(event.getMessageId(), Instant.now()));
        } catch (DataIntegrityViolationException duplicate) {
            return; // already processed, skip
        }
        paymentService.apply(event);
    }
}
Prune the dedup table on a retention window (e.g. 7-30 days) so it doesn't grow unbounded — Kafka rarely redelivers older than the consumer's max retry/replay horizon.

5. How do exchanges, queues, and bindings work together in RabbitMQ with Spring AMQP?

Producers never publish directly to a queue in RabbitMQ — they publish to an exchange, which routes the message to zero or more bound queues based on a routing key and the exchange type. A TopicExchange matches routing keys against wildcard patterns, DirectExchange requires an exact match, and FanoutExchange broadcasts to every bound queue ignoring the key. Spring AMQP lets you declare exchanges, queues, and the bindings between them as beans, which get created automatically on the broker at startup.

@Configuration
public class OrderRabbitConfig {

    static final String EXCHANGE = "orders.exchange";
    static final String QUEUE = "orders.created.queue";
    static final String ROUTING_KEY = "order.created";

    @Bean
    public TopicExchange ordersExchange() {
        return new TopicExchange(EXCHANGE);
    }

    @Bean
    public Queue orderCreatedQueue() {
        return new Queue(QUEUE, true);
    }

    @Bean
    public Binding orderCreatedBinding(Queue orderCreatedQueue, TopicExchange ordersExchange) {
        return BindingBuilder.bind(orderCreatedQueue).to(ordersExchange).with(ROUTING_KEY);
    }
}

@Service
public class OrderEventPublisher {

    private final RabbitTemplate rabbitTemplate;

    public OrderEventPublisher(RabbitTemplate rabbitTemplate) {
        this.rabbitTemplate = rabbitTemplate;
    }

    public void publish(OrderCreatedEvent event) {
        rabbitTemplate.convertAndSend(OrderRabbitConfig.EXCHANGE, "order.created", event);
    }
}

6. What is the transactional outbox pattern and why is it needed for reliable event publishing?

Writing to a database and publishing to Kafka/RabbitMQ are two separate systems, so a failure between them (a "dual write") can silently lose or duplicate an event. The outbox pattern solves this by writing the business row and an outbox row describing the event in the same local transaction, guaranteeing atomicity; a separate poller (or a CDC tool like Debezium) then reads unpublished outbox rows and publishes them, marking them sent afterward.

@Entity
@Table(name = "outbox_event")
public class OutboxEvent {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String aggregateId;
    private String eventType;
    @Column(columnDefinition = "text")
    private String payload;
    private boolean published;
    private Instant createdAt;
}

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final OutboxEventRepository outboxEventRepository;
    private final ObjectMapper objectMapper;

    public OrderService(OrderRepository orderRepository,
                         OutboxEventRepository outboxEventRepository,
                         ObjectMapper objectMapper) {
        this.orderRepository = orderRepository;
        this.outboxEventRepository = outboxEventRepository;
        this.objectMapper = objectMapper;
    }

    @Transactional
    public void placeOrder(Order order) throws JsonProcessingException {
        orderRepository.save(order);

        OutboxEvent event = new OutboxEvent();
        event.setAggregateId(order.getId());
        event.setEventType("OrderPlaced");
        event.setPayload(objectMapper.writeValueAsString(order));
        event.setCreatedAt(Instant.now());
        outboxEventRepository.save(event);
    }
}

@Component
public class OutboxPublisher {

    private final OutboxEventRepository outboxEventRepository;
    private final KafkaTemplate<String, String> kafkaTemplate;

    public OutboxPublisher(OutboxEventRepository outboxEventRepository,
                            KafkaTemplate<String, String> kafkaTemplate) {
        this.outboxEventRepository = outboxEventRepository;
        this.kafkaTemplate = kafkaTemplate;
    }

    @Scheduled(fixedDelay = 500)
    @Transactional
    public void publishPending() {
        outboxEventRepository.findTop100ByPublishedFalseOrderByIdAsc().forEach(event -> {
            kafkaTemplate.send("order-events", event.getAggregateId(), event.getPayload());
            event.setPublished(true);
        });
    }
}
The outbox only guarantees at-least-once delivery, not exactly-once — downstream consumers still need to be idempotent since a crash after publish but before marking "published" can resend an event.

7. What is the difference between manual and automatic acknowledgment in Kafka listeners, and when should you use manual ack?

With auto ack, the container commits offsets on a timer independent of whether your listener actually finished successfully, which risks losing a message if the process crashes after the commit but before completing work, or reprocessing it if it crashes before the commit. Manual acknowledgment (AckMode.MANUAL or MANUAL_IMMEDIATE) hands control to the listener, which calls Acknowledgment.acknowledge() only after business logic — including any downstream writes — has completed successfully. Use manual ack whenever processing has side effects that must not be silently skipped, such as financial transactions or anything feeding a dead-letter/retry flow.

@Bean
public ConcurrentKafkaListenerContainerFactory<String, InvoiceEvent> kafkaListenerContainerFactory(
        ConsumerFactory<String, InvoiceEvent> consumerFactory) {
    ConcurrentKafkaListenerContainerFactory<String, InvoiceEvent> factory =
            new ConcurrentKafkaListenerContainerFactory<>();
    factory.setConsumerFactory(consumerFactory);
    factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL_IMMEDIATE);
    return factory;
}

@Component
public class InvoiceEventConsumer {

    @KafkaListener(topics = "invoices", groupId = "invoice-service")
    public void onMessage(InvoiceEvent event, Acknowledgment acknowledgment) {
        invoiceService.process(event);
        acknowledgment.acknowledge(); // commit only after successful processing
    }
}
MANUAL_IMMEDIATE commits synchronously right after acknowledge() is called, which is safer but slower than MANUAL, which batches the commit on the next poll.

8. How do you handle schema evolution for Kafka messages using Avro and a Schema Registry?

Avro schemas evolve safely by adding new fields with defaults, which keeps old and new schema versions backward- and forward-compatible; renaming or removing a required field breaks compatibility. Confluent Schema Registry stores every schema version, assigns it an ID that's embedded in each serialized record, and enforces a compatibility rule (BACKWARD by default) at registration time so an incompatible producer deploy is rejected before it can break existing consumers.

Map<String, Object> producerProps = new HashMap<>();
producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, KafkaAvroSerializer.class);
producerProps.put("schema.registry.url", "http://schema-registry:8081");
producerProps.put("auto.register.schemas", "true");

// Adding an optional field with a default is BACKWARD-compatible:
// { "name": "currency", "type": "string", "default": "USD" }
// Consumers on the old schema simply ignore the new field.
Never change a field's type or remove a required field without a default — that breaks compatibility for any consumer still running the previous schema version.

9. How do you test a @KafkaListener without connecting to a real Kafka cluster?

The spring-kafka-test module provides @EmbeddedKafka, which starts an in-memory, in-process Kafka broker for the test's lifetime, letting you exercise the real producer/consumer wiring without any external infrastructure. The test publishes a message with an injected KafkaTemplate and then asserts the expected side effect, polling with Awaitility since consumption happens asynchronously on a listener container thread.

@SpringBootTest
@EmbeddedKafka(partitions = 1, topics = "orders")
class OrderEventConsumerTest {

    @Autowired
    private KafkaTemplate<String, OrderEvent> kafkaTemplate;

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void consumesAndPersistsOrder() {
        OrderEvent event = new OrderEvent("order-42", "PLACED");

        kafkaTemplate.send("orders", event.getOrderId(), event);

        Awaitility.await()
                .atMost(Duration.ofSeconds(5))
                .untilAsserted(() ->
                        assertThat(orderRepository.findById("order-42")).isPresent());
    }
}

10. When would you choose Kafka over RabbitMQ, or vice versa, for a given messaging use case?

Kafka is built as a durable, replayable log: it excels at high-throughput event streaming, partition-ordered processing, and letting multiple independent consumer groups replay history, but its parallelism is capped by partition count and it has no built-in per-message priority or complex routing. RabbitMQ is a general-purpose broker optimized for flexible routing (direct/topic/fanout exchanges), low-latency task distribution, and per-message acknowledgment/priority/TTL, but it isn't designed for long-term retention or replay. Pick Kafka for event sourcing, analytics pipelines, or audit trails; pick RabbitMQ for work queues, RPC-style request/reply, or scenarios needing intricate routing topologies.

// Kafka: parallelism is bounded by partition count within a consumer group
@KafkaListener(topics = "orders", groupId = "order-service", concurrency = "6")
public void onOrder(OrderEvent event) {
    orderService.process(event);
}

// RabbitMQ: parallelism is bounded by prefetch and concurrent consumers on a queue
@RabbitListener(queues = "orders.queue", concurrency = "4-10")
public void onOrder(OrderMessage message) {
    orderService.process(message);
}
A quick heuristic: if you need to replay history or fan out to many independent consumer groups, reach for Kafka; if you need smart routing or strict per-task delivery control, reach for RabbitMQ.

Deployment, Observability & DevOps

1. How do you build an efficient Docker image for a Spring Boot application?

Spring Boot's layered jar feature splits the fat jar into layers (dependencies, spring-boot-loader, snapshot-dependencies, application) so Docker can cache dependency layers separately from your frequently-changing application code. A multi-stage build extracts these layers and copies them in order of change frequency, so a code-only change only invalidates the last, smallest layer. Cloud Native Buildpacks (via spring-boot:build-image) achieve the same result without hand-writing a Dockerfile, and also produce SBOM-friendly, non-root, JRE-only images.

FROM eclipse-temurin:21-jre-alpine AS builder
WORKDIR /workspace
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} application.jar
RUN java -Djarmode=layertools -jar application.jar extract

FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
RUN addgroup -S spring && adduser -S spring -G spring
COPY --from=builder /workspace/dependencies/ ./
COPY --from=builder /workspace/spring-boot-loader/ ./
COPY --from=builder /workspace/snapshot-dependencies/ ./
COPY --from=builder /workspace/application/ ./
USER spring:spring
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
Alternative: run ./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=myapp:latest — Paketo buildpacks apply the same layering automatically and pick a JRE that matches your build's Java version.

2. How do you externalize configuration for a Spring Boot app running on Kubernetes?

Non-sensitive configuration goes into a ConfigMap and sensitive values (passwords, API keys, certificates) go into a Secret; both are mounted into the pod either as environment variables or as files, and Spring's spring.config.import can pull mounted files in directly. Mounting as files rather than env vars lets you use Kubernetes' automatic mount refresh (with kubelet's config sync) and keeps secrets out of `env` listings visible via kubectl describe pod. Spring Cloud Kubernetes or a simple volume mount both work; the volume-mount approach avoids an extra dependency.

apiVersion: v1
kind: ConfigMap
metadata:
  name: order-service-config
data:
  application.yaml: |
    order:
      retry-count: 3
    logging:
      level:
        root: INFO
---
apiVersion: v1
kind: Secret
metadata:
  name: order-service-secrets
type: Opaque
stringData:
  DB_PASSWORD: "s3cr3t"
---
# Deployment spec (excerpt)
        volumeMounts:
        - name: config-volume
          mountPath: /workspace/config
        envFrom:
        - secretRef:
            name: order-service-secrets
      volumes:
      - name: config-volume
        configMap:
          name: order-service-config
Never bake secrets into the image or a ConfigMap in plaintext for real credentials — use Secrets (ideally backed by a vault like AWS Secrets Manager via the External Secrets Operator), since base64 in a Secret is encoding, not encryption.

3. How do you implement graceful shutdown so in-flight requests aren't dropped during a pod termination?

Setting server.shutdown=graceful tells the embedded web server (Tomcat, Netty, Jetty) to stop accepting new requests but wait for in-flight requests to finish, up to spring.lifecycle.timeout-per-shutdown-phase, before the JVM exits. This matters because Kubernetes sends SIGTERM and may still route traffic to the pod for a brief window while endpoint updates propagate, so combining graceful shutdown with a `preStop` hook delay avoids dropped connections. Without this, Spring's default behavior is to shut down immediately, killing active requests mid-flight.

# application.yaml
server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 20s
---
# Deployment spec (excerpt)
      terminationGracePeriodSeconds: 30
      containers:
      - name: order-service
        lifecycle:
          preStop:
            exec:
              command: ["sh", "-c", "sleep 5"]
The preStop sleep gives kube-proxy/endpoint controllers time to remove the pod from Service endpoints before the app stops accepting connections, closing the race between "pod terminating" and "traffic still arriving".

4. How do you expose and scrape Prometheus metrics from a Spring Boot app?

Adding micrometer-registry-prometheus alongside Actuator automatically registers a /actuator/prometheus endpoint that renders all Micrometer meters (JVM, HTTP, datasource, custom) in Prometheus text format. You must explicitly enable and expose the endpoint, since Actuator exposes only health and info by default, and Prometheus scrapes it on an interval defined in its scrape config or via a ServiceMonitor in Kubernetes.

// build.gradle: implementation 'io.micrometer:micrometer-registry-prometheus'

@RestController
class OrderController {

    private final Counter ordersPlaced;

    OrderController(MeterRegistry registry) {
        this.ordersPlaced = Counter.builder("orders.placed")
                .description("Number of orders placed")
                .tag("channel", "web")
                .register(registry);
    }

    @PostMapping("/orders")
    ResponseEntity<Void> placeOrder() {
        ordersPlaced.increment();
        return ResponseEntity.accepted().build();
    }
}
# application.yaml
management:
  endpoints:
    web:
      exposure:
        include: health, info, prometheus
  metrics:
    tags:
      application: order-service
Give every instance a distinguishing tag (management.metrics.tags.application) — otherwise metrics from multiple replicas collapse into indistinguishable series once aggregated in Prometheus.

5. How do you implement structured logging with correlation/trace IDs propagated across service calls?

Micrometer Tracing (successor to Sleuth in Spring Boot 3) auto-generates a trace ID and span ID per request, puts them in MDC, and propagates them across outbound HTTP/messaging calls via headers like traceparent. Pairing this with a JSON log encoder (Logback's logstash-logback-encoder) turns each log line into a structured document that log aggregators (ELK, Loki, Datadog) can query by trace ID across every service the request touched.

// build.gradle
// implementation 'io.micrometer:micrometer-tracing-bridge-brave'
// implementation 'io.zipkin.reporter2:zipkin-reporter-brave'
// implementation 'net.logstash.logback:logstash-logback-encoder:7.4'

@Service
class PaymentService {
    private static final Logger log = LoggerFactory.getLogger(PaymentService.class);

    void charge(String orderId) {
        // traceId/spanId are already in MDC — no manual wiring needed
        log.info("Charging order {}", orderId);
        restClient.post().uri("/payments").body(orderId).retrieve().toBodilessEntity();
    }
}
# logback-spring.xml (excerpt, conceptually)
# <encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
# includes traceId, spanId, and MDC fields automatically

management:
  tracing:
    sampling:
      probability: 1.0
A sampling probability of 1.0 traces every request — fine for debugging or low-traffic services, but dial it down (e.g. 0.1) in high-throughput production services to control tracing backend cost and overhead.

6. How do you achieve zero-downtime rolling deployments with Kubernetes and Spring Boot?

A Kubernetes RollingUpdate strategy replaces pods incrementally, but zero downtime only happens if new pods are added to Service endpoints exactly when they're ready to serve traffic, and old pods are removed before they stop accepting connections. That requires an accurate readiness probe (Actuator's /actuator/health/readiness) tied to maxUnavailable: 0 so the old pod count never dips below what's needed, plus graceful shutdown so terminating pods finish in-flight work instead of dropping it.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
spec:
  replicas: 4
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0
      maxSurge: 1
  template:
    spec:
      containers:
      - name: order-service
        readinessProbe:
          httpGet:
            path: /actuator/health/readiness
            port: 8080
          initialDelaySeconds: 5
          periodSeconds: 5
          failureThreshold: 3
Enable management.endpoint.health.probes.enabled=true and management.health.readiness-state.enabled=true so readiness reflects real dependency health (DB, downstream services), not just "the JVM booted".

7. How do you configure Kubernetes liveness and readiness probes correctly for a Spring Boot app, and why aren't they the same check?

Liveness answers "is this process stuck and should Kubernetes restart it?" — it should only fail on unrecoverable deadlocks, never on a slow downstream dependency, or Kubernetes will restart a perfectly healthy pod in a crash loop. Readiness answers "can this pod currently serve traffic?" — it should fail when a required dependency (database, message broker) is down, so the pod is pulled from load balancing without being killed. Spring Boot's Kubernetes probe groups (liveness and readiness) map health indicators to the right question: liveness checks internal application state, readiness checks external dependencies.

# application.yaml
management:
  endpoint:
    health:
      probes:
        enabled: true
      group:
        readiness:
          include: readinessState, db, redis
        liveness:
          include: livenessState
---
# Deployment spec (excerpt)
livenessProbe:
  httpGet:
    path: /actuator/health/liveness
    port: 8080
  initialDelaySeconds: 30
  periodSeconds: 10
  failureThreshold: 3
readinessProbe:
  httpGet:
    path: /actuator/health/readiness
    port: 8080
  initialDelaySeconds: 10
  periodSeconds: 5
  failureThreshold: 3
A common production incident is wiring the database health check into liveness — when the DB has a brief blip, Kubernetes restarts every pod simultaneously instead of just marking them not-ready, turning a transient dependency issue into a full outage.

8. How do you tune the JVM and Spring Boot for containerized environments with CPU/memory limits?

Modern JVMs (Java 11+) are container-aware by default: they read cgroup limits and size the heap as a percentage of the container's memory limit rather than the host's, via -XX:MaxRAMPercentage (default caps at 25%, often too low for a heap-heavy Spring Boot app). You should explicitly set MaxRAMPercentage, pick G1GC (default and generally fine) or the low-latency ZGC for large heaps, and always set a container memory limit — an unbounded pod risks OOM-killing the node instead of just the pod.

ENTRYPOINT ["java", \
  "-XX:MaxRAMPercentage=75.0", \
  "-XX:InitialRAMPercentage=50.0", \
  "-XX:+UseG1GC", \
  "-XX:+ExitOnOutOfMemoryError", \
  "org.springframework.boot.loader.launch.JarLauncher"]
# Deployment spec (excerpt)
resources:
  requests:
    memory: "512Mi"
    cpu: "500m"
  limits:
    memory: "768Mi"
    cpu: "1"
Leave headroom between the heap and the container limit for metaspace, thread stacks, and direct buffers — sizing the heap at exactly the container limit is a frequent cause of OOMKilled pods even though "the heap never filled up".
No comments
Leave a Comment