Learn Spring for interviews

Concept first, versions last. For each feature: problem → inside → use → breaks, then say it in 60 seconds and check yourself. Buttons open the matching explorer scenario.

How to learn each feature

Ask the same four questions for every Spring feature. Most people can answer 1 and 3; interviews are decided by 2 and 4.

  1. Problem — what pain does it remove?
  2. Inside — which 3–5 classes do the work, in what order?
  3. Use — what annotation, bean or property do you write?
  4. Breaks — where does it fail or surprise people?

The loop (one topic every 2–3 days)

StepWhereTime
Read the flowExplorer scenario with Guess mode + Hide unseen boxes30 min
See it for realYour SecurePay project: breakpoint, TRACE log, break it on purpose30 min
Say it out loudAnswer the 4 questions without looking, then check this page10 min
Write it downOne interview line + one gotcha in your notebook5 min

Answer in three layers (~60 seconds)

  1. One line — what it does.
  2. Mechanism — the classes, in order.
  3. Gotcha + your project — "In SecurePay I…"

Avoid

Order

  1. IoC & Dependency Injection Spring Core
  2. Bean lifecycle & scopes Spring Core
  3. AOP & proxies Spring Core
  4. @Transactional Transactions
  5. Spring Boot auto-configuration Spring Boot
  6. Spring MVC request flow Web
  7. Validation & error handling Web
  8. JPA: persistence context, lazy loading, N+1 Data
  9. Spring Security: filter chain & JWT Security
  10. Service-to-service calls & resilience Microservices
  11. Kafka with Spring Messaging

Topics 1–8 cover most Spring interview questions. Do them before 9–11.

1

IoC & Dependency Injection

Spring Core

1 · Problem

Classes that create their own dependencies with new are tightly coupled and hard to test. Spring creates the objects (beans) and hands each class what it needs.

2 · Inside

Component scan → BeanDefinitions in DefaultListableBeanFactory → at startup every singleton is created → constructor parameters resolved by type (then @Qualifier, @Primary, parameter name).

3 · Use

@Service
public class PaymentService {
    private final PaymentRepository repository;
    private final FeeCalculator feeCalculator;

    // single constructor → Spring injects it, no @Autowired needed
    public PaymentService(PaymentRepository repository, FeeCalculator feeCalculator) {
        this.repository = repository;
        this.feeCalculator = feeCalculator;
    }
}

@Configuration
public class ClockConfig {
    @Bean                       // a class you can't annotate (JDK, library) as a bean
    Clock clock() {
        return Clock.systemUTC();
    }
}

4 · Breaks

  • Two beans of the same type → NoUniqueBeanDefinitionException at startup.
  • No bean of the type (class outside the scanned package) → NoSuchBeanDefinitionException.
  • Circular dependency A ↔ B → startup fails (Boot forbids it by default).
  • Field injection: dependency is null inside the constructor and hidden from tests.
// two PaymentGateway beans → pick one explicitly
public PaymentService(@Qualifier("stripeGateway") PaymentGateway gateway) { ... }

// or mark the default implementation
@Primary
@Component
class StripeGateway implements PaymentGateway { ... }

60-second answer

  1. Spring's container creates and wires my objects instead of me calling new.
  2. It scans @Component classes into bean definitions, creates singletons at startup and injects constructor parameters by type, using @Qualifier or @Primary when there are several.
  3. I use constructor injection with final fields — in SecurePay the controller gets PaymentService injected; wiring mistakes fail at startup, not in production.

Check yourself

Why prefer constructor injection over field injection?

Dependencies are explicit and final, the object is complete after construction, and unit tests can pass mocks without Spring.

What happens if two beans implement the same interface?

NoUniqueBeanDefinitionException at startup unless @Qualifier, @Primary or a matching parameter name picks one; or inject List<T> to get all.

When are singleton beans created?

At startup (preInstantiateSingletons), so wiring errors fail fast — unless lazy initialization is on.

2

Bean lifecycle & scopes

Spring Core

1 · Problem

