Skip to main content

CommandLineRunner vs ApplicationRunner vs ApplicationReadyEvent in Spring Boot 4

Which Spring Boot startup hook should you use? A beginner-first comparison on Spring Boot 4.1.2 of CommandLineRunner, ApplicationRunner, an ApplicationReadyEvent listener and SmartLifecycle, with the exact order they run in, how @Order sorts both runner kinds in one list (and why unordered ties come out differently on different JVMs), what happens when a runner or a ready-event listener throws, how a blocking runner holds readiness at 503 while the port is already open, and what tests run. Every figure comes from a companion project whose test suite writes the transcripts.

You need some code to run once, when your Spring Boot application starts: load a lookup table, check that a file exists, print the effective configuration, kick off a warm-up. You search, and Spring Boot offers you three tools that look identical — CommandLineRunner, ApplicationRunner and a listener for ApplicationReadyEvent — and a fourth, SmartLifecycle, that people mention when the first three stop being enough. This article tells you which one to pick, and then goes further than the usual answer. The usual answer is “the runners get different arguments”, which is true and is the least interesting difference. The differences that cost people days are about order (what runs before what, and what happens when you do not say), failure (a runner that throws takes the whole application down; so does a ready-event listener, after the application was already “ready”) and blocking (a slow runner keeps your readiness probe red while the port is already open). Everything below was run, not remembered. The companion project is asmhatre/spring-boot-demo/startup-runners; its test suite writes the transcripts in output/ that every console block here is copied from, and the ordering claims were additionally checked against the Spring Boot sources and with javap. If you only want the answer: use CommandLineRunner when you want the raw arguments, ApplicationRunner when you want parsed --options, an ApplicationReadyEvent listener when you want to react to “the app has started” without arguments, and SmartLifecycle when the thing you start also has to be stopped. The rest of the page is why, with the traps.
Versions. Spring Boot 4.1.2 (the newest GA at the time of writing, on Maven Central since 2026-10-22; the <release> element in Maven Central’s metadata currently points at 4.2.0-RC2, which is a release candidate, not a release), Spring Framework 7.0.10, JDK 25 (25.0.3), Maven 3.9. Ordering facts were read from the shipped sources and with javap; the contrast with older Boot lines (3.0.13 and 3.1.12) is from their sources only — I did not run those.

All of them run after your beans exist — the difference is where, and what happens on failure

Picture startup as a short line of events. Spring creates your beans, finishes refreshing the application context, and starts any lifecycle beans. Spring Boot then announces ApplicationStartedEvent, calls every runner, and only then announces ApplicationReadyEvent and flips the application’s readiness to “accepting traffic”. Your three candidates sit at different points on that line.
1. beans built@PostConstructafterPropertiesSet 2. lifecycleSmartLifecycle.start() 3. refreshedContextRefreshedEvent 4. startedApplicationStartedEvent, liveness UP 5. runnersCommandLineRunnerApplicationRunner 6. readyApplicationReadyEvent too early for“the app is up” still “starting”:readiness is refusing traffic “started”, thenreadiness flips Runners run between step 4 and step 6. If one is slow, steps 6 and the readiness flip wait for it. If one throws, step 6 never happens: the application stops instead. The order of the boxes is the one printed by a real run in the next sections.
That picture is the whole article in one line: the runners sit inside startup, before the application is declared ready, which is why a runner can delay readiness and why its failure is a startup failure. The sections below prove each part of it. First, the smallest working program. Going deeper:

The smallest working example: one line from each hook

Here is a whole application with all three hooks, each printing one line. This is HelloApp.java (the two @Order lines are explained two sections down; ignore them for now):
    @Bean
    @Order(1)
    CommandLineRunner commandLineRunner() {
        return args -> System.out.println("hello: CommandLineRunner  args = " + Arrays.toString(args));
    }

    @Bean
    @Order(2)
    ApplicationRunner applicationRunner() {
        return args -> System.out.println("hello: ApplicationRunner  options = " + args.getOptionNames()
                + ", non-option args = " + args.getNonOptionArgs());
    }

    @EventListener(ApplicationReadyEvent.class)
    void onReady() {
        System.out.println("hello: ApplicationReadyEvent listener");
    }
