The previous article built an auto-configuration and a starter of your own, which is the polite way to add beans to somebody else's application. This one goes underneath that: the callbacks the container invokes on its own way up, where your code can read and rewrite what the container is about to do.
There is more folklore in this corner of Spring than anywhere else, most of it written before Spring Boot 3, so this article traces what Spring Boot 4.1.1 on Java 21 actually does. The app runs on port 8202 instead of the default 8080, which is the port you will see in its logs.
![]()
Article 9 of the Basics course traced the lifecycle of a single bean — constructor, property population, the Aware callbacks, postProcessBeforeInitialization, @PostConstruct, afterPropertiesSet(), the init method, postProcessAfterInitialization, and the destruction hooks in reverse. That order is assumed here, not repeated. This article zooms out one level to the startup that contains all of it.
The phases of one real startup
The application is deliberately small: two PriceService implementations, a handful of beans registered from configuration, and one class at every extension point, each printing its position through a shared counter.
package com.example.demo.trace;
import java.util.concurrent.atomic.AtomicInteger;
/** One global counter so every extension point prints its own position in the startup. */
public final class Trace {
private static final AtomicInteger N = new AtomicInteger();
private Trace() {
}
public static void step(String phase, String detail) {
System.out.printf("%2d %-32s %s%n", N.incrementAndGet(), phase, detail);
}
}Running the jar with a deliberately messy command line:
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --lab.mode=full --tag=alpha --tag=beta -v report.csvproduces this. Boot's own log is removed except for the two lines that matter, which are left exactly where they appeared:
1 EnvironmentPostProcessor added property source lab-defaults; context does not exist yet
2 org.springframework.boot.env.EPP the pre-4.0 package still on the classpath
3 ApplicationContextInitializer registered through META-INF/spring.factories
4 ApplicationContextInitializer AnnotationConfigServletWebServerApplicationContext exists, refresh() has not started, 6 bean definitions
5 EnvironmentAware on the BDRPP setEnvironment before postProcessBeanDefinitionRegistry
6 BeanDefinitionRegistryPostProcessor registered 3 ReportJob definitions from lab.reports.*
7 BDRPP.postProcessBeanFactory the same object is called again as a plain BeanFactoryPostProcessor
8 BeanFactoryPostProcessor definitions=240, singletons registered=18, PriceService=[listPriceService, promoPriceService]
9 BeanFactoryPostProcessor marked listPriceService primary; still no PriceService instance exists
10 BeanClassLoaderAware LaunchedClassLoader
11 EnvironmentAware lab.origin=EnvironmentPostProcessor
12 ResourceLoaderAware AnnotationConfigServletWebServerApplicationContext
13 ApplicationEventPublisherAware AnnotationConfigServletWebServerApplicationContext
14 BPP.before awareBean AwareBean
15 BPP.after awareBean AwareBean
16 BPP.before listPriceService ListPriceService
17 BPP.after listPriceService returning a proxy instead: jdk.proxy2.$Proxy94
18 BPP.before promoPriceService PromoPriceService
19 BPP.after promoPriceService PromoPriceService
20 BPP.before reportJob-daily ReportJob
21 BPP.after reportJob-daily ReportJob
22 BPP.before reportJob-weekly ReportJob
23 BPP.after reportJob-weekly ReportJob
24 BPP.before reportJob-monthly ReportJob
25 BPP.after reportJob-monthly ReportJob
26 SmartInitializingSingleton every non-lazy singleton exists, refresh() is nearly done
INFO TomcatWebServer : Tomcat started on port 8202 (http) with context path '/'
27 ContextRefreshedEvent refresh() finished
INFO DemoApplication : Started DemoApplication in 1.228 seconds (process running for 1.42)
28 ApplicationStartedEvent published just before the runners
29 ApplicationRunner @Order(1) optionNames=[lab.mode, tag] nonOptionArgs=[-v, report.csv]
30 CommandLineRunner @Order(2) raw args=[--lab.mode=full, --tag=alpha, --tag=beta, -v, report.csv]
31 ApplicationRunner @Order(3) injected PriceService is a jdk.proxy2.$Proxy94
32 ApplicationRunner @Order(5) the last runner in the chain
33 ApplicationReadyEvent published after the last runner returned
34 main() after run() returned the context is up and the runners are done
Four things in that trace are worth fixing in your head before anything else.
Steps 1 to 4 run before the context can hold a bean. At step 4 the ApplicationContext object exists and has six bean definitions in it — the ones SpringApplication registers itself. Your @Component classes have not been scanned yet, because scanning is what step 5 is about to trigger.
At step 8 there are 240 bean definitions and not one application bean instance. That is the entire point of a BeanFactoryPostProcessor: full knowledge of what is about to be built, zero commitment to building it.
The Started … in 1.228 seconds line is printed before the runners, not after. This contradicts a great deal of advice written for Spring Boot 2. In 4.1.1 SpringApplication.run publishes ApplicationStartedEvent — which is what logs that line's timing — and only then calls the runners, with ApplicationReadyEvent at the very end. If your runner takes nine seconds, the log still says the application started in one.
Everything numbered happens inside SpringApplication.run. Step 34 is the first line of your main method after run returns.
Registering what runs before the context exists
The two callbacks at steps 1 to 4 cannot be beans, because there is no container to hold them. They are discovered from the classpath or handed to SpringApplication directly, and Boot 4 changed both mechanisms.
An EnvironmentPostProcessor is the earliest hook there is. It receives the ConfigurableEnvironment and the SpringApplication, and nothing else exists yet:
package com.example.demo.startup;
import com.example.demo.trace.Trace;
import java.util.Map;
import org.springframework.boot.EnvironmentPostProcessor;
import org.springframework.boot.SpringApplication;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.MapPropertySource;
public class LabEnvironmentPostProcessor implements EnvironmentPostProcessor {
@Override
public void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
environment.getPropertySources().addLast(
new MapPropertySource("lab-defaults", Map.of("lab.origin", "EnvironmentPostProcessor")));
Trace.step("EnvironmentPostProcessor", "added property source lab-defaults; context does not exist yet");
}
}Note the import. In Boot 4 the interface lives in org.springframework.boot, not org.springframework.boot.env. The old type is still on the classpath and still works — a class implementing it fired at step 2 of the trace above — but javap -v on the 4.1.1 jar says what it is:
Deprecated: true
RuntimeVisibleAnnotations:
java.lang.Deprecated(
since="4.0.0"
forRemoval=trueRegistration is still META-INF/spring.factories, keyed on the interface name. This is the one place where spring.factories is not legacy: auto-configurations moved to META-INF/spring/….imports files in Boot 2.7, but EnvironmentPostProcessor never did, because it has to be read before any of that machinery is available.
org.springframework.boot.EnvironmentPostProcessor=\
com.example.demo.startup.LabEnvironmentPostProcessor
org.springframework.context.ApplicationContextInitializer=\
com.example.demo.startup.FactoriesContextInitializerAn ApplicationContextInitializer runs next, once the context object has been constructed and before refresh():
package com.example.demo.startup;
import com.example.demo.trace.Trace;
import org.springframework.context.ApplicationContextInitializer;
import org.springframework.context.ConfigurableApplicationContext;
public class LabContextInitializer
implements ApplicationContextInitializer<ConfigurableApplicationContext> {
@Override
public void initialize(ConfigurableApplicationContext context) {
Trace.step("ApplicationContextInitializer",
context.getClass().getSimpleName() + " exists, refresh() has not started, "
+ context.getBeanFactory().getBeanDefinitionCount() + " bean definitions");
}
}There are three registration routes in the tutorials, and only two of them work on Boot 4.1.1. The spring.factories entry above works — that is step 3. The SpringApplication API works — that is step 4:
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication application = new SpringApplication(DemoApplication.class);
application.addInitializers(new LabContextInitializer());
application.run(args);
Trace.step("main() after run() returned", "the context is up and the runners are done");
}
}The third route, the context.initializer.classes property, is dead. Set as a property and as a command-line argument, it produced nothing:
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --context.initializer.classes=com.example.demo.startup.PropertyContextInitializerThe initializer named there never ran, and no warning was printed either. The reason is visible in the jar: the class that used to implement the property, DelegatingApplicationContextInitializer, is not in spring-boot-4.1.1.jar at all. The same applies to its sibling context.listener.classes. If you inherit a configuration file containing either, it is silently doing nothing.
Between the two live routes, prefer spring.factories when the initializer belongs to a library that has to work wherever it is dropped, and addInitializers when it belongs to this application — it is ordinary code, it is visible from main, and it can take constructor arguments.
BeanFactoryPostProcessor: definitions before instances
Once refresh() starts, the first thing your code can touch is the set of bean definitions — the recipes, not the objects. A BeanFactoryPostProcessor is handed the ConfigurableListableBeanFactory and can read or change every definition in it.
package com.example.demo.startup;
import com.example.demo.pricing.PriceService;
import com.example.demo.trace.Trace;
import java.util.Arrays;
import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.context.EnvironmentAware;
import org.springframework.core.Ordered;
import org.springframework.core.env.Environment;
public class TraceBeanFactoryPostProcessor
implements BeanFactoryPostProcessor, EnvironmentAware, Ordered {
private Environment environment;
@Override
public void setEnvironment(Environment environment) {
this.environment = environment;
}
@Override
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) {
// allowEagerInit = false: read the definitions without instantiating anything
String[] names = beanFactory.getBeanNamesForType(PriceService.class, true, false);
Trace.step("BeanFactoryPostProcessor",
"definitions=" + beanFactory.getBeanDefinitionCount()
+ ", singletons registered=" + beanFactory.getSingletonCount()
+ ", PriceService=" + Arrays.toString(names));
if (environment.getProperty("lab.mark-primary", Boolean.class, true)) {
beanFactory.getBeanDefinition("listPriceService").setPrimary(true);
Trace.step("BeanFactoryPostProcessor",
"marked listPriceService primary; still no PriceService instance exists");
}
}
@Override
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE;
}
}Two details in that class earn their place. The third argument of getBeanNamesForType is allowEagerInit, and passing false is the whole discipline of this extension point: it asks the factory to answer from metadata alone rather than instantiating factory beans to find out. And setPrimary(true) rewrites a definition that a @Component produced, from outside the class, with no annotation on it.
That second one is not cosmetic. There are two PriceService beans and a runner that injects the interface. Switch the marking off and the application does not start:
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --lab.mark-primary=falseAPPLICATION FAILED TO START
***************************
Description:
Parameter 0 of constructor in com.example.demo.runner.PriceRunner required a single bean, but 2 were found:
- listPriceService: defined in URL [jar:nested:/…/demo-0.0.1-SNAPSHOT.jar/!BOOT-INF/classes/!/com/example/demo/pricing/ListPriceService.class]
- promoPriceService: defined in URL [jar:nested:/…/demo-0.0.1-SNAPSHOT.jar/!BOOT-INF/classes/!/com/example/demo/pricing/PromoPriceService.class]
Action:
Consider marking one of the beans as @Primary, updating the consumer to accept multiple beans, or using @Qualifier to identify the bean that should be consumedThe same handle sets setLazyInit, setScope, setDependsOn, setRole or a constructor argument, and removeBeanDefinition deletes a bean outright. This is how you adjust beans you do not own — a third-party auto-configuration, a scanned component in a shared module — without forking them.
The 18 singletons reported at step 8 are container infrastructure registered directly rather than built from a definition: the Environment, systemProperties, systemEnvironment, springApplicationArguments, the logging system, the auto-configuration report, and the post-processors that have already been instantiated to do this work. No application bean is among them.
BeanDefinitionRegistryPostProcessor: one bean per configuration entry
BeanFactoryPostProcessor can edit definitions. Its sub-interface BeanDefinitionRegistryPostProcessor can add them, because it runs earlier and receives the BeanDefinitionRegistry itself. This is how you turn configuration into beans — one bean per entry, with the count known only at runtime.
lab.reports.daily=0 0 6 * * *
lab.reports.weekly=0 0 7 * * MON
lab.reports.monthly=0 0 8 1 * *package com.example.demo.startup;
import com.example.demo.pricing.ReportJob;
import com.example.demo.trace.Trace;
import java.util.Map;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.beans.factory.support.BeanDefinitionBuilder;
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor;
import org.springframework.boot.context.properties.bind.Bindable;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.context.EnvironmentAware;
import org.springframework.core.env.Environment;
/** One ReportJob bean per entry under lab.reports.* in the configuration. */
public class ReportRegistrar implements BeanDefinitionRegistryPostProcessor, EnvironmentAware {
private Environment environment;
@Override
public void setEnvironment(Environment environment) {
this.environment = environment;
Trace.step("EnvironmentAware on the BDRPP", "setEnvironment before postProcessBeanDefinitionRegistry");
}
@Override
public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) {
Map<String, String> reports = Binder.get(environment)
.bind("lab.reports", Bindable.mapOf(String.class, String.class))
.orElseGet(Map::of);
reports.forEach((name, cron) -> {
var definition = BeanDefinitionBuilder.genericBeanDefinition(ReportJob.class)
.addConstructorArgValue(name)
.addConstructorArgValue(cron)
.getBeanDefinition();
registry.registerBeanDefinition("reportJob-" + name, definition);
});
Trace.step("BeanDefinitionRegistryPostProcessor",
"registered " + reports.size() + " ReportJob definitions from lab.reports.*");
}
@Override
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) {
Trace.step("BDRPP.postProcessBeanFactory", "the same object is called again as a plain BeanFactoryPostProcessor");
}
}Both post-processors are declared as static @Bean methods, which is the detail people get wrong:
@Configuration
public class LabConfig {
@Bean
public static ReportRegistrar reportRegistrar() {
return new ReportRegistrar();
}
@Bean
public static TraceBeanFactoryPostProcessor traceBeanFactoryPostProcessor() {
return new TraceBeanFactoryPostProcessor();
}
}static means the container can build the post-processor without first building the @Configuration class that declares it. A non-static method forces the configuration class — and, through it, anything that class injects — into existence far too early, which is exactly the trap two sections below.
The registered beans are ordinary beans from that point on. They go through the full BeanPostProcessor chain — steps 20 to 25 of the trace — and a runner that asks for Map<String, ReportJob> gets all three:
reportJob-daily -> daily @ 0 0 6 * * *
reportJob-weekly -> weekly @ 0 0 7 * * MON
reportJob-monthly -> monthly @ 0 0 8 1 * *Steps 6 and 7 show the other thing to know: a BeanDefinitionRegistryPostProcessor is called twice — once as itself, then again as a plain BeanFactoryPostProcessor, on the same instance. Put registration in the first method and definition edits in the second; do not duplicate work across them.
Note also step 5. EnvironmentAware fired on the post-processor before its callback, even though the post-processor runs before nearly everything else. That works because ApplicationContextAwareProcessor is registered during prepareBeanFactory(), before any post-processor is instantiated. It is the cleanest way to give a BeanFactoryPostProcessor access to configuration, and it is safe precisely because the Environment is not a bean you are forcing into existence.
BeanPostProcessor: wrapping a bean in a proxy
A BeanPostProcessor sees every bean, twice: once before the initialisation callbacks and once after. Both methods return Object, and returning a different object replaces the bean everywhere. That sentence is the whole extension point.
Here is one that wraps any bean carrying a @Timed annotation in a JDK dynamic proxy:
package com.example.demo.startup;
import com.example.demo.pricing.Timed;
import com.example.demo.trace.Trace;
import java.lang.reflect.Proxy;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.core.Ordered;
public class TimingBeanPostProcessor implements BeanPostProcessor, Ordered {
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
Class<?> type = bean.getClass();
if (!type.isAnnotationPresent(Timed.class) || type.getInterfaces().length == 0) {
return bean;
}
Object proxy = Proxy.newProxyInstance(
type.getClassLoader(),
type.getInterfaces(),
(p, method, args) -> {
long start = System.nanoTime();
try {
return method.invoke(bean, args);
} finally {
System.out.printf(" [timed] %s.%s took %.1f ms%n",
type.getSimpleName(), method.getName(), (System.nanoTime() - start) / 1e6);
}
});
Trace.step("BPP.after " + beanName,
"returning a proxy instead: " + proxy.getClass().getName());
return proxy;
}
@Override
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE - 10;
}
}ListPriceService carries @Timed and sleeps 12 ms in price(). PromoPriceService does not. The trace shows exactly one of them being swapped:
16 BPP.before listPriceService ListPriceService
17 BPP.after listPriceService returning a proxy instead: jdk.proxy2.$Proxy94
18 BPP.before promoPriceService PromoPriceService
19 BPP.after promoPriceService PromoPriceServiceand the runner that injects PriceService is holding the proxy, not the service:
31 ApplicationRunner @Order(3) injected PriceService is a jdk.proxy2.$Proxy94
[timed] ListPriceService.price took 15.1 ms
price(SKU-1) -> 19.90
Two consequences follow from the swap. The bean registered under the name listPriceService is the proxy — nothing in the container still points at the original object except the closure inside the InvocationHandler. And postProcessBeforeInitialization is the wrong place to do this: the object would be wrapped before its own @PostConstruct ran, so the initialisation callbacks would fire on the proxy instead of the target.
This is the mechanism behind @Transactional, @Async, @Cacheable and every other annotation that changes behaviour without changing the method body: AnnotationAwareAspectJAutoProxyCreator is a BeanPostProcessor that decides in postProcessAfterInitialization whether any advisor applies to the bean, and returns a proxy when one does. Article 30 of the Basics course covered what that proxy does at call time and why self-invocation escapes it; the AOP article later in this course goes into JDK versus CGLIB proxies properly.
The practical limit of a JDK dynamic proxy is visible in the code: type.getInterfaces(). A bean with no interface cannot be proxied this way, which is why Spring's own infrastructure falls back to a CGLIB subclass and why the AuditService in the next section comes back as AuditService$$SpringCGLIB$$0.
The early-initialization trap
A BeanPostProcessor is created before the beans it processes — it has to be. So anything it depends on is created before it, which means before the post-processor chain is complete. A bean created at that moment misses every post-processor not yet registered.
Here is the mistake, in a form that looks entirely reasonable:
public class BrokenAuditBeanPostProcessor implements BeanPostProcessor, PriorityOrdered {
private final AuditService auditService;
public BrokenAuditBeanPostProcessor(AuditService auditService) {
this.auditService = auditService;
}
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (beanName.equals("listPriceService")) {
auditService.record("listPriceService");
}
return bean;
}
@Override
public int getOrder() {
return PriorityOrdered.HIGHEST_PRECEDENCE;
}
}AuditService is a normal @Service with an @Autowired field, a @PostConstruct and a @Transactional method. Starting the application with that post-processor active prints, at WARN:
WARN PostProcessorRegistrationDelegate$BeanPostProcessorChecker : Bean 'auditService' of type
[com.example.demo.trap.AuditService] is not eligible for getting processed by all BeanPostProcessors
(for example: not eligible for auto-proxying). Is this bean getting eagerly injected/applied to a
currently created BeanPostProcessor [brokenAuditBeanPostProcessor]? Check the corresponding
BeanPostProcessor declaration and its dependencies/advisors. If this bean does not have to be
post-processed, declare it with ROLE_INFRASTRUCTURE.That warning is usually shrugged off. Here is what it actually cost, printed from a runner that injects the same AuditService:
injected AuditService : com.example.demo.trap.AuditService
AopUtils.isAopProxy : false
@PostConstruct ran = false, @Autowired field set = false
record(from the runner) transaction active = falseThree separate failures, none of which throws. There is no AOP proxy, so @Transactional does nothing at all — TransactionSynchronizationManager.isActualTransactionActive() returns false inside the annotated method. @PostConstruct never ran, because CommonAnnotationBeanPostProcessor had not been registered yet. And the @Autowired field is still null, because AutowiredAnnotationBeanPostProcessor had not been registered either.
An application that writes audit rows without a transaction, on a service whose initialisation never happened, starting perfectly and passing its health check: that is what one constructor parameter bought.

