Command Palette

Search for a command to run...

[Advanced Spring Boot] Extending the Spring Container: BeanFactoryPostProcessor, BeanPostProcessor and Aware

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.

Four numbered stops on a timeline from SpringApplication.run to the application being ready

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.

src/main/java/com/example/demo/trace/Trace.java
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:

Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --lab.mode=full --tag=alpha --tag=beta -v report.csv

produces this. Boot's own log is removed except for the two lines that matter, which are left exactly where they appeared:

Text
 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

The eight phases of a Spring Boot 4.1.1 startup with the step numbers each one printed

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:

src/main/java/com/example/demo/startup/LabEnvironmentPostProcessor.java
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:

Text
Deprecated: true
RuntimeVisibleAnnotations:
    java.lang.Deprecated(
      since="4.0.0"
      forRemoval=true

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

src/main/resources/META-INF/spring.factories
org.springframework.boot.EnvironmentPostProcessor=\
com.example.demo.startup.LabEnvironmentPostProcessor
 
org.springframework.context.ApplicationContextInitializer=\
com.example.demo.startup.FactoriesContextInitializer

An ApplicationContextInitializer runs next, once the context object has been constructed and before refresh():

src/main/java/com/example/demo/startup/LabContextInitializer.java
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:

src/main/java/com/example/demo/DemoApplication.java
@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:

Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --context.initializer.classes=com.example.demo.startup.PropertyContextInitializer

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

src/main/java/com/example/demo/startup/TraceBeanFactoryPostProcessor.java
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:

Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --lab.mark-primary=false
Text
APPLICATION 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 consumed

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

src/main/resources/application.properties
lab.reports.daily=0 0 6 * * *
lab.reports.weekly=0 0 7 * * MON
lab.reports.monthly=0 0 8 1 * *
src/main/java/com/example/demo/startup/ReportRegistrar.java
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:

src/main/java/com/example/demo/startup/LabConfig.java
@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:

Text
    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:

src/main/java/com/example/demo/startup/TimingBeanPostProcessor.java
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:

Text
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

and the runner that injects PriceService is holding the proxy, not the service:

Text
31  ApplicationRunner @Order(3)      injected PriceService is a jdk.proxy2.$Proxy94
    [timed] ListPriceService.price took 15.1 ms
    price(SKU-1)     -> 19.90

A bean passing through the two BeanPostProcessor methods, the second returning a proxy the caller then holds

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:

src/main/java/com/example/demo/trap/BrokenAuditBeanPostProcessor.java
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:

Text
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:

Text
    injected AuditService : com.example.demo.trap.AuditService
    AopUtils.isAopProxy   : false
    @PostConstruct ran = false, @Autowired field set = false
    record(from the runner) transaction active = false

Three 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 same AuditService with and without an ObjectProvider, side by side

The BeanFactoryPostProcessor version of the same mistake is worse, because it is silent:

src/main/java/com/example/demo/trap/EagerBeanFactoryPostProcessor.java
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());
    }
}
Text
    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 = false

Identical 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 getBean call inside a BeanFactoryPostProcessor as 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.

src/main/java/com/example/demo/trap/FixedAuditBeanPostProcessor.java
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:

Text
    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 = true

Every 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:

ClassHow it declares its order
Alphaimplements PriorityOrdered, getOrder() returns 5000 — a low precedence number
Betaimplements Ordered, getOrder() returns HIGHEST_PRECEDENCE
Gamma@Order(Ordered.HIGHEST_PRECEDENCE) on the class, no interface
Deltanothing at all

They are declared as @Bean methods in the order Delta, Gamma, Beta, Alpha. Each prints its name from postProcessBeforeInitialization. The measured order:

Text
    Alpha   PriorityOrdered, getOrder() = 5000
    Beta    Ordered, getOrder() = HIGHEST_PRECEDENCE
    Delta   no ordering at all
    Gamma   @Order(HIGHEST_PRECEDENCE), no interface

Two 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:

Java
@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:

Text
     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.ApplicationListenerDetector

Entries 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:

src/main/java/com/example/demo/aware/AwareBean.java
@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:

Text
10  BeanClassLoaderAware             LaunchedClassLoader
11  EnvironmentAware                 lab.origin=EnvironmentPostProcessor
12  ResourceLoaderAware              AnnotationConfigServletWebServerApplicationContext
13  ApplicationEventPublisherAware   AnnotationConfigServletWebServerApplicationContext
14  BPP.before  awareBean            AwareBean

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

InterfaceWhat it hands youUse it when
EnvironmentAwarethe Environmentinfrastructure that reads configuration before binding exists
ResourceLoaderAwarea ResourceLoader for classpath: and file: URLsloading resources whose location is computed
ApplicationEventPublisherAwarethe event publisherpublishing events from a class that must not depend on the context
BeanClassLoaderAwarethe loader that loaded the bean classreflection, 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.