Started with --greeting=hi --verbose notes.txt, it prints this, copied from output/00-hello.txt:
hello: CommandLineRunner  args = [--greeting=hi, --verbose, notes.txt]
hello: ApplicationRunner  options = [greeting, verbose], non-option args = [notes.txt]
hello: ApplicationReadyEvent listener
process exit code: 0
Three things to notice. Both runners were given the command line, but in different shapes: the CommandLineRunner got the arguments as the plain strings you typed, while the ApplicationRunner got an ApplicationArguments object that has already split them into options (--greeting=hi, --verbose) and non-option arguments (notes.txt). The ready listener got nothing, because it is an event listener, not a runner. And the listener printed last. Going deeper:

They always run in one fixed sequence — and readiness only flips at the very end

Do not trust a diagram, including the one above; print the order. The test behind output/01-startup-hooks-in-order.txt registers a listener or hook for every step and runs the application twice, once without a web server and once with Tomcat. Each runner and the ready listener also asks ApplicationAvailability what state the application believes it is in. The second half, with the web server:
--- servlet web application (Tomcat on a random port) ---
@PostConstruct
InitializingBean.afterPropertiesSet
SmartInitializingSingleton.afterSingletonsInstantiated
WebServerInitializedEvent (port is open)
SmartLifecycle.start (default phase)
ContextRefreshedEvent
ApplicationStartedEvent
AvailabilityChangeEvent -> CORRECT
CommandLineRunner       (readiness seen from inside: REFUSING_TRAFFIC, liveness: CORRECT)
ApplicationRunner       (readiness seen from inside: REFUSING_TRAFFIC)
ApplicationReadyEvent   (readiness seen from inside: REFUSING_TRAFFIC)
AvailabilityChangeEvent -> ACCEPTING_TRAFFIC
Read it top to bottom. The three bean-level callbacks come first (the last section of this part shows why they are “too early”). Tomcat is already listening before ContextRefreshedEvent. Then ApplicationStartedEvent and the liveness state CORRECT, then both runners, then ApplicationReadyEvent, and only after that listener returns does readiness become ACCEPTING_TRAFFIC. So from inside a runner and from inside a ready listener, the application still reports REFUSING_TRAFFIC. If you expected “ready event means readiness is already UP”, that is the first surprise; the source explains it, because the run listener publishes the event first and the availability change second:
	public void started(ConfigurableApplicationContext context, @Nullable Duration timeTaken) {
		context.publishEvent(new ApplicationStartedEvent(this.application, this.args, context, timeTaken));
		AvailabilityChangeEvent.publish(context, LivenessState.CORRECT);
	}
	public void ready(ConfigurableApplicationContext context, @Nullable Duration timeTaken) {
		context.publishEvent(new ApplicationReadyEvent(this.application, this.args, context, timeTaken));
		AvailabilityChangeEvent.publish(context, ReadinessState.ACCEPTING_TRAFFIC);
	}
