☕ Spring Boot Internals — Principal Engineer Deep-Dive¶
A reference on Spring Boot, the DI container, AOP, transactions, security and production patterns Written for Staff/Principal Engineer interviews. Current as of October 2026: Spring Boot 4.1 (June 2026) and 4.0 (November 2025) on Spring Framework 7.0; Spring Boot 3.5 was the last 3.x line and its open-source support ended in June 2026.
Table of Contents¶
- Bean Lifecycle & Post-Processors
- Auto-Configuration & Conditionals
- AOP — Aspect-Oriented Programming
- @Transactional — Propagation & Isolation
- Spring Data JPA & Hibernate
- Spring Security Internals
- Spring MVC — Request Processing
- Spring Boot Actuator & Observability
- Testing Strategies
- Production Patterns & Pitfalls
- Spring Boot Interview Questions
The interview questions page has longer worked answers on auto-configuration and the bean lifecycle (Q4) and on transactions (Q8).
What Changed in Spring Boot 4 / Framework 7¶
30-second answer
Boot 4 / Framework 7 keep the Java 17 baseline (Java 25 recommended) and move to Jakarta EE 11 (Servlet 6.1, JPA 3.2, Bean Validation 3.1), Jackson 3, JSpecify null-safety annotations, and a modularized Boot (one spring-boot-<technology> module per integration, with matching starters). Framework 7 adds first-class API versioning, HTTP service client registries, @Retryable/@ConcurrencyLimit in core, BeanRegistrar, RestTestClient and JmsClient. Undertow, @MockBean, Spock integration and the deprecated Spring Security "access" APIs are gone or moved out. Most of the production features people associate with "modern Spring Boot" (virtual threads, RestClient, structured logging, @ServiceConnection, CDS) arrived during 3.x.
| Area | What to know | Since |
|---|---|---|
| Baselines | Java 17+ (25 recommended), Kotlin 2.2+, GraalVM 25+ for native images | Boot 4.0 |
| Jakarta EE 11 | Servlet 6.1 (Tomcat 11, Jetty 12.1), JPA 3.2 (Hibernate 7), Bean Validation 3.1. Undertow is not supported (no Servlet 6.1 support) | Boot 4.0 |
| Modularization | spring-boot-autoconfigure split into spring-boot-<technology> modules with packages org.springframework.boot.<technology>. Starters renamed: spring-boot-starter-web → spring-boot-starter-webmvc, -aop → -aspectj, -web-services → -webservices. spring-boot-starter-classic / -test-classic ease migration |
Boot 4.0 |
| JSON | Jackson 3 (tools.jackson group and packages; annotations stay com.fasterxml.jackson.annotation). Jackson 2 support only in a deprecated module |
Boot 4.0 |
| Null safety | JSpecify annotations (org.jspecify.annotations.Nullable) across Framework and Boot, replacing org.springframework.lang / JSR 305 |
Framework 7 / Boot 4 |
| HTTP clients | RestClient (Framework 6.1 / Boot 3.2) and HTTP interface clients (@HttpExchange); Framework 7 adds @ImportHttpServices groups and Boot auto-configures them. RestTemplate is planned to be deprecated in Framework 7.1 and removed in 8.0 |
3.2 → 4.0 |
| API versioning | Map requests to controller methods by API version (header, query param, path or media type) in MVC and WebFlux | Framework 7 |
| Resilience | @Retryable, @ConcurrencyLimit with @EnableResilientMethods, and RetryTemplate in spring-core (org.springframework.core.retry) |
Framework 7 |
| Testing | @MockBean/@SpyBean removed → @MockitoBean/@MockitoSpyBean (Framework 6.2+); RestTestClient; JUnit 6; JUnit 4 support removed from the TestContext framework |
Boot 4.0 |
| Observability | Micrometer Observation API + Micrometer Tracing; new spring-boot-starter-opentelemetry exports metrics and traces over OTLP |
Boot 3.0 → 4.0 |
| Virtual threads | spring.threads.virtual.enabled=true (Java 21+) switches Tomcat/Jetty request handling, @Async, scheduling and several clients to virtual threads |
Boot 3.2 |
| Startup | AOT processing + GraalVM native images (Boot 3.0); CDS support (3.3); the JDK's AOT cache (Java 24+) works with Boot apps too | 3.x |
| Other 3.x features worth knowing | @ServiceConnection + Testcontainers/Docker Compose dev services (3.1), structured JSON logging logging.structured.format.console=ecs (3.4), graceful shutdown on by default (3.4) |
3.x |
1. IoC Container & Dependency Injection¶
30-second answer
The container reads bean definitions (from component scanning, @Bean methods, imports and auto-configuration), lets BeanFactoryPostProcessors modify them, then instantiates singletons eagerly at refresh(), injecting dependencies and running BeanPostProcessors, which is where proxies for @Transactional, @Async and aspects are created. Prefer constructor injection: dependencies are explicit, final and testable, and cycles fail fast.
Container Hierarchy¶
BeanFactory (interface)
└── The root container contract: getBean(), containsBean(), isSingleton()
└── A plain DefaultListableBeanFactory creates singletons on first getBean()
ApplicationContext (extends BeanFactory)
└── Pre-instantiates all non-lazy singletons during refresh()
└── Event publication (ApplicationEventPublisher)
└── Resource loading (ResourceLoader)
└── Message i18n (MessageSource)
└── Environment and profiles (EnvironmentCapable)
ConfigurableApplicationContext
└── refresh(), close(), registerShutdownHook()
AbstractApplicationContext
└── Template method implementation of refresh()
AnnotationConfigApplicationContext
└── Plain Java-config context (@Configuration, @ComponentScan)
ServletWebServerApplicationContext (and its annotation-config subclass)
└── What Spring Boot creates for a servlet web app: refresh() also creates and
starts the embedded Tomcat/Jetty server (ReactiveWebServerApplicationContext
for WebFlux)
The refresh() Method — 13 Steps¶
// AbstractApplicationContext.refresh(), simplified (Spring Framework 6/7):
@Override
public void refresh() throws BeansException, IllegalStateException {
this.startupShutdownLock.lock();
try {
// 1. Prepare: startup date, active flag, validate required properties
prepareRefresh();
// 2. Obtain the bean factory (for annotation-based contexts the
// BeanDefinitions are registered later, by step 5)
ConfigurableListableBeanFactory beanFactory = obtainFreshBeanFactory();
// 3. Configure the factory: class loader, SpEL resolver, Aware-interface
// support (ApplicationContextAwareProcessor), environment beans
prepareBeanFactory(beanFactory);
try {
// 4. Hook for subclasses (e.g. web contexts register request/session scopes)
postProcessBeanFactory(beanFactory);
// 5. Run BeanFactoryPostProcessors. The most important one,
// ConfigurationClassPostProcessor, parses @Configuration classes and
// processes @ComponentScan, @Import, @Bean and @PropertySource:
// this is where most BeanDefinitions (incl. auto-configuration) appear.
invokeBeanFactoryPostProcessors(beanFactory);
// 6. Instantiate and register BeanPostProcessors (@Autowired support,
// @PostConstruct support, auto-proxy creators...)
registerBeanPostProcessors(beanFactory);
// 7. MessageSource (i18n)
initMessageSource();
// 8. Event multicaster
initApplicationEventMulticaster();
// 9. Subclass hook: Spring Boot's web contexts CREATE THE WEB SERVER here
onRefresh();
// 10. Register ApplicationListener beans; publish early events
registerListeners();
// 11. Instantiate all remaining non-lazy singletons
finishBeanFactoryInitialization(beanFactory);
// 12. Lifecycle processor: start SmartLifecycle beans (Boot starts
// accepting web requests here), publish ContextRefreshedEvent
finishRefresh();
} catch (RuntimeException | Error ex) {
// destroy already-created singletons, reset the active flag, rethrow
destroyBeans();
cancelRefresh(ex);
throw ex;
} finally {
// 13. Clear reflection/metadata caches that are no longer needed
resetCommonCaches();
}
} finally {
this.startupShutdownLock.unlock();
}
}
Bean Scopes¶
| Scope | Description | Use Case |
|---|---|---|
| singleton | One instance per container (default) | Stateless services, repositories, clients |
| prototype | New instance per injection point / getBean(); the container does not manage its destruction |
Stateful helper objects |
| request | One instance per HTTP request (web contexts) | Request-scoped data |
| session | One instance per HTTP session | User session data |
| application | One instance per ServletContext |
Shared state across contexts in one web app |
| websocket | One instance per WebSocket session | WebSocket state |
Injecting a shorter-lived bean (request scope, prototype) into a singleton captures one instance forever. Fix with a scoped proxy (@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)), ObjectProvider<T>, or @Lookup methods.
Injection Types¶
// ═══════════════════════════════════════════════════════════════
// 1. Constructor Injection (preferred; recommended by the Spring team)
// ═══════════════════════════════════════════════════════════════
@Service
public class UserService {
private final UserRepository userRepository;
private final EmailService emailService;
// A single constructor needs no @Autowired (Spring 4.3+).
// ✅ final fields, immutable after construction
// ✅ plain `new UserService(mockRepo, mockEmail)` in unit tests
// ✅ circular dependencies fail fast at startup instead of half-working
// ✅ a long constructor is a visible smell: the class does too much
public UserService(UserRepository userRepository, EmailService emailService) {
this.userRepository = userRepository;
this.emailService = emailService;
}
}
// ═══════════════════════════════════════════════════════════════
// 2. Setter Injection
// ═══════════════════════════════════════════════════════════════
@Service
public class ReportService {
private ReportFormatter formatter;
@Autowired(required = false) // good fit for OPTIONAL dependencies
public void setFormatter(ReportFormatter formatter) {
this.formatter = formatter;
}
// ⚠️ Can't be final; the object exists in a partially initialized state
// ⚠️ Cycles through setters can be resolved by the container, but Spring Boot
// rejects circular references by default since 2.6
}
// ═══════════════════════════════════════════════════════════════
// 3. Field Injection (avoid in production code)
// ═══════════════════════════════════════════════════════════════
@Service
public class LegacyService {
@Autowired
private UserRepository userRepository;
// ❌ Can't be final
// ❌ Dependencies are hidden from the API
// ❌ Tests need the container or reflection (@InjectMocks)
// (Acceptable in test classes, e.g. @Autowired MockMvc.)
}
// ═══════════════════════════════════════════════════════════════
// 4. @Bean method parameters (Java config)
// ═══════════════════════════════════════════════════════════════
@Configuration(proxyBeanMethods = false)
public class AppConfig {
@Bean
public UserService userService(UserRepository userRepository, EmailService emailService) {
return new UserService(userRepository, emailService); // constructor injection
}
}
// Choosing among several candidates of one type:
// @Primary on the default bean, @Qualifier("name") at the injection point,
// or inject List<T> / Map<String, T> / ObjectProvider<T> to get all or pick lazily.
2. Bean Lifecycle & Post-Processors¶
Complete Bean Lifecycle (11 Steps)¶
// Step 1: Instantiate: constructor (or factory method); constructor injection here
// Step 2: Populate: field and setter injection (AutowiredAnnotationBeanPostProcessor)
// Step 3: Aware callbacks: BeanNameAware, BeanClassLoaderAware, BeanFactoryAware
// Step 4: BeanPostProcessor.postProcessBeforeInitialization, for every BPP:
// - ApplicationContextAware, EnvironmentAware etc. are invoked here
// (ApplicationContextAwareProcessor)
// - @PostConstruct runs here (CommonAnnotationBeanPostProcessor)
// Step 5: InitializingBean.afterPropertiesSet()
// Step 6: Custom init method (@Bean(initMethod = "init"))
// Step 7: BeanPostProcessor.postProcessAfterInitialization
// → AOP PROXIES are created here (auto-proxy creators) for @Transactional,
// @Cacheable, @Async, @Validated, @Retryable, aspects
// → the returned proxy REPLACES the raw bean in the container
// Step 8: Ready. After ALL singletons exist: SmartInitializingSingleton callbacks,
// then SmartLifecycle.start()
// ...
// On context close (reverse dependency order):
// Step 9: @PreDestroy
// Step 10: DisposableBean.destroy()
// Step 11: Custom destroy method (@Bean(destroyMethod = ...); public close()/shutdown()
// methods are inferred by default for @Bean)
Bean lifecycle: instantiate, populate, run init callbacks around the BeanPostProcessors (where AOP proxies replace the raw bean), then destroy callbacks on context close.
flowchart TD
A["1. Instantiate: constructor or factory method"] --> B["2. Populate: field and setter injection"]
B --> C["3. Aware callbacks"]
C --> D["4. BPP before-init: Aware processors, @PostConstruct"]
D --> E["5. afterPropertiesSet"]
E --> F["6. Custom init method"]
F --> G["7. BPP after-init: AOP proxy created and replaces the raw bean"]
G --> H["8. Ready: SmartInitializingSingleton, SmartLifecycle.start"]
H --> I["Context close: @PreDestroy, DisposableBean.destroy, custom destroy"]
BeanPostProcessor — The Most Powerful Extension Point¶
@Component
public class TimingBeanPostProcessor implements BeanPostProcessor {
@Override
public Object postProcessBeforeInitialization(Object bean, String beanName) {
// Before @PostConstruct of this bean. Inspect or wrap; must return a bean.
return bean;
}
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
// After init callbacks. Spring's own auto-proxy creators work at this point.
if (bean instanceof MonitoredService) {
return Proxy.newProxyInstance(
bean.getClass().getClassLoader(),
bean.getClass().getInterfaces(),
(proxy, method, args) -> {
long start = System.nanoTime();
try {
return method.invoke(bean, args);
} catch (InvocationTargetException e) {
throw e.getCause(); // rethrow the real exception
} finally {
log.info("{} took {} µs", method.getName(), (System.nanoTime() - start) / 1000);
}
});
// Caveat: a JDK proxy only implements the interfaces, so anything that
// injects the concrete class now fails. Prefer Spring AOP or
// Micrometer's @Timed / @Observed for this in real code.
}
return bean;
}
}
// BPPs are created early (step 6 of refresh()), so beans THEY depend on are created
// before all BPPs exist and miss some post-processing ("Bean 'x' is not eligible
// for getting processed by all BeanPostProcessors" in the log). Keep BPPs'
// dependencies minimal; declare them via static @Bean methods.
BeanFactoryPostProcessor — Modify Bean Definitions Before Creation¶
// Runs in step 5 of refresh(), after definitions are registered and BEFORE any
// regular bean is instantiated. Works on metadata (BeanDefinitions), not objects.
@Configuration(proxyBeanMethods = false)
public class LazyReportingConfig {
// static: a BFPP must be instantiable before the rest of this @Configuration
@Bean
public static BeanFactoryPostProcessor lazyReportingBeans() {
return factory -> {
for (String name : factory.getBeanNamesForType(ReportGenerator.class, true, false)) {
factory.getBeanDefinition(name).setLazyInit(true); // create on first use
}
};
}
}
// Real examples in Spring itself: ConfigurationClassPostProcessor (parses
// @Configuration), PropertySourcesPlaceholderConfigurer (resolves ${...}).
// Note getBeanNamesForType(type, includeNonSingletons, allowEagerInit=false): with
// allowEagerInit=true it could instantiate FactoryBeans too early.
BeanDefinitionRegistryPostProcessor — Register New Bean Definitions¶
// Runs before ordinary BeanFactoryPostProcessors and may ADD definitions.
// This is the mechanism behind @Configuration/@ComponentScan/@Import processing
// (ConfigurationClassPostProcessor implements it).
public class TenantClientsRegistrar implements BeanDefinitionRegistryPostProcessor {
@Override
public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) {
for (String tenant : List.of("acme", "globex")) {
RootBeanDefinition bd = new RootBeanDefinition(TenantClient.class);
bd.getConstructorArgumentValues().addGenericArgumentValue(tenant);
registry.registerBeanDefinition(tenant + "Client", bd);
}
}
@Override
public void postProcessBeanFactory(ConfigurableListableBeanFactory factory) {
// inherited from BeanFactoryPostProcessor; nothing to do here
}
}
// Framework 7 adds a simpler, AOT-friendly API for programmatic registration:
public class TenantClients implements BeanRegistrar {
@Override
public void register(BeanRegistry registry, Environment env) {
for (String tenant : env.getProperty("app.tenants", String[].class, new String[0])) {
registry.registerBean(tenant + "Client", TenantClient.class,
spec -> spec.supplier(ctx -> new TenantClient(tenant)));
}
}
}
// Activated with @Import(TenantClients.class) on a @Configuration class.
3. Auto-Configuration & Conditionals¶
30-second answer
@EnableAutoConfiguration (inside @SpringBootApplication) imports a deferred selector that loads candidate classes from every META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports on the classpath, orders them, filters them cheaply by class presence, and registers those whose @Conditionals match. Because they're processed after your configuration, @ConditionalOnMissingBean lets your beans win. Debug with --debug or /actuator/conditions.
How Spring Boot Auto-Configuration Works¶
// 1. @SpringBootApplication = @SpringBootConfiguration (a @Configuration)
// + @EnableAutoConfiguration
// + @ComponentScan (this package and below)
// 2. @EnableAutoConfiguration imports AutoConfigurationImportSelector
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@AutoConfigurationPackage
@Import(AutoConfigurationImportSelector.class)
public @interface EnableAutoConfiguration {}
// It's a DeferredImportSelector: it runs after all user @Configuration classes
// have been parsed.
// 3. Candidates come from
// META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
// in EVERY jar on the classpath (Boot 2.7+; Boot 3.0 dropped the old
// META-INF/spring.factories EnableAutoConfiguration key).
// In Boot 4 each technology module (spring-boot-jdbc, spring-boot-webmvc, ...)
// ships its own imports file and its own package, e.g.
// org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration
// (Boot 3: org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration).
// 4. Before full evaluation, an AutoConfigurationImportFilter removes candidates
// whose @ConditionalOnClass/@ConditionalOnBean/@ConditionalOnWebApplication
// can't match, using precomputed metadata (no class loading). This keeps
// startup fast despite hundreds of candidates.
// 5. A typical auto-configuration (simplified from JdbcTemplateAutoConfiguration):
@AutoConfiguration(after = DataSourceAutoConfiguration.class)
@ConditionalOnClass({ DataSource.class, JdbcTemplate.class })
@ConditionalOnSingleCandidate(DataSource.class) // exactly one (or one @Primary) DataSource
@EnableConfigurationProperties(JdbcProperties.class)
public class JdbcTemplateAutoConfiguration {
@Bean
@ConditionalOnMissingBean(JdbcOperations.class) // back off if the user defined one
public JdbcTemplate jdbcTemplate(DataSource dataSource, JdbcProperties properties) {
JdbcTemplate template = new JdbcTemplate(dataSource);
template.setFetchSize(properties.getTemplate().getFetchSize());
return template;
}
}
Auto-configuration selection: candidates from every AutoConfiguration.imports file are pre-filtered by metadata, then each surviving class is applied only if its conditions match, backing off when the user defines the bean.
flowchart TD
A["@EnableAutoConfiguration: AutoConfigurationImportSelector (deferred)"] --> B["Read AutoConfiguration.imports from every jar"]
B --> C["Import filter: drop candidates whose @ConditionalOnClass cannot match (no class loading)"]
C --> D["Evaluate remaining classes after user @Configuration is parsed"]
D --> E{"Class-level conditions match?"}
E -->|no| X["Skip configuration"]
E -->|yes| F{"@Bean method: @ConditionalOnMissingBean satisfied?"}
F -->|"user already defined a bean"| Y["Back off: user bean wins"]
F -->|yes| Z["Register auto-configured bean"]
@Conditional Mechanism¶
// Every @ConditionalOn... annotation is backed by the Condition SPI:
@FunctionalInterface
public interface Condition {
boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata);
}
// ConditionContext gives access to:
// - the BeanDefinitionRegistry and BeanFactory (bean definitions registered so far)
// - the Environment (properties, profiles)
// - the ResourceLoader and ClassLoader
// @ConditionalOnClass / @ConditionalOnMissingClass
// The annotation's class attributes are read from bytecode metadata (ASM), so a
// missing class doesn't break parsing; the check then tries to load the class by
// name (without initializing it).
// @ConditionalOnBean / @ConditionalOnMissingBean
// Look at bean DEFINITIONS registered so far, so the result depends on ordering.
// Reliable in auto-configuration (processed after user config); unreliable in
// ordinary @Configuration classes.
// @ConditionalOnSingleCandidate: exactly one candidate, or one marked @Primary.
// @ConditionalOnProperty (simplified):
@Retention(RetentionPolicy.RUNTIME)
@Conditional(OnPropertyCondition.class)
public @interface ConditionalOnProperty {
String prefix() default "";
String[] name() default {};
String havingValue() default ""; // "" = any value except "false"
boolean matchIfMissing() default false;
}
@Bean
@ConditionalOnProperty(name = "myapp.feature.enabled", havingValue = "true")
public FeaturedService featuredService() {
return new FeaturedService();
}
// Boot 3.5+: @ConditionalOnBooleanProperty("myapp.feature.enabled") says the same thing.
// Others: @ConditionalOnResource, @ConditionalOnWebApplication,
// @ConditionalOnExpression (SpEL), @ConditionalOnJava, @ConditionalOnThreading
// (virtual vs platform threads, Boot 3.2+), @ConditionalOnCloudPlatform.
//
// Keep custom conditions cheap and side-effect free (no network calls): they run
// at startup and, for native images/AOT, at BUILD time.
Auto-Configuration Ordering¶
// Ordering between auto-configurations:
// 1. @AutoConfiguration(before = ..., after = ...) (also beforeName/afterName strings)
// 2. @AutoConfigureOrder (numeric)
// 3. @AutoConfigureBefore / @AutoConfigureAfter (older equivalents)
// Ordering affects only the order in which definitions are REGISTERED (which
// matters for @ConditionalOnBean), not bean creation order, which follows
// dependencies.
// Excluding auto-configuration:
@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
public class MyApplication {}
// or: spring.autoconfigure.exclude=<fully qualified class name>
// Seeing what happened:
// java -jar app.jar --debug → CONDITIONS EVALUATION REPORT at startup
// GET /actuator/conditions → same report at runtime
Custom Auto-Configuration¶
// A reusable starter = an autoconfigure module + a starter POM that depends on it.
// 1. The auto-configuration class
@AutoConfiguration
@ConditionalOnClass(AcmeClient.class)
@EnableConfigurationProperties(AcmeProperties.class)
public class AcmeAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public AcmeClient acmeClient(AcmeProperties properties) {
return new AcmeClient(properties.url(), properties.timeout());
}
@Bean
@ConditionalOnBooleanProperty("acme.monitoring.enabled")
public AcmeMonitor acmeMonitor(AcmeClient client) {
return new AcmeMonitor(client);
}
}
// 2. Type-safe properties (records are supported with constructor binding)
@ConfigurationProperties(prefix = "acme")
public record AcmeProperties(
@DefaultValue("http://localhost:8080") URI url,
@DefaultValue("5s") Duration timeout) {}
// 3. Register it in the autoconfigure module:
// src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
// containing one line:
// com.acme.autoconfigure.AcmeAutoConfiguration
//
// 4. Add spring-boot-configuration-processor to generate metadata for IDE
// completion of acme.* properties, and test with ApplicationContextRunner:
// new ApplicationContextRunner()
// .withConfiguration(AutoConfigurations.of(AcmeAutoConfiguration.class))
// .withPropertyValues("acme.monitoring.enabled=true")
// .run(ctx -> assertThat(ctx).hasSingleBean(AcmeMonitor.class));
4. AOP — Aspect-Oriented Programming¶
30-second answer
Spring AOP is proxy-based: callers get a proxy that runs the advice chain (transactions, caching, security, your aspects) and then calls the target. Spring Boot uses CGLIB subclass proxies by default; plain Spring uses JDK interface proxies when the bean has interfaces. Because advice lives in the proxy, self-invocation (this.method()) and private/final methods are never advised. Use AspectJ weaving if you need advice on those.
How Spring AOP Works¶
// 1. Auto-proxy creators (BeanPostProcessors) wrap beans that match an advisor
// (@Transactional, @Cacheable, @Async, @PreAuthorize, @Aspect pointcuts)
// 2. A call through the proxy runs the interceptor chain, then the target method
// 3. Only calls that go THROUGH the proxy are advised:
// - JDK proxy: public methods of the proxied interfaces
// - CGLIB proxy: public and protected methods (and package-visible ones when
// called from the same package); never private, final or static methods
// 4. @Aspect pointcuts in Spring AOP match method executions only (no field
// access, constructor or static-method join points; that needs AspectJ)
Proxy Mechanisms¶
// JDK dynamic proxy (java.lang.reflect.Proxy):
// - implements all the bean's interfaces; InvocationHandler dispatches to the chain
// - only interface methods can be advised
// - the bean can only be injected by INTERFACE type
// CGLIB proxy (Spring's repackaged copy, org.springframework.cglib, in spring-core):
// - generates a SUBCLASS of the bean class at runtime (or at build time with AOT)
// - overrides non-final methods; final classes/methods can't be proxied
// - the constructor runs once for the target; Objenesis creates the proxy instance
// without calling your constructor again
// Who picks:
@EnableAspectJAutoProxy // plain Spring default: proxyTargetClass = false
// → JDK proxy if the bean has interfaces
@EnableAspectJAutoProxy(proxyTargetClass = true) // always CGLIB
// SPRING BOOT sets spring.aop.proxy-target-class=true by default (since 2.0), so
// Boot apps get CGLIB proxies even for beans that implement interfaces.
// Framework 7: @Proxyable on a bean, or a ProxyConfig bean, customizes this per bean.
The Self-Invocation Problem¶
@Service
public class UserService {
@Transactional
public void createUser(User user) {
save(user);
sendWelcomeEmail(user.getEmail()); // this.sendWelcomeEmail(): bypasses the proxy
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void sendWelcomeEmail(String email) {
// Called via `this`, REQUIRES_NEW is ignored: this runs inside createUser's
// transaction. Called from ANOTHER bean, it would suspend that transaction
// and run in its own.
}
}
// Why?
// - The bean other beans hold is the PROXY wrapping the real UserService
// - userService.createUser(...) → proxy → transaction begins → target.createUser()
// - Inside createUser(), `this` is the TARGET object, not the proxy
// - so this.sendWelcomeEmail() is a plain Java call: no advice
// FIX 1 (cleanest): move sendWelcomeEmail to another bean (EmailService) and inject it.
// The need for a different transaction boundary usually means a different
// responsibility anyway.
// FIX 2: programmatic transactions with explicit propagation
@Service
public class UserRegistration {
private final TransactionTemplate tx; // PROPAGATION_REQUIRED (default)
private final TransactionTemplate newTx; // PROPAGATION_REQUIRES_NEW
public UserRegistration(PlatformTransactionManager tm) {
this.tx = new TransactionTemplate(tm);
this.newTx = new TransactionTemplate(tm);
this.newTx.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
}
public void createUser(User user) {
tx.executeWithoutResult(status -> {
save(user);
newTx.executeWithoutResult(inner -> recordEmailIntent(user.getEmail()));
// reusing `tx` here would just JOIN the outer transaction
});
}
}
// FIX 3: self-injection through the proxy: @Lazy @Autowired UserService self;
// (needs @Lazy/ObjectProvider since Boot forbids circular references by default;
// works, but hides a design problem)
// FIX 4: AspectJ compile-time or load-time weaving: advice is woven into the class
// itself, so self-calls (and private methods) are advised. More build setup.
Self-invocation: callers hold the proxy, but inside the target this is the raw object, so an internal call skips the transactional advice (REQUIRES_NEW is ignored).
sequenceDiagram
participant C as Caller
participant P as Proxy
participant T as Target UserService
C->>P: createUser(user)
P->>P: interceptor chain: begin transaction
P->>T: createUser(user)
T->>T: this.sendWelcomeEmail(email), plain Java call
Note over T: no proxy involved, REQUIRES_NEW ignored, same transaction
T-->>P: return
P->>P: commit
P-->>C: return
AspectJ Pointcut Expressions¶
// EXECUTION: most common
@Pointcut("execution(public * com.myapp.service.*.*(..))")
// all public methods of classes directly in com.myapp.service
// WITHIN: all methods of types in a package (.. = including subpackages)
@Pointcut("within(com.myapp.service..*)")
// ANNOTATION on the method
@Pointcut("@annotation(org.springframework.transaction.annotation.Transactional)")
// ANNOTATION on the type
@Pointcut("@within(org.springframework.stereotype.Service)")
// BEAN name (Spring AOP only)
@Pointcut("bean(*Service)")
// COMBINING, and binding the annotation as an argument:
@Around("execution(* com.myapp.service..*(..)) && @annotation(timed)")
public Object around(ProceedingJoinPoint pjp, Timed timed) throws Throwable {
return pjp.proceed();
}
Creating a Custom Aspect¶
@Aspect
@Component
public class LoggingAspect {
private static final Logger log = LoggerFactory.getLogger(LoggingAspect.class);
@Pointcut("execution(public * com.myapp.service..*(..))")
public void serviceMethods() {}
@Around("serviceMethods()")
public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
String method = joinPoint.getSignature().toShortString();
long start = System.nanoTime();
try {
return joinPoint.proceed(); // invoke the target (or next advice)
} finally {
log.debug("{} took {} µs", method, (System.nanoTime() - start) / 1000);
}
// Don't log raw arguments/results by default: they often contain PII or
// secrets, and toString() on entities can trigger lazy loading.
}
@AfterThrowing(pointcut = "serviceMethods()", throwing = "ex")
public void logException(JoinPoint joinPoint, Exception ex) {
log.warn("Exception in {}: {}", joinPoint.getSignature().toShortString(), ex.toString());
}
}
// Ordering: @Order on the aspect class. Lower value = outer advice. Matters when
// combining with @Transactional (e.g. retry must wrap the transaction, not run
// inside it). Spring Boot 4 starter: spring-boot-starter-aspectj
// (spring-boot-starter-aop in Boot 3).
5. @Transactional — Propagation & Isolation¶
30-second answer
TransactionInterceptor (in the proxy) asks the PlatformTransactionManager to begin, join, suspend or savepoint according to propagation, binds the connection/EntityManager to the current thread, and commits or rolls back on return. Default rollback is on unchecked exceptions only. REQUIRES_NEW needs a second connection, which can deadlock a small pool. Transactions are thread-bound: they don't follow work into @Async methods or other threads. See Question 8 in the interview questions for the full worked answer.
Propagation Levels¶
// REQUIRED (default): join the current transaction, or start one
@Transactional(propagation = Propagation.REQUIRED)
// REQUIRES_NEW: suspend the current transaction (if any), run in a NEW one that
// commits/rolls back independently, then resume. Needs a second connection.
@Transactional(propagation = Propagation.REQUIRES_NEW)
// NESTED: inside an existing transaction, set a JDBC SAVEPOINT and roll back to it
// on failure (the outer transaction can continue); with no transaction, like
// REQUIRED. Works with DataSourceTransactionManager / JdbcTemplate; JPA's
// JpaTransactionManager does not support savepoints for the EntityManager.
@Transactional(propagation = Propagation.NESTED)
// MANDATORY: must run inside an existing transaction, else IllegalTransactionStateException
@Transactional(propagation = Propagation.MANDATORY)
// NEVER: must NOT run inside a transaction, else exception
@Transactional(propagation = Propagation.NEVER)
// NOT_SUPPORTED: suspend any current transaction and run without one
@Transactional(propagation = Propagation.NOT_SUPPORTED)
// SUPPORTS: join if one exists, otherwise run non-transactionally
@Transactional(propagation = Propagation.SUPPORTS)
Transaction Suspension Internals¶
// When a REQUIRES_NEW method is called inside a transaction:
// 1. TransactionInterceptor → AbstractPlatformTransactionManager.getTransaction()
// sees an existing transaction and REQUIRES_NEW
// 2. suspend():
// a. unbind the current resources (ConnectionHolder, EntityManagerHolder) from
// TransactionSynchronizationManager's thread-locals
// b. suspend registered synchronizations
// c. keep it all in a SuspendedResourcesHolder
// (the first connection stays checked out, its transaction open, its locks held)
// 3. begin a new transaction: borrow a NEW connection, setAutoCommit(false), bind it
// 4. run the method
// 5. commit or roll back the new transaction; release its connection
// 6. resume(): rebind the suspended resources and synchronizations
//
// Consequences:
// - Each such call needs TWO connections at once. With a pool of 10 and 10
// concurrent callers all holding their first connection, nobody gets a second:
// all block until the pool's connectionTimeout (Hikari default 30 s), then fail.
// - If the inner transaction touches rows the outer one has locked, it waits for
// the outer transaction, which waits for it: a self-deadlock until lock timeout.
REQUIRES_NEW suspends the outer transaction (its connection stays checked out), runs the inner method on a second connection, then resumes the outer one.
sequenceDiagram
participant O as Outer tx method
participant TM as TransactionManager
participant DB as Connection pool
O->>TM: call REQUIRES_NEW method
TM->>TM: suspend: unbind connection holder, keep outer tx open
TM->>DB: borrow a second connection, autoCommit false
TM->>TM: run inner method
TM->>DB: commit or rollback inner, release connection
TM->>TM: resume: rebind outer resources
TM-->>O: return
Note over O,DB: two connections held at once, risk of pool starvation
Isolation Levels¶
// DEFAULT: whatever the database/connection uses
// (PostgreSQL, Oracle, SQL Server: READ COMMITTED; MySQL InnoDB: REPEATABLE READ)
@Transactional(isolation = Isolation.DEFAULT)
// READ_UNCOMMITTED: dirty reads allowed in theory (PostgreSQL treats it as READ COMMITTED)
// READ_COMMITTED: each statement sees data committed before it started (MVCC)
// REPEATABLE_READ:
// - PostgreSQL: one snapshot for the whole transaction; updating a row changed
// concurrently fails with a serialization error (SQLSTATE 40001) → retry
// - MySQL: consistent snapshot for plain reads; locking reads and writes use
// next-key/gap locks
// SERIALIZABLE:
// - PostgreSQL: Serializable Snapshot Isolation, aborts one transaction of a
// dangerous read/write dependency cycle → retry
// - MySQL: plain SELECTs become locking reads
@Transactional(isolation = Isolation.REPEATABLE_READ)
// Isolation is applied only when Spring STARTS a transaction; a method that joins
// an existing one inherits that transaction's isolation.
Read-Only Optimization¶
@Transactional(readOnly = true)
public List<UserDto> findAllUsers() {
// Hibernate (via JpaTransactionManager): flush mode MANUAL and a read-only
// session: no dirty checking at commit and no snapshot copies of loaded entities
// JDBC: Connection.setReadOnly(true); PgJDBC then runs the transaction READ ONLY
// Replica routing is NOT automatic: it needs a routing DataSource
// (e.g. AbstractRoutingDataSource keyed on
// TransactionSynchronizationManager.isCurrentTransactionReadOnly(), wrapped in a
// LazyConnectionDataSourceProxy so the decision happens after the tx starts)
return userRepository.findAll().stream().map(UserDto::from).toList();
}
// Common pattern: @Transactional(readOnly = true) on the service class, and plain
// @Transactional on the methods that write.
Common Transaction Pitfalls¶
// ═══════════════════════════════════════════════════════════════
// PITFALL 1: @Transactional on private methods
// ═══════════════════════════════════════════════════════════════
@Service
public class MyService {
@Transactional // ← IGNORED: proxies can't intercept private methods
private void doWork() { }
}
// Since Spring 6.0, protected and package-private methods ARE transactional with
// class-based (CGLIB) proxies; private methods never are.
// ═══════════════════════════════════════════════════════════════
// PITFALL 2: Methods not on the interface, with JDK proxies
// ═══════════════════════════════════════════════════════════════
@Service
public class MyService implements MyInterface {
@Transactional // not declared in MyInterface
public void doWork() { }
}
// With JDK interface proxies (plain Spring, proxyTargetClass=false) callers can't
// even reach doWork() through the proxy. Spring Boot's default CGLIB proxies avoid
// this, but self-invocation still bypasses the proxy (section 4).
// ═══════════════════════════════════════════════════════════════
// PITFALL 3: Checked exceptions commit
// ═══════════════════════════════════════════════════════════════
@Transactional
public void process() throws BusinessException {
// Default: roll back on RuntimeException and Error only.
// A checked BusinessException leaves the transaction to COMMIT.
}
@Transactional(rollbackFor = BusinessException.class)
public void processAndRollBack() throws BusinessException { }
// ═══════════════════════════════════════════════════════════════
// PITFALL 4: Swallowed exceptions
// ═══════════════════════════════════════════════════════════════
@Transactional
public void process() {
try {
someMethod();
} catch (Exception e) {
// The interceptor sees normal completion → COMMIT of whatever happened.
// To roll back while still handling it:
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}
}
// The reverse trap: an inner @Transactional (REQUIRED) bean method throws, which
// marks the SHARED transaction rollback-only; the outer method catches it and
// returns normally → UnexpectedRollbackException at commit.
// ═══════════════════════════════════════════════════════════════
// PITFALL 5: Remote calls inside transactions
// ═══════════════════════════════════════════════════════════════
// An HTTP call or message send inside @Transactional holds a DB connection (and
// locks) for its whole duration, and can't be rolled back. Do I/O outside the
// transaction; publish events after commit (@TransactionalEventListener(phase =
// AFTER_COMMIT)) or through a transactional outbox.
6. Spring Data JPA & Hibernate¶
Entity State Transitions¶
new Entity()
│
▼
┌───────────────┐
│ TRANSIENT │ not associated with any persistence context
└───────┬───────┘
│ persist()
▼
┌───────────────┐ remove() ┌───────────────┐
│ MANAGED │────────────►│ REMOVED │──► DELETE at flush
│ (in the EM's │◄────────────│ (scheduled │
│ persistence │ persist() │ for delete) │
│ context; │ └───────────────┘
│ dirty-checked│
│ at flush) │
└───┬───────▲───┘
detach(), clear(),│ │ merge() copies the detached state into a
EM closed, │ │ MANAGED instance and returns THAT instance
transaction ended │ │ (the argument itself stays detached)
▼ │
┌───────────┴───┐
│ DETACHED │ has an id, but changes are not tracked
└───────────────┘
Spring Data's save() calls persist() for new entities and merge() otherwise ("new" = null id or null @Version, unless the entity implements Persistable). With assigned (non-generated) ids every save() is a merge(), which first SELECTs the row.
Fetch Strategies¶
@Entity
public class Order {
@Id @GeneratedValue
private Long id;
// LAZY is the JPA default for @OneToMany/@ManyToMany
@OneToMany(mappedBy = "order")
private List<OrderItem> items;
// EAGER is the JPA default for @ManyToOne/@OneToOne: make it LAZY explicitly.
// Eager to-one associations load on every query of Order, often as extra
// SELECTs (an N+1 you didn't ask for).
@ManyToOne(fetch = FetchType.LAZY)
private User user;
// How lazy loading works:
// - to-many: Hibernate's persistent collection wrappers (PersistentBag, ...)
// - to-one: a ByteBuddy-generated proxy subclass (Hibernate 5.3+)
// Both need an open persistence context when first touched, or you get
// LazyInitializationException.
}
// N+1:
// List<Order> orders = orderRepository.findAll(); // 1 query
// for (Order o : orders) o.getItems().size(); // +N queries
// FIX 1: fetch join (one query; Hibernate 6+ de-duplicates root entities)
@Query("select o from Order o join fetch o.items where o.status = :status")
List<Order> findWithItems(@Param("status") Status status);
// Don't combine collection fetch joins with pagination: Hibernate paginates IN
// MEMORY (warning HHH90003004). Page over ids first, then fetch.
// FIX 2: entity graph
@EntityGraph(attributePaths = "items")
List<Order> findByStatus(Status status);
// FIX 3: batch fetching: load collections for many owners per query
@OneToMany(mappedBy = "order")
@BatchSize(size = 50)
private List<OrderItem> items;
// or globally: spring.jpa.properties.hibernate.default_batch_fetch_size=50
// FIX 4: DTO projections for read paths (no entities, no lazy loading at all)
// record OrderSummary(Long id, String status, long itemCount) {}
// @Query("select new com.acme.OrderSummary(o.id, o.status, size(o.items)) from Order o")
// Detect: spring.jpa.properties.hibernate.generate_statistics=true in tests,
// SQL logging, or assert query counts in integration tests.
Spring Data JPA Query Methods¶
public interface UserRepository extends JpaRepository<User, Long> {
// Derived queries (method name → JPQL)
Optional<User> findByEmail(String email);
List<User> findByNameAndAgeGreaterThan(String name, int age);
List<User> findTop10ByOrderByCreatedAtDesc();
boolean existsByEmail(String email);
long countByStatus(UserStatus status);
// JPQL
@Query("select u from User u where u.email = :email")
Optional<User> findByEmailCustom(@Param("email") String email);
// Native SQL
@Query(value = "select * from users u where u.email = :email", nativeQuery = true)
Optional<User> findByEmailNative(@Param("email") String email);
// Bulk update: bypasses the persistence context. Clear it afterwards so loaded
// entities don't show stale values; needs a transaction.
@Transactional
@Modifying(clearAutomatically = true)
@Query("update User u set u.status = :status where u.lastLogin < :date")
int deactivateInactiveUsers(@Param("date") LocalDate date, @Param("status") UserStatus status);
// Interface projection: selects only the needed columns
interface UserSummary {
String getName();
String getEmail();
}
List<UserSummary> findAllProjectedBy();
}
Locking Strategies¶
// OPTIMISTIC LOCKING: opt-in by adding a @Version attribute
@Entity
public class Account {
@Id
private Long id;
private BigDecimal balance;
@Version
private Long version; // UPDATE ... SET version = version + 1 WHERE id = ? AND version = ?
// 0 rows updated → ObjectOptimisticLockingFailureException → retry or report a conflict
}
// PESSIMISTIC LOCKING
public interface AccountRepository extends JpaRepository<Account, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE) // SELECT ... FOR UPDATE
@Query("select a from Account a where a.id = :id")
Optional<Account> findByIdForUpdate(@Param("id") Long id);
@Lock(LockModeType.PESSIMISTIC_READ) // SELECT ... FOR SHARE (PostgreSQL)
@Query("select a from Account a where a.id = :id")
Optional<Account> findByIdForShare(@Param("id") Long id);
}
// Set a lock timeout (jakarta.persistence.lock.timeout hint) and lock rows in a
// consistent order to avoid deadlocks.
Hibernate Common Pitfalls¶
// ═══════════════════════════════════════════════════════════════
// PITFALL 1: LazyInitializationException
// ═══════════════════════════════════════════════════════════════
@Transactional(readOnly = true)
public Order getOrder(Long id) {
return orderRepository.findById(id).orElseThrow();
} // persistence context closes here
// later, in the controller or the JSON serializer:
// order.getItems().size(); // ← LazyInitializationException (with OSIV disabled)
// Fixes: fetch what the use case needs inside the service (fetch join,
// @EntityGraph), or return a DTO built inside the transaction.
// Open Session In View hides the problem: spring.jpa.open-in-view is TRUE by
// default in Spring Boot (still in 4.x; Boot logs a warning). Set it to false.
// ═══════════════════════════════════════════════════════════════
// PITFALL 2: Serializing entities directly
// ═══════════════════════════════════════════════════════════════
@Entity
public class User {
@OneToMany(mappedBy = "user")
@JsonIgnore // stops Jackson from walking the lazy collection
private List<Order> orders;
public int getOrderCount() { // ...but Jackson still calls this getter: one
return orders.size(); // lazy load per serialized User = N+1
}
}
// Fix: return DTOs/records from controllers; compute counts in the query.
// Entities as API payloads also leak schema details and invite over-posting.
// ═══════════════════════════════════════════════════════════════
// PITFALL 3: equals/hashCode on entities
// ═══════════════════════════════════════════════════════════════
// Generated ids are null until persist, so id-based equals/hashCode changes over
// the entity's life and breaks HashSets. Use a natural/business key, or id-based
// equals with a constant hashCode. Lombok @Data on entities is a common trap
// (also triggers lazy loading in toString/hashCode).
7. Spring Security Internals¶
30-second answer
Spring Security is a servlet Filter (DelegatingFilterProxy → FilterChainProxy) that picks the first matching SecurityFilterChain and runs its ordered filters: load the SecurityContext, authenticate (form login, bearer token, basic...), then ExceptionTranslationFilter and finally AuthorizationFilter, which asks an AuthorizationManager for a decision. Authentication results live in the thread-bound SecurityContextHolder. Spring Security 7 (with Boot 4) removed the legacy authorizeRequests/and() DSL and moved the old AccessDecisionManager API into a separate legacy module.
Security Filter Chain¶
// Servlet container
// → DelegatingFilterProxy ("springSecurityFilterChain")
// → FilterChainProxy: picks the FIRST SecurityFilterChain whose matcher matches
// → that chain's filters, in a fixed order (simplified):
// 1. SecurityContextHolderFilter (replaced SecurityContextPersistenceFilter, removed in 6.0)
// - Lazily loads the SecurityContext from the SecurityContextRepository
// (HTTP session by default; nothing for stateless APIs)
// - Does NOT save it automatically any more: authentication mechanisms save
// explicitly (SecurityContextRepository.saveContext) when they authenticate
// - Clears the thread-local after the request
// 2. CsrfFilter: for state-changing requests, checks the CSRF token
// (cookie/session-based apps; usually disabled for pure bearer-token APIs)
// 3. LogoutFilter
// 4. Authentication filters, e.g.:
// - BearerTokenAuthenticationFilter (OAuth2 resource server): extracts the JWT,
// AuthenticationManager → JwtAuthenticationProvider validates it
// - UsernamePasswordAuthenticationFilter (form login POST /login)
// - BasicAuthenticationFilter
// On success: SecurityContextHolder.getContext().setAuthentication(...)
// 5. ExceptionTranslationFilter
// - AuthenticationException, or AccessDeniedException for an anonymous user
// → AuthenticationEntryPoint (401 / redirect to login)
// - AccessDeniedException for an authenticated user → AccessDeniedHandler (403)
// 6. AuthorizationFilter (replaced FilterSecurityInterceptor)
// - Asks the AuthorizationManager built from authorizeHttpRequests(...) rules
// - Denied → AccessDeniedException → handled by step 5
A request enters Spring Security through FilterChainProxy, which picks the first matching chain; authentication filters populate the SecurityContext, and authorization failures are translated to 401 or 403.
flowchart TD
R["HTTP request"] --> D["DelegatingFilterProxy, then FilterChainProxy: first matching SecurityFilterChain"]
D --> S["SecurityContextHolderFilter: load context lazily"]
S --> CS["CsrfFilter, LogoutFilter"]
CS --> AU["Authentication filter (Bearer, form login, Basic) via AuthenticationManager"]
AU --> ET["ExceptionTranslationFilter"]
ET --> AZ["AuthorizationFilter: AuthorizationManager decision"]
AZ -->|allowed| APP["Continue to DispatcherServlet"]
AZ -->|denied| EX["AccessDeniedException"]
EX --> ET
ET -->|"not authenticated"| E401["AuthenticationEntryPoint: 401 or login redirect"]
ET -->|"authenticated but denied"| E403["AccessDeniedHandler: 403"]
Authentication Flow¶
// AuthenticationManager (usually ProviderManager) tries each AuthenticationProvider
// that supports the token type:
// - DaoAuthenticationProvider: UserDetailsService + PasswordEncoder
// - JwtAuthenticationProvider: JwtDecoder (signature, exp/nbf, issuer, audience)
// + a converter from claims to GrantedAuthorities
// - your own provider
// Result: an authenticated Authentication (principal + authorities) stored in the
// SecurityContext, which is held in SecurityContextHolder (a ThreadLocal by default).
//
// Authorization:
// - URL rules → AuthorizationFilter → AuthorizationManager
// (e.g. AuthorityAuthorizationManager for hasRole/hasAuthority)
// - Method security: @EnableMethodSecurity + @PreAuthorize("hasRole('ADMIN')")
// or @PreAuthorize("@guard.canEdit(#id, authentication)"), implemented with
// AOP interceptors and AuthorizationManagers
// - The legacy AccessDecisionManager/AccessDecisionVoter API (and
// @EnableGlobalMethodSecurity) lives in the separate spring-security-access
// module in Security 7, for migration only.
//
// Threads: the SecurityContext is thread-bound, so it doesn't follow @Async or
// executor tasks unless you propagate it (DelegatingSecurityContextExecutor, or
// Micrometer context propagation).
Security Configuration (Java Config)¶
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())))
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.csrf(csrf -> csrf.disable()) // stateless bearer-token API: no cookies to forge
.exceptionHandling(ex -> ex
.authenticationEntryPoint((req, resp, authEx) ->
resp.sendError(HttpServletResponse.SC_UNAUTHORIZED))
.accessDeniedHandler((req, resp, deniedEx) ->
resp.sendError(HttpServletResponse.SC_FORBIDDEN)));
return http.build();
}
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter authorities = new JwtGrantedAuthoritiesConverter();
authorities.setAuthoritiesClaimName("roles");
authorities.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(authorities);
return converter;
}
@Bean
public PasswordEncoder passwordEncoder() {
// Stores "{bcrypt}..." style hashes, so the algorithm can be upgraded later
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
// The JwtDecoder is auto-configured by Spring Boot from
// spring.security.oauth2.resourceserver.jwt.issuer-uri=https://auth.example.com/
// (discovers the JWKS endpoint and validates the issuer). Define your own
// JwtDecoder bean only to customize validation (audience, clock skew).
8. Spring MVC — Request Processing¶
Request Lifecycle¶
HTTP Request
│
▼
Tomcat connector: acceptor/poller threads → request handed to a worker thread
(a platform thread from the pool, server.tomcat.threads.max = 200 by default,
or a new virtual thread per request with spring.threads.virtual.enabled=true)
│
▼
Servlet filter chain (ordered), e.g. in a Boot app:
├── CharacterEncodingFilter
├── ServerHttpObservationFilter (metrics + tracing for http.server.requests)
├── FormContentFilter
├── RequestContextFilter
└── DelegatingFilterProxy → Spring Security's FilterChainProxy
│
▼
DispatcherServlet (front controller)
├── 1. Multipart check (MultipartResolver)
├── 2. LocaleResolver
├── 3. HandlerMapping: find the handler + its interceptors
│ ├── RequestMappingHandlerMapping (@RequestMapping, incl. API version
│ │ conditions in Framework 7)
│ ├── RouterFunctionMapping (functional endpoints)
│ └── SimpleUrlHandlerMapping (static resources, explicit mappings)
├── 4. HandlerInterceptor.preHandle() for each interceptor (can short-circuit)
├── 5. HandlerAdapter invokes the handler
│ └── RequestMappingHandlerAdapter
│ ├── resolve arguments (HandlerMethodArgumentResolver):
│ │ @PathVariable, @RequestParam, @RequestBody (HttpMessageConverter),
│ │ @Valid validation, Principal, custom resolvers
│ ├── invoke the controller method
│ └── handle the return value (HandlerMethodReturnValueHandler):
│ @ResponseBody / ResponseEntity → HttpMessageConverter (Jackson),
│ view name / ModelAndView → view rendering
├── 6. HandlerInterceptor.postHandle() (not called if the handler threw)
├── 7. Exceptions → HandlerExceptionResolvers:
│ ExceptionHandlerExceptionResolver (@ExceptionHandler/@ControllerAdvice),
│ ResponseStatusExceptionResolver, DefaultHandlerExceptionResolver;
│ ProblemDetail (RFC 9457) bodies via spring.mvc.problemdetails.enabled
├── 8. View rendering, if any (ViewResolver)
└── 9. HandlerInterceptor.afterCompletion() (always, even on exceptions)
(Theme support, ThemeResolver, was removed in Framework 7. HiddenHttpMethodFilter
is off by default in Boot since 2.2.)
Spring MVC request path: servlet filters, then DispatcherServlet finds a handler, runs interceptors and the handler method, and afterCompletion always runs.
sequenceDiagram
participant C as Client
participant F as Servlet filters
participant D as DispatcherServlet
participant H as HandlerMapping
participant I as Interceptors
participant A as HandlerAdapter and controller
C->>F: HTTP request on a worker thread
F->>D: through filter chain (incl. Security)
D->>H: find handler and interceptors
D->>I: preHandle
D->>A: resolve arguments, invoke controller method
A-->>D: return value via HttpMessageConverter or view
D->>I: postHandle (skipped if handler threw)
Note over D: exceptions go to HandlerExceptionResolvers
D->>I: afterCompletion (always)
D-->>C: response
HandlerMethodArgumentResolver — Custom¶
public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(CurrentUser.class)
&& parameter.getParameterType().equals(UserPrincipal.class);
}
@Override
public Object resolveArgument(MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !(auth.getPrincipal() instanceof UserPrincipal user)) {
return null;
}
return user;
}
}
// Registering it: being a @Component is NOT enough; add it to MVC config.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(new CurrentUserArgumentResolver());
}
}
@GetMapping("/profile")
public UserProfile profile(@CurrentUser UserPrincipal user) {
return userService.getProfile(user.id());
}
// Spring Security already ships this: @AuthenticationPrincipal UserPrincipal user.
API Versioning (Framework 7)¶
// application.properties (Boot 4): read the version from a request header
// spring.mvc.apiversion.use.header=API-Version
// spring.mvc.apiversion.default=1.0
@RestController
@RequestMapping("/orders")
public class OrderController {
@GetMapping(path = "/{id}", version = "1.0")
public OrderV1 getV1(@PathVariable long id) { /* ... */ return null; }
@GetMapping(path = "/{id}", version = "2.0+") // 2.0 and later
public OrderV2 getV2(@PathVariable long id) { /* ... */ return null; }
}
// Versions can also come from a query parameter, a path segment or the media type;
// deprecated versions can be advertised with Deprecation/Sunset headers.
9. Spring Boot Actuator & Observability¶
30-second answer
Actuator exposes operational endpoints (health, metrics, info, loggers...). Only health is exposed over HTTP by default; expose more deliberately and secure them. Instrumentation is built on the Micrometer Observation API: one instrumentation point produces metrics (Micrometer, e.g. Prometheus) and traces (Micrometer Tracing with an OpenTelemetry or Brave bridge). Boot 4 adds spring-boot-starter-opentelemetry for OTLP export of metrics and traces. Use Kubernetes liveness/readiness health groups, and keep liveness free of dependency checks.
Actuator Endpoints¶
// Exposure: only /actuator/health over HTTP by default (Boot 2.5+). Others via
// management.endpoints.web.exposure.include=health,info,prometheus,metrics
// Serve them on a separate port if possible: management.server.port=8081
//
// /actuator/health — health (groups: /health/liveness, /health/readiness)
// /actuator/info — build/git/app info
// /actuator/metrics — browse Micrometer meters
// /actuator/prometheus — Prometheus scrape format
// /actuator/env — environment (values masked by default; sensitive)
// /actuator/configprops — @ConfigurationProperties (masked by default)
// /actuator/beans — beans in the context
// /actuator/conditions — auto-configuration report
// /actuator/loggers — view and CHANGE log levels at runtime
// /actuator/threaddump — thread dump
// /actuator/heapdump — heap dump (large and full of secrets: never public)
// /actuator/httpexchanges — recent HTTP exchanges (needs an HttpExchangeRepository
// bean; was /httptrace before Boot 3)
// /actuator/startup — startup steps (with BufferingApplicationStartup)
// /actuator/sbom — software bill of materials (Boot 3.3+)
Custom Health Indicator¶
@Component
public class PaymentGatewayHealthIndicator implements HealthIndicator {
private final PaymentGatewayClient client;
public PaymentGatewayHealthIndicator(PaymentGatewayClient client) {
this.client = client;
}
@Override
public Health health() {
try {
Duration latency = client.ping(Duration.ofMillis(500)); // short timeout
return Health.up().withDetail("latencyMs", latency.toMillis()).build();
} catch (Exception e) {
return Health.down(e).build();
}
}
}
// Boot already provides indicators for DataSources, Redis, Kafka, disk space, etc.
//
// Kubernetes:
// - management.endpoint.health.probes.enabled=true (automatic on Kubernetes)
// gives /actuator/health/liveness and /actuator/health/readiness
// - LIVENESS should reflect only the app's own state: a failing liveness probe
// RESTARTS the pod, so a database outage in liveness turns into a restart storm
// - Put downstream dependencies in READINESS (or nowhere), deliberately:
// management.endpoint.health.group.readiness.include=readinessState,db
// - Health checks run on every probe: keep them cheap and time-bounded
Micrometer, Tracing & OpenTelemetry¶
// Boot auto-instruments: HTTP server/client requests (http.server.requests,
// http.client.requests), JVM (memory, GC, threads), HikariCP, Tomcat, Kafka,
// executors, caches... so you rarely need a hand-written request counter.
@Service
public class ItemService {
private final ItemRepository repository;
private final Counter importedItems;
public ItemService(ItemRepository repository, MeterRegistry registry) {
this.repository = repository;
this.importedItems = Counter.builder("items.imported")
.description("Items imported from the catalog feed")
.tag("source", "catalog") // LOW-cardinality tags only
.register(registry);
}
// One annotation → a timer metric AND a trace span (needs the
// ObservedAspect bean / spring-boot-starter-aspectj)
@Observed(name = "items.import", contextualName = "import-items")
public void importItems(List<Item> items) {
repository.saveAll(items);
importedItems.increment(items.size());
}
}
// Latency percentiles: prefer histograms that the backend aggregates
// management.metrics.distribution.percentiles-histogram.http.server.requests=true
// over client-side publishPercentiles(...), which can't be aggregated across instances.
// Never use user IDs, raw URLs or other unbounded values as tag values: each distinct
// value is a new time series (memory leak in the app and cost in the backend).
// Tracing:
// - Micrometer Tracing with the OpenTelemetry bridge (or Brave) propagates W3C
// traceparent headers through RestClient/WebClient/Kafka and puts traceId/spanId
// into the logging MDC
// - management.tracing.sampling.probability=0.1 (default 0.1)
// - Boot 4: spring-boot-starter-opentelemetry brings OTLP export for metrics and
// traces (endpoints configured under management.otlp.* / management.opentelemetry.*;
// check the property names for your Boot version, they moved between 3.x and 4.x)
// - Context propagation across threads (@Async, executors, Reactor) uses the
// Micrometer context-propagation library
//
// Logs: structured JSON logging is built in since Boot 3.4:
// logging.structured.format.console=ecs (or logstash, gelf)
10. Testing Strategies¶
30-second answer
Most tests should be plain unit tests (constructor injection makes them trivial). Use slices (@WebMvcTest, @DataJpaTest, @JsonTest...) to test one layer with a minimal context, and a few @SpringBootTests with real dependencies in Testcontainers for integration. Replace beans with @MockitoBean (Boot 4 removed @MockBean). Keep the number of distinct test contexts small, because Spring caches and reuses identical contexts and every new combination costs a full startup.
Unit Testing with Mockito¶
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private EmailService emailService;
@InjectMocks
private UserService userService; // or simply: new UserService(userRepository, emailService)
@Test
void createUser_savesAndSendsEmail() {
User user = new User("test@example.com", "Test");
when(userRepository.save(any(User.class))).thenReturn(user);
User result = userService.createUser(user);
assertThat(result).isEqualTo(user);
verify(userRepository).save(user);
verify(emailService).sendWelcomeEmail("test@example.com");
}
@Test
void createUser_duplicateEmail_throws() {
when(userRepository.existsByEmail("existing@example.com")).thenReturn(true);
assertThatThrownBy(() -> userService.createUser(new User("existing@example.com", "Test")))
.isInstanceOf(DuplicateEmailException.class);
verify(userRepository, never()).save(any());
}
}
Slice and Integration Testing¶
// Web slice: controllers, advice, converters, MockMvc; no services/repositories
@WebMvcTest(UserController.class)
class UserControllerTest {
@Autowired MockMvc mockMvc; // or MockMvcTester (AssertJ-style, Boot 3.4+)
@MockitoBean UserService userService; // Framework 6.2+; replaces Boot's @MockBean
@Test
void getUser_returnsJson() throws Exception {
when(userService.find(1L)).thenReturn(new UserDto(1L, "alice@example.com"));
mockMvc.perform(get("/api/users/1"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.email").value("alice@example.com"));
}
}
// In Boot 4 the slice annotations live in per-technology test modules
// (e.g. spring-boot-webmvc-test); spring-boot-starter-test-classic brings the
// old all-in-one set back during migration.
// Full context with a real server and a real HTTP client:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient // Boot 4 / Framework 7 RestTestClient
class UserApiIT {
@Autowired RestTestClient client;
@Test
void createUser_returnsCreated() {
client.post().uri("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.body("""
{"email": "new@example.com", "name": "New User"}
""")
.exchange()
.expectStatus().isCreated();
}
}
// (MockMvc needs no real port: use the default MOCK web environment with
// @AutoConfigureMockMvc; RANDOM_PORT is for tests over real HTTP.)
Database Testing with Testcontainers¶
@SpringBootTest
@Testcontainers
class UserRepositoryIT {
// @ServiceConnection (Boot 3.1+) derives spring.datasource.* from the container:
// no @DynamicPropertySource boilerplate.
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:17-alpine");
@Autowired
UserRepository userRepository;
@Test
void findByEmail_findsSavedUser() {
userRepository.save(new User("test@example.com", "Test"));
assertThat(userRepository.findByEmail("test@example.com"))
.get()
.extracting(User::getName)
.isEqualTo("Test");
}
}
// The same container definitions can power local development:
// SpringApplication.from(App::main).with(TestcontainersConfig.class).run(args),
// or Docker Compose support (spring-boot-docker-compose) for `./mvnw spring-boot:run`.
//
// Use @DataJpaTest + @AutoConfigureTestDatabase(replace = NONE) for a JPA slice
// against the real database instead of an in-memory H2 that behaves differently.
11. Production Patterns & Pitfalls¶
Graceful Shutdown¶
// Since Spring Boot 3.4, graceful shutdown is the DEFAULT for all embedded servers:
// server.shutdown=graceful (default; "immediate" to opt out)
// spring.lifecycle.timeout-per-shutdown-phase=30s (default 30s)
//
// On SIGTERM: the server stops accepting new requests, in-flight requests get up
// to the timeout to finish, then the context closes (@PreDestroy, pools closed).
//
// On Kubernetes, also:
// - flip readiness to OUT_OF_SERVICE first (Boot does this on shutdown) and let
// endpoints update: a short preStop sleep (e.g. 5-10 s) avoids traffic arriving
// after the server stopped listening
// - terminationGracePeriodSeconds > preStop + shutdown timeout
// - make consumers (Kafka, queues) stop polling and commit/ack before exit
Common Production Pitfalls¶
// ═══════════════════════════════════════════════════════════════
// PITFALL 1: @ComponentScan replacing the default scan
// ═══════════════════════════════════════════════════════════════
@SpringBootApplication
@ComponentScan("com.otherpackage") // REPLACES the default scan of this package
public class App {}
// Fix: put the main class in a root package above everything; to add packages use
// @SpringBootApplication(scanBasePackages = {"com.myapp", "com.otherpackage"}),
// or better, @Import the configuration you need.
// ═══════════════════════════════════════════════════════════════
// PITFALL 2: Scattered @Value and silent defaults
// ═══════════════════════════════════════════════════════════════
@Value("${myapp.api.key:default-key}") // a "default" secret hides misconfiguration
private String apiKey;
// Failing at startup when a required property is missing is a FEATURE.
// Prefer validated, typed configuration:
@ConfigurationProperties("myapp.api")
@Validated
public record ApiProperties(@NotBlank String key, @DefaultValue("5s") Duration timeout) {}
// ═══════════════════════════════════════════════════════════════
// PITFALL 3: @Async and transactions
// ═══════════════════════════════════════════════════════════════
@Service
public class OrderService {
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order);
notifier.sendConfirmation(order.getId()); // @Async method on another bean
// The async method runs on another thread, OUTSIDE this transaction. It may
// run BEFORE this transaction commits, so it may not see the new order,
// and it still runs if this transaction later rolls back.
}
}
// Fix: publish an event and handle it after commit:
// applicationEventPublisher.publishEvent(new OrderPlaced(order.getId()));
// @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) @Async
// void on(OrderPlaced e) { ... }
// (@Async + @Transactional on the SAME method does work: it starts a new
// transaction on the async thread. What never happens is the CALLER's
// transaction propagating across threads.)
// Also configure the async executor: by default Boot's applicationTaskExecutor
// (ThreadPoolTaskExecutor, or virtual threads when enabled) is used.
// ═══════════════════════════════════════════════════════════════
// PITFALL 4: Open Session In View (on by default)
// ═══════════════════════════════════════════════════════════════
// spring.jpa.open-in-view=true (default) keeps the EntityManager open for the whole
// request so lazy loading "works" in controllers and serializers:
// - N+1 queries hide outside the service layer
// - a connection can be held while the response renders
// Fix: spring.jpa.open-in-view=false and fetch what each use case needs.
// ═══════════════════════════════════════════════════════════════
// PITFALL 5: @Cacheable on self-invocation
// ═══════════════════════════════════════════════════════════════
@Service
public class ProductService {
@Cacheable("products")
public Product getProduct(Long id) {
return productRepository.findById(id).orElseThrow();
}
public Product getProductWithDiscount(Long id) {
Product product = getProduct(id); // this.getProduct(): proxy bypassed, no cache
return applyDiscount(product);
}
}
// Fix: call it through another bean (or the proxy), or use the CacheManager directly.
// Also: configure a real cache (Caffeine with size + TTL); the default
// ConcurrentMapCacheManager never evicts.
// ═══════════════════════════════════════════════════════════════
// PITFALL 6: Virtual threads turned on without limits
// ═══════════════════════════════════════════════════════════════
// spring.threads.virtual.enabled=true (Java 21+) removes Tomcat's 200-thread cap.
// The bottleneck moves to the connection pool and downstream services: size
// Hikari deliberately, set timeouts everywhere, and add concurrency limits
// (Framework 7's @ConcurrencyLimit, a Semaphore, or a bulkhead) for fragile
// dependencies. Use Java 24+ (JEP 491) to avoid pinning in synchronized code of
// older libraries, or check JFR jdk.VirtualThreadPinned on Java 21.
// ═══════════════════════════════════════════════════════════════
// PITFALL 7: HTTP clients without timeouts
// ═══════════════════════════════════════════════════════════════
// Configure connect/read timeouts on every RestClient/WebClient/HTTP interface
// client (spring.http.client.* properties / ClientHttpRequestFactorySettings).
// A slow dependency without timeouts exhausts threads or connections and takes
// the whole service down. Use RestClient for new blocking code; RestTemplate is
// in maintenance and slated for deprecation in Framework 7.1.
Native Images and Startup¶
// GraalVM native image (Boot 3.0+, GraalVM 25+ for Boot 4):
// ./mvnw -Pnative native:compile or ./gradlew nativeCompile
// or build a container: ./mvnw -Pnative spring-boot:build-image
// Spring AOT runs at build time: evaluates conditions, generates bean definitions
// as code, and emits reachability metadata (reflection, proxies, resources).
// + startup in tens of milliseconds, low memory: good for scale-to-zero and CLIs
// - long builds, no JIT (lower peak throughput unless using PGO), closed world:
// classpath fixed at build time, @Profile/@ConditionalOnProperty decisions that
// change bean STRUCTURE can't flip at runtime, reflection needs hints
// (RuntimeHintsRegistrar, @RegisterReflectionForBinding)
//
// Staying on the JVM but starting faster:
// - CDS (Boot 3.3+ documents the training-run workflow)
// - JDK AOT cache (Java 24+; JEP 483/514/515): java -XX:AOTCacheOutput=app.aot ...
// - Spring AOT on the JVM: -Dspring.aot.enabled=true with AOT-processed classes
// - spring.main.lazy-initialization=true (startup only; shifts cost and errors to
// the first request)
12. Spring Boot Interview Questions¶
Beginner¶
Q1: What is Dependency Injection? Why does Spring prefer constructor injection?
**Answer:** Dependency Injection means an object receives its collaborators from outside instead of creating or looking them up itself, so the container (or a test) decides which implementations to use. Spring recommends constructor injection because: 1. **Immutability**: dependencies can be `final` 2. **Testability**: `new Service(mockA, mockB)` with no container or reflection 3. **Completeness**: the object can't exist without its required dependencies; missing beans fail at startup 4. **Cycles fail fast**: a constructor cycle can't be resolved, so it's reported at startup instead of producing half-initialized beans 5. **Visibility**: a long constructor makes "this class does too much" obviousQ2: What is the difference between @Component, @Service, @Repository, and @Controller?
**Answer:** All are `@Component` specializations, picked up by component scanning: - **@Component**: generic Spring-managed bean - **@Service**: business/service layer; no extra behaviour, it documents intent (and can be targeted by pointcuts) - **@Repository**: data access layer; enables **exception translation** (via `PersistenceExceptionTranslationPostProcessor`) from technology-specific exceptions to Spring's `DataAccessException` hierarchy. Spring Data repository interfaces get this without the annotation. - **@Controller**: web layer; detected by `RequestMappingHandlerMapping` as a handler. `@RestController` = `@Controller` + `@ResponseBody`.Q3: What is Spring Boot auto-configuration?
**Answer:** Auto-configuration registers beans based on: 1. **Classpath**: e.g. a JDBC driver and HikariCP present → configure a `DataSource` 2. **Existing beans**: if you defined a `DataSource`, Boot backs off (`@ConditionalOnMissingBean`) 3. **Properties**: `spring.datasource.*` customize it 4. **Conditions**: `@ConditionalOnClass`, `@ConditionalOnProperty`, etc. Candidates are listed in `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` files, processed after user configuration, filtered by conditions. Exclude with `@SpringBootApplication(exclude = ...)` or `spring.autoconfigure.exclude`; inspect with `--debug` or `/actuator/conditions`.Intermediate¶
Q4: How does Spring resolve circular dependencies?
**Answer:** First: **Spring Boot forbids circular references by default since 2.6** (`spring.main.allow-circular-references=false`); the startup failure lists the cycle. The mechanism below applies when they're allowed (plain Spring Framework, or that property set to `true`). `DefaultSingletonBeanRegistry` keeps three maps: 1. **`singletonFactories`** (level 3): right after a singleton is instantiated (before population), an `ObjectFactory` for it is registered. 2. **`earlySingletonObjects`** (level 2): when another bean needs it while it's still being created, the factory is called. It calls `getEarlyBeanReference()`, which lets the auto-proxy creator return an **early proxy** if the bean needs one, and the result is cached here. 3. **`singletonObjects`** (level 1): fully initialized beans. **Limitations:** constructor-injection cycles can't be resolved (no reference exists before a constructor returns); prototype-scoped cycles aren't supported; and if a post-processor wraps the bean differently *after* the early reference was handed out (e.g. `@Async`), Spring fails with `BeanCurrentlyInCreationException`. **Fix the design** rather than enabling cycles: extract shared logic into a third bean, use events, or inject lazily (`@Lazy`, `ObjectProvider`).Q5: What is the difference between JDK Dynamic Proxy and CGLIB Proxy?
**Answer:** - **JDK proxy**: `java.lang.reflect.Proxy` implementing the bean's interfaces; only interface methods are advised and the bean can only be injected by interface type. Plain Spring's default when the bean implements an interface. - **CGLIB proxy**: a runtime-generated **subclass** (Spring's repackaged CGLIB in `spring-core`); advises public/protected/package-visible non-final methods; can't proxy final classes or methods. **Spring Boot's default** (`spring.aop.proxy-target-class=true`). Performance differences between them are negligible for application code. The practical differences are injection by concrete type, `final` methods silently not being advised, and (for both) self-invocation bypassing the proxy. Framework 7's `@Proxyable` lets you choose per bean.Q6: How does @Transactional work with AOP?
**Answer:** 1. `@EnableTransactionManagement` (auto-configured by Boot) registers `BeanFactoryTransactionAttributeSourceAdvisor` (pointcut: methods/classes with `@Transactional`; advice: `TransactionInterceptor`). 2. An **auto-proxy creator** (a `BeanPostProcessor`) wraps matching beans in a proxy after initialization. 3. On a call through the proxy, `TransactionInterceptor`: - reads the attributes (propagation, isolation, timeout, readOnly, rollback rules) - asks the `PlatformTransactionManager` to begin, join, suspend or create a savepoint - invokes the method (`invocation.proceed()`) - commits on normal return; rolls back on `RuntimeException`/`Error` (or configured rules), otherwise commits 4. Resources and state are bound to the current thread by `TransactionSynchronizationManager`. **Critical:** only calls through the proxy are transactional (self-invocation isn't), private methods never are, and the transaction doesn't cross threads. Reactive code uses `ReactiveTransactionManager` with the Reactor context instead of thread-locals.Advanced¶
Q7: What happens on Spring Boot startup when `@SpringBootApplication` is encountered?
**Answer:** `SpringApplication.run()`: 1. **Create `SpringApplication`**: deduce the web application type (servlet, reactive, none), load `BootstrapRegistryInitializer`s, `ApplicationContextInitializer`s and `ApplicationListener`s from `META-INF/spring.factories`. 2. **Starting**: `ApplicationStartingEvent`. 3. **Prepare the `Environment`**: property sources (command line, env vars, `application.properties`/`.yml`, profiles, config data imports), `EnvironmentPostProcessor`s; `ApplicationEnvironmentPreparedEvent`. 4. **Banner** (optional). 5. **Create the context**: a `ServletWebServerApplicationContext` variant for servlet apps (reactive equivalent for WebFlux). 6. **Prepare the context**: apply initializers, register the main class as a bean source; `ApplicationContextInitializedEvent`, then `ApplicationPreparedEvent`. 7. **Refresh** (`AbstractApplicationContext.refresh()`): parse configuration (component scan, auto-configuration), register post-processors, `onRefresh()` **creates the embedded web server**, instantiate singletons, `finishRefresh()` starts `SmartLifecycle` beans (the web server starts accepting requests) and publishes `ContextRefreshedEvent`. 8. **Started**: `ApplicationStartedEvent`, liveness = CORRECT. 9. **Runners**: `ApplicationRunner` and `CommandLineRunner` beans execute. 10. **Ready**: `ApplicationReadyEvent`, readiness = ACCEPTING_TRAFFIC. If anything fails, `ApplicationFailedEvent` is published and `FailureAnalyzer`s print a readable explanation.Q8: Design a multi-tenant SaaS application with Spring Boot. How do you isolate tenant data?
**Answer:** Resolve the tenant once per request (subdomain, header, or a JWT claim), store it in a request-scoped context (a `ScopedValue` on Java 25, or a `ThreadLocal` cleared in `finally`), and choose an isolation model: 1. **Database per tenant**: strongest isolation, per-tenant backup/restore and scaling, noisy neighbours contained. Cost: many connection pools and databases to migrate. Route with `AbstractRoutingDataSource`: 2. **Schema per tenant**: one database, a schema per tenant. Use Hibernate's multi-tenancy (`MultiTenantConnectionProvider` + `CurrentTenantIdentifierResolver`) to set the schema on a shared pool's connections (`SET search_path` in PostgreSQL). Migrations run per schema (Flyway/Liquibase per tenant), which gets slow with thousands of tenants. 3. **Shared tables with a `tenant_id` column**: cheapest and simplest to operate at scale; weakest isolation (one missing filter leaks data). Enforce it in more than one place: (`@Where` can't take parameters and is deprecated in favour of `@SQLRestriction`; for dynamic filters use `@TenantId` or `@Filter`.) Many SaaS products mix them: shared tables for the long tail of small tenants, dedicated databases for large or regulated ones. Whatever you choose, include the tenant in cache keys, metrics/log context and async messages, and test cross-tenant access explicitly.Q9: How would you optimize a Spring Boot application that's too slow to start?
**Answer:** Measure first: `/actuator/startup` (with `BufferingApplicationStartup`) or JFR shows which beans and steps are slow. Then: 1. **Remove work**: unused starters and auto-configurations (fewer beans, fewer conditions), narrow component scanning, no eager network calls or cache warming in `@PostConstruct` (move them to `ApplicationReadyEvent` or make them async). 2. **Background initialization** of slow, independent beans: Framework 6.2+ `@Bean(bootstrap = Bean.Bootstrap.BACKGROUND)` with a bootstrap executor. 3. **Lazy initialization**: `spring.main.lazy-initialization=true` speeds startup but moves cost and configuration errors to the first request; acceptable in dev, risky in prod. 4. **JVM-level**: CDS (Boot 3.3+) or the JDK AOT cache (Java 24+) cut class-loading time without code changes; Spring AOT on the JVM precomputes bean definitions. 5. **GraalVM native image** when startup and memory matter most (serverless, scale to zero), accepting longer builds and closed-world constraints. 6. **Don't rely on** `spring-context-indexer`: it's deprecated since Framework 6.1. Also check for slow `BeanPostProcessor`s, Hibernate schema validation/DDL on startup, and Flyway migrations running at boot.Q10: Design a rate-limiting system for a Spring Boot REST API. Could you implement it as a custom filter or interceptor?
**Answer:** Decide *where* first. A gateway or service mesh (or a CDN/WAF) is usually the right place for coarse per-client limits. In the app, use a **servlet filter** if you need to reject requests before authentication, deserialization or any MVC work, or a **`HandlerInterceptor`** if limits depend on the resolved handler or the authenticated user (it runs after Spring Security's filters). The limiter state must be **shared across instances** (Redis), and the check must be **atomic**.@Component
public class RateLimitingInterceptor implements HandlerInterceptor {
private final RateLimiter rateLimiter; // e.g. Bucket4j with a Redis backend
public RateLimitingInterceptor(RateLimiter rateLimiter) {
this.rateLimiter = rateLimiter;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {
String key = clientKey(request) + ":" + request.getMethod() + ":" + routeOf(request);
RateLimitResult result = rateLimiter.tryConsume(key);
response.setHeader("RateLimit-Remaining", String.valueOf(result.remaining()));
if (!result.allowed()) {
response.setStatus(429);
response.setHeader("Retry-After", String.valueOf(result.retryAfterSeconds()));
response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
response.getWriter().write("""
{"title": "Too Many Requests", "status": 429}
""");
return false;
}
return true;
}
private String clientKey(HttpServletRequest request) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated() && !(auth instanceof AnonymousAuthenticationToken)) {
return "user:" + auth.getName();
}
// Behind a load balancer use the forwarded client IP (server.forward-headers-strategy),
// and only trust X-Forwarded-For from your own proxies.
return "ip:" + request.getRemoteAddr();
}
private String routeOf(HttpServletRequest request) {
// Use the matched pattern (/orders/{id}), not the raw URI, or every id gets its own bucket
Object pattern = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
return pattern != null ? pattern.toString() : request.getRequestURI();
}
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final RateLimitingInterceptor rateLimitingInterceptor;
public WebConfig(RateLimitingInterceptor rateLimitingInterceptor) {
this.rateLimitingInterceptor = rateLimitingInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(rateLimitingInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns("/api/public/**");
}
}
-- KEYS[1] = bucket key; ARGV = now_ms, window_ms, limit, unique_request_id
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1] - ARGV[2])
if redis.call('ZCARD', KEYS[1]) >= tonumber(ARGV[3]) then
return 0
end
redis.call('ZADD', KEYS[1], ARGV[1], ARGV[4])
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return 1
Quick Reference: Spring Boot at a Glance¶
| Concept | Key Point |
|---|---|
| Versions (Oct 2026) | Boot 4.1 / 4.0 on Framework 7.0; Java 17+ (25 recommended); Jakarta EE 11; Boot 3.5 OSS support ended June 2026 |
| IoC container | Bean definitions → BFPPs → BPPs → eager singletons at refresh() |
| Bean lifecycle | instantiate → populate → aware → BPP before (incl. @PostConstruct) → init → BPP after (proxies) → ready → destroy |
| Auto-configuration | AutoConfiguration.imports + conditions, processed after user config; --debug to see why |
| AOP proxy | CGLIB by default in Boot; JDK proxies in plain Spring for interface beans; self-invocation bypasses both |
| @Transactional | Thread-bound; default REQUIRED; rollback on unchecked exceptions; REQUIRES_NEW = second connection |
| Circular dependencies | Forbidden by default since Boot 2.6; redesign instead of re-enabling |
| OSIV | On by default; turn it off and fetch explicitly |
| Security | FilterChainProxy → SecurityFilterChain; AuthorizationFilter + AuthorizationManager; lambda DSL only in Security 7 |
| HTTP clients | RestClient, HTTP interface clients (@HttpExchange, @ImportHttpServices); RestTemplate heading for deprecation |
| Observability | Micrometer Observation → metrics + traces; OTLP via spring-boot-starter-opentelemetry (Boot 4); structured logging (3.4+) |
| Virtual threads | spring.threads.virtual.enabled=true; add explicit concurrency limits |
| Testing | Unit tests + slices + Testcontainers with @ServiceConnection; @MockitoBean replaces @MockBean |
| Startup | CDS / JDK AOT cache on the JVM; GraalVM native image for the fastest start |
Staff/Principal interviews focus on WHY Spring works the way it does: proxies, transaction boundaries, auto-configuration, and how those choices fail in production.