src/main/java/com/example/demo/runner/ArgumentRunner.java
@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:

Text
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:

Text
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 returned

The 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:

Text
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:

src/main/java/com/example/demo/runner/RunnerFailure.java
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;
    }
}
Bash
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --lab.fail-runner=true ; echo "exit=$?"
Text
exit=42

An 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 pointWhen it runsWhat it can seeTypical useCheaper alternative
EnvironmentPostProcessorbefore the context is createdthe Environment and the SpringApplicationadding a property source from a vault, a file, a remote config servicespring.config.import, a @ConfigurationProperties default
ApplicationContextInitializercontext created, before refresh()the context object, profiles, its bean factoryregistering a BeanFactoryPostProcessor, setting profiles from codeSpringApplication.setAdditionalProfiles, an auto-configuration
BeanDefinitionRegistryPostProcessorfirst thing in refresh()the BeanDefinitionRegistryregistering N beans from configuration or a scana @Bean returning a Map or List, when N is known at compile time
BeanFactoryPostProcessorafter all definitions are registeredevery BeanDefinition, no instancesmarking primary or lazy, editing a definition you do not own@Primary, @Lazy, @Qualifier on your own code
BeanPostProcessoraround every bean's init callbackseach bean instance, twicewrapping a bean in a proxy, validating, injecting a custom annotationan @Aspect, a @Bean method that wraps it once
Aware interfacesduring bean creationone container object eachinfrastructure classes created before injection worksconstructor injection, in nearly all application code
SmartInitializingSingletonend of refresh(), before the runnersevery non-lazy singleton, finishedcross-bean wiring that needs all beans present@EventListener(ContextRefreshedEvent.class)
ApplicationRunner / CommandLineRunnerafter refresh(), inside run()the whole live context plus the argumentsone-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.

Related Posts

[Advanced Spring Boot] Caching in Spring Boot: the Cache Abstraction, Caffeine, Redis and Invalidation

Spring Boot 4.1.1 caching on a product lookup with PostgreSQL and Redis: why @EnableCaching is still required, @Cacheable, @CachePut, @CacheEvict with allEntries and @Caching, SimpleKey and SpEL keys, condition, unless and cached Optional nulls, the LazyInitializationException a cached entity causes, Caffeine eviction and the cache.gets metrics, why Boot picks Redis over Caffeine, JDK serialization versus GenericJacksonJsonRedisSerializer for Jackson 3 and the type validator that rejects BigDecimal, the two serializer configurations that silently drop settings, latency with no cache, Caffeine and Redis, a cache stampede that sync = true does not stop on Redis, the evict-before-commit race and transactionAware(), stale reads across two instances, and what a stopped or hung Redis does to requests with and without a CacheErrorHandler.

[Advanced Spring Boot] Writing Your Own Auto-configuration and Starter

Build and ship a real Spring Boot 4.1.1 starter: the three Gradle projects and the x-spring-boot-starter naming rule, an @AutoConfiguration class with @ConditionalOnMissingBean and @ConditionalOnProperty, registration in AutoConfiguration.imports, ordering with before/after against a Boot auto-configuration, a validated @ConfigurationProperties record with generated spring-configuration-metadata.json, a custom SpringBootCondition with its ConditionOutcome message in the report, five ApplicationContextRunner tests including FilteredClassLoader, publishing to mavenLocal and consuming it, and a FailureAnalyzer for the misconfiguration.

[Advanced Spring Boot] Spring AOP: JDK and CGLIB Proxies, Aspects and Self-Invocation

Spring AOP on Spring Boot 4.1.1: spring-boot-starter-aop is gone from the BOM and spring-boot-starter-aspectj replaces it, JDK dynamic proxy against CGLIB subclass with the real class names, the ClassCastException a JDK proxy causes, the final class that throws AopConfigException, the final method that quietly NPEs because Objenesis skipped the constructor, the pointcut designators that matter, the measured order of all five advice kinds on both paths, @Order between aspects, the self-invocation trap underneath @Transactional and @Async with three fixes compared, the nanosecond cost of a proxied call, and Advised#getAdvisors for debugging.

[Advanced Spring Boot] NoSQL with Spring Data: MongoDB and Redis

Spring Data MongoDB and Spring Data Redis on Spring Boot 4.1.1: @Document, String vs ObjectId ids, the _class field, embedded vs @DocumentReference with the queries each sends, MongoRepository and MongoTemplate with the logged commands, $push/$inc vs load-modify-save under 50 threads, @Version, whether Boot creates @Indexed indexes, COLLSCAN vs IXSCAN on 300,000 documents, an aggregation pipeline, transactions on a replica set, a renamed field, RedisTemplate serialization, INCR, sorted sets, hashes, TTL, @RedisHash keys and phantom keys, pipelining and Lettuce.