That is EventPublishingRunListener from the 4.1.2 sources, quoted from output/15-callrunners-source.txt.
Which one is “first”? Runners come before the ready listener, always. There is no setting that puts an ApplicationReadyEvent listener ahead of a runner, and no way to make a runner run after it. If you need “after everything, including all runners”, the ready event is that place. If you need “before the app is declared ready, in an order I control”, a runner is that place.
Why @PostConstruct, InitializingBean and ContextRefreshedEvent are the wrong place for startup work
They are not wrong tools; they answer a different question. @PostConstruct and afterPropertiesSet() run while one bean is being built, which can be long before other beans exist and, in a web application, before the web server accepts a request. afterSingletonsInstantiated() runs once all singleton beans exist, still before the lifecycle beans start. In the transcript the web server opens its port after all three and before the default-phase SmartLifecycle.start. ContextRefreshedEvent looks like “the app has started” and is the classic wrong choice: it fires before ApplicationStartedEvent, before any runner, and, according to the Spring documentation (I did not run this part), it is published again for a child context or an explicit refresh(), so a handler that does real work may do it more than once. Use it for Spring-level setup that must follow a refresh; use the Boot events for “the application has started”. The probe class behind the transcript is LifecycleProbe.java; the event side is EventProbe.java.
Where the web server sits among SmartLifecycle beans
The web server is itself a SmartLifecycle. Listing every lifecycle bean in a web application with its phase gives output/10-lifecycle-beans-in-a-web-app.txt:
phase -2147483647  springBootLoggingLifecycle     Lifecycle
phase        -100  low                            (this project)
phase           0  mid                            (this project)
phase          50  manual                         (this project)
phase         100  high                           (this project)
phase         200  slow                           (this project)
phase  1073741823  applicationTaskExecutor        ThreadPoolTaskExecutor
phase  2147481599  webServerStartStop             WebServerStartStopLifecycle
phase  2147482623  webServerGracefulShutdown      WebServerGracefulShutdownLifecycle
SmartLifecycle.DEFAULT_PHASE = 2147483647
Integer.MAX_VALUE            = 2147483647
timeout per shutdown phase   = 30000 ms (spring.lifecycle.timeout-per-shutdown-phase, set by Boot)
Spring Boot 4.1.2, Spring Framework 7.0.10
webServerStartStop has phase 2147481599, which is Integer.MAX_VALUE minus 2048, and a bean that does not override getPhase() gets DEFAULT_PHASE, Integer.MAX_VALUE. Beans start in ascending phase order, so a default-phase bean starts after Tomcat is listening — which is exactly what the web transcript shows (WebServerInitializedEvent before SmartLifecycle.start (default phase)). The graceful-shutdown bean sits 1024 below MAX_VALUE, so on shutdown (descending order) a default-phase bean is stopped first, then graceful shutdown begins, then the server closes. I derived that stop order from the printed phases and the rule demonstrated in the SmartLifecycle section; I did not trace the web server’s own stop separately. The default spring.lifecycle.timeout-per-shutdown-phase is 30 s, set by Boot (Spring Framework’s own default is 10 s).
Going deeper:

CommandLineRunner gets raw strings; ApplicationRunner gets them parsed

This is the difference everybody lists, so here it is once, with the edge cases. The test behind output/04-arguments.txt passes one deliberately awkward command line — an option given twice, an option with no value, a bare word, a single-dash flag, and a lone -- followed by something that looks like an option:
args passed to SpringApplication.run: [--name=Ankur, --verbose, --name=Second, data.csv, -x, --, --after-the-dashes]

CommandLineRunner  String[] args = [--name=Ankur, --verbose, --name=Second, data.csv, -x, --, --after-the-dashes]
ApplicationRunner  getSourceArgs()      = [--name=Ankur, --verbose, --name=Second, data.csv, -x, --, --after-the-dashes]
ApplicationRunner  getOptionNames()     = [name, verbose]
ApplicationRunner  getOptionValues(name)    = [Ankur, Second]
ApplicationRunner  getOptionValues(verbose) = []
ApplicationRunner  getOptionValues(missing) = null
ApplicationRunner  containsOption(verbose)  = true
ApplicationRunner  getNonOptionArgs()   = [data.csv, -x, --after-the-dashes]

