What this post covers
This is the twenty-seventh post in the "Digging into Spring's Source" series, and the last one. Part 26 covered spring-test. This post reads the final unit on the map from Part 1, U25: caching, scheduling, validation and resilience (coping with failure), all of which live inside spring-context.
Section 7 of Part 2 talked about "three pieces that many features repeat": @EnableXxx registers bean definitions through @Import, a BeanPostProcessor steps into bean creation, and a proxy intercepts calls. Back then a single table had to do. This post checks that table against the source, row by row. There are four questions to answer.
- What does one line of
@EnableCaching,@EnableScheduling,@EnableAsyncor@EnableResilientMethodsregister in the container? - When a
@Cacheablemethod is called, in what order do the cache lookup, the method body, the store and the eviction happen? @Scheduledand@Asyncboth mean "runs on another thread", so why does one have no proxy while the other does?- How many times does
@Retryable, new in 7.0, call the method body, and when several features sit on one method, which one ends up outermost?
| Item | Details |
|---|---|
| Source | Spring Framework 7.1.0-SNAPSHOT |
| Commit | f447f3c310 (2026-10-01, main branch) |
| Modules read most | spring-context (cache, scheduling, validation, resilience), spring-aop (aop.framework, aop.interceptor), spring-core (core.retry) |
| Classes read most | CachingConfigurationSelector, ProxyCachingConfiguration, CacheAspectSupport, ScheduledAnnotationBeanPostProcessor, ScheduledTaskRegistrar, AsyncAnnotationBeanPostProcessor, AsyncExecutionInterceptor, AbstractAdvisingBeanPostProcessor, DataBinder, SpringValidatorAdapter, MethodValidationPostProcessor, RetryAnnotationBeanPostProcessor, AbstractRetryInterceptor, RetryTemplate |
Every line number and every detail in this post refers to that commit. In other versions the line numbers will differ and some details may too. Part 1 explains how to get the source at the same commit.
The scope is wide, so for each feature this post looks only at the skeleton (switch → what gets registered → where calls are intercepted) and one core method. What was left out is listed by name and location at the end, under "What this post leaves out".
Words to know first
Proxy and advisor come from earlier posts. A proxy is an object that wraps the original and receives calls first (Part 10). An advisor bundles "where to step in" (a pointcut) with "what to do there" (an advice) (Part 11). An advice that wraps a method call is called an interceptor (MethodInterceptor).
A bean post-processor (BeanPostProcessor, BPP for short) is an extension point the container calls every time it creates a bean. If it returns a different object from the one it received, that object becomes the bean (Part 4 section 3, Part 6).
A cache is a store that writes down a computed result under a key and hands it back, without computing again, when asked with the same key. In Spring, the Cache interface is one such store and CacheManager is the desk that finds a Cache by name.
An executor (Executor) is an object you hand work to; it runs the work on a thread it owns. One that runs work at a given time or interval is called a scheduler (TaskScheduler).
Bean Validation is the Jakarta standard for writing rules about values with constraint annotations such as @NotBlank and @Min(1) (package jakarta.validation). The standard defines only interfaces; a library such as Hibernate Validator does the actual checking. Separately, Spring has its own interface, org.springframework.validation.Validator. That there are two things named Validator matters in section 4.
Resilience is a collective name for mechanisms that help a system hold up when calls fail or pile up. At this commit the org.springframework.resilience package has two: @Retryable, which calls again after a failure, and @ConcurrencyLimit, which caps how many calls may be in progress at once.
1. Four features, one pattern
Here is the conclusion first, as a table. Every class name was checked in the source at this commit, and the lines that back each cell are linked in the sections below (default mode PROXY).
| Feature | Switch | What @Import brings in |
What reads the annotation | What intercepts the call | Post-processor that builds the proxy |
|---|---|---|---|---|---|
@Cacheable and friends |
@EnableCaching |
AutoProxyRegistrar, ProxyCachingConfiguration |
AnnotationCacheOperationSource |
CacheInterceptor |
the auto-proxy creator |
@Async |
@EnableAsync |
ProxyAsyncConfiguration |
the pointcut of AsyncAnnotationAdvisor |
AnnotationAsyncExecutionInterceptor |
AsyncAnnotationBeanPostProcessor |
@Scheduled |
@EnableScheduling |
SchedulingConfiguration |
ScheduledAnnotationBeanPostProcessor |
nothing | none (no proxy is built) |
@Retryable |
@EnableResilientMethods |
ResilientMethodsConfiguration |
RetryAnnotationBeanPostProcessor |
RetryAnnotationInterceptor |
RetryAnnotationBeanPostProcessor |
@ConcurrencyLimit |
@EnableResilientMethods |
ResilientMethodsConfiguration |
ConcurrencyLimitBeanPostProcessor |
ConcurrencyLimitInterceptor |
ConcurrencyLimitBeanPostProcessor |
Method validation (@Validated) |
none: you register the post-processor as a bean yourself | — | MethodValidationPostProcessor |
MethodValidationInterceptor |
MethodValidationPostProcessor |
Add @Transactional from Part 17 as one more row and it has exactly the shape of the caching row (@EnableTransactionManagement → AutoProxyRegistrar + ProxyTransactionManagementConfiguration → AnnotationTransactionAttributeSource → TransactionInterceptor).
Read the table downward and the road to a proxy splits into three paths.
Grey is the switch, blue is what gets registered, green is the result. Paths 1 and 2 end in a proxy; path 3 ends without one.
- Path 1: register an advisor as a bean. Caching and transactions go this way. The configuration class registers only an advisor bean, and the wrapping is done by the auto-proxy creator from Part 11 (
InfrastructureAdvisorAutoProxyCreator). That creator collects advisor beans from the container, checks each bean against them, and wraps the ones that match. - Path 2: register a post-processor that holds an advisor.
@Async,@Retryable,@ConcurrencyLimitand method validation go this way. The post-processor keeps one advisor in a field and builds the proxy itself. All four extendAbstractAdvisingBeanPostProcessorinspring-aop(section 3). - Path 3: build no proxy. That is
@Scheduled. There is no call to intercept; the method only needs to be called at the right time.
In the "three pieces" of Part 2, paths 1 and 2 use all three pieces and path 3 uses only the first two.
Easy to get wrong
- Not every feature has an
@EnableXxx. At this commit no@Importbrings inMethodValidationPostProcessor. In thesrc/maintrees of the modules, the class name appears only in the class itself, in the Javadoc ofMethodValidationInterceptorand@Validated, and in documentation sample code. In plain Spring Framework you have to register this post-processor with@Beanyourself to turn method validation on (the example in section 4 does that). - One
@EnableResilientMethodsregisters two post-processors. Retry and the concurrency limit share a switch but have separate post-processors (ResilientMethodsConfigurationlines 63–77).
2. Caching: from @EnableCaching to CacheAspectSupport.execute
What the switch registers
Open @EnableCaching and you find a single @Import(CachingConfigurationSelector.class) (EnableCaching lines 164–168). It is an ImportSelector from Part 9, a class that "returns the names of what to import next". When mode has its default value PROXY (line 198), these are the names it returns.
private String[] getProxyImports() {
List<String> result = new ArrayList<>(3);
result.add(AutoProxyRegistrar.class.getName());
result.add(ProxyCachingConfiguration.class.getName());
if (JSR_107_PRESENT && JCACHE_IMPL_PRESENT) {
result.add(PROXY_JCACHE_CONFIGURATION_CLASS);
}
return StringUtils.toStringArray(result);
}
The transaction selector of Part 17 returns the same kind of pair: AutoProxyRegistrar plus its own configuration class (ProxyTransactionManagementConfiguration). AutoProxyRegistrar registers the auto-proxy creator (AutoProxyRegistrar lines 72–73), and ProxyCachingConfiguration is a configuration class that registers three beans (lines 38–72).
| Bean | Type | What it does |
|---|---|---|
cacheOperationSource |
AnnotationCacheOperationSource |
reads @Cacheable, @CachePut and @CacheEvict from methods and classes and turns them into a list of CacheOperations |
cacheInterceptor |
CacheInterceptor |
the advice that intercepts a call and looks up and stores in the cache |
cacheAdvisor |
BeanFactoryCacheOperationSourceAdvisor |
the advisor that ties the two together. Its pointcut is "does this method have at least one cache operation?" (CacheOperationSourcePointcut lines 54–57) |
It is the same structure as the transaction side's …AttributeSource + interceptor + advisor, under different names. Keeping the part that reads annotations apart from the part that executes means the interceptor can stay the same even when cache operations are defined some other way than annotations.
This configuration does not create the actual store. After all singletons have been created, CacheInterceptor looks for a bean of type CacheManager in the container, and if there is none it throws an exception telling you to register a CacheManager bean or remove @EnableCaching (CacheAspectSupport lines 260–292). CacheManager is an interface whose core is getCache(String name), which finds a Cache by name (CacheManager line 43), and Cache is a store with get, put, evict and clear (from Cache line 43). The ConcurrentMapCache used by this post's example is the simplest implementation: it wraps one ConcurrentMap (lines 55–59).
At call time: the order execute follows
When a method on the proxy is called, CacheInterceptor.invoke receives it. All it does is wrap "go on to the next one" (invocation.proceed()) in a lambda and pass it to execute in its parent class (lines 48–69). The parent, CacheAspectSupport, gets this method's cache operations from the CacheOperationSource (lines 381–396) and enters the following method.
private @Nullable Object execute(CacheOperationInvoker invoker, Method method, CacheOperationContexts contexts) {
if (contexts.isSynchronized()) {
// Special handling of synchronized invocation
return executeSynchronized(invoker, method, contexts);
}
// Process any early evictions
processCacheEvicts(contexts.get(CacheEvictOperation.class), true,
CacheOperationExpressionEvaluator.NO_RESULT);
// Check if we have a cached value matching the conditions
Object cacheHit = findCachedValue(invoker, method, contexts);
if (cacheHit == null || cacheHit instanceof Cache.ValueWrapper) {
return evaluate(cacheHit, invoker, method, contexts);
}
return cacheHit;
}
The evaluate method that follows decides the rest of the order.
if (cacheHit != null && !hasCachePut(contexts)) {
// If there are no put requests, just use the cache hit
cacheValue = unwrapCacheValue(cacheHit);
returnValue = wrapCacheValue(method, cacheValue);
}
else {
// Invoke the method if we don't have a cache hit
returnValue = invokeOperation(invoker);
cacheValue = unwrapReturnValue(returnValue);
}
// Collect puts from any @Cacheable miss, if no cached value is found
List<CachePutRequest> cachePutRequests = new ArrayList<>(1);
if (cacheHit == null) {
collectPutRequests(contexts.get(CacheableOperation.class), cacheValue, cachePutRequests);
}
// Collect any explicit @CachePuts
collectPutRequests(contexts.get(CachePutOperation.class), cacheValue, cachePutRequests);
// Process any collected put requests, either from @CachePut or a @Cacheable miss
for (CachePutRequest cachePutRequest : cachePutRequests) {
Object returnOverride = cachePutRequest.apply(cacheValue);
// ...
}
// Process any late evictions
Object returnOverride = processCacheEvicts(
contexts.get(CacheEvictOperation.class), false, returnValue);
Yellow is where the cache is read and written; green is where the method body is skipped.
- Early eviction. Only
@CacheEvict(beforeInvocation = true)operations are evicted now. The filter isbeforeInvocation == evict.isBeforeInvocation()(lines 648–650). - Lookup. For each
@Cacheableit evaluatescondition; if that passes it builds the key and looks in the cache (lines 494–513). - Method body. If a value was found and there is no
@CachePut, the cached value is used and the body is not called. OtherwiseinvokeOperation(invoker)runs that lambda from before, which is the next step in the chain (in the end, the original method). - Store. The store for a
@Cacheablemiss and the store for@CachePutare collected and run together.unlessis evaluated here, after the result exists (canPutToCache, lines 914–927, called from line 1041). - Late eviction. The default for
beforeInvocationisfalse(CacheEvictline 161), so an ordinary@CacheEvictevicts after the body has finished without an exception. If the body throws, step 5 is never reached.
The key is computed with SpEL if the key attribute holds an expression, and left to a KeyGenerator otherwise (lines 932–941). The default generator, SimpleKeyGenerator, has short rules: no arguments gives SimpleKey.EMPTY; one argument that is neither null nor an array gives that argument itself; anything else gives a SimpleKey holding all the arguments (lines 54–65). The expression side is the SpEL of Part 15, unchanged. An expression can refer to an argument by name, as in #item, because the caching context CacheEvaluationContext extends MethodBasedEvaluationContext (line 46), and that parent sets a variable for each argument under a0, p0 and the parameter name (lines 84–111). So that the expression is not parsed again on every call, CachedExpressionEvaluator keeps the parsed result (an Expression) in a map keyed by "which expression on which method" (CachedExpressionEvaluator lines 82–87). The caching subclass keeps a separate map each for key, condition and unless (CacheOperationExpressionEvaluator lines 104–116).
sync = true takes a different road
There is a gap between steps 2 and 4 above. If two threads arrive with the same key at the same time, both are told "not there" and both call the body. @Cacheable(sync = true) (Cacheable line 211) hands lookup and store to a single Cache.get(key, valueLoader) so that the store itself closes the gap (executeSynchronized, lines 431–486; the core is line 472). In ConcurrentMapCache that is ConcurrentMap.computeIfAbsent (lines 146–155).
It comes with restrictions. It cannot be combined with other cache operations, there may be only one @Cacheable with sync = true, there may be only one cache, and unless is not allowed. Break any one of them and you get an IllegalStateException (determineSyncFlag, lines 769–803).
Example 1: call it twice, how many times does the body run?
// An example written for this post (not Spring source code)
@Configuration
@EnableCaching
static class Config {
@Bean
CacheManager cacheManager() {
return new ConcurrentMapCacheManager("prices");
}
@Bean
PriceService priceService() {
return new PriceService();
}
}
public static class PriceService {
@Cacheable("prices")
public int price(String item) {
System.out.println(" [body] computing price(" + item + ")");
return item.length() * 1000;
}
// build the key with SpEL, and cache only when the name is longer than 3 characters
@Cacheable(cacheNames = "prices", key = "'upper:' + #item.toUpperCase()", condition = "#item.length() > 3")
public int priceIgnoreCase(String item) {
System.out.println(" [body] computing priceIgnoreCase(" + item + ")");
return item.length() * 1000;
}
@CacheEvict(cacheNames = "prices", key = "#item")
public void changed(String item) {
System.out.println(" [body] changed(" + item + ")");
}
public int priceTwice(String item) {
return price(item) + price(item); // this.price(): does not go through the proxy
}
}
This example was run on the released Spring Framework 7.0.9 and produced the output below (it was not run against a self-built 7.1.0-SNAPSHOT, the version this series reads). The program printed its labels in Korean; they are translated here. "store" is the map wrapped by ConcurrentMapCache, printed as is.
bean class: CacheExample$PriceService$$SpringCGLIB$$0, is a proxy: true
== price(apple) twice
[body] computing price(apple)
result 5000
result 5000
store: {apple=5000}
== price(apple) after changed(apple)
[body] changed(apple)
store: {}
[body] computing price(apple)
result 5000
== priceTwice(pear): this.price() twice inside
[body] computing price(pear)
[body] computing price(pear)
result 8000
store: {apple=5000}
== priceIgnoreCase: kiwi, KIWI, fig, fig
[body] computing priceIgnoreCase(kiwi)
[body] computing priceIgnoreCase(fig)
[body] computing priceIgnoreCase(fig)
store: {upper:KIWI=4000, apple=5000}
- The second call to
price("apple")has no[body]line. The store also shows that the key is the argumentappleitself (the "one argument" rule ofSimpleKeyGenerator). - For
changed("apple")the body is printed first and the store is empty afterwards. That is step 5 of the order above. priceTwice("pear")ran the body twice and the store has nopear. Section 6 comes back to this.kiwiandKIWIboth have the keyupper:KIWI, so the body ran only once; forfigtheconditionis false, so the body runs every time and nothing is stored.
Easy to get wrong
conditionandunlessare evaluated at different times.conditionis evaluated before the lookup (step 2),unlessafter the body has run, just before the store (step 4). That is why the return value#resultonly means something inunless.- With a
@CachePuton the same method, the body runs even when the cache has a value. The firstifinevaluatecontains!hasCachePut(contexts)(line 567). ConcurrentMapCacheManagercan create a cache for an unknown name on the spot. Created without names it is in "dynamic" mode andgetCachecreates a new cache when given a name it does not have; created with names in the constructor, as in the example, it is in a fixed mode with only those names (lines 169–175).
3. Scheduling and async: one only registers, the other proxies
The two features live in the same package (scheduling.annotation) and in both the body runs on another thread, but they are attached in opposite ways.
Left is @Async (a proxy), right is @Scheduled (registration only). The grey box is where the proxy appears.
@Scheduled: find the method, wrap it in a Runnable, hand it over
@EnableScheduling is @Import(SchedulingConfiguration.class) (line 203), and that configuration class registers one bean only, ScheduledAnnotationBeanPostProcessor (lines 38–48). There is no selector and no mode. Abbreviated, the post-processor's "after initialization" hook looks like this.
public Object postProcessAfterInitialization(Object bean, String beanName) {
// ...
Class<?> targetClass = AopProxyUtils.ultimateTargetClass(bean);
if (!this.nonAnnotatedClasses.contains(targetClass) &&
AnnotationUtils.isCandidateClass(targetClass, List.of(Scheduled.class, Schedules.class))) {
Map<Method, Set<Scheduled>> annotatedMethods = MethodIntrospector.selectMethods(targetClass,
(MethodIntrospector.MetadataLookup<Set<Scheduled>>) method -> {
Set<Scheduled> scheduledAnnotations = AnnotatedElementUtils.getMergedRepeatableAnnotations(
method, Scheduled.class, Schedules.class);
return (!scheduledAnnotations.isEmpty() ? scheduledAnnotations : null);
});
if (annotatedMethods.isEmpty()) {
this.nonAnnotatedClasses.add(targetClass);
// ...
}
else {
// Non-empty set of methods
annotatedMethods.forEach((method, scheduledAnnotations) ->
scheduledAnnotations.forEach(scheduled -> processScheduled(scheduled, method, bean)));
// ...
}
}
return bean;
}
The last line is return bean;. It returns the bean it received unchanged, so no proxy appears. Instead, processScheduled does three things per method.
- It wraps the bean and the method in a
ScheduledMethodRunnable. A method with parameters is rejected here with "Only no-arg methods may be annotated with @Scheduled" (lines 542–550). The body of thatRunnablecallsmethod.invoke(target)by reflection, wheretargetis the bean the post-processor received (ScheduledMethodRunnablelines 125–128). - It looks at which of
cron,fixedDelayandfixedRatethe annotation specifies, builds the matching task object (CronTask,FixedDelayTask,FixedRateTask) and passes it to theScheduledTaskRegistrar. ForfixedRatethat isthis.registrar.scheduleFixedRateTask(new FixedRateTask(runnable, fixedRate, delayToUse))(lines 486–492). - It records the returned
ScheduledTask(a handle that can cancel) per bean (lines 522–525). When the container closes,destroy()callscancelon all of them (lines 622–645).
ScheduledTaskRegistrar is, as the name says, a register of schedules. While no scheduler has been decided yet it only puts tasks on a list (scheduleFixedRateTask, lines 544–568), and later scheduleTasks() hands everything collected to the TaskScheduler in one go (lines 436–481). "Later" is when ContextRefreshedEvent arrives, the event that says the container has finished starting (Part 12); the post-processor receives that event and calls finishRegistration() (lines 654–661, lines 246–267). Schedules must not start firing before every bean is ready.
Which scheduler is used. Unless you set one, TaskSchedulerRouter looks for one when the first task is scheduled (determineDefaultScheduler, lines 163–225). The order is: (1) a bean of type TaskScheduler, and if there are several, the one named taskScheduler; (2) if there is no TaskScheduler bean at all, a bean of type ScheduledExecutorService, and again the one named taskScheduler if there are several; (3) if that search picks nothing, Executors.newSingleThreadScheduledExecutor(), an executor with a single thread.
@Async: the post-processor holds an advisor and wraps beans itself
@EnableAsync is @Import(AsyncConfigurationSelector.class) (line 167), and in PROXY mode the selector returns a single name, ProxyAsyncConfiguration (lines 44–50). Unlike caching there is no AutoProxyRegistrar, which means the auto-proxy creator is not used. The bean the configuration class registers is not an advisor either; it is a post-processor, AsyncAnnotationBeanPostProcessor (lines 44–59).
The moment this post-processor receives the BeanFactory, it creates an AsyncAnnotationAdvisor and puts it in its advisor field (lines 142–151). That advisor's pointcut is "is there an @Async on the class or the method?" and its advice is an AnnotationAsyncExecutionInterceptor (AsyncAnnotationAdvisor line 98, lines 159–165, lines 172–186). The wrapping code is in the grandparent class, AbstractAdvisingBeanPostProcessor.
public Object postProcessAfterInitialization(Object bean, String beanName) {
// ...
if (bean instanceof Advised advised) {
if (!advised.isFrozen() && isEligible(AopUtils.getTargetClass(bean))) {
// Add our local Advisor to the existing proxy's Advisor chain.
if (this.beforeExistingAdvisors) {
advised.addAdvisor(0, this.advisor);
}
// ...
else {
advised.addAdvisor(this.advisor);
}
return bean;
}
}
if (isEligible(bean, beanName)) {
ProxyFactory proxyFactory = prepareProxyFactory(bean, beanName);
// ...
proxyFactory.addAdvisor(this.advisor);
// ...
return proxyFactory.getProxy(classLoader);
}
// No proxy needed.
return bean;
}
There are two branches. If the bean it receives is already a proxy (Advised), it builds no new proxy and slips its own advisor into that proxy's advisor list. If the bean is not a proxy and matches the pointcut, it builds one with the ProxyFactory of Part 10. When beforeExistingAdvisors is true the advisor goes to the very front of the list (index 0), and AsyncAnnotationBeanPostProcessor turns that flag on in its constructor (lines 87–89). That one line decides the order in section 6.
Call time is AsyncExecutionInterceptor.invoke.
public @Nullable Object invoke(final MethodInvocation invocation) throws Throwable {
// ...
AsyncTaskExecutor executor = determineAsyncExecutor(userMethod);
// ...
Callable<Object> task = () -> {
try {
Object result = invocation.proceed();
if (result instanceof Future<?> future) {
return future.get();
}
}
// ...
return null;
};
return doSubmit(task, executor, userMethod.getReturnType());
}
It has the shape of the cache interceptor. It does not call invocation.proceed() now; it puts the call in a Callable and hands that to the executor. doSubmit branches on the return type: submitCompletable for CompletableFuture, submit for any other Future, submit-and-return-null for void, and an IllegalArgumentException for anything else (AsyncExecutionAspectSupport lines 278–293). The caller does not wait for the body to finish, so the only way to get a result is to receive "a container that will be filled in later".
Which executor is used. determineAsyncExecutor decides once per method and records the answer in a map (lines 168–190).
- If a value is given, as in
@Async("name"), it looks for the executor bean with that name (qualifier). The value is read from the method's annotation, or failing that from the class's (AnnotationAsyncExecutionInterceptorlines 83–91). - Otherwise it is the default executor. If there is an
AsyncConfigurerbean and itsgetAsyncExecutor()returns an executor, that one comes first (AbstractAsyncConfigurationlines 71–84,AsyncExecutionAspectSupportlines 119–124). Without it the container is searched. If there is exactly one bean of typeTaskExecutor, that one; if there are several or none, it looks for anExecutorbean namedtaskExecutor(lines 232–268). - If that is missing too,
new SimpleAsyncTaskExecutor()(AsyncExecutionInterceptorlines 154–158).
Example 2: is it a proxy, and which thread?
// An example written for this post (not Spring source code)
@Configuration
@EnableScheduling
@EnableAsync
static class Config {
// registers Ticker and Mailer with @Bean (omitted)
}
public static class Ticker {
final AtomicInteger count = new AtomicInteger();
final CountDownLatch firstThree = new CountDownLatch(3);
@Scheduled(fixedRate = 100)
public void tick() {
int n = this.count.incrementAndGet();
if (n <= 3) {
System.out.println(" tick " + n + " on thread " + Thread.currentThread().getName());
this.firstThree.countDown();
}
}
}
public static class Mailer {
@Async
public CompletableFuture<String> send(String to) {
return CompletableFuture.completedFuture("sent to " + to + " on thread " + Thread.currentThread().getName());
}
}
main waits until tick has run three times, calls send once, closes the context in finally, and then counts for 300ms whether tick runs any more. This is the output on Spring Framework 7.0.9 (labels translated from Korean).
main thread: main
is Ticker a proxy: false
is Mailer a proxy: true
registered schedules: 1
== @Scheduled(fixedRate = 100)
tick 1 on thread pool-1-thread-1
tick 2 on thread pool-1-thread-1
tick 3 on thread pool-1-thread-1
ran three times: true
== @Async
sent to kim on thread SimpleAsyncTaskExecutor-1
== ticks added in the 300ms after closing the context: 0
Ticker is not a proxy and Mailer is. No scheduler or executor bean was registered, so tick ran on the single-thread executor of (3) above (pool-1-thread-1 is the name the JDK gives it) and send ran on a SimpleAsyncTaskExecutor thread. The schedule stopped when the context closed because destroy() cancels the schedules and stops the executor it created itself with shutdownNow() (TaskSchedulerRouter lines 253–255).
Easy to get wrong
- The default scheduler has one thread. Unless you register one, every
@Scheduledmethod shares a single thread. If one of them takes long, the others are delayed by that much. - "
@Scheduledbuilds no proxy" and "that bean is not a proxy" are different statements. If the same class also carries something like@Cacheableand the bean has already become a proxy, this post-processor, whose order value isLOWEST_PRECEDENCEand which therefore runs late (lines 182–184), receives that proxy and builds theRunnablefrom a method it can invoke on the object it received (line 548). I only read the source for this case; I did not run an example for it. - If you do not close the context, the JVM does not exit. Remove
ctx.close()from the example above and the scheduler thread stays alive, so the process remains aftermainreturns. That is why it is infinally. - An
@Asyncmethod returnsvoidor something in theFuturefamily. Write one that returnsStringand the call fails with "Invalid return type for async method (only Future and void supported)" (lines 289–292). - When
@Asyncreturnsvoid, exceptions do not come back to the caller. The body is already on another thread. TheCallableininvokeabove catches the exception, passes it tohandleErrorand returnsnull(lines 117–124).
4. Validation: Validator, DataBinder, and method validation
Validation has two branches: object validation, which your code calls directly, and method validation, which a proxy calls for you.
Left is method validation, right is object validation. The yellow box is where errors pile up; the red box is the exception.
Object validation: Validator and DataBinder.validate
Spring's Validator is an interface with two methods to implement. supports(Class) answers "can you validate this type?", and validate(Object target, Errors errors) writes the outcome of the check into Errors (Validator lines 63–116). It collects errors instead of throwing. If a form has three wrong fields, you want to hear about all three at once.
The typical caller is DataBinder. Using the BeanWrapper and the converters of Part 14, it puts a bundle of "name → value" pairs into an object's properties (bind, lines 1254–1261), and it gathers the conversion failures from that step and the validation errors that follow into one BindingResult (an interface that extends Errors). The method that validates is short.
public void validate() {
Object target = getTarget();
Assert.state(target != null, "No target to validate");
BindingResult bindingResult = getBindingResult();
// Call each validator with the same binding result
for (Validator validator : getValidatorsToApply()) {
validator.validate(target, bindingResult);
}
}
spring-context/src/main/java/org/springframework/validation/DataBinder.java lines 1439–1447
The thing to notice is that bind and validate are separate. DataBinder only binds; it does not validate on its own. The caller has to call validate after bind. In the web layer, the argument resolver that builds a controller method's arguments does it (ModelAttributeMethodProcessor line 154, lines 226–230; argument resolvers are in Part 22).
The adapter that connects Bean Validation
Constraint annotations such as @NotBlank are understood by jakarta.validation.Validator, not by Spring's Validator. SpringValidatorAdapter connects the two. Its declaration shows the role directly.
public class SpringValidatorAdapter implements SmartValidator, jakarta.validation.Validator {
It implements both interfaces at once. When the Spring-side validate(target, errors) is called, it passes the object to the jakarta validator it holds (lines 101–105), then turns the returned violations (ConstraintViolation) into FieldErrors and puts them into Errors (processConstraintViolations, lines 147–198). The error code becomes the simple name of the constraint annotation (NotBlank, Min).
LocalValidatorFactoryBean extends this adapter (lines 84–85) and creates the jakarta validator to put inside by itself. At initialization it finds and configures the implementation library on the classpath through Validation.byDefaultProvider() (lines 247–257) and plugs in the resulting validator with setTargetValidator (lines 325–326). Register this one bean and you can inject it either as Spring's Validator or as jakarta's Validator.
Method validation: once more, "a post-processor that holds an advisor"
Checking the constraints on a method's parameters and return value at every call is path 2 from section 1. MethodValidationPostProcessor has the same parent as AsyncAnnotationBeanPostProcessor in section 3 (lines 69–70), and the code that builds its advisor is two lines.
public void afterPropertiesSet() {
Pointcut pointcut = new AnnotationMatchingPointcut(this.validatedAnnotationType, true);
this.advisor = new DefaultPointcutAdvisor(pointcut, createMethodValidationAdvice(this.validator));
}
The pointcut is "does the class carry @Validated?" (the default for validatedAnnotationType is Validated.class, line 72). The advice, MethodValidationInterceptor.invoke, validates the arguments, throws ConstraintViolationException if there are violations, otherwise calls the body with invocation.proceed() and then validates the return value in the same way (lines 143–185). Unlike object validation, this one throws: a method call has no Errors to write into and hand back.
Example 3: the two branches side by side
// An example written for this post (not Spring source code)
@Configuration
static class Config {
@Bean
static MethodValidationPostProcessor methodValidationPostProcessor() {
return new MethodValidationPostProcessor();
}
@Bean
LocalValidatorFactoryBean validator() {
return new LocalValidatorFactoryBean();
}
// registers OrderService and PlainService with @Bean (omitted)
}
@Validated
public static class OrderService {
public String order(@NotBlank String item, @Min(1) int qty) {
return item + " x " + qty;
}
}
// same constraint annotations, but no @Validated on the class
public static class PlainService {
public String order(@NotBlank String item, @Min(1) int qty) {
return "[" + item + "] x " + qty;
}
}
The object-validation half creates an OrderForm with two fields, @NotBlank String item and @Min(1) int qty, binds values into it with a DataBinder and calls validate(). The implementation library is Hibernate Validator 9.0.1. This is the output on Spring Framework 7.0.9 (labels translated from Korean; two log lines in which Hibernate Validator announces its version come first and are left out).
is OrderService a proxy: true
is PlainService a proxy: false
== method validation: OrderService.order
order(pen, 2) → pen x 2
order("", 0) → ConstraintViolationException
order.item : NotBlank
order.qty : Min
PlainService.order("", 0) → [] x 0
== object validation: DataBinder.bind → validate
item="", qty=0
item : NotBlank (binding failure: false)
qty : Min (binding failure: false)
item="", qty=abc
item : NotBlank (binding failure: false)
qty : typeMismatch (binding failure: true)
Easy to get wrong
- Method validation is on only when the class carries
@Validated.PlainServicehas the same constraints on its parameters, yet it is not a proxy, and an empty string and 0 went straight through. A constraint annotation is only a "rule"; what comes to check it is the proxy. - A field that failed conversion gets no validation error added. In the last case
qtycould not turn"abc"into anintand kept onlytypeMismatch. Theqtythat never received a value is 0 and so also violates@Min(1), butprocessConstraintViolationsfirst collects the fields whose binding failed and skips violations on those fields (lines 148–162). - The
@Beanmethod forMethodValidationPostProcessorisstatic. A post-processor has to be created before other beans, and withoutstaticthe container would have to create the configuration class early just to create it. The "Easy to get wrong" list in section 2 of Part 6 covers this.
5. Resilience: @Retryable and @ConcurrencyLimit
The org.springframework.resilience package is new in 7.0 (the two annotations, the switch and the two post-processors all say @since 7.0). The pattern is that of @Async in section 3.
The switch and the post-processors
@EnableResilientMethods is @Import(ResilientMethodsConfiguration.class) (lines 41–45). It imports the configuration class directly, with no selector, and that class registers two post-processors (lines 63–77). The constructor of the retry post-processor shows everything.
public RetryAnnotationBeanPostProcessor() {
setBeforeExistingAdvisors(true);
Pointcut cpc = new AnnotationMatchingPointcut(Retryable.class, true);
Pointcut mpc = new AnnotationMatchingPointcut(null, Retryable.class, true);
this.advisor = new DefaultPointcutAdvisor(new ComposablePointcut(cpc).union(mpc), this.interceptor);
}
It ties a pointcut, "@Retryable on the class (cpc) or on the method (mpc)", to the interceptor and puts the pair in advisor. The rest, wrapping the bean, is done by the same AbstractAdvisingBeanPostProcessor.postProcessAfterInitialization we saw in section 3. The constructor of ConcurrencyLimitBeanPostProcessor has the same shape with a different annotation name (lines 60–68).
The interceptor calls RetryTemplate
The logic that calls again is not in the interceptor. It is in RetryTemplate in spring-core; the interceptor only reads the annotation and calls it.
public @Nullable Object invoke(MethodInvocation invocation) throws Throwable {
Method method = invocation.getMethod();
Object target = invocation.getThis();
MethodRetrySpec spec = getRetrySpec(method, (target != null ? target.getClass() : method.getDeclaringClass()));
if (spec == null) {
return invocation.proceed();
}
// ...
RetryPolicy retryPolicy = RetryPolicy.builder()
.includes(spec.includes())
.excludes(spec.excludes())
.predicate(spec.predicate().forMethod(method))
.maxRetries(spec.maxRetries())
// ...
.build();
RetryTemplate retryTemplate = new RetryTemplate(retryPolicy);
// ...
try {
return retryTemplate.execute(new Retryable<@Nullable Object>() {
@Override
public @Nullable Object execute() throws Throwable {
return (invocation instanceof ProxyMethodInvocation pmi ?
pmi.invocableClone().proceed() : invocation.proceed());
}
// ...
});
}
catch (RetryException ex) {
// ...
throw ex.getCause();
}
}
Three places deserve a look.
getRetrySpecreads@Retryablefrom the method, or failing that from the class, turns it into aMethodRetrySpecand records it in a map (RetryAnnotationBeanPostProcessorlines 89–117). This is the jobCacheOperationSourcedid for caching.pmi.invocableClone().proceed(). As Part 11 showed,proceed()tracks "how far along the chain we are" with a single index, so callingproceed()twice on the sameinvocationwould find the index already at the end the second time. So for each attempt it makes a copy at the current position and advances the copy. That is why the chain inside the retry interceptor (cache, transaction, the original method) runs from the start again on every attempt.throw ex.getCause(). When it finally gives up,RetryTemplatethrows aRetryException, and the interceptor takes the original exception out of it and throws that. Adding@Retryabledoes not change the caller'scatch.
The skeleton of RetryTemplate.execute is as in the figure (lines 126–207).
Blue is one attempt and the wait between attempts; red is the road to giving up.
- It executes once first. If that succeeds, it is done (lines 133–136).
- On failure it enters
while (this.retryPolicy.shouldRetry(lastException) && ...)(line 149).shouldRetryanswers "is this an exception worth trying again for?" usingincludes,excludesandpredicate. backOffExecution.nextBackOff()gives the time to wait until the next attempt; if it givesSTOP, the retries are used up and the loop ends (lines 153–156). Otherwise it executes again afterThread.sleep(sleepTime)(line 160, line 174).- Leaving the loop means a
RetryException. The last exception is itscauseand the earlier ones are attached assuppressed(lines 196–200,RetryExceptionlines 63–69).
The defaults are the same in the annotation and in the policy: maxRetries is 3 and delay is 1000ms (Retryable line 128, line 181, RetryPolicy line 153, line 158).
@ConcurrencyLimit
ConcurrencyLimitInterceptor keeps a ConcurrencyThrottleInterceptor per proxy object, and per method if the annotation is on the method (if it is on the class, one shared by that class's methods), and passes the call to it (lines 82–133). When the limit is reached, the default (policy = BLOCK, ConcurrencyLimit line 110) is to wait for a free slot; with REJECT it throws InvocationRejectedException (lines 176–186).
Example 4: how many times is the body called?
// An example written for this post (not Spring source code)
public static class Flaky {
private final AtomicInteger calls = new AtomicInteger();
@Retryable(maxRetries = 3, delay = 10)
public String alwaysFails() {
int n = this.calls.incrementAndGet();
System.out.println(" [body] alwaysFails call #" + n);
throw new IllegalStateException("failure " + n);
}
@Retryable(maxRetries = 3, delay = 10)
public String thirdTimeLucky() {
int n = this.calls.incrementAndGet();
System.out.println(" [body] thirdTimeLucky call #" + n);
if (n < 3) {
throw new IllegalStateException("failure " + n);
}
return "success (call #" + n + ")";
}
// call again only for IOException
@Retryable(includes = IOException.class, maxRetries = 3, delay = 10)
public String otherException() {
int n = this.calls.incrementAndGet();
System.out.println(" [body] otherException call #" + n);
throw new IllegalArgumentException("not an exception to retry on");
}
// ...
}
The configuration class carries @EnableResilientMethods, and at the end the example uses RetryTemplate directly, without a container.
RetryTemplate template = new RetryTemplate(
RetryPolicy.builder().maxRetries(2).delay(Duration.ofMillis(10)).build());
AtomicInteger calls = new AtomicInteger();
try {
template.execute(() -> {
int n = calls.incrementAndGet();
System.out.println(" [lambda] call #" + n);
throw new IllegalStateException("failure " + n);
});
}
catch (RetryException ex) {
// prints the exception type, retryCount, cause, and the number of suppressed exceptions
}
This is the output on Spring Framework 7.0.9 (labels translated from Korean).
is a proxy: true
== alwaysFails (maxRetries = 3)
[body] alwaysFails call #1
[body] alwaysFails call #2
[body] alwaysFails call #3
[body] alwaysFails call #4
exception the caller got: IllegalStateException / failure 4
== thirdTimeLucky (maxRetries = 3)
[body] thirdTimeLucky call #1
[body] thirdTimeLucky call #2
[body] thirdTimeLucky call #3
success (call #3)
== otherException (includes = IOException)
[body] otherException call #1
exception the caller got: IllegalArgumentException
== RetryTemplate directly (maxRetries = 2)
[lambda] call #1
[lambda] call #2
[lambda] call #3
exception received: RetryException
retryCount = 2, cause = failure 3, suppressed = 2
Easy to get wrong
- With
maxRetries = 3the body runs at most four times. The first attempt is not a "retry". Step 1 of the skeleton above sits outside the loop. @Retryablegives you the original exception;RetryTemplate.executegives you aRetryException. Same logic, different exception. The first is the result of the interceptor unwrappinggetCause().- The wait puts the calling thread to sleep. It is
Thread.sleep. The defaultdelayis one second, so retrying three times with defaults on a request-handling thread holds that thread for more than three seconds. - The examples ran on 7.0.9; the source read is 7.1.0-SNAPSHOT. Searching every
@sincein theresilienceandcore.retrypackages at this commit, the highest value is7.0.9and there is no7.1. Every type seen in this section also exists in 7.0.9 (policyon@ConcurrencyLimitandInvocationRejectedExceptionare@since 7.0.3). I did not, however, compare method bodies against 7.0.9. What I confirmed is only that the example output agrees with the skeleton above.
6. Shared pitfalls: self-invocation and ordering
Self-invocation: it applies to everything proxy-based
In the example of section 2, priceTwice("pear") ran the body twice and left nothing in the cache. The reason is the one from section 6 of Part 10 and Example 2 of Part 17. Interceptors exist only inside the proxy, and when the original object calls its own method with this.price(), the call does not go through the proxy. The Javadoc of @EnableCaching also says that local calls within the same class cannot be intercepted (lines 190–196).
This is not just about caching. Every row of the table in section 1 that builds a proxy behaves the same: @Cacheable, @Async, @Retryable, @ConcurrencyLimit, method validation, and @Transactional. Call this.sendAsync() from another method of the same class and it simply runs on the same thread; this.retryable() runs only once.
@Scheduled is a different case. It is not a feature that intercepts calls, so the problem "@Scheduled does not apply because of self-invocation" does not exist. But if the body of a @Scheduled method calls this.price(), that is still a self-invocation, because the body runs inside the original object.
Ordering: who stands outermost
Put several features on one method and a single proxy holds several advisors. The order is settled in two stages.
- Path 1 goes first. The auto-proxy creator is registered with the earliest
ordervalue (HIGHEST_PRECEDENCE,AopConfigUtilsline 139). It runs before the other post-processors in this section, so the proxy holding the cache and transaction advisors is built first. - The path 2 post-processors slip their advisors into that proxy. That is the
advised.addAdvisor(0, this.advisor)from section 3. Since each goes to the front, the later a post-processor runs, the further out its advisor stands. The defaultorderisLOWEST_PRECEDENCE - 1for@EnableResilientMethods(line 69) andLOWEST_PRECEDENCEfor@EnableAsync(line 215), so the retry side runs first and@Asynclater.MethodValidationPostProcessordoes not turn onbeforeExistingAdvisors, so it attaches at the very end (closest to the target).
Example 5: all five at once
One method got @Async, @Retryable, @ConcurrencyLimit(2) and @Cacheable, its class got @Validated, and the example printed the proxy's advisors in order (((Advised) bean).getAdvisors()). This is the output on Spring Framework 7.0.9 (label translated from Korean).
one proxy, 5 advisors (outside → inside)
0: AnnotationAsyncExecutionInterceptor
1: ConcurrencyLimitInterceptor
2: RetryAnnotationInterceptor
3: CacheInterceptor
4: MethodValidationInterceptor
Top to bottom is outside to inside. The red box is self-invocation; the grey box is @Scheduled, which is not part of this line.
Read it like this: first the call moves to another thread (@Async), on that thread the concurrent calls are counted, the retry loop runs, and each attempt checks the cache again. The Javadoc of @Retryable says the same: retry advice is applied outside @Transactional and @Cacheable, so each attempt starts a fresh transaction or checks the cache, and inside @Async, so all attempts run on the async executor thread (Retryable lines 45–53). The same comment says the order relative to @Async can be changed through the order of the two switches, while the order relative to @Transactional and @Cacheable is fixed. That is stage 1 above: path 1 always runs first.
Easy to get wrong
- There is one proxy. Five features do not mean five layers of proxies. A path 2 post-processor that meets a bean which is already a proxy only adds an advisor.
- In the output above,
@ConcurrencyLimitwas outside@Retryable. That is what the example showed, not an order the source guarantees. The two post-processors have the sameorder(LOWEST_PRECEDENCE - 1), and the configuration class lists the retry one first. That the output above has the concurrency limit at index 1 and retry at index 2 appears to follow from that. It means one concurrency slot stays held while retrying. I found nothing in the source that promises the order between these two, so it is better not to write code that depends on it. - The order between caching and transactions was not checked with this example. Both advisors default to an
orderofLOWEST_PRECEDENCE(EnableCachingline 205). If the order matters, set it through theorderattribute of the two switches.
What this post leaves out
The scope was wide and a lot was left out. Here are the names and locations only. In caching: @EnableCaching(mode = ASPECTJ) and support for the JSR-107 (javax.cache) annotations (CachingConfigurationSelector lines 43–61), CacheErrorHandler, which decides whether a cache failure is swallowed or thrown (AbstractCacheInvoker lines 76–83), the class-level shared settings of @CacheConfig, the handling of CompletableFuture and reactive return types (CacheAspectSupport lines 515–554), and other store implementations such as Caffeine (the spring-context-support module). In scheduling: parsing of cron expressions (scheduling.support.CronExpression), schedules for reactive return types and Kotlin suspend methods (ScheduledAnnotationBeanPostProcessor lines 378–391), SchedulingConfigurer for adding schedules in code, and executor implementations such as ThreadPoolTaskScheduler and ThreadPoolTaskExecutor (scheduling.concurrent). In validation: validation groups and SmartValidator, constructor binding (from DataBinder line 911), adaptConstraintViolations, which turns violations into a MethodValidationResult (MethodValidationPostProcessor lines 120–130), and SpringConstraintValidatorFactory, which injects beans into constraint validators. In resilience: retry for reactive return types (from AbstractRetryInterceptor line 173), the MethodRetryEvent published on each retry, back-off that grows the wait through jitter, multiplier and maxDelay, and the overall time limit timeout (RetryTemplate lines 209–228). In the four packages this post read, @since 7.1 at this commit appears in only a few places, such as setMaxNestedPathDepth on DataBinder (line 315) and the addCronTask overload on ScheduledTaskRegistrar that takes a time zone (line 309), and the examples in this post do not use them.
Summary
| Concept | One-line summary |
|---|---|
| One pattern | a switch (@EnableXxx = @Import) → a configuration class registers infrastructure beans → a post-processor wraps or inspects each bean |
| Path 1 | register an advisor bean; the auto-proxy creator wraps. Caching, transactions |
| Path 2 | a post-processor holding an advisor (AbstractAdvisingBeanPostProcessor) wraps beans itself. @Async, @Retryable, @ConcurrencyLimit, method validation |
| Path 3 | no proxy; the method is wrapped in a Runnable and scheduled. @Scheduled |
CacheAspectSupport.execute |
early eviction → lookup → (on a miss) body → store → late eviction. sync = true is a single Cache.get(key, loader) |
| Cache key | SpEL if there is a key expression, otherwise SimpleKeyGenerator. With one argument, that argument is the key |
ScheduledAnnotationBeanPostProcessor |
returns the bean unchanged and only registers schedules. They are actually scheduled on ContextRefreshedEvent |
| Default scheduler and executor | the scheduler has a single thread; @Async uses SimpleAsyncTaskExecutor. Register a bean and that is used instead |
DataBinder.validate |
calls each registered Validator and collects errors in the BindingResult. It must be called separately from bind |
SpringValidatorAdapter |
both Spring's Validator and jakarta's Validator. Turns violations into FieldErrors |
| Method validation | the class must carry @Validated to be proxied. A violation means ConstraintViolationException |
@Retryable |
the interceptor calls RetryTemplate. maxRetries = 3 means at most four calls. The caller gets the original exception |
| Self-invocation | no proxy-based feature applies to this.method(). @Scheduled is not proxy-based, so it is not affected |
| Advisor order | by default @Async → @Retryable → caching and transactions → method validation → target. @ConcurrencyLimit sat between @Async and @Retryable in the example, but its position relative to @Retryable is not guaranteed |
Wrapping up the series
With this post, all 25 units written on the map in Part 1 have been read. Here is the road travelled, phase by phase.
Phase 1 (the container) and Phase 2 (AOP) were the longest. They covered what the bean blueprint BeanDefinition is (Part 3), how an object is created from that blueprint and how three maps resolve circular references (Part 4), the rules by which @Autowired picks a candidate (Part 5), where post-processors and lifecycle callbacks step in (Part 6), the twelve steps of refresh() (Part 7), how annotation configuration becomes blueprints (Parts 8 and 9), and events, Environment and Resource (Part 12); and, on top of that, the two ways a proxy is built and the advice chain (Parts 10 and 11). Phase 3 (foundations) looked at what everything else borrows: ResolvableType and MergedAnnotations (Part 13), type conversion and BeanWrapper (Part 14), SpEL (Part 15), and utilities and AOT (Part 16).
Phase 4 (transactions and data access) read how a proxy opens and closes @Transactional (Part 17), the template-callback pattern and exception translation in JdbcTemplate (Part 18), and JPA integration and reactive transactions (Part 19). Phase 5 (web) started from the foundation in spring-web (Part 20) and went through request handling in DispatcherServlet (Part 21), handler mappings and adapters (Part 22), and exception handling, interceptors and views (Part 23), on to WebFlux (Part 24) and WebSocket and messaging (Part 25). Phase 6 (the rest) is spring-test (Part 26) and this post.
The table in section 1 of this post shows once more why the series was read in that order. Caching, async and retry are all combinations of the @Import of Part 9, the post-processors of Part 6, and the proxies of Parts 10 and 11. That was the reason Part 2 said "read the middle layer first". After Spring, this blog plans to read the source of Tomcat, nginx, MariaDB and Gradle in the same way.
The whole series:
- Part 1: How I Read It with AI, and the Map
- Part 2: The Big Map — Modules and Packages
- Part 3: The BeanFactory Hierarchy and BeanDefinition
- Part 4: How a Bean Gets Created — the Creation Flow and the Three-Level Cache
- Part 5: How @Autowired Picks the Bean to Inject — Dependency Resolution
- Part 6: Lifecycle Callbacks and PostProcessors — Where You Can Step into the Container
- Part 7: The Twelve Steps of refresh() — the Order in Which the Container Starts
- Part 8: How @Configuration and @ComponentScan Become Blueprints
- Part 9: @Conditional, @Profile and @Import — Choosing Beans by Condition
- Part 10: How an AOP Proxy Gets Built — the JDK Way and the CGLIB Way
- Part 11: How @Aspect Gets Discovered and How the Advice Chain Runs
- Part 12: Events, Environment, Resource — Three Abstractions Around the Container
- Part 13: ResolvableType and MergedAnnotations — How Spring Reads Types and Annotations
- Part 14: Type Conversion and BeanWrapper — From a String to an Object's Property
- Part 15: SpEL — How an Expression Is Parsed and Evaluated
- Part 16: Utilities and AOT — the Shared Toolbox and Computing Ahead of Time
- Part 17: @Transactional — Who Opens a Transaction and Who Closes It
- Part 18: JdbcTemplate — the Template-Callback Pattern and Exception Translation
- Part 19: ORM and R2DBC Integration — JPA Support and Reactive Transactions
- Part 20: The spring-web Foundation — HttpMessageConverter, WebApplicationContext, Filters
- Part 21: DispatcherServlet — How One Request Becomes a Response
- Part 22: Handler Mappings and Adapters — How @RequestMapping Methods Are Found and Called
- Part 23: Exception Handling, Interceptors, Views — Before, After, and the End of a Request
- Part 24: WebFlux — DispatcherHandler, Functional Endpoints, and WebClient
- Part 25: WebSocket and Messaging — How a STOMP Message Flows
- Part 26: spring-test — SpringExtension, TestContext, MockMvc
- Part 27: Caching, Scheduling, Validation, Resilience — Features Built on Top of the Container (this post)
After Spring, I read the source of the Apache HTTP Server (httpd) the same way. If you want to compare how a server built from C structs and function pointers, instead of Java objects and interfaces, solves the same problems (request-processing phases, extension points, merging configuration), start with Digging into Apache httpd's Source 1.