Some beans must do work after injection (load a cache) or before shutdown (close a client); some objects must not be shared.

2 · Inside

constructor → field/setter injection → Aware callbacks → BeanPostProcessor before-init (runs @PostConstruct) → afterPropertiesSet/initMethod → BeanPostProcessor after-init (proxies created here) → ready … @PreDestroy at shutdown.

3 · Use

@Component
public class RateCache {
    private final RateClient client;
    private Map<String, BigDecimal> rates;

    public RateCache(RateClient client) {   // 1. constructor
        this.client = client;
    }

    @PostConstruct                           // 2. after injection
    void load() {
        rates = client.fetchAll();
    }

    @PreDestroy                              // 3. on shutdown
    void clear() {
        rates.clear();
    }
}

// prototype bean used from a singleton: ask the container each time
@Service
class ReportService {
    private final ObjectProvider<ReportBuilder> builders;   // ReportBuilder is @Scope("prototype")

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

    Report create() {
        return builders.getObject().build();                 // new instance per call
    }
}

4 · Breaks

  • A prototype bean injected directly into a singleton is created only once.
  • Calling this bean's own @Transactional method from @PostConstruct runs without a transaction (no proxy yet + self-invocation).
  • Prototype beans never get @PreDestroy.

60-second answer

  1. Spring constructs the bean, injects dependencies, runs @PostConstruct, then post-processors may wrap it in a proxy; @PreDestroy runs at shutdown.
  2. Order: constructor → injection → Aware → @PostConstruct → afterPropertiesSet → BeanPostProcessor after-init (AOP proxy) → destroy callbacks.
  3. Default scope is singleton; for per-call objects I use prototype with ObjectProvider, and request scope uses a scoped proxy.

Check yourself

Where in the lifecycle is the @Transactional proxy created?

In BeanPostProcessor after-initialization (the auto-proxy creator), after @PostConstruct.

Why does a prototype injected into a singleton behave like a singleton?

Injection happens once, when the singleton is created. Use ObjectProvider, @Lookup or a scoped proxy.

Which beans get destroy callbacks?

Singletons at shutdown, request/session beans at the end of the request/session; prototypes never.

3

AOP & proxies

Spring Core

1 · Problem

Cross-cutting code (transactions, security, timing, caching) would otherwise be copied into every method.

2 · Inside

A BeanPostProcessor (AnnotationAwareAspectJAutoProxyCreator) finds advisors matching the bean and replaces it with a CGLIB subclass proxy (Boot default). Calls from outside go through the interceptor chain, then to the real object.

3 · Use

@Aspect
@Component
public class TimingAspect {
    private static final Logger log = LoggerFactory.getLogger(TimingAspect.class);

    @Around("@annotation(com.securepay.Timed)")
    public Object time(ProceedingJoinPoint pjp) throws Throwable {
        long start = System.nanoTime();
        try {
            return pjp.proceed();                       // must call proceed()
        } finally {
            log.info("{} took {} ms", pjp.getSignature().toShortString(),
                     (System.nanoTime() - start) / 1_000_000);
        }
    }
}

4 · Breaks

  • Self-invocation: this.method() skips the proxy → @Transactional, @Async, @Cacheable, @PreAuthorize do nothing.
  • final methods are not overridden by CGLIB (they run on the proxy with null fields → NPE); private methods get no advice.
  • An @Aspect without @Component (or @Bean) is never registered — @Aspect is not picked up by component scanning, so the advice silently never runs. (@Aspect needs aspectjweaver, via spring-boot-starter-aop.)
public void process(PaymentRequest r) {
    this.save(r);        // ❌ direct call on the target: @Transactional on save() is ignored
}
// fix: move save() to another bean and call it through that bean

60-second answer

  1. Spring AOP wraps a bean in a proxy that runs extra logic around method calls.
  2. The auto-proxy creator (a BeanPostProcessor) builds a CGLIB proxy with an interceptor chain; the last interceptor calls the real object.
  3. Because it's proxy-based, internal this.x() calls, final and private methods are not intercepted — I put transactional methods in a separate bean.