Environment property 'name' (both values, joined by a comma): Ankur,Second
You want…CommandLineRunnerApplicationRunner
the raw strings, in orderString... argsargs.getSourceArgs()
the value of --name=Ankurparse it yourselfargs.getOptionValues("name") → a list
to know --verbose was presentsearch the arrayargs.containsOption("verbose")
the plain words (data.csv)filter out anything starting with --args.getNonOptionArgs()
Two details the output settles. An option given twice keeps both values in a list ([Ankur, Second]), and the Spring Environment sees the same property as the single string Ankur,Second. And everything after a lone -- is treated as non-option, but the -- itself is dropped from getNonOptionArgs() (it is still in the source args); the single-dash -x is not an option to Spring, only --name style is. The practical advice is plain: if you only read a file name or a flag, CommandLineRunner is fine. As soon as you catch yourself writing args[0].startsWith("--"), switch to ApplicationRunner. Neither type has any other capability: same moment, same ordering rules, same failure behaviour (all shown next). Going deeper:

Both kinds of runner are one list, sorted by @Order — and ties are not stable

A very common belief is that Boot calls all ApplicationRunner beans and then all CommandLineRunner beans. It does not. Both interfaces extend a package-private marker interface, org.springframework.boot.Runner (read with javap in output/16-javap-callrunners.txt), and SpringApplication.callRunners collects every bean of that type into one collection and sorts it once. From output/15-callrunners-source.txt:
	private void callRunners(ConfigurableApplicationContext context, ApplicationArguments args) {
		ConfigurableListableBeanFactory beanFactory = context.getBeanFactory();
		String[] beanNames = beanFactory.getBeanNamesForType(Runner.class);
		Map<Runner, String> instancesToBeanNames = new IdentityHashMap<>();
		for (String beanName : beanNames) {
			instancesToBeanNames.put(beanFactory.getBean(beanName, Runner.class), beanName);
		}
		Comparator<Object> comparator = getOrderComparator(beanFactory)
			.withSourceProvider(new FactoryAwareOrderSourceProvider(beanFactory, instancesToBeanNames));
		instancesToBeanNames.keySet().stream().sorted(comparator).forEach((runner) -> callRunner(runner, args));
	}
So @Order(1) on an ApplicationRunner and @Order(2) on a CommandLineRunner run in exactly that order, and a lower number means earlier. The scenario in OrderingApp.java declares seven runners in a deliberately scrambled order. Using @Order on a class, on a @Bean method, and the Ordered interface all work, and the sort puts them here (output/02-order-annotation-and-ordered.txt):
(declaration order in OrderingApp.java is NOT the order below)

CommandLineRunner  @Order(HIGHEST_PRECEDENCE)
ApplicationRunner  @Order(1) on the class
CommandLineRunner  @Order(2) on the class
ApplicationRunner  implements Ordered, getOrder() = 3
Both  @Order(4)  -> ApplicationRunner.run
Both  @Order(4)  -> CommandLineRunner.run
CommandLineRunner  @Order(5) on a @Bean method
ApplicationRunner  no order at all (lowest precedence)
Two oddities are visible. A bean that implements both interfaces (Both) is called twice at the same position, ApplicationRunner.run first and then CommandLineRunner.run, because callRunner checks instanceof for each. And the runner with no order at all runs last, since an unordered bean is treated as lowest precedence.
What happens when two runners have the same order, or none? Nothing is promised, and the implementation makes it genuinely arbitrary: the collection is an IdentityHashMap, whose iteration order depends on identity hash codes, which differ from JVM to JVM. Four runners with no @Order, started in eight fresh JVMs (output/03-ties-have-no-promised-order.txt), produced four different sequences:

JVM 1: arD arB cliC cliA
JVM 2: cliC cliA arD arB
JVM 3: cliA arB arD cliC
JVM 4: arD arB cliC cliA
JVM 5: arD arB cliC cliA
JVM 6: cliC cliA arB arD
JVM 7: cliC cliA arB arD
JVM 8: arD arB cliC cliA

distinct sequences seen: 4
  arD arB cliC cliA      x4
  cliC cliA arD arB      x1
  cliA arB arD cliC      x1
  cliC cliA arB arD      x2

