namalzi

← All posts

Digging into Spring's Source 27: Caching, Scheduling, Validation, Resilience — Features Built on Top of the Container

Spring · Published

@Cacheable, @Scheduled, @Async, @Validated and @Retryable look like separate features, but inside they repeat one pattern. For readers new to Spring, this post checks in the source what each switch annotation registers, which post-processor wraps the bean, and which interceptor intercepts the call, and then wraps up the whole series.

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.

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.

@EnableXxx brings in a configuration class through @Import, and the infrastructure beans that class registers split into three paths. Path 1, used by caching and transactions, registers an advisor bean that the auto-proxy creator finds and wraps with. Path 2, used by @Async, @Retryable, @ConcurrencyLimit and method validation, is a post-processor that holds an advisor and wraps beans itself. Both end in a proxy. Path 3 is @Scheduled: a post-processor that builds no proxy wraps the method in a Runnable, schedules it, and leaves the bean as it is. 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.

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

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);
	}

spring-context/src/main/java/org/springframework/cache/annotation/CachingConfigurationSelector.java lines 81–89

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;
	}

spring-context/src/main/java/org/springframework/cache/interceptor/CacheAspectSupport.java lines 412–428

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);

spring-context/src/main/java/org/springframework/cache/interceptor/CacheAspectSupport.java lines 567–597

The order inside CacheAspectSupport.execute. CacheInterceptor.invoke calls execute, which gets the list of cache operations from the CacheOperationSource. If sync is true it goes to executeSynchronized and handles everything with a single cache.get(key, valueLoader). Otherwise it goes: 1 early eviction, 2 lookup, then if a value was found and there is no @CachePut it uses the cached value, else 3 it calls the method body, then 4 store, then 5 late eviction. Yellow is where the cache is read and written; green is where the method body is skipped.

  1. Early eviction. Only @CacheEvict(beforeInvocation = true) operations are evicted now. The filter is beforeInvocation == evict.isBeforeInvocation() (lines 648–650).
  2. Lookup. For each @Cacheable it evaluates condition; if that passes it builds the key and looks in the cache (lines 494–513).
  3. Method body. If a value was found and there is no @CachePut, the cached value is used and the body is not called. Otherwise invokeOperation(invoker) runs that lambda from before, which is the next step in the chain (in the end, the original method).
  4. Store. The store for a @Cacheable miss and the store for @CachePut are collected and run together. unless is evaluated here, after the result exists (canPutToCache, lines 914–927, called from line 1041).
  5. Late eviction. The default for beforeInvocation is false (CacheEvict line 161), so an ordinary @CacheEvict evicts 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}

Easy to get wrong

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.

@Async compared with @Scheduled. For @Async, AsyncAnnotationBeanPostProcessor returns, in its after-initialization hook, a proxy that carries the AsyncAnnotationAdvisor; on a call AsyncExecutionInterceptor.invoke picks an executor with determineAsyncExecutor and hands the body to an executor thread with doSubmit. For @Scheduled, ScheduledAnnotationBeanPostProcessor finds @Scheduled methods in its after-initialization hook and wraps each in a ScheduledMethodRunnable; ScheduledTaskRegistrar collects the schedules and, on ContextRefreshedEvent, finishRegistration hands them to the TaskScheduler, whose thread then calls that method on the bean. 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;
	}

spring-context/src/main/java/org/springframework/scheduling/annotation/ScheduledAnnotationBeanPostProcessor.java lines 280–320

The last line is return bean;. It returns the bean it received unchanged, so no proxy appears. Instead, processScheduled does three things per method.

  1. 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 that Runnable calls method.invoke(target) by reflection, where target is the bean the post-processor received (ScheduledMethodRunnable lines 125–128).
  2. It looks at which of cron, fixedDelay and fixedRate the annotation specifies, builds the matching task object (CronTask, FixedDelayTask, FixedRateTask) and passes it to the ScheduledTaskRegistrar. For fixedRate that is this.registrar.scheduleFixedRateTask(new FixedRateTask(runnable, fixedRate, delayToUse)) (lines 486–492).
  3. It records the returned ScheduledTask (a handle that can cancel) per bean (lines 522–525). When the container closes, destroy() calls cancel on 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;
	}

spring-aop/src/main/java/org/springframework/aop/framework/AbstractAdvisingBeanPostProcessor.java lines 88–134

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());
	}

spring-aop/src/main/java/org/springframework/aop/interceptor/AsyncExecutionInterceptor.java lines 100–128

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).

  1. 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 (AnnotationAsyncExecutionInterceptor lines 83–91).
  2. Otherwise it is the default executor. If there is an AsyncConfigurer bean and its getAsyncExecutor() returns an executor, that one comes first (AbstractAsyncConfiguration lines 71–84, AsyncExecutionAspectSupport lines 119–124). Without it the container is searched. If there is exactly one bean of type TaskExecutor, that one; if there are several or none, it looks for an Executor bean named taskExecutor (lines 232–268).
  3. If that is missing too, new SimpleAsyncTaskExecutor() (AsyncExecutionInterceptor lines 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

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.

The two branches of validation. In method validation, MethodValidationPostProcessor builds a proxy when the class carries @Validated, and MethodValidationInterceptor.invoke validates the arguments, runs the body, validates the return value, and throws ConstraintViolationException on any violation. In object validation, DataBinder.bind puts values into the object and DataBinder.validate calls each registered Validator in turn; SpringValidatorAdapter delegates to a jakarta.validation.Validator and turns the returned ConstraintViolations into FieldErrors collected in the BindingResult. 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 {

spring-context/src/main/java/org/springframework/validation/beanvalidation/SpringValidatorAdapter.java line 66

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));
	}

spring-context/src/main/java/org/springframework/validation/beanvalidation/MethodValidationPostProcessor.java lines 134–137

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

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);
	}

spring-context/src/main/java/org/springframework/resilience/annotation/RetryAnnotationBeanPostProcessor.java lines 64–70

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();
		}
	}

spring-context/src/main/java/org/springframework/resilience/retry/AbstractRetryInterceptor.java lines 88–152

Three places deserve a look.

The skeleton of RetryTemplate.execute is as in the figure (lines 126–207).

The retry flow. AbstractRetryInterceptor.invoke reads @Retryable into a MethodRetrySpec, builds a RetryPolicy and creates a RetryTemplate. If the first attempt succeeds the result is returned. On an exception it checks shouldRetry and whether retries are left; if so it sleeps with Thread.sleep for the time the BackOff returns and retries with invocableClone().proceed(), returning the result on success and going back to the check on an exception. If not, a RetryException whose cause is the last exception is thrown; the interceptor unwraps and throws the cause, so the caller sees the original exception. Blue is one attempt and the wait between attempts; red is the road to giving up.

  1. It executes once first. If that succeeds, it is done (lines 133–136).
  2. On failure it enters while (this.retryPolicy.shouldRetry(lastException) && ...) (line 149). shouldRetry answers "is this an exception worth trying again for?" using includes, excludes and predicate.
  3. backOffExecution.nextBackOff() gives the time to wait until the next attempt; if it gives STOP, the retries are used up and the loop ends (lines 153–156). Otherwise it executes again after Thread.sleep(sleepTime) (line 160, line 174).
  4. Leaving the loop means a RetryException. The last exception is its cause and the earlier ones are attached as suppressed (lines 196–200, RetryException lines 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

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.

  1. Path 1 goes first. The auto-proxy creator is registered with the earliest order value (HIGHEST_PRECEDENCE, AopConfigUtils line 139). It runs before the other post-processors in this section, so the proxy holding the cache and transaction advisors is built first.
  2. 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 default order is LOWEST_PRECEDENCE - 1 for @EnableResilientMethods (line 69) and LOWEST_PRECEDENCE for @EnableAsync (line 215), so the retry side runs first and @Async later. MethodValidationPostProcessor does not turn on beforeExistingAdvisors, 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

The order a call passes through when five features sit on one method. A call from outside passes through @Async's AnnotationAsyncExecutionInterceptor, @ConcurrencyLimit's ConcurrencyLimitInterceptor, @Retryable's RetryAnnotationInterceptor, @Cacheable's CacheInterceptor and @Validated's MethodValidationInterceptor in turn and reaches the target method. A self-invocation, where the target method calls another method through this, passes through none of the five. @Scheduled builds no proxy; the scheduler calls the bean method. 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

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:

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.

More posts