Check yourself

Why doesn't @Transactional work on a method called from the same class?

The call uses this (the target), not the proxy, so no interceptor runs.

JDK proxy vs CGLIB?

JDK proxies implement interfaces only; CGLIB subclasses the class. Boot defaults to CGLIB (proxy-target-class=true).

How do @PreAuthorize and @Transactional relate?

Both are advisors on the same proxy; security runs before the transaction interceptor.

4

@Transactional

Transactions

1 · Problem

Several database writes must succeed or fail together; managing connections, commit and rollback by hand is error-prone.

2 · Inside

proxy → TransactionInterceptor → reads the attribute → JpaTransactionManager.getTransaction → connection + EntityManager bound to the thread (TransactionSynchronizationManager) → your method → flush + commit, or rollback.

3 · Use

@Service
public class PaymentService {
    private final PaymentRepository repository;
    private final AuditService auditService;          // a separate bean
    // constructor omitted

    @Transactional(rollbackFor = Exception.class)     // also roll back on checked exceptions
    public Payment refund(Long id) {
        Payment p = repository.findById(id)
                .orElseThrow(() -> new PaymentNotFoundException(id));
        p.markRefunded();                 // no save(): dirty checking → UPDATE at commit
        auditService.log("REFUND", id);   // REQUIRES_NEW: commits even if refund rolls back
        return p;
    }
}

@Service
class AuditService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void log(String action, Long paymentId) {
        // insert audit row in its own transaction (needs a 2nd DB connection)
    }
}

4 · Breaks

  • Checked exceptions commit by default (only RuntimeException/Error roll back).
  • Catching the exception inside the method → it commits.
  • Inner REQUIRED method throws, outer catches → UnexpectedRollbackException at commit.
  • @Async / other threads don't see the transaction (ThreadLocal).
  • Long transactions (HTTP calls inside) hold a DB connection the whole time.

60-second answer

  1. @Transactional makes a method's DB work atomic: commit on success, rollback on failure.
  2. The proxy's TransactionInterceptor asks the PlatformTransactionManager to begin, binds the connection and EntityManager to the thread, and commits or rolls back by the rollback rules.
  3. Traps I watch: checked exceptions commit, self-invocation skips it, REQUIRES_NEW needs a second connection — in SecurePay I keep HTTP calls to Service B outside the transaction.

Check yourself

Does a checked exception roll back?

No, it commits by default. Use rollbackFor = Exception.class (or unchecked exceptions).

REQUIRED vs REQUIRES_NEW?

REQUIRED joins the existing transaction; REQUIRES_NEW suspends it and starts an independent one on a new connection.

Why is a constraint violation sometimes thrown after my method returned?

The INSERT/UPDATE is flushed at commit, which happens in the proxy after your method body.

5

Spring Boot auto-configuration

Spring Boot

1 · Problem

Plain Spring needs lots of configuration for a DataSource, JPA, MVC, Jackson, Tomcat. Boot configures sensible defaults from what's on the classpath.

2 · Inside

@SpringBootApplication → @EnableAutoConfiguration → classes listed in AutoConfiguration.imports → guarded by @ConditionalOnClass / @ConditionalOnMissingBean / @ConditionalOnProperty → processed after your beans so yours win.

3 · Use

# application.yml
server:
  port: 8081
spring:
  datasource:
    url: jdbc:h2:mem:payments
  jpa:
    open-in-view: false
payment:
  max-amount: 5000
  currency: CAD
@ConfigurationProperties("payment")
@Validated
public record PaymentProperties(@Positive BigDecimal maxAmount, @NotBlank String currency) {}

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

// tweak Boot's ObjectMapper instead of replacing it
@Bean
Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() {
    return builder -> builder.serializationInclusion(JsonInclude.Include.NON_NULL);
}

4 · Breaks

  • "Why isn't my bean created?" → run with --debug and read the conditions report.
  • @EnableWebMvc in a Boot app switches off Boot's MVC auto-configuration.
  • Property precedence: command line > env vars > profile yml > application.yml.
  • Bean definition overriding is disabled by default (duplicate bean names fail).