Your own run will differ; that is the point. If runner B needs runner A to have finished, give them different @Order values. Do not rely on “application runners first”.
Where did the old behaviour go? Source of Boot 3.0.13 versus 3.1.12 and later
I read callRunners in the sources of several Boot lines. Boot 3.0.13 used context.getBeanProvider(Runner.class).orderedStream() (shown in the 3.0.13 section of output/15-callrunners-source.txt), where unordered beans come out in registration order. The IdentityHashMap version is already there in 3.1.12 and is the same in 3.2.12, 3.3.13, 3.5.16 and 4.1.2 (I read those sources; I only ran 4.1.2). The FactoryAwareOrderSourceProvider in the excerpt is what lets Boot see an @Order placed on the @Bean method that produced a runner (transcript 02 shows that working). That the ties lost their stable order is something I observed in the 4.1.2 runs; I did not find it documented. So a project that “worked” with unordered runners on an old 3.0 line and moved to 3.1 or later may have started to see a different sequence.
Going deeper:

If a runner throws, the application does not start

A runner is part of startup. When one throws, SpringApplication.run fails, the context is closed, and the JVM exits with a non-zero code. The scenario in FailureApp.java has three ordered runners where the second throws, plus a listener for each interesting event. It is started in a separate JVM so the exit code is real. From output/05-runner-failure.txt (the TRACE lines are the application’s own trace output; the ERROR line and exception lines come from the log):
--- three ordered runners, the second throws (non-web, in a separate JVM) ---
TRACE ApplicationStartedEvent
TRACE runner 1 ran
TRACE runner 2 is about to throw
TRACE ApplicationFailedEvent: java.lang.IllegalStateException: boom from runner 2
TRACE ContextClosedEvent (the context is shutting down)
ERROR o.s.b.SpringApplication : Application run failed
java.lang.IllegalStateException: boom from runner 2
process exit code: 1
Runner 1 ran, runner 2 threw, runner 3 never ran, ApplicationReadyEvent never fired, and the process ended with exit code 1. ApplicationFailedEvent was published, so a listener on it can log or alert. A runtime exception comes through unchanged; a checked exception (a runner’s run method is declared throws Exception) is wrapped, and an exception that implements ExitCodeGenerator chooses the exit code (same file, output/05-runner-failure.txt):
--- a runner exception that implements ExitCodeGenerator ---
ERROR o.s.b.SpringApplication : Application run failed
com.ankurm.runners.failure.FailureApp$ExitWith42Exception: the data file is missing
process exit code: 42

--- a runner that throws a checked exception (declared with throws Exception) ---
ERROR o.s.b.SpringApplication : Application run failed
java.lang.IllegalStateException: Failed to execute CommandLineRunner
Caused by: java.io.IOException: cannot read data.csv
process exit code: 1
The wrapping comes from the ThrowingConsumer in callRunner (the third excerpt in transcript 15 shows the "Failed to execute " message). On Kubernetes a container that exits at startup is typically restarted over and over (I did not run that part here); on a developer machine it is a stack trace and a prompt. Decide deliberately whether a given startup task is allowed to take the application down.
The catch-and-continue pattern. If the task is optional (a warm-up, a non-critical sync), catch the exception inside the runner, log it, and return. Then runners after it still run and the application still becomes ready. If it is required (the schema must exist), let it throw and let the process fail loudly. Both are legitimate; the bug is not choosing.
Going deeper:

If an ApplicationReadyEvent listener throws, the application dies too — after it said it was ready

