32 min read
Engineering6,842 words

Spring & Spring Boot

What this is: the framework itself as the topic. Every other guide in this folder uses Spring; this one explains how Spring actually works, deeply enough to answer "why" questions in a Staff+ loop, with the trade-offs and failure modes an interviewer probes for. Dual Kotlin + Java, Spring Boot 3.2–4.0 / Spring Framework 6.2–7.0. Code is written to be spoken aloud at a whiteboard, not just compiled.


The one mental model: three mechanisms explain everything#

If you remember one thing, remember this. Spring is a container that owns your objects' lifecycle and their cross-cutting behavior. You write plain components; the container instantiates them, wires their dependencies, wraps them in proxies to add behavior (transactions, security, caching, retries) without you editing the code, and manages how long they live. Spring Boot is that same container, auto-populated from your classpath by conditional configuration.

Almost every piece of "magic" — and almost every "why didn't my annotation work?" bug — traces back to exactly one of three mechanisms:

  1. The bean lifecycle — how the container builds, wires, initializes and destroys your objects. (Explains: injection, scopes, @PostConstruct, startup ordering, "why is my field null?")
  2. The proxy — Spring adds behavior by wrapping your bean in a proxy object that runs advice before/after your method. (Explains: @Transactional, @Async, @Cacheable, @Retryable, @Validated, AND the single most common Spring bug — self-invocation.)
  3. Conditional auto-configuration — Boot registers beans conditionally based on what's on the classpath and what you haven't already defined. (Explains: "it just works," starters, @ConditionalOnMissingBean, and "why is there a bean I never declared?")

When you can trace any Spring behavior back to lifecycle / proxy / auto-config, you sound like someone who has operated the framework, not just used it. That is the Staff+ signal. Everything below is those three mechanisms in increasing depth.

Table of contents#

  1. The IoC container & dependency injection
  2. Bean lifecycle & the two extension points that run the framework
  3. AOP & proxies — how @Transactional/@Async/@Cacheable really work
  4. Transactions in depth
  5. Spring Boot auto-configuration — the mechanism, not the magic
  6. Externalized configuration & profiles
  7. The web layer: Spring MVC
  8. The web layer: WebFlux, reactive vs. virtual threads
  9. Testing strategy (the part that separates senior from staff)
  10. Observability & production-readiness
  11. Modern Boot 3 → 4: Jakarta, AOT/native, and what changed
  12. Best-practices checklist
  13. Problems → root cause → fix
  14. Interview framing — how to sound senior
  15. Cheat-sheets
  16. Self-test

1. The IoC container & dependency injection#

Inversion of Control (IoC): you don't new your collaborators; you declare what you need and the container supplies it. Dependency Injection (DI) is the concrete form Spring uses. The payoff isn't "less new" — it's that your business objects don't know how their dependencies are constructed, so you can swap implementations, inject test doubles, and let the framework wrap dependencies in proxies.

The container is an ApplicationContext (a superset of the lower-level BeanFactory). BeanFactory is lazy, bare bean instantiation + DI. ApplicationContext adds what you actually use in production: eager singleton instantiation at startup (fail-fast), BeanPostProcessor/BeanFactoryPostProcessor auto-detection, event publishing, MessageSource i18n, and environment/property abstraction. In an interview: "BeanFactory is the DI engine; ApplicationContext is the production-grade container built on top of it."

A bean is just an object whose lifecycle the container manages. You declare beans two ways:

  • Stereotype/component scanning@Component (and its specializations @Service, @Repository, @Controller/@RestController, which are @Component + semantic intent; @Repository also gets JDBC/JPA exception translation). Boot's @ComponentScan (via @SpringBootApplication) discovers them.
  • @Bean factory methods inside a @Configuration class — for third-party types you can't annotate, or when construction needs logic.

Injection styles — and why constructor injection is the only correct default#

This is a near-guaranteed question. The answer must include the why, not just "constructor is best practice."

Kotlin