60-second answer

  1. Spring Boot auto-configures beans based on the classpath and my properties, and backs off when I define my own.
  2. @EnableAutoConfiguration imports ~150 auto-config classes, each guarded by conditions like @ConditionalOnClass and @ConditionalOnMissingBean, evaluated after my configuration.
  3. I override with properties first, customizers second, my own bean last — e.g. my SecurityFilterChain makes Boot's default security back off.

Check yourself

How does Boot know to create a DataSource?

DataSourceAutoConfiguration matches because JDBC/Hikari classes are on the classpath and no DataSource bean exists; it binds spring.datasource.*.

What does @SpringBootApplication combine?

@SpringBootConfiguration, @EnableAutoConfiguration and @ComponentScan.

How do you create your own auto-configuration?

An @AutoConfiguration class with conditions, listed in META-INF/spring/…AutoConfiguration.imports.

6

Spring MVC request flow

Web

1 · Problem

Mapping HTTP requests to Java methods, converting JSON, validating input and producing responses — without servlet boilerplate.

2 · Inside

Filters (incl. Security) → DispatcherServlet.doDispatch → RequestMappingHandlerMapping finds the method → RequestMappingHandlerAdapter resolves arguments (Jackson for @RequestBody) → your method → return value handler writes JSON.

3 · Use

@RestController
@RequestMapping("/payments")
public class PaymentController {
    private final PaymentService service;

    public PaymentController(PaymentService service) {
        this.service = service;
    }

    @PostMapping
    public ResponseEntity<PaymentResponse> create(@Valid @RequestBody CreatePaymentRequest req,
                                                  @AuthenticationPrincipal Jwt jwt) {
        PaymentResponse created = service.create(req, jwt.getSubject());   // user from the token
        return ResponseEntity.created(URI.create("/payments/" + created.id())).body(created);
    }

    @GetMapping("/{id}")
    public PaymentResponse get(@PathVariable Long id) {
        return service.get(id);
    }
}

4 · Breaks

  • Wrong method → 405, wrong Content-Type → 415, unsupported Accept → 406, bad path variable → 400.
  • Unknown URL → 404 (NoResourceFoundException in 6.1+); /payments/ no longer matches /payments.
  • Returning JPA entities → lazy-loading surprises, infinite recursion; return DTOs.

60-second answer

  1. Every request goes through the DispatcherServlet, which finds the controller method, converts the input, calls it and writes the result.
  2. HandlerMapping picks the method from @RequestMapping info built at startup; HandlerAdapter uses argument resolvers and HttpMessageConverters (Jackson) in and out.
  3. Spring Security runs as a filter before all this — in SecurePay the JWT is validated before the DispatcherServlet sees the request.

Check yourself

What is the DispatcherServlet?

The front controller: one servlet that dispatches every request to handler methods.

How is @RequestBody converted?

RequestResponseBodyMethodProcessor uses an HttpMessageConverter (Jackson) chosen by Content-Type.

Filter vs HandlerInterceptor?

Filter: servlet level, before the DispatcherServlet. Interceptor: around the handler, knows the HandlerMethod.

7

Validation & error handling

Web

1 · Problem

Invalid input and business errors must become consistent HTTP responses, not stack traces.

2 · Inside

@Valid → Hibernate Validator → MethodArgumentNotValidException → ExceptionHandlerExceptionResolver finds your @ExceptionHandler in a @RestControllerAdvice → ProblemDetail (RFC 9457).

3 · Use

public record CreatePaymentRequest(
        @NotNull @Positive BigDecimal amount,
        @NotBlank @Size(min = 3, max = 3) String currency,
        @NotBlank String toAccount) {}

@RestControllerAdvice
public class GlobalErrorHandler {

    @ExceptionHandler(PaymentNotFoundException.class)
    ProblemDetail notFound(PaymentNotFoundException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    ProblemDetail invalid(MethodArgumentNotValidException ex) {
        ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
        pd.setTitle("Validation failed");
        pd.setProperty("errors", ex.getBindingResult().getFieldErrors().stream()
                .map(e -> e.getField() + ": " + e.getDefaultMessage())
                .toList());
        return pd;
    }
}

4 · Breaks