It is tempting to think that a ready-event listener is a harmless side channel: the application is up, the listener is a bonus. It is not. The listener is called from SpringApplication.run itself, and the last lines of run (the listeners.ready(...) block in the excerpt in transcript 15) wrap it in the same failure handling. I ran it both without and with a web server. For the web case the listener first calls its own /ping endpoint, to prove the server really was answering when it threw (output/06-ready-listener-failure.txt):
--- web application (Tomcat is up when the listener runs) ---
TRACE ApplicationStartedEvent
TRACE GET /ping from inside the listener -> 200 pong
TRACE ready listener 1 is about to throw (readiness: REFUSING_TRAFFIC)
TRACE ContextClosedEvent (the context is shutting down)
ERROR o.s.b.SpringApplication : Application run failed
java.lang.IllegalStateException: boom from the ready listener
process exit code: 1
does the port answer after the process ended? false
Look at what that shows. The web server was serving requests (200 pong), the listener threw, the context was closed, the process exited with code 1, and afterwards the port no longer answers. The readiness state seen from inside the listener was still REFUSING_TRAFFIC, so a probe never saw a “ready” application that then collapsed, but any request that arrived in that window was served and then the server went away. Also, a second ready listener ordered after the failing one (the scenario has one) never ran. There is one more difference from the runner case, and it is easy to miss: in the runner transcript an ApplicationFailedEvent was received; here the observer bean that listens for it printed nothing. The final line of run passes null instead of the run listeners to handleRunFailure (the second-to-last excerpt line in transcript 15), and the call that publishes ApplicationFailedEvent is guarded by listeners != null (I read that in the 4.1.2 sources). So a failure in a ready listener is reported only as the logged error and the exit code, with no ApplicationFailedEvent for your monitoring to hook.
Rule of thumb. A listener on ApplicationReadyEvent is not “after startup, safe”. It fails the application exactly like a runner does. If you want best-effort behaviour, catch inside it.
Going deeper:

A slow runner keeps readiness red — but the port is already open

Because the runners run before readiness flips, a runner that takes 40 seconds delays the application’s “ready” state by 40 seconds. In a web application there is a second subtlety: the web server started earlier, in step 2. So the application accepts connections while it reports itself as not ready. The test behind output/07-blocking-runner-readiness.txt starts a web application with actuator health probes enabled and a runner that waits on a latch (no sleeps), and queries it while the runner is blocked:
--- while the runner is blocked ---
has SpringApplication.run() returned?  false
GET /ping                        -> 200 pong
GET /actuator/health/liveness    -> 200 {"status":"UP"}
GET /actuator/health/readiness   -> 503 {"status":"OUT_OF_SERVICE"}

--- after the runner returned ---
has SpringApplication.run() returned?  true
GET /actuator/health/readiness   -> 200 {"status":"UP"}
While the runner blocks, SpringApplication.run() has not returned, /ping is answered with 200, the liveness probe is UP, and the readiness probe is 503 OUT_OF_SERVICE. Once the runner returns, readiness is UP. (In the trace the runner reports a thread called app-main because the test starts the application on its own thread; in a real application it is main.)
port openaccepts connections (Tomcat started during refresh) livenessUP from ApplicationStartedEvent runnerblocked (the 40 s warm-up) readiness503 OUT_OF_SERVICE200 UP runner returns The red stretch is exactly as long as the runner. Traffic that skips the probe (a direct call) is still served.
The diagram shows the four lanes of the transcript: the port is open first, liveness is up from the started event, and readiness is red for precisely as long as the runner is busy. The lesson is not “never block”; it is that readiness is a promise made to the load balancer, and a runner decides when the promise is made. That is what you want for work the instance must finish before taking requests, and it is a bug for work that merely should happen eventually.
Do not rely on the red readiness probe to keep traffic out. Only components that consult the probe (Kubernetes, a load balancer) keep traffic away. Anything that calls the port directly gets served, as 200 pong shows. If a request must not run before the runner finishes, the code that serves it has to check, for example via ApplicationAvailability.
Going deeper:

If the work can wait, hand it to another thread — and own its failures

When the work does not have to finish before traffic arrives, return from the runner at once and do the work elsewhere. The same application with its alternative runner starts the work on a virtual thread and returns. The work then fails after readiness is up (output/08-async-runner.txt):

--- the background work is still waiting ---
has SpringApplication.run() returned?  true
GET /actuator/health/readiness   -> 200 {"status":"UP"}

--- after the background work threw ---
GET /actuator/health/readiness   -> 200 {"status":"UP"}
GET /ping                        -> 200 pong