kotlin
@Service class PricingService( private val rateRepo: RateRepository, // required, immutable, non-null private val fxClient: FxClient, ) { fun quote(sku: String, currency: String): Money { /* ... */ } }

Java

java
@Service public class PricingService { private final RateRepository rateRepo; // final = guaranteed set, thread-safe publication private final FxClient fxClient; // Since Spring 4.3, a single constructor needs no @Autowired public PricingService(RateRepository rateRepo, FxClient fxClient) { this.rateRepo = rateRepo; this.fxClient = fxClient; } public Money quote(String sku, String currency) { /* ... */ } }

Why constructor injection wins, point by point (say these):

  • Immutability & safe publication. Fields are final/val; once constructed the object is fully initialized and safely publishable across threads. Spring singletons are shared across request threads, so this matters (see the concurrency guide).
  • Honest dependencies. The constructor signature is the dependency list. If it needs 9 things, the constructor screams it — that's a design smell you can see, pushing you to split the class. Field injection hides that.
  • No partially-constructed objects / no NPE window. The object can't exist without its dependencies.
  • Trivially unit-testable without Springnew PricingService(mockRepo, mockFx). No reflection, no container.
  • Circular dependencies fail fast at startup instead of being silently tolerated (field injection hides cycles until they bite at runtime).

Field injection (@Autowired private RateRepository repo;) is the anti-pattern: needs reflection to set in tests, can't be final, hides the dependency count, and lets cycles slip through. Setter injection is fine only for genuinely optional or reconfigurable dependencies. Interview trap: if you show field injection in your code sample, expect to defend it — don't.

Disambiguation: @Primary, @Qualifier, and Boot's @ConditionalOnMissingBean#

When two beans satisfy one type, Spring throws NoUniqueBeanDefinitionException. Resolve it:

java
@Bean @Primary // the default winner PaymentGateway stripeGateway() { ... } @Bean @Qualifier("legacy") // opt-in by name PaymentGateway adyenGateway() { ... } @Service class Checkout { Checkout(PaymentGateway primary, // gets Stripe @Qualifier("legacy") PaymentGateway legacy) { ... } }

Kotlin uses @Qualifier identically; for injecting all implementations, take a List<PaymentGateway> (ordered by @Order) or a Map<String, PaymentGateway> (bean-name → bean). Taking a collection of an interface is a clean Strategy-pattern setup and a nice thing to show.

Bean scopes#

Default is singletonone instance per container, not the GoF singleton (not one per JVM). This is the scope you must reason about most because it's shared across all request threads.

ScopeLifetimeUse for
singleton (default)one per container, eagerstateless services, repositories — 99% of beans
prototypenew instance per injection/lookupstateful helpers; note: container does NOT manage prototype destruction
request / sessionper HTTP request / session (web)request-scoped context; injected via scoped proxy
application / websocketper ServletContext / WS sessionrare

The singleton state trap (Staff-level): a singleton with a mutable field shared across threads is a data race. Keep singletons stateless; put per-request state on the stack (method params/locals) or in request scope. This is the #1 Spring concurrency bug and it ties directly to your concurrency guide's "the enemy is shared mutable state."

Scoped-proxy gotcha: injecting a request-scoped bean into a singleton needs a proxy (proxyMode = TARGET_CLASS) so the singleton holds a proxy that resolves to the right instance per request — otherwise the singleton captures one instance forever.

@Configuration full mode vs. lite mode — the CGLIB detail interviewers love#

java
@Configuration class AppConfig { @Bean DataSource dataSource() { return buildDs(); } @Bean Flyway flyway() { return Flyway.configure().dataSource(dataSource()).load(); } // ^^^^^^^^^^^^ calls another @Bean method }

How many DataSource instances exist? One. A @Configuration class is subclassed by CGLIB at startup; calls between @Bean methods are intercepted and return the shared singleton from the container. That's "full mode."

Set @Configuration(proxyBeanMethods = false) ("lite mode") and the interception is gone — dataSource() is a plain method call returning a new instance each time. Lite mode is faster (no CGLIB subclass) and is what Spring Boot's own auto-configurations use, because they inject collaborators as method parameters instead of calling sibling @Bean methods. Knowing this explains both a subtle double-instantiation bug and how Boot keeps startup cheap.

Circular dependencies#

A needs B, B needs A. With constructor injection neither can be built first → BeanCurrentlyInCreationException at startup. That's a feature: the container is telling you the design is wrong. Since Boot 2.6, circular references are disallowed by default (spring.main.allow-circular-references=false).

Fixes, best first: redesign (extract the shared logic into a third bean, or merge if they're really one responsibility); if you must, @Lazy on one injection point breaks the cycle by injecting a proxy that resolves on first use. Reaching for setter/field injection to "solve" a cycle is hiding the smell, not fixing it.


2. Bean lifecycle & the two extension points that run the framework#

You need the lifecycle order cold, because half of Spring's features (and bugs) live in it. For a singleton:

  1. Instantiate — constructor runs (constructor injection happens here).
  2. Populate — setter/field dependencies injected.
  3. Aware callbacksBeanNameAware, BeanFactoryAware, ApplicationContextAware (if implemented).
  4. BeanPostProcessor.postProcessBeforeInitialization — for each BPP.
  5. Initialization@PostConstructInitializingBean.afterPropertiesSet() → custom @Bean(initMethod=...).
  6. BeanPostProcessor.postProcessAfterInitializationthis is where AOP proxies are created (your bean gets wrapped and the proxy is what's stored in the context).
  7. Bean is ready.
  8. On shutdown (singletons only): @PreDestroyDisposableBean.destroy() → custom destroyMethod.

Two consequences worth stating out loud: (a) @PostConstruct runs after injection, so it's the right place for "validate/warm-up once everything is wired"; (b) because the proxy is created at step 6, the object your @PostConstruct sees is the raw target, not the proxy — a reason self-invocation of @Transactional/@Async methods from @PostConstruct silently does nothing.

The two extension points that are the framework#

Nearly every Spring feature is implemented as one of these. Naming them is a strong signal.

BeanFactoryPostProcessor (BFPP) — operates on bean definitions (metadata) before any bean is instantiated. Example: PropertySourcesPlaceholderConfigurer resolves ${...} placeholders in definitions. Use it to mutate configuration before objects exist.

BeanPostProcessor (BPP) — operates on bean instances, around initialization. This is the workhorse:

  • AutowiredAnnotationBeanPostProcessor implements @Autowired/@Value injection.
  • CommonAnnotationBeanPostProcessor implements @PostConstruct/@PreDestroy/@Resource.
  • AbstractAutoProxyCreator (a BPP) creates all your AOP proxies in postProcessAfterInitialization.

So @Transactional, @Autowired, and @PostConstruct are not language features — they're BPPs walking your beans. When you internalize that, "why doesn't my annotation work?" becomes "which BPP should have handled this, and why didn't it see the call?"

Application events — the in-process decoupling tool#

Spring's ApplicationEventPublisher gives you observer-pattern decoupling inside one JVM (not to be confused with Kafka/EDA across services — see your event-broker guide). Useful for "after commit, do X."

Kotlin

kotlin
data class OrderPlaced(val orderId: Long) @Service class OrderService(private val events: ApplicationEventPublisher) { @Transactional fun place(cmd: PlaceOrder): Long { val id = /* persist */ 42L events.publishEvent(OrderPlaced(id)) // published now, listener can defer to commit return id } } @Component class Emailer { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) fun on(e: OrderPlaced) { /* send only if the tx actually committed */ } }

Java

java
@Component class Emailer { // Plain events use @EventListener; @TransactionalEventListener adds commit-phase awareness. // Add @Async (with @EnableAsync) to run the handler off the publishing thread. @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) void on(OrderPlaced e) { /* fires only if the tx committed */ } }

@TransactionalEventListener(AFTER_COMMIT) is the Staff-flavored detail: it fires the side effect only if the surrounding transaction commits, which is exactly the semantic you want for "email/notify after order is durably saved" — and a stepping stone to the transactional-outbox pattern when you need cross-service reliability.


3. AOP & proxies — how @Transactional/@Async/@Cacheable really work#

This is the mechanism behind Spring's most-used annotations, and the source of its most infamous bug. Own it completely.

AOP (Aspect-Oriented Programming) factors out cross-cutting concerns (transactions, security, caching, retries, metrics) from business logic. Vocabulary in one breath: an aspect bundles the concern; advice is the code to run (before/after/around); a pointcut selects where it runs; a join point is a specific matched location (in Spring AOP, always a method execution on a Spring bean).

How Spring does it: proxies. Spring AOP is proxy-based, not bytecode weaving. At bean creation (step 6 above), the auto-proxy-creator BPP wraps your bean in a proxy that implements the advice, then delegates to your real object. Callers get the proxy. Two proxy types:

JDK dynamic proxyCGLIB proxy
Mechanismimplements the bean's interface(s)generates a subclass of the bean
Requiresbean implements ≥1 interfacecan proxy concrete classes
Can't proxyfinal classes / final methods
Spring Boot defaultyes (proxy-target-class=true)

Spring Boot defaults to CGLIB (spring.aop.proxy-target-class=true) so proxying works whether or not you have interfaces. Trade-off: CGLIB can't subclass final types — in Kotlin, classes and methods are final by default, so Spring-proxied Kotlin beans need the kotlin-spring compiler plugin (bundled in Spring Initializr) which auto-opens @Component/@Transactional/etc. classes. If you've ever seen "@Transactional silently ignored on a Kotlin class," the missing kotlin-spring plugin is a prime suspect. Great thing to know cold.

The self-invocation trap — the most important Spring bug to be able to explain#

Because the behavior lives in the proxy, it only runs when the call goes through the proxy. A call from inside the same bean (this.method()) hits the raw target directly and bypasses all advice.

Kotlin — the bug

kotlin
@Service class ReportService { fun generateAll(ids: List<Long>) { ids.forEach { generateOne(it) } // internal call → NOT through the proxy } @Transactional // ❌ NEVER STARTS A TRANSACTION here fun generateOne(id: Long) { /* writes */ } }

generateOne looks transactional but isn't, because generateAll called it via this. Same failure hits @Async (runs on the caller thread), @Cacheable (never caches), @Retryable, @Validated. This is the single most common "my annotation does nothing" cause and a favorite interview question.

Fixes, in order of preference:

  1. Move the annotated method to another bean (best — the call is now external, and it usually reflects a real responsibility boundary).
  2. Self-inject the proxy and call through it:
kotlin
@Service class ReportService(@Lazy private val self: ReportService) { // @Lazy avoids a construction cycle fun generateAll(ids: List<Long>) = ids.forEach { self.generateOne(it) } // ✅ via proxy @Transactional fun generateOne(id: Long) { /* ... */ } }
  1. AopContext.currentProxy() — requires @EnableAspectJAutoProxy(exposeProxy = true); works but couples code to Spring, use sparingly.

Also state the corollary limits: private, final, and static methods can't be advised (no subclass override), and the proxy only intercepts external calls on the bean reference held by the container.

@Cacheable / @Async shown once (same proxy machinery)#

Java

java
@Service public class CountryService { @Cacheable("countries") // proxy checks cache first, skips method on hit public Country byIso(String iso) { return slowLookup(iso); } @CacheEvict(value = "countries", key = "#c.iso") public void update(Country c) { /* ... */ } @Async // proxy submits to a TaskExecutor, returns immediately public CompletableFuture<Void> reindex() { /* ... */ return CompletableFuture.completedFuture(null); } }

Requires @EnableCaching / @EnableAsync. @Async must return void or Future/CompletableFuture (a plain non-future return can't be delivered asynchronously) and — like everything here — only works through the proxy. In Boot 3.2+, set a virtual-thread executor so @Async scales cheaply (§8).


4. Transactions in depth#

@Transactional is proxy-based AOP (§3) plus a thread-bound resource manager. The proxy's around-advice opens a transaction before your method and commits/rolls back after; the actual connection/EntityManager is stashed in TransactionSynchronizationManager, which is backed by a ThreadLocal. Two facts flow from that single sentence and both are interview gold:

  1. Because it's proxy-based, self-invocation and private/final methods break it (§3).
  2. Because the resource is thread-bound, a transaction does not follow work onto another thread — not across @Async, not onto a manually spawned thread, not across a reactive operator boundary or a Kotlin coroutine dispatcher hop. (Reactive needs ReactiveTransactionManager + TransactionalOperator; see §8.)

Propagation — know these five, especially the first three#

PropagationBehaviorWhen
REQUIRED (default)join current tx, or start onealmost always
REQUIRES_NEWsuspend current, run in an independent new txmust-commit-independently: audit rows, outbox writes that survive caller rollback
NESTEDsavepoint inside current tx; inner rollback ≠ outer rollbackpartial rollback on JDBC (not JPA-portable)
MANDATORYmust already be in a tx, else throwenforce "callers own the tx boundary"
SUPPORTS / NOT_SUPPORTED / NEVERrun with-if-present / suspend / forbidedge cases

REQUIRES_NEW is a Staff trap: it suspends the outer transaction and needs a second connection from the pool while holding the first. Nest a few and you can deadlock the connection pool (every thread holding one connection, waiting for a second that never frees). Say that unprompted and you look experienced.

Rollback rules — the checked-exception gotcha#

By default Spring rolls back only on RuntimeException and Error (unchecked). A checked exception does NOT trigger rollback.

Kotlin — Kotlin has no checked exceptions, so this bites Java interop / explicit @Throws:

kotlin
@Transactional(rollbackFor = [Exception::class]) // be explicit if you throw checked types fun transfer(...) { /* ... */ }

Java

java
@Transactional // ⚠️ IOException below will COMMIT the partial work public void importFile() throws IOException { repo.save(x); readFile(); /* throws */ } @Transactional(rollbackFor = Exception.class) // ✅ now checked exceptions roll back too public void importFileFixed() throws IOException { ... }

This surprises people who assume "exception = rollback." State the rule and the fix (rollbackFor). Also: catching an exception inside the method swallows the rollback — if you catch and don't rethrow, the tx commits; if you must recover, mark it TransactionInterceptor-visibly or call setRollbackOnly().

readOnly, boundaries, and the anti-patterns#

  • @Transactional(readOnly = true) on queries: hints the driver and, on Hibernate, sets flush mode to MANUAL (skips dirty-checking/auto-flush) — real performance win on read paths and it documents intent.
  • Put the boundary at the application-service layer, one transaction per use-case. Not on the controller (you'd hold the tx across HTTP serialization) and not on every repository call (chatty, no atomicity across the use-case).
  • Never do remote/slow I/O inside a transaction (HTTP calls, waiting on a queue). You'd pin a DB connection for the duration → pool exhaustion. Do the I/O outside, keep the tx short. This is the DB-facing twin of the "don't block while holding a lock" rule from your locking guide.
  • Programmatic transactions when annotations don't fit (dynamic boundaries, partial commits): inject TransactionTemplate (imperative) or TransactionalOperator (reactive). Cleaner than @Transactional for "commit this chunk, then that chunk."

5. Spring Boot auto-configuration — the mechanism, not the magic#

You picked "container & Boot internals" as a focus, and this is the section that makes an interviewer nod. The wrong answer is "Boot configures things automatically." The right answer explains the actual mechanism and its override semantics.

@SpringBootApplication decomposes into three annotations:

  • @SpringBootConfiguration — a @Configuration.
  • @ComponentScan — scan this package and below for your @Components.
  • @EnableAutoConfiguration — the interesting one.

How auto-configuration actually works:

  1. @EnableAutoConfiguration triggers a load of auto-configuration class names from every jar's META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports file (one FQN per line). (This replaced the old spring.factories EnableAutoConfiguration= key in Boot 2.7; the legacy mechanism was removed in Boot 3.)
  2. Each listed class is an @AutoConfiguration class full of @Bean methods, but every bean is guarded by @Conditional annotations:
    • @ConditionalOnClass — only if a type is on the classpath (e.g., configure JPA only if Hibernate is present).
    • @ConditionalOnMissingBeanonly if you haven't already defined this bean yourself.
    • @ConditionalOnProperty — only if a property is set.
    • @ConditionalOnBean, @ConditionalOnWebApplication, @ConditionalOnMissingClass, etc.
  3. Ordering is controlled by @AutoConfigureBefore / @AutoConfigureAfter / @AutoConfigureOrder.

The key semantic — "you always win": because auto-config beans are @ConditionalOnMissingBean, the moment you define your own bean of that type, Boot backs off. That's why "convention over configuration" doesn't trap you: defaults apply until you override, then your definition takes precedence. Being able to say "@ConditionalOnMissingBean is what makes Boot's defaults overridable" is the whole game.

Starters are the other half: a spring-boot-starter-* is a curated, version-aligned set of transitive dependencies (managed by the Boot BOM). Adding spring-boot-starter-data-jpa puts Hibernate on the classpath, which flips the @ConditionalOnClass switches, which activates the JPA auto-configuration. Starters bring the classpath; conditions react to it. That's the causal chain to narrate.

Debugging it: run with --debug (or -Ddebug) to print the ConditionEvaluationReport — a list of every auto-config, matched (positive) and not (negative), with the reason. Knowing this tool signals real operational experience: "if a bean I expected isn't there, I read the condition report to see which condition failed."

Writing your own auto-configuration (Staff-level "I've built platform code")#

If you've built shared libraries/starters for other teams, show it:

java
@AutoConfiguration @ConditionalOnClass(RateLimiter.class) @EnableConfigurationProperties(RateLimitProperties.class) public class RateLimitAutoConfiguration { @Bean @ConditionalOnMissingBean // team can override with their own @ConditionalOnProperty(prefix = "acme.ratelimit", name = "enabled", havingValue = "true") RateLimiter rateLimiter(RateLimitProperties props) { return new TokenBucketRateLimiter(props.permitsPerSecond()); } } // registered by listing this class in // META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

That single class demonstrates you understand @AutoConfiguration, @Conditional*, @ConfigurationProperties, and the "let the consumer override" contract — i.e., you've written framework-shaped code, not just app code.


6. Externalized configuration & profiles#

The mechanism: Spring's Environment aggregates ordered PropertySources; a higher-priority source overrides a lower one. The practical precedence, high → low (memorize the shape, not every rung): command-line args → OS environment variables → application-{profile}.ymlapplication.yml → defaults in code. (Env vars overriding files is what makes 12-factor containerized config work; profile-specific overrides base.) Relaxed binding means acme.pageSize, acme.page-size, and env var ACME_PAGESIZE all bind to the same property — which is why kebab-case in YAML maps to camelCase in code.

@ConfigurationProperties over @Value — a real best-practice question#

@Value("${...}") injects a single value via SpEL. Fine for a one-off. For anything structured it's the wrong tool: no type safety on groups, no validation, no relaxed binding niceties, scattered across the codebase.

@ConfigurationProperties binds a whole tree to a typed, validated object. In Boot 3 a record (or single-constructor class) is constructor-bound automatically — immutable config, no setters.

Kotlin

kotlin
@ConfigurationProperties(prefix = "acme.billing") @Validated data class BillingProperties( @field:NotBlank val currency: String, @field:Positive val retryAttempts: Int = 3, val fx: Fx, ) { data class Fx(val provider: String, val timeout: Duration) }

Java

java
@ConfigurationProperties(prefix = "acme.billing") @Validated public record BillingProperties( @NotBlank String currency, @Positive int retryAttempts, Fx fx ) { public record Fx(String provider, Duration timeout) {} }

Register with @EnableConfigurationProperties(BillingProperties.class) or @ConfigurationPropertiesScan. Benefits to name: type safety, @Validated fail-fast at startup (bad config crashes the app instead of failing at 3am), IDE autocomplete via the config metadata processor, nested grouping, native Duration/DataSize binding (timeout: 500ms). "@Value for one value, @ConfigurationProperties for a group, always validated" is the crisp rule.

Profiles#

@Profile("prod") on a bean/config includes it only when that profile is active (SPRING_PROFILES_ACTIVE=prod). Use for environment-varying beans (e.g., a real vs. in-memory MailSender). Staff caution: don't overload profiles with business logic — profiles are for environment wiring, not feature flags. Deep profile-conditional graphs make "what's actually running in prod?" hard to answer. Prefer explicit config + @ConditionalOnProperty for features.


7. The web layer: Spring MVC#

Spring MVC is the Servlet-stack, thread-per-request web framework. Know the DispatcherServlet request lifecycle — it's the "front controller" and a common whiteboard ask:

  1. Request hits the single DispatcherServlet.
  2. HandlerMapping resolves the request to a handler method (+ its interceptors).
  3. HandlerAdapter invokes the controller; HandlerMethodArgumentResolvers bind arguments (@RequestBody via an HttpMessageConverter like Jackson, @PathVariable, @RequestParam, @Valid).
  4. The return value is handled by a HandlerMethodReturnValueHandler — for @RestController, a message converter serializes the object to JSON; for view-based apps a ViewResolver renders a template.
  5. HandlerInterceptor postHandle/afterCompletion run; exceptions route to a HandlerExceptionResolver.

Thread model: each request occupies one thread from the server pool (Tomcat, default 200) for its whole duration, blocking included. Simple and debuggable (real stack traces, ThreadLocal works, including @Transactional), but throughput under many slow/blocking requests is capped by pool size. (§8 is the fix menu.)

A controller worth showing — thin, validated, correct error contract#

Kotlin

kotlin
@RestController @RequestMapping("/api/orders") class OrderController(private val orders: OrderService) { @PostMapping @ResponseStatus(HttpStatus.CREATED) fun place(@Valid @RequestBody req: PlaceOrderRequest): OrderResponse = orders.place(req.toCommand()).toResponse() @GetMapping("/{id}") fun get(@PathVariable id: Long): OrderResponse = orders.find(id)?.toResponse() ?: throw OrderNotFound(id) }

Java

java
@RestController @RequestMapping("/api/orders") class OrderController { private final OrderService orders; OrderController(OrderService orders) { this.orders = orders; } @PostMapping @ResponseStatus(HttpStatus.CREATED) OrderResponse place(@Valid @RequestBody PlaceOrderRequest req) { return orders.place(req.toCommand()).toResponse(); } }

Points to make: thin controllers (HTTP mapping/validation only; business logic in the service), DTOs at the boundary (never expose JPA entities — you leak the schema and invite LazyInitializationException on serialization), and validation at the edge with @Valid.

Error handling: @ControllerAdvice + RFC 9457 ProblemDetail#

Centralize exception → HTTP mapping. Since Spring 6 / Boot 3, ProblemDetail gives you the standard application/problem+json body (RFC 9457, formerly 7807) for free.

Java

java
@RestControllerAdvice class ApiExceptionHandler { @ExceptionHandler(OrderNotFound.class) ProblemDetail onNotFound(OrderNotFound e) { var pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage()); pd.setType(URI.create("https://api.acme.com/errors/order-not-found")); return pd; // serialized as application/problem+json } }

A consistent, machine-readable error contract across every endpoint is a "product ownership" signal — it's what API consumers need, not just what the happy path returns.


8. The web layer: WebFlux, reactive vs. virtual threads#

This is where you show current judgment, because Project Loom changed the calculus and many candidates still give the pre-2023 answer.

Spring MVC (Servlet) — thread-per-request, blocking, simple. Throughput bound by thread-pool size when requests block on I/O.

Spring WebFlux (Reactive) — built on Reactor (Mono/Flux) over Netty's event loop: a small, fixed number of threads handle many concurrent connections because no thread ever blocks. You get backpressure (consumer signals demand, so a fast producer can't overwhelm a slow consumer) and composable streaming. The costs are real: you must never block an event-loop thread (one blocking JDBC call can stall all requests on that thread), you need reactive drivers end-to-end (R2DBC instead of JDBC), @Transactional needs the reactive variant, and debugging is harder (no linear stack traces, ThreadLocal/MDC needs Reactor Context plumbing).

kotlin
// WebFlux, Kotlin coroutines flavor — suspend controller, non-blocking all the way down @GetMapping("/prices/{sku}") suspend fun price(@PathVariable sku: String): PriceView = pricingService.quote(sku) // suspends, doesn't block the event-loop thread
java
// WebFlux, Reactor flavor @GetMapping(value = "/prices", produces = MediaType.TEXT_EVENT_STREAM_VALUE) Flux<PriceTick> stream() { return pricingService.streamTicks(); } // streaming + backpressure

The modern take: virtual threads often beat reactive for pure concurrency#

Virtual threads (Project Loom, GA in Java 21) make blocking cheap: a blocking call parks the virtual thread and frees the underlying OS carrier thread, so you can have millions of thread-per-request tasks on a handful of OS threads. Spring Boot 3.2 turns them on with one line:

properties
spring.threads.virtual.enabled=true # Tomcat, @Async, etc. run on virtual threads

The Staff-level judgment call — say this: if your problem is "I have many concurrent I/O-bound requests and I'm thread-pool-bound," virtual threads solve it while keeping the simple, debuggable, blocking MVC programming model (real stack traces, ThreadLocal, plain @Transactional). Reach for WebFlux when you specifically need what reactive uniquely gives: streaming with backpressure, rich async composition operators, or you're integrating an already-reactive ecosystem. "Post-Loom, I default to MVC + virtual threads for concurrency scaling, and choose WebFlux for streaming and backpressure — not just to handle load" is a current, senior answer.

One caveat from your concurrency guide worth dropping: pre-JDK 24, synchronized blocks pinned a virtual thread to its carrier (hurting scalability), so the advice was to prefer ReentrantLock; JEP 491 (JDK 24) removed that pinning, so on modern JDKs synchronized no longer defeats virtual threads. Version-anchor it and you show you track the platform.


9. Testing strategy (the part that separates senior from staff)#

Anyone can write @SpringBootTest. Staff engineers have a strategy: the fastest test that still proves the thing, and a fast suite as a design constraint. The key idea is test slices — load only the part of the context you're testing.

AnnotationLoadsUse for
(none) — plain JUnitnothing Springpure logic; constructor injection makes this trivial and it's most of your tests
@WebMvcTest(OrderController.class)web layer only, MockMvc, no services/DBcontroller mapping, validation, error contract (mock the service with @MockitoBean)
@DataJpaTestJPA + a real DB via Testcontainersrepository queries, mappings, custom SQL
@JsonTest, @RestClientTest, @WebFluxTestjust that sliceserialization, HTTP clients, reactive web
@SpringBootTestthe whole contexttrue end-to-end integration; use sparingly

Why slices matter beyond speed — the context cache. Spring caches the ApplicationContext across test classes keyed by configuration. Tests sharing an identical configuration reuse one context; every distinct configuration builds a new context. So a suite full of subtly-different @SpringBootTest setups (different @MockitoBeans, properties, @TestConfigurations) thrashes the cache and rebuilds contexts constantly — the classic "our tests take 20 minutes" cause. The fix — standardize context configuration and prefer slices — is exactly the kind of leverage a Staff engineer owns for a whole team.

Integration tests done right: Testcontainers + @ServiceConnection#

Don't mock the database — test against the real engine (Postgres, not H2). Boot 3.1's @ServiceConnection auto-wires the container's host/port/credentials into Spring's datasource with zero @DynamicPropertySource boilerplate.

Kotlin

kotlin
@SpringBootTest @Testcontainers class OrderFlowIT { companion object { @Container @ServiceConnection // Boot 3.1: no manual property wiring val postgres = PostgreSQLContainer("postgres:17") } @Autowired lateinit var orders: OrderService @Test fun `places and reloads an order`() { /* real DB, real SQL */ } }

Java — the same, with @MockitoBean (Boot 3.4, replacing the deprecated @MockBean) when you do need to stub a collaborator:

java
@WebMvcTest(OrderController.class) class OrderControllerTest { @Autowired MockMvc mvc; @MockitoBean OrderService orders; // was @MockBean pre-3.4 @Test void returns201() throws Exception { when(orders.place(any())).thenReturn(new Order(1L)); mvc.perform(post("/api/orders").contentType(APPLICATION_JSON).content("{...}")) .andExpect(status().isCreated()); } }

Say the philosophy: push tests down the pyramid — most as plain unit tests (constructor injection is what makes this possible), a layer of slice tests, a thin top of @SpringBootTest + Testcontainers for real end-to-end confidence. Fast suite = design feedback; slow suite = design smell.


10. Observability & production-readiness#

"Presentable in an interview" includes knowing how the app behaves in prod, not just how it's wired.

Spring Boot Actuator exposes operational endpoints under /actuator: health, info, metrics, prometheus, loggers (change log levels at runtime — very handy in an incident), httpexchanges, threaddump, heapdump. Expose deliberately — lock it down (management.endpoints.web.exposure.include, a separate management port, secured) since these leak internals.

Kubernetes health probes: Actuator splits health into liveness (/actuator/health/liveness — am I broken, restart me) and readiness (/actuator/health/readiness — can I take traffic, route to me). Wiring these correctly is what makes rolling deploys and autoscaling behave. server.shutdown=graceful drains in-flight requests before exit.

Micrometer is the metrics facade (SLF4J-for-metrics): you instrument once against Micrometer, then export to Prometheus/OTel/etc. In Boot 3 the Observation API unifies metrics and tracing behind one @Observed / ObservationRegistry — a single observation emits a timer and a trace span:

java
@Observed(name = "pricing.quote") // one annotation → metric + trace span public Money quote(String sku) { ... }

Distributed tracing: Micrometer Tracing (bridging Brave or OpenTelemetry) replaced Spring Cloud Sleuth; it propagates trace/span IDs across service hops so a request is followable end-to-end (this is the Spring realization of your trace-context guide). Boot 4 ships Micrometer 1.16 / Micrometer Tracing 1.6 and adds a first-class OpenTelemetry starter unifying metrics/logs/traces. Naming the why — "correlate a single user request across ten microservices" — matters more than the annotation.


11. Modern Boot 3 → 4: Jakarta, AOT/native, and what changed#

Version-anchoring shows you track the platform. The big epochs:

Spring Boot 3.0 (Nov 2022) = Spring Framework 6, Java 17 baseline, and the javax.*jakarta.* earthquake. Jakarta EE moved the namespace (Oracle trademark), so javax.persistence.Entity became jakarta.persistence.Entity. Every dependency had to cross the divide together; this, not features, is why the 2→3 migration was heavy. Also arrived in 3.0: ProblemDetail, out-of-the-box observability, and the GraalVM AOT engine.

Incremental 3.x wins to know: @ServiceConnection (3.1), virtual-thread support spring.threads.virtual.enabled (3.2), CDS startup support (3.3), @MockitoBean replacing @MockBean + structured logging (3.4).

GraalVM native images & AOT — the concept to explain: an ahead-of-time build step (process-aot) runs at build time to pre-compute the bean definitions and emit reachability hints (reflection/resources/proxies), then GraalVM compiles a closed-world native binary. Result: near-instant startup (tens of ms) and low memory — great for serverless/scale-to-zero, CLIs, high-density deployments. Costs: long builds, GraalVM-specific testing, and dynamic features (reflection, dynamic proxies, classpath scanning) only work if hinted (RuntimeHints, @RegisterReflectionForBinding, @ImportRuntimeHints). @ConfigurationProperties binding needs hints too (Boot generates most for you). The pragmatic middle path when you want faster startup without native's constraints: CDS / Project Leyden AOT cache — a JVM-based warmup snapshot, far cheaper to adopt than going native.

Spring Boot 4.0 / Spring Framework 7.0 (Nov 2025) — the current generation. What's genuinely new and worth naming:

  • JSpecify null-safety across the portfolio — the framework is annotated with @Nullable/@NonNull (JSpecify standard), which the Kotlin compiler consumes so Spring APIs surface as proper nullable/non-null types. Big Kotlin-interop win.
  • API versioning, first-class — a version attribute on @GetMapping/@RequestMapping plus an ApiVersionConfigurer, with path/param/header/media-type strategies. No more hand-rolled version routing.
  • Declarative HTTP clients built in — define an interface with @HttpExchange methods and get a type-safe client via HttpServiceProxyFactory (the Spring-native answer to Feign/Retrofit):
java
public interface FxClient { @GetExchange("/rates/{base}") RateTable rates(@PathVariable String base); } // no implementation you write — Spring generates the proxy
  • Resilience annotations in core@Retryable (with backoff) and @ConcurrencyLimit, enabled by @EnableResilientMethods. Basic retry/limiting now ships in the framework (you still reach for Resilience4j for circuit breakers/bulkheads — see your circuit-breaker guide).
  • Jakarta EE 11 baseline (Servlet 6.1, JPA 3.2, Bean Validation 3.1), Java 17 minimum / 25 first-class, and a modularized codebase (smaller, focused jars → less classpath scanning, smaller native images).
  • Micrometer 1.16 + a first-class OpenTelemetry starter for unified telemetry; RestTestClient for WebTestClient-style REST tests without reactive dependencies; test-context pausing to cut memory in big suites.
  • Deprecations & removals to remember: Jackson 3 becomes the default and Jackson 2.x is deprecated (not yet removed — configured via spring.jackson2.* in the interim), JUnit 4 support is deprecated (JUnit 6 is the baseline), and Spring JCL is removed. (The javax.* removal already happened back in Boot 3 — don't attribute it to Boot 4.) Native-image builds now target GraalVM 25.

You don't need all of this by heart, but being able to say "Boot 3 was the Jakarta/Java-17 reset plus AOT; Boot 4 in late 2025 adds JSpecify null-safety, built-in API versioning and declarative HTTP clients, core resilience annotations, and Jakarta EE 11 on a modularized codebase" is exactly the kind of current, precise framing that reads as Staff+.


12. Best-practices checklist#

A tight list you can rattle off — each item has a why, because "best practice" without a reason is a red flag.

  • Constructor injection, always — immutable, testable, honest dependencies, fail-fast cycles. Never field injection.
  • Keep singletons stateless — they're shared across request threads; mutable fields are data races.
  • Thin controllers, rich services, DTOs at the boundary — never serialize JPA entities; keep HTTP concerns out of business logic.
  • One transaction boundary per use-case at the service layer — not on controllers, not on every repo call; no remote I/O inside a tx.
  • @ConfigurationProperties (validated) over scattered @Value — typed, relaxed-bound, fail-fast at startup.
  • Respect the proxy boundary — remember self-invocation kills @Transactional/@Async/@Cacheable; final/private methods aren't advised; Kotlin needs kotlin-spring.
  • Prefer test slices + Testcontainers over @SpringBootTest-everything and mocking the DB — fast suite, real database, standardized context config.
  • Let auto-config defaults ride; override with a bean when needed — don't fight Boot; use @ConditionalOnMissingBean-friendly overrides.
  • Externalize config the 12-factor way — env vars over baked-in values; profiles for environment wiring, not features (use @ConditionalOnProperty for feature flags).
  • Wire Actuator liveness/readiness + graceful shutdown — deploys and autoscaling depend on it; expose endpoints deliberately and secured.
  • Package by feature, not by layerorder/, billing/ over controllers/, services/; it localizes change and maps to bounded contexts (ties to your DDD guide).

13. Problems → root cause → fix#

The interview-favorite failure catalog. For each: the symptom, the actual mechanism, the fix.

@Transactional / @Async / @Cacheable does nothing. Root cause: self-invocation — an internal this.method() call bypassed the proxy (or the method is private/final, or in Kotlin the class isn't open and kotlin-spring is missing). Fix: move the method to another bean, self-inject the proxy (@Lazy), or AopContext.currentProxy(); add kotlin-spring.

A checked exception committed partial work instead of rolling back. Root cause: default rollback is only on unchecked exceptions. Fix: @Transactional(rollbackFor = Exception.class); and don't swallow the exception with a catch that doesn't rethrow or setRollbackOnly().

NoUniqueBeanDefinitionException. Root cause: two beans satisfy one injection type. Fix: @Primary for the default, @Qualifier("name") at the injection point, or inject a List/Map if you actually want all of them.

App won't start: BeanCurrentlyInCreationException / circular reference. Root cause: a constructor-injection cycle (Boot 2.6+ forbids cycles by default). Fix: redesign — extract shared logic into a third bean; @Lazy as a last resort. Don't "fix" it by switching to field injection (that hides it).

Tests take forever. Root cause: many distinct @SpringBootTest configurations thrash the ApplicationContext cache, rebuilding contexts. Fix: standardize context config, prefer slices (@WebMvcTest/@DataJpaTest), minimize per-test @MockitoBean/property variations.

LazyInitializationException when serializing a response. Root cause: a lazy JPA association is accessed after the transaction/session closed — usually because you returned the entity to the web layer. Fix: map to a DTO inside the transaction, or fetch with a JOIN FETCH / @EntityGraph; never expose entities at the HTTP boundary. (Avoid open-in-view; it hides the problem and holds connections.)

Connection pool exhaustion / mysterious timeouts. Root cause: holding a DB connection too long — remote/slow I/O inside a transaction, REQUIRES_NEW nesting (two connections per thread), or blocking a WebFlux event-loop thread. Fix: keep transactions short and I/O-free; avoid nested REQUIRES_NEW; never block the event loop (offload or use virtual threads).

An expected auto-configured bean isn't there (or a surprise bean is). Root cause: a @Conditional didn't match — missing classpath dependency (@ConditionalOnClass), or your own bean triggered @ConditionalOnMissingBean backoff. Fix: run with --debug, read the ConditionEvaluationReport, add the starter or adjust your bean.

ClassCastException casting a bean to its concrete class. Root cause: a JDK dynamic proxy exposes only the interface, not your implementation type. Fix: program to the interface, or force CGLIB (proxyTargetClass=true, already Boot's default).

Native image works as a JVM app but crashes at runtime with reflection/resource errors. Root cause: GraalVM's closed-world couldn't see a reflective access that wasn't hinted. Fix: provide RuntimeHints / @RegisterReflectionForBinding / @ImportRuntimeHints; test on GraalVM in CI, not just the JVM.

Config value not binding. Root cause: using @Value with a name mismatch, or a @ConfigurationProperties class that isn't registered. Fix: prefer @ConfigurationProperties + @EnableConfigurationProperties/@ConfigurationPropertiesScan; rely on relaxed binding (kebab-case YAML ↔ camelCase field); @Validated to catch it at startup.


14. Interview framing — how to sound senior#

The meta-skill: trace every behavior to lifecycle / proxy / auto-config, and every choice to a trade-off. Concretely:

  • Lead with the mechanism, not the annotation. "@Transactional is proxy-based AOP plus a thread-bound connection, which is why self-invocation breaks it and why it doesn't cross threads." That one sentence demonstrates depth two questions deep.
  • Volunteer the trade-off before you're asked. WebFlux vs. virtual threads, native vs. CDS vs. plain JVM, mocking vs. Testcontainers, REQUIRES_NEW's pool cost. Staff engineers are hired to make trade-offs, so show the axis you're weighing.
  • Show "product ownership" (your active-loop's exact bar). Explain the why behind a choice in business terms: "I standardized the error contract on ProblemDetail so API consumers get machine-readable errors and our support load dropped," not "I added @ControllerAdvice."
  • Version-anchor claims. "Since Boot 3.2…", "as of JDK 24, JEP 491 removed…". It signals you track the platform and know facts have dates.
  • Name the failure mode. For any feature, the senior move is to also say how it breaks: proxy self-invocation, checked-exception rollback, context-cache thrash, event-loop blocking, connection-pool exhaustion.
  • Connect Spring to the architecture guides you already have. @TransactionalEventListener(AFTER_COMMIT) → outbox; Micrometer Tracing → trace-context; virtual threads → your concurrency guide; @Transactional boundaries → your locking guide; package-by-feature → DDD bounded contexts. Showing the framework as the implementation layer of those patterns is exactly the integrative thinking Staff+ loops look for.
  • Tie it to your domain when you can. e.g., in the roaming/OCPI session pipeline: stateless singleton services processing session events, one tx boundary per session-update use-case, @Transactional(readOnly=true) on the CQRS read models, Actuator readiness gating traffic during a rolling deploy.

15. Cheat-sheets#

Injection & config

WantUse
Required dependencyConstructor injection (final/val)
Optional/reconfigurable depSetter injection
One config value@Value("${...}")
A group of config@ConfigurationProperties + @Validated
Pick among many impls@Primary / @Qualifier / inject List/Map
Env-varying bean@Profile
Feature toggle@ConditionalOnProperty (not a profile)

Proxy / AOP

FactDetail
Proxy typesJDK (interface) vs CGLIB (subclass); Boot defaults CGLIB
Can't adviseprivate, final, static methods; final/Kotlin-closed classes
Self-invocationthis.method() bypasses advice — the #1 bug
Fixesseparate bean › self-inject @LazyAopContext.currentProxy()
Kotlinneeds kotlin-spring to open classes

Transactions

ItemRule
Default rollbackunchecked + Error only; checked → rollbackFor
Default propagationREQUIRED
Independent commitREQUIRES_NEW (2 connections — pool risk)
SavepointNESTED (JDBC)
Read pathreadOnly = true (skips Hibernate flush)
Boundaryservice layer, short, no remote I/O, one per use-case
Threadingthread-bound; doesn't cross @Async/reactive

Boot internals

ThingDetail
@SpringBootApplication@SpringBootConfiguration + @EnableAutoConfiguration + @ComponentScan
Auto-config registryMETA-INF/spring/...AutoConfiguration.imports
Overridable defaults@ConditionalOnMissingBean = you win
Activated by classpath@ConditionalOnClass (starters bring the classpath)
Debug what matched--debugConditionEvaluationReport

Testing

NeedTool
Pure logicplain JUnit, no Spring
Controller@WebMvcTest + MockMvc + @MockitoBean
Repository@DataJpaTest + Testcontainers
Full integration@SpringBootTest + Testcontainers @ServiceConnection
Fast suitestandardize context config; slices; watch the context cache

Web / concurrency choice

SituationPick
Many blocking I/O requests, pool-boundMVC + virtual threads (spring.threads.virtual.enabled)
Streaming + backpressure, async compositionWebFlux
Simple CRUD, debuggability priorityMVC (thread-per-request)

16. Self-test#

If you can answer these out loud with a mechanism and a trade-off, you're ready.

  1. Explain, in one sentence each, the three mechanisms that account for most of Spring's behavior.
  2. Why is constructor injection the default? Give four distinct reasons.
  3. @Configuration vs @Configuration(proxyBeanMethods = false) — what changes, and why do Boot's auto-configs use the latter?
  4. Walk the singleton bean lifecycle. At which step are AOP proxies created, and why does that make @Transactional self-calls from @PostConstruct fail?
  5. What are BeanPostProcessor and BeanFactoryPostProcessor, and which one implements @Autowired? Which one creates proxies?
  6. Why does calling one @Transactional method from another method in the same class not start a transaction? Give three fixes, ranked.
  7. Which exceptions trigger rollback by default? How do you roll back on a checked exception?
  8. Explain REQUIRES_NEW's connection-pool hazard.
  9. How does Boot's auto-configuration actually work — file, annotations, and the override semantic — end to end?
  10. What single condition annotation makes Boot's defaults overridable, and how would you debug a bean that failed to auto-configure?
  11. @Value vs @ConfigurationProperties — when each, and what does @Validated buy you?
  12. Trace a request through DispatcherServlet naming the components.
  13. WebFlux vs MVC-with-virtual-threads: when do you pick each, post-Loom? What does reactive give you that virtual threads don't?
  14. Why can a suite of @SpringBootTest classes be slow, and what's the mechanism behind the fix?
  15. Name three ways the proxy boundary silently breaks a feature.
  16. What was the defining change in Boot 3.0, and name three genuinely new things in Boot 4.0 / Framework 7.0.
  17. What does a GraalVM native image buy you, what does it cost, and what's the cheaper middle-ground for faster startup?
  18. How does @TransactionalEventListener(AFTER_COMMIT) relate to the outbox pattern?

Lineage & version notes: Spring Framework 6.2 / Spring Boot 3.4 (late 2024) introduced @MockitoBean; Spring Framework 7.0 GA 2025-11-13 and Spring Boot 4.0.0 GA 2025-11-20 (JSpecify null-safety, API versioning, declarative @HttpExchange clients, @Retryable/@ConcurrencyLimit core resilience, Jakarta EE 11, Java 17 baseline / 25 first-class, Micrometer 1.16 + OTel starter, Jackson 3 default with Jackson 2 deprecated, GraalVM 25 for native). Loom virtual threads GA in JDK 21; JEP 491 (JDK 24) removed synchronized pinning. Verify any single version-stamped fact against current docs before quoting it in a loop — release specifics drift.