  • Security 401/403 from filters never reach @RestControllerAdvice (use an AuthenticationEntryPoint / AccessDeniedHandler).
  • A catch-all @ExceptionHandler(Exception.class) turns @PreAuthorize 403s into 500s unless you rethrow AccessDeniedException.
  • Unhandled exceptions go to /error; if Security doesn't permit /error you may see 401/403 instead of 500.

60-second answer

  1. I validate DTOs with @Valid and map exceptions to responses in one @RestControllerAdvice.
  2. Validation failures raise MethodArgumentNotValidException; the ExceptionHandlerExceptionResolver finds the matching @ExceptionHandler and I return a ProblemDetail.
  3. Filter-level security errors bypass the advice, so in SecurePay I configure the entry point for JSON 401s and permit /error.

Check yourself

Which exception does @Valid @RequestBody throw?

MethodArgumentNotValidException (400). Parameter constraints like @Min on @RequestParam → HandlerMethodValidationException.

What is ProblemDetail?

Spring's RFC 9457 error body: type, title, status, detail, instance, plus custom properties.

Why can't @RestControllerAdvice handle an invalid JWT?

The bearer filter rejects the request before the DispatcherServlet; MVC never runs.

8

JPA: persistence context, lazy loading, N+1

Data

1 · Problem

Mapping objects to tables and writing SQL by hand for every query and update.

2 · Inside

Repository proxy → SimpleJpaRepository → the transaction's EntityManager (persistence context = first-level cache of managed entities) → dirty checking at flush → SQL via Hibernate → JDBC.

3 · Use

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

    @Version
    private Long version;                               // optimistic locking

    private BigDecimal amount;

    @Enumerated(EnumType.STRING)
    private PaymentStatus status;

    @ManyToOne(fetch = FetchType.LAZY)                  // EAGER by default → make it LAZY
    private Account account;

    @OneToMany(mappedBy = "payment", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<PaymentItem> items = new ArrayList<>(); // LAZY by default
    // constructors, getters, domain methods omitted
}

public interface PaymentRepository extends JpaRepository<Payment, Long> {

    List<Payment> findByStatus(PaymentStatus status);   // derived query, checked at startup

    @EntityGraph(attributePaths = "items")              // load items in the same query → no N+1
    List<Payment> findWithItemsByStatus(PaymentStatus status);
}

4 · Breaks

  • N+1: list query + one query per parent when touching a lazy collection.
  • LazyInitializationException: lazy data touched after the transaction/session closed.
  • Updates "not saved": method not @Transactional (entity detached) or readOnly = true.
  • save() on a detached object = merge → returns a different (managed) instance.
  • @Version conflict → ObjectOptimisticLockingFailureException (map to 409).

60-second answer