--- trace ---
runner: started the work on a virtual thread and returned
background work on a virtual thread failed: the warm-up blew up
The runner reported success immediately, readiness was UP while the background task was still waiting, and when the task threw, the application did not notice: readiness stayed UP and /ping kept working. That is the trade. You gain startup speed and you lose Spring’s failure handling, so the thread has to deal with its own errors (log them, retry, flip a flag, publish a health indicator). In this demo the failure was only visible because the thread had an uncaught-exception handler that wrote to the trace. Going deeper:

When the thing you start must also be stopped, use SmartLifecycle

Runners and ready listeners are for one-off startup work. Their API has a start and no stop. If what you launch is long-lived — a queue consumer, a scheduler, a socket, a poller — you also need to stop it cleanly and in a sensible order when the application shuts down. That is the job of SmartLifecycle: Spring calls start() during startup and stop() during shutdown, ordered by a phase number. The scenario in LifecycleApp.java registers five lifecycle beans (low -100, mid 0, manual 50 with isAutoStartup() false, high 100, slow 200 whose stop takes 300 ms), a runner and event listeners. The class that implements the interface is Svc.java. Here is the whole run, from output/09-smartlifecycle-phases.txt:

--- startup ---
start  low    (phase -100) on main
start  mid    (phase 0) on main
start  high   (phase 100) on main
start  slow   (phase 200) on main
ApplicationStartedEvent
CommandLineRunner runs
ApplicationReadyEvent
manual.isAutoStartup()=false isRunning()=false (never started by the context)

--- context.close() ---
start  manual (phase 50) on main
ContextClosedEvent (stop phase begins)
stop   slow   (phase 200) begins, will finish in 300 ms
stop   slow   finished, calling the callback
stop   high   (phase 100) on main
stop   manual (phase 50) on main
stop   mid    (phase 0) on main
stop   low    (phase -100) on main
startup: ascending phaseshutdown: descending phase start low (-100) start mid (0) start high (100) start slow (200) started, runner, ready stop slow (200, 300 ms) stop high (100) stop manual (50) stop mid (0) stop low (-100) the mirrorimage Whatever starts last stops first. The runners are not part of this ladder: they run once, after all of the starts. manual (50) was started by hand, so it joined the stop ladder at its phase.
The diagram is the transcript drawn as a ladder. Four facts are in it. Lifecycle beans start in ascending phase order and before ApplicationStartedEvent and the runner, so a runner can rely on every auto-started lifecycle bean being up. Shutdown is the exact mirror: ContextClosedEvent, then descending phases. A bean with isAutoStartup() false (manual) is not started by the context at all; in the run the test starts it by hand, and it is then stopped with the rest. And the stop(Runnable callback) variant lets a bean take its time: slow announced its stop, finished 300 ms later on another thread, called the callback, and only then did the next phase begin. That wait is bounded by spring.lifecycle.timeout-per-shutdown-phase.
Two ways a SmartLifecycle goes wrong (both run)
start() throws. The failure happens during the context refresh, before ApplicationStartedEvent and before any runner, and the beans that did start are stopped on the way out. From output/11-smartlifecycle-start-fails.txt:
start  healthy (phase 0)
start  broken (phase 10)
stop   healthy

application started? false
  org.springframework.context.ApplicationContextException: Failed to start bean 'broken'
  java.lang.IllegalStateException: could not connect to the broker
isRunning() never returns true. Spring calls start() only when isRunning() is false, and calls stop() only when it is true (doStart and doStop in output/17-lifecycleprocessor-source.txt). Forget to record the state and your stop() is silently never called (output/12-smartlifecycle-forgot-isrunning.txt):
--- startup ---
start() was called
--- after context.close() ---
(nothing)
SmartLifecycle is not a fancier runner. A runner is the right tool for a task that finishes. Do not put a never-returning loop in a runner (it blocks readiness forever, as the blocking section showed) and do not use SmartLifecycle.start() for a long task either — start() is also called on the main startup thread and must return quickly; spawn the worker thread from it and stop that thread in stop().
Going deeper:

In tests, @SpringBootTest runs your startup hooks; a web slice runs only some of them

A startup hook that does real work (it calls a service, writes a file) also runs when you test, which is a frequent surprise. The scenario in SlicesApp.java declares four hooks four ways: a top-level @Component runner (ComponentRunner.java), a top-level @Component ready listener (ReadyListener.java), a runner declared as a @Bean method, and a runner as a @Component class nested inside SlicesApp. With @SpringBootTest(args = "--from-the-test=1") everything runs before the first test method, and the runners receive the test’s arguments (output/13-spring-boot-test-runs-the-hooks.txt):
CommandLineRunner from a top-level @Component ran, args = [--from-the-test=1]
CommandLineRunner nested in SlicesApp ran, args = [--from-the-test=1]
ApplicationRunner from a @Bean method ran, source args = [--from-the-test=1]
ApplicationReadyEvent listener (a top-level @Component) ran
With @WebMvcTest(controllers = Hello.class) only two of the four ran (output/14-webmvctest-slice-hooks.txt):
CommandLineRunner nested in SlicesApp ran, args = []
ApplicationRunner from a @Bean method ran, source args = []
The slice filters out ordinary scanned @Component classes, so the scanned runner and the ready listener are gone. The @Bean-method runner survives because it lives on the application class that every slice loads. The nested class survived too, and that one surprised me while writing the scenario: classes nested inside a configuration class are registered as part of that class, so no scan filter removes them. (My first version nested everything, and the slice ran all four hooks until I moved them out; the probe that showed it was not kept.) Practical consequence: if a runner must not run in slices, make it a top-level @Component, or guard it with @Profile or a property. Going deeper:

Which one should I use?

You want to…UseBecause
run a short task once, with the raw argumentsCommandLineRunnerruns before readiness, ordered with @Order, fails the start if it throws
run a short task once and read --optionsApplicationRunnerthe same, with parsed arguments
react to “the application has started”, after every runner@EventListener(ApplicationReadyEvent.class)runs after all runners; still fails the start if it throws; readiness is not yet UP
start something long-lived that must be stopped in orderSmartLifecyclestart and stop, phases, async stop; runs before the runners
prepare a single bean’s own state@PostConstructper bean, early; do not depend on other beans being started
do slow work that may finish after startupa runner that hands off to an executorreadiness is not delayed; you own the failures
Should you put this much in startup at all? Probably less than you think. Every runner is code that can crash your deployment, delays readiness on every restart, and runs again in every test that loads the context. Before adding one, ask whether the task belongs somewhere else: a schema change belongs in a migration tool, a periodic job belongs in a scheduler, and a lookup table that is only needed on first use can be loaded lazily. A runner is the right home for a small, fast, required check (“does this directory exist”) or a deliberate warm-up. That last paragraph is my opinion, not something the transcripts prove; what they prove is the cost side: failure kills the start, and slowness holds readiness down.

What I had to correct or check along the way

Things that were not what I expected.

1. Boot does not run ApplicationRunners before CommandLineRunners: one list, one sort, and ties come out in an arbitrary order (four sequences in eight JVMs).
2. A runtime exception from a runner is not wrapped; only a checked one becomes IllegalStateException: Failed to execute CommandLineRunner.
3. A throwing ApplicationReadyEvent listener ends the application and publishes no ApplicationFailedEvent.
4. ApplicationReadyEvent arrives while readiness is still REFUSING_TRAFFIC; the flip is the next statement.
5. The web server starts before default-phase SmartLifecycle beans, so such a bean does not delay the port opening.
6. Classes nested in a configuration class are not removed by a test slice’s component filter.
7. Maven Central’s <release> element for spring-boot-starter-parent points at 4.2.0-RC2; the newest GA is 4.1.2.

Further reading

No Comments yet!

This site uses Akismet to reduce spam. Learn how your comment data is processed.