The BeanFactoryPostProcessor version of the same mistake is worse, because it is silent:
public class EagerBeanFactoryPostProcessor implements BeanFactoryPostProcessor {
@Override
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) {
AuditService auditService = beanFactory.getBean(AuditService.class);
System.out.println(" BFPP asked for AuditService: " + auditService.getClass().getName());
}
} BFPP asked for AuditService: com.example.demo.trap.AuditService
injected AuditService : com.example.demo.trap.AuditService
AopUtils.isAopProxy : false
@PostConstruct ran = false, @Autowired field set = false
record(from the runner) transaction active = falseIdentical damage, and no warning is printed — grepping the whole startup log for WARN returns nothing. The checker that prints the warning is itself installed at the start of registerBeanPostProcessors(), which happens after all BeanFactoryPostProcessors have run. So the earliest extension point is the one with the least protection.
⚠️ Treat "not eligible for getting processed by all BeanPostProcessors" as an error, not a warning. And treat a
getBeancall inside aBeanFactoryPostProcessoras a bug whether or not anything complains.
The three fixes, and the proof
The fix is always the same shape: ask for the bean later, at a point when the chain is complete. ObjectProvider is the version to reach for.
public class FixedAuditBeanPostProcessor implements BeanPostProcessor, PriorityOrdered {
private final AuditService auditService;
private final ObjectProvider<AuditService> auditService;
public FixedAuditBeanPostProcessor(AuditService auditService) {
public FixedAuditBeanPostProcessor(ObjectProvider<AuditService> auditService) {
this.auditService = auditService;
}
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (beanName.equals("listPriceService")) {
auditService.record("listPriceService");
auditService.getObject().record("listPriceService");
}
return bean;
}
@Override
public int getOrder() {
return PriorityOrdered.HIGHEST_PRECEDENCE;
}
}ObjectProvider is a handle on the lookup, not the result: the container injects it without resolving anything, and getObject() runs the lookup at the moment you call it. Same application, same bean, one parameter different:
record(listPriceService) transaction active = true
injected AuditService : com.example.demo.trap.AuditService$$SpringCGLIB$$0
AopUtils.isAopProxy : true
@PostConstruct ran = true, @Autowired field set = true
record(from the runner) transaction active = trueEvery line flipped, and the WARN is gone — zero WARN lines in the whole startup.
The alternatives are the same idea spelled differently. @Lazy on the constructor parameter also works — the same post-processor taking @Lazy AuditService produced a proxied, fully initialised bean and no warning — because the lazy proxy defers the real lookup to the first method call; it is less explicit than ObjectProvider and it puts a second proxy in the picture. Implementing BeanFactoryAware and calling beanFactory.getBean(AuditService.class) inside the callback works for the same reason and is the only option when the dependency is chosen at runtime, but it hides the dependency from every reader.
The fourth answer is usually the right one: do not have the dependency. A post-processor is infrastructure. If it needs an application service, the design is normally inverted — publish an event from the post-processor, or do the work in a SmartInitializingSingleton or an ApplicationRunner, where every bean is finished and none of this applies.
Ordering post-processors: Ordered, PriorityOrdered and @Order
Four BeanPostProcessors, deliberately contradictory:
| Class | How it declares its order |
|---|---|
Alpha | implements PriorityOrdered, getOrder() returns 5000 — a low precedence number |
Beta | implements Ordered, getOrder() returns HIGHEST_PRECEDENCE |
Gamma | @Order(Ordered.HIGHEST_PRECEDENCE) on the class, no interface |
Delta | nothing at all |
They are declared as @Bean methods in the order Delta, Gamma, Beta, Alpha. Each prints its name from postProcessBeforeInitialization. The measured order:
Alpha PriorityOrdered, getOrder() = 5000
Beta Ordered, getOrder() = HIGHEST_PRECEDENCE
Delta no ordering at all
Gamma @Order(HIGHEST_PRECEDENCE), no interfaceTwo rules fall out of those four lines, and the second is the one that catches people.
PriorityOrdered beats Ordered regardless of the numbers. Alpha asked for precedence 5000 and still ran before Beta, which asked for Integer.MIN_VALUE. PostProcessorRegistrationDelegate sorts into three buckets — PriorityOrdered, then Ordered, then everything else — and only sorts within a bucket. The bucket wins.
@Order on a BeanPostProcessor does nothing. Gamma asked for the highest precedence there is and ran last. Bucket selection is an isTypeMatch check against the PriorityOrdered and Ordered interfaces, so an annotation cannot put a processor into an ordered bucket; and the unordered bucket is registered without being sorted, so the annotation has no effect there either. Gamma ran after Delta simply because Delta's @Bean method comes first. The fix is one interface:
@Order(Ordered.HIGHEST_PRECEDENCE)
public static class Gamma implements BeanPostProcessor {
public static class Gamma implements BeanPostProcessor, Ordered {
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE;
} A runner that walks ((AbstractBeanFactory) beanFactory).getBeanPostProcessors() prints the finished chain, which is where Boot's own infrastructure becomes visible:
1 - org.springframework.context.support.ApplicationContextAwareProcessor
2 - org.springframework.boot.web.server.servlet.context.WebApplicationContextServletContextAwareProcessor
3 - org.springframework.context.annotation.ConfigurationClassPostProcessor$ImportAwareBeanPostProcessor
4 - org.springframework.context.support.PostProcessorRegistrationDelegate$BeanPostProcessorChecker
5 PriorityOrdered org.springframework.boot.context.properties.ConfigurationPropertiesBindingPostProcessor
6 PriorityOrdered org.springframework.boot.jdbc.autoconfigure.HikariJdbcConnectionDetailsBeanPostProcessor
7 PriorityOrdered com.example.demo.order.OrderedBpps$Alpha
8 Ordered com.example.demo.order.OrderedBpps$Beta
9 Ordered org.springframework.aop.aspectj.annotation.AnnotationAwareAspectJAutoProxyCreator
10 Ordered com.example.demo.startup.TimingBeanPostProcessor
11 Ordered org.springframework.dao.annotation.PersistenceExceptionTranslationPostProcessor
12 - com.example.demo.order.OrderedBpps$Delta
13 - com.example.demo.order.OrderedBpps$Gamma
14 - com.example.demo.startup.TraceBeanPostProcessor
15 - org.springframework.data.web.config.ProjectingArgumentResolverRegistrar$ProjectingArgumentResolverBeanPostProcessor
16 - org.springframework.boot.web.server.WebServerFactoryCustomizerBeanPostProcessor
17 - org.springframework.boot.web.error.ErrorPageRegistrarBeanPostProcessor
18 PriorityOrdered org.springframework.orm.jpa.support.PersistenceAnnotationBeanPostProcessor
19 PriorityOrdered org.springframework.context.annotation.CommonAnnotationBeanPostProcessor
20 PriorityOrdered org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor
21 - org.springframework.context.support.ApplicationListenerDetectorEntries 1 to 4 are added to the chain directly by the container rather than resolved as ordered beans, so they sit outside the sort entirely — which is why ApplicationContextAwareProcessor, and therefore EnvironmentAware and friends, always runs first. Entries 18 to 20 are Spring's own annotation processors, pulled out of the sort and re-appended at the end, PriorityOrdered notwithstanding. That is the mechanical reason your postProcessBeforeInitialization always sees a bean before its @PostConstruct has run, whatever order you give it — the Basics article demonstrated the symptom; entry 19 is the cause.
Entry 9 is the one to keep in view: the AOP auto-proxy creator is an Ordered processor at LOWEST_PRECEDENCE, so a processor of yours in the PriorityOrdered bucket sees beans before they are proxied, and one in the unordered bucket sees the proxy.
The Aware interfaces beyond the first three
Basics 9 traced BeanNameAware, BeanFactoryAware and ApplicationContextAware. There are a dozen more; four are worth knowing:
@Component
public class AwareBean implements EnvironmentAware, ResourceLoaderAware,
ApplicationEventPublisherAware, BeanClassLoaderAware {
@Override
public void setEnvironment(Environment environment) {
Trace.step("EnvironmentAware", "lab.origin=" + environment.getProperty("lab.origin"));
}
@Override
public void setResourceLoader(ResourceLoader resourceLoader) {
Trace.step("ResourceLoaderAware", resourceLoader.getClass().getSimpleName());
}
@Override
public void setApplicationEventPublisher(ApplicationEventPublisher publisher) {
Trace.step("ApplicationEventPublisherAware", publisher.getClass().getSimpleName());
}
@Override
public void setBeanClassLoader(ClassLoader classLoader) {
Trace.step("BeanClassLoaderAware", classLoader.getClass().getSimpleName());
}
}The run splits them into two groups, which is not obvious from the source:
10 BeanClassLoaderAware LaunchedClassLoader
11 EnvironmentAware lab.origin=EnvironmentPostProcessor
12 ResourceLoaderAware AnnotationConfigServletWebServerApplicationContext
13 ApplicationEventPublisherAware AnnotationConfigServletWebServerApplicationContext
14 BPP.before awareBean AwareBeanBeanClassLoaderAware is invoked by the bean factory itself, in invokeAwareMethods, alongside BeanNameAware and BeanFactoryAware. The other three are invoked by ApplicationContextAwareProcessor — entry 1 of the chain above — which is a BeanPostProcessor doing its work in postProcessBeforeInitialization. Hence the gap at step 13: everything the context knows arrives through one processor, and your own processors see the bean afterwards.
Note also steps 12 and 13: ResourceLoader and ApplicationEventPublisher are both the ApplicationContext, because ApplicationContext implements both interfaces. The narrow interfaces exist so your class does not have to say it wants the whole container.
| Interface | What it hands you | Use it when |
|---|---|---|
EnvironmentAware | the Environment | infrastructure that reads configuration before binding exists |
ResourceLoaderAware | a ResourceLoader for classpath: and file: URLs | loading resources whose location is computed |
ApplicationEventPublisherAware | the event publisher | publishing events from a class that must not depend on the context |
BeanClassLoaderAware | the loader that loaded the bean class | reflection, Class.forName, building a JDK proxy |
For application code, the answer is almost always constructor injection instead. Environment, ResourceLoader, ApplicationEventPublisher and ApplicationContext are all injectable as ordinary constructor parameters; that keeps the field final, keeps the class testable with plain new, and keeps a Spring interface out of your type signature. The Aware interfaces earn their place in exactly one situation — a class the container creates before injection is available, which is to say a BeanFactoryPostProcessor, a BeanPostProcessor or an ApplicationContextInitializer. ReportRegistrar above uses EnvironmentAware for precisely that reason.
ApplicationRunner and CommandLineRunner
Both run after the context is refreshed and before run() returns. The only difference is the argument.
@Order(1)
@Component
public class ArgumentRunner implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) {
Trace.step("ApplicationRunner @Order(1)", "optionNames=" + args.getOptionNames()
+ " nonOptionArgs=" + args.getNonOptionArgs());
System.out.println(" --tag -> " + args.getOptionValues("tag"));
System.out.println(" --lab.mode -> " + args.getOptionValues("lab.mode"));
System.out.println(" containsOption(v)-> " + args.containsOption("v"));
System.out.println(" getSourceArgs -> " + java.util.Arrays.toString(args.getSourceArgs()));
}
}Run against --lab.mode=full --tag=alpha --tag=beta -v report.csv:
29 ApplicationRunner @Order(1) optionNames=[lab.mode, tag] nonOptionArgs=[-v, report.csv]
--tag -> [alpha, beta]
--lab.mode -> [full]
containsOption(v)-> false
getSourceArgs -> [--lab.mode=full, --tag=alpha, --tag=beta, -v, report.csv]
30 CommandLineRunner @Order(2) raw args=[--lab.mode=full, --tag=alpha, --tag=beta, -v, report.csv]ApplicationArguments has already done the parsing. An option argument is exactly --name or --name=value; everything else is a non-option argument. So -v with a single dash is not an option — containsOption("v") is false and -v lands in getNonOptionArgs() next to report.csv. A repeated option accumulates: --tag twice gives a two-element list. CommandLineRunner gets the same array Boot got, unparsed, which is what getSourceArgs() returns on the other interface.
Use ApplicationRunner unless you have a reason not to. CommandLineRunner is worth it only when you are handing the arguments to a library that does its own parsing.
@Order does work between runners — unlike on a BeanPostProcessor — because SpringApplication.callRunners sorts the runner beans with AnnotationAwareOrderComparator, which reads the annotation. Both interfaces are sorted together in one list, so an ApplicationRunner at @Order(1) runs before a CommandLineRunner at @Order(2), as the trace shows.
The position relative to the log is the part people get backwards:
27 ContextRefreshedEvent refresh() finished
INFO DemoApplication : Started DemoApplication in 1.228 seconds (process running for 1.42)
28 ApplicationStartedEvent published just before the runners
29 ApplicationRunner @Order(1) ...
33 ApplicationReadyEvent published after the last runner returnedThe web server is already accepting connections at step 27, and the "Started" line is already printed. A runner is not a startup gate: traffic can reach the application while it is still running. If you need work to finish before the instance takes traffic, it belongs in a @PostConstruct, a SmartInitializingSingleton, or a readiness probe you control — not a runner.
What an exception in a runner does
A runner that throws takes the whole application down. Here is @Order(4) throwing, with @Order(5) still to come:
32 ApplicationRunner @Order(4) about to throw
Error starting ApplicationContext. To display the condition evaluation report re-run your application with 'debug' enabled.
ERROR SpringApplication : Application run failed
com.example.demo.runner.RunnerFailure: the runner failed on purpose
at com.example.demo.runner.FailingRunner.run(FailingRunner.java:18)
at org.springframework.boot.SpringApplication.lambda$callRunner$0(SpringApplication.java:788)
...
INFO GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete
INFO GracefulShutdown : Graceful shutdown complete
INFO LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'default'
INFO HikariDataSource : HikariPool-1 - Shutdown initiated...
INFO HikariDataSource : HikariPool-1 - Shutdown completed.Four observable facts. The @Order(5) runner never ran — the chain stops at the first failure. The context is closed, not left dangling: Tomcat drains and stops, and the entity manager factory and the connection pool are shut down through the normal destruction callbacks. curl against port 8202 immediately afterwards returns nothing at all, the connection refused. And the process exits non-zero.
The exit code is 1 by default. Make the exception implement ExitCodeGenerator and you choose it:
package com.example.demo.runner;
import org.springframework.boot.ExitCodeGenerator;
public class RunnerFailure extends RuntimeException implements ExitCodeGenerator {
public RunnerFailure(String message) {
super(message);
}
@Override
public int getExitCode() {
return 42;
}
}java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --lab.fail-runner=true ; echo "exit=$?"exit=42An ExitCodeGenerator bean does the same for a normal shutdown, and ExitCodeExceptionMapper maps exception types to codes without touching the exception classes — which is what you want for a batch job whose orchestrator reads the code.
Extension points at a glance
| Extension point | When it runs | What it can see | Typical use | Cheaper alternative |
|---|---|---|---|---|
EnvironmentPostProcessor | before the context is created | the Environment and the SpringApplication | adding a property source from a vault, a file, a remote config service | spring.config.import, a @ConfigurationProperties default |
ApplicationContextInitializer | context created, before refresh() | the context object, profiles, its bean factory | registering a BeanFactoryPostProcessor, setting profiles from code | SpringApplication.setAdditionalProfiles, an auto-configuration |
BeanDefinitionRegistryPostProcessor | first thing in refresh() | the BeanDefinitionRegistry | registering N beans from configuration or a scan | a @Bean returning a Map or List, when N is known at compile time |
BeanFactoryPostProcessor | after all definitions are registered | every BeanDefinition, no instances | marking primary or lazy, editing a definition you do not own | @Primary, @Lazy, @Qualifier on your own code |
BeanPostProcessor | around every bean's init callbacks | each bean instance, twice | wrapping a bean in a proxy, validating, injecting a custom annotation | an @Aspect, a @Bean method that wraps it once |
Aware interfaces | during bean creation | one container object each | infrastructure classes created before injection works | constructor injection, in nearly all application code |
SmartInitializingSingleton | end of refresh(), before the runners | every non-lazy singleton, finished | cross-bean wiring that needs all beans present | @EventListener(ContextRefreshedEvent.class) |
ApplicationRunner / CommandLineRunner | after refresh(), inside run() | the whole live context plus the arguments | one-shot startup work, CLI applications | @EventListener(ApplicationReadyEvent.class) |
Read the last column as the default. Most jobs people reach for a post-processor to do are better done with an annotation, an auto-configuration or an event; the extension points earn their keep when you have to act on beans you do not own, or on a count that is not known until runtime.
FAQ
What is the difference between BeanFactoryPostProcessor and BeanPostProcessor?
They operate on different things at different times. A BeanFactoryPostProcessor runs once, early in refresh(), and sees bean definitions — the metadata — before any application bean is instantiated; it can edit, add or remove them. A BeanPostProcessor runs twice for every bean instance, around that bean's initialisation callbacks, and can replace the instance with something else. The names are close enough to be confusing and the phases are not adjacent: every BeanFactoryPostProcessor in the context has finished before the first BeanPostProcessor is registered.
Why does my BeanPostProcessor ignore @Order?
Because bucket selection uses isTypeMatch against the PriorityOrdered and Ordered interfaces, and the leftover bucket is registered without being sorted. A processor carrying only @Order therefore lands in the unordered group and runs after everything ordered, whatever number the annotation names — measured above, where @Order(HIGHEST_PRECEDENCE) ran last of four. Implement Ordered (or PriorityOrdered) and return the value from getOrder(). Between runners, on the other hand, @Order works fine, because callRunners sorts with AnnotationAwareOrderComparator.
What causes "is not eligible for getting processed by all BeanPostProcessors"?
Something that is being created during the post-processor registration phase pulled an ordinary bean into existence with it — usually a BeanPostProcessor with a constructor dependency, or a non-static @Bean method declaring one, where the enclosing @Configuration class drags its own dependencies along. The bean is built with only the part of the chain registered so far, so it can lose its AOP proxy, its @PostConstruct and its @Autowired fields, all silently. Inject ObjectProvider<T> instead and call getObject() inside the callback.
Does context.initializer.classes still work in Spring Boot 4?
No. DelegatingApplicationContextInitializer, the class that read that property, is not in spring-boot-4.1.1.jar, and setting the property in application.properties or on the command line produced no output and no warning in the run above. The same goes for context.listener.classes. Register an ApplicationContextInitializer through META-INF/spring.factories or SpringApplication.addInitializers instead.
Where do I register an EnvironmentPostProcessor in Spring Boot 4?
In META-INF/spring.factories, keyed on org.springframework.boot.EnvironmentPostProcessor — the interface moved out of the org.springframework.boot.env package in Boot 4.0, and the old type is @Deprecated(since = "4.0.0", forRemoval = true) even though it still functions. This is not the META-INF/spring/….imports mechanism that auto-configurations use; environment post-processors run long before that machinery is available, so spring.factories remains correct for them.
Should I use ApplicationRunner or an ApplicationReadyEvent listener?
They run at almost the same moment, and — measured here — they fail the same way: an exception from a runner and an exception from an ApplicationReadyEvent listener each log Application run failed, close the context gracefully and exit non-zero. So pick on ergonomics. Use ApplicationRunner when you want the command-line arguments already parsed and ordering between several pieces of startup work. Use @EventListener(ApplicationReadyEvent.class) when the work belongs to a bean that exists for another reason anyway, or when you want @Async on it.
Conclusion
The container's startup is not a black box with one hook in it. Traced on Boot 4.1.1 it runs EnvironmentPostProcessor before the context exists, ApplicationContextInitializer before refresh(), BeanDefinitionRegistryPostProcessor and then BeanFactoryPostProcessor over the definitions, the Aware callbacks and both BeanPostProcessor methods around every bean, SmartInitializingSingleton at the end of refresh(), and finally the runners — after the Started … in line has already been printed, not before it. Registration moved in Boot 4: spring.factories for EnvironmentPostProcessor and ApplicationContextInitializer, and context.initializer.classes does nothing at all.
The two rules worth carrying out of this are both measured above. A BeanPostProcessor that returns a different object replaces the bean everywhere, which is how @Transactional and every other behavioural annotation is implemented. And a post-processor that injects an ordinary bean creates it too early — no AOP proxy, no @PostConstruct, no @Autowired field, a @Transactional method with no transaction, and either a warning nobody reads or, from a BeanFactoryPostProcessor, no warning at all. ObjectProvider costs one word and removes the whole class of bug.
The next article opens the proxy itself: AOP and the proxying mechanism — JDK dynamic proxy against CGLIB, @Aspect, pointcuts and advice, and the self-invocation bug that makes an annotation quietly do nothing.