  1. JPA maps entities to tables; inside a transaction Hibernate tracks managed entities and writes changes at flush.
  2. The persistence context caches one object per id, keeps snapshots, and at commit dirty-checks them into UPDATEs; lazy associations load on first access while the session is open.
  3. I keep associations LAZY, fetch what a use case needs with @EntityGraph/JOIN FETCH or DTO projections, and turn off open-in-view — that's how I avoid N+1 in SecurePay.

Check yourself

What is the N+1 problem and how do you fix it?

1 query for N rows + N queries for a lazy association; fix with fetch join/@EntityGraph, batch fetching or DTO projections.

Why is save() not needed after changing a loaded entity?

It is managed; dirty checking writes the change at flush/commit.

What does @Version do?

Adds WHERE version=? to updates; 0 rows updated means a concurrent change → optimistic locking exception.

9

Spring Security: filter chain & JWT

Security

1 · Problem

Every request must be authenticated (who are you?) and authorized (may you do this?) before business code runs.

2 · Inside

DelegatingFilterProxy → FilterChainProxy → BearerTokenAuthenticationFilter → JwtAuthenticationProvider → NimbusJwtDecoder (signature via JWKS, exp, iss) → SecurityContextHolder → AuthorizationFilter checks SCOPE_… → DispatcherServlet.

3 · Use

@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain api(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())                         // Bearer tokens, no cookies
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health/**", "/error").permitAll()
                .requestMatchers(HttpMethod.POST, "/payments").hasAuthority("SCOPE_payment.write")
                .requestMatchers(HttpMethod.GET, "/payments/**").hasAuthority("SCOPE_payment.read")
                .anyRequest().authenticated())
            .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()));
        return http.build();
    }
}
# application.yml
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:9000

4 · Breaks

  • Missing/expired/tampered token → 401; valid token without the scope → 403.
  • Scopes become SCOPE_x authorities — hasRole won't match them.
  • Browser apps: CORS preflight must be handled by .cors(Customizer.withDefaults()) (with a CorsConfigurationSource bean) before authentication.
  • SecurityContextHolder is ThreadLocal: @Async / other threads lose the user.

60-second answer

  1. Spring Security is a chain of servlet filters in front of MVC that authenticates the caller and checks permissions.
  2. For JWT, BearerTokenAuthenticationFilter hands the token to JwtAuthenticationProvider; NimbusJwtDecoder verifies the signature with cached JWKS keys and exp/iss, scopes become SCOPE_ authorities, and AuthorizationFilter applies my rules.
  3. In SecurePay the Auth Server issues RS256 tokens; Service A and B validate them independently and A relays the same token to B.

Check yourself

401 vs 403?

401: not authenticated (no/invalid credentials). 403: authenticated but not allowed.

Does the resource server call the Auth Server on every request?

No, it verifies the signature offline with the public keys it fetched and cached from the JWKS endpoint.

Why disable CSRF for a JWT API?

CSRF abuses cookies the browser sends automatically; Bearer tokens are added by the client explicitly.

10

Service-to-service calls & resilience

Microservices

1 · Problem

Service A must call Service B safely: pass the user's token, survive B being slow or down, and never double-charge.

2 · Inside

WebClient built from Boot's builder (tracing, metrics) → ServletBearerExchangeFilterFunction copies the JWT from the SecurityContext (with RestClient you write a small ClientHttpRequestInterceptor) → Resilience4j circuit breaker counts failures → OPEN = fail fast → fallback.

3 · Use

@Configuration
class ClientConfig {
    @Bean
    WebClient paymentBClient(WebClient.Builder builder) {        // Boot's builder: observability
        return builder.baseUrl("http://localhost:8081")
                      .filter(new ServletBearerExchangeFilterFunction())   // relay the caller's JWT
                      .build();
    }
}

@Service
class PaymentBClient {
    private final WebClient client;
    // constructor omitted

    @CircuitBreaker(name = "serviceB", fallbackMethod = "unavailable")   // resilience4j-spring-boot3
    public PaymentDto create(CreatePayment req) {
        return client.post().uri("/payments").bodyValue(req)
                     .retrieve().bodyToMono(PaymentDto.class)
                     .block(Duration.ofSeconds(3));                      // always set a timeout
    }

    // narrow type: runs only when the breaker is OPEN; B's 4xx still propagate
    PaymentDto unavailable(CreatePayment req, CallNotPermittedException ex) {
        throw new PaymentServiceUnavailableException(ex);                // → 503, never fake success
    }
}

4 · Breaks

  • No timeout → threads pile up waiting for a slow B.
  • Retrying a POST can create a duplicate payment → use an Idempotency-Key.
  • Work moved to another thread (ThreadPoolBulkhead / TimeLimiter, @Async) loses SecurityContext → no token relayed → B returns 401.
  • A fallback taking Throwable catches everything, including B's 4xx → narrow the type, and list 4xx in ignore-exceptions so they don't open the breaker.
  • B's 404/409 turned into a generic 500 in A unless you map errors deliberately.

60-second answer

  1. A calls B over HTTP with the user's JWT, with timeouts and a circuit breaker so B's problems don't take A down.
  2. The bearer exchange filter copies the token from the SecurityContext; the circuit breaker opens when the failure rate crosses the threshold and calls fail fast into a fallback.
  3. For payments I only retry idempotent calls or use an idempotency key, and I pass B's errors through as proper 4xx/503 responses.

Check yourself

Token relay vs client credentials?

Relay forwards the user's token (B knows the user); client credentials uses the service's own token (machine identity).

Circuit breaker states?

CLOSED → OPEN when the failure rate exceeds the threshold → HALF_OPEN trial calls → CLOSED or OPEN.

Why is retrying POST /payments dangerous?

The first attempt may have succeeded; a retry creates a second payment unless the server deduplicates by an idempotency key.

11

Kafka with Spring

Messaging

1 · Problem

Other services (notifications, reporting) must react to payments without A waiting for them.

2 · Inside

KafkaTemplate.send → partition by key (ordering) → broker ack (acks=all) … @KafkaListener container polls → your method → offset commit. Delivery is at least once.

3 · Use

@Service
class PaymentEventPublisher {
    private static final Logger log = LoggerFactory.getLogger(PaymentEventPublisher.class);
    private final KafkaTemplate<String, PaymentCreated> kafka;
    // constructor omitted

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)   // only after the DB commit
    void on(PaymentCreated event) {
        kafka.send("payments.events", event.paymentId().toString(), event)   // key → partition → order
             .whenComplete((result, ex) -> {
                 if (ex != null) log.error("send failed for {}", event.paymentId(), ex);
             });
    }
}

@Component
class NotificationListener {
    private final ProcessedEvents processed;   // table with a unique event_id
    private final Mailer mailer;
    // constructor omitted

    @KafkaListener(id = "notifications", topics = "payments.events")
    void on(PaymentCreated event) {
        if (!processed.markIfNew(event.eventId())) return;   // duplicate delivery → skip
        mailer.sendReceipt(event);
    }
}

@Bean
DefaultErrorHandler errorHandler(KafkaTemplate<Object, Object> template) {
    return new DefaultErrorHandler(new DeadLetterPublishingRecoverer(template),
                                   new FixedBackOff(1000L, 3));   // 3 retries, then payments.events-dlt
}

4 · Breaks

  • Sending inside the DB transaction → "ghost" events if the commit fails; after commit can lose events on a crash → outbox pattern for reliability.
  • Consumers see duplicates (rebalance, crash before offset commit) → make them idempotent.
  • Ordering is only per partition — choose the key deliberately.
  • No config: DefaultErrorHandler retries 9 times with no delay, then logs and skips the record (data lost). A deserialization failure without ErrorHandlingDeserializer can loop forever.
  • @TransactionalEventListener is silently skipped when no transaction is active (unless fallbackExecution = true).

60-second answer

  1. Kafka decouples services: A publishes PaymentCreated, other services consume it at their own pace.
  2. The producer picks a partition from the key and waits for acks=all; listener containers poll, call my method and commit offsets after processing — so delivery is at least once.
  3. I publish after commit (or via an outbox), keep consumers idempotent with an event id, and send failures to a dead-letter topic.

Check yourself

What guarantees ordering in Kafka?

Only within a partition; records with the same key go to the same partition.

Why must consumers be idempotent?

At-least-once delivery: a crash or rebalance before the offset commit replays records.

What is the dual-write problem?

DB commit and Kafka send are separate systems; one can succeed while the other fails. The outbox pattern fixes it.

Versions — only after the concepts

VersionRemember
Spring 2.5Annotations: @Component, @Autowired
Spring 3Java config: @Configuration, @Bean
Spring 4@RestController, Java 8
Spring 5 / Boot 2WebFlux (reactive)
Spring 6 / Boot 3Java 17 baseline, javax → jakarta, ProblemDetail, observability
Spring 7 / Boot 4Nov 2025: Jakarta EE 11, JSpecify null-safety, API versioning, built-in @Retryable / @ConcurrencyLimit, Jackson 3; Java 17 baseline kept