Spring Boot Essentials

The Application Lifecycle & Runners

18 min Lesson 7 of 13

The Application Lifecycle & Runners

Every Spring Boot application goes through a well-defined lifecycle from the moment SpringApplication.run() is called to the moment the JVM exits. Understanding that lifecycle lets you hook into exactly the right phase — whether you need to run a database check before the first request, seed initial data, or perform a clean shutdown. This lesson walks through the internal startup sequence and the two runner interfaces — CommandLineRunner and ApplicationRunner — that are the recommended way to execute code once the application context is fully initialised.

What Happens Inside SpringApplication.run()

When your main method calls SpringApplication.run(MyApp.class, args), the following sequence takes place (simplified):

  1. Bootstrap phase: Spring loads SpringApplicationRunListeners and fires starting().
  2. Environment preparation: application.properties / .yml, environment variables, and command-line arguments are merged into an Environment.
  3. Context creation: The correct ApplicationContext type is created (AnnotationConfigServletWebServerApplicationContext for a web app, AnnotationConfigApplicationContext for a non-web app).
  4. Bean definition loading: Your @Configuration classes are processed; all bean definitions are registered.
  5. Context refresh: Every bean is instantiated, dependencies are injected, @PostConstruct callbacks run, and the embedded server starts.
  6. Runners execute: All CommandLineRunner and ApplicationRunner beans are invoked, in order.
  7. Ready: ApplicationReadyEvent is published. The application is serving requests.
Key insight: Runners execute after the embedded server is already started and the context is fully refreshed. This means you can safely inject any bean, open database connections, or call REST endpoints inside a runner. You are not in the startup critical path — the application is already live.

CommandLineRunner

CommandLineRunner is a single-method functional interface. Its run(String... args) method receives the raw command-line arguments exactly as passed to main().

package com.example.demo; import org.springframework.boot.CommandLineRunner; import org.springframework.stereotype.Component; @Component public class DataSeedRunner implements CommandLineRunner { private final UserRepository userRepository; public DataSeedRunner(UserRepository userRepository) { this.userRepository = userRepository; } @Override public void run(String... args) throws Exception { if (userRepository.count() == 0) { userRepository.save(new User("admin", "admin@example.com")); System.out.println("Seeded admin user."); } } }

Because it is a plain @Component, it participates in dependency injection like any other bean. The constructor receives UserRepository — no field injection required.

ApplicationRunner

ApplicationRunner is almost identical but receives an ApplicationArguments object instead of a raw String[]. This is more convenient when you pass structured arguments on the command line.

package com.example.demo; import org.springframework.boot.ApplicationArguments; import org.springframework.boot.ApplicationRunner; import org.springframework.stereotype.Component; @Component public class StartupCheckRunner implements ApplicationRunner { @Override public void run(ApplicationArguments args) throws Exception { // Check if --dry-run was passed: java -jar app.jar --dry-run boolean dryRun = args.containsOption("dry-run"); // Access non-option args: java -jar app.jar migrate for (String nonOption : args.getNonOptionArgs()) { System.out.println("Non-option arg: " + nonOption); } // Get the value of --env=prod if (args.containsOption("env")) { String env = args.getOptionValues("env").get(0); System.out.println("Running in environment: " + env); } System.out.println("Startup check complete. Dry-run=" + dryRun); } }

ApplicationArguments distinguishes between option arguments (prefixed with --, like --env=prod) and non-option arguments (plain strings like migrate). This removes the need to parse String[] by hand.

Controlling Execution Order

When you have multiple runners, Spring Boot executes them in the order defined by the @Order annotation (lower value = higher priority) or the Ordered interface.

import org.springframework.core.annotation.Order; @Component @Order(1) public class SchemaCheckRunner implements CommandLineRunner { @Override public void run(String... args) { System.out.println("Step 1: verify DB schema"); } } @Component @Order(2) public class DataSeedRunner implements CommandLineRunner { @Override public void run(String... args) { System.out.println("Step 2: seed reference data"); } }
Best practice: Use @Order to make the dependency explicit in code rather than relying on bean registration order, which is not guaranteed and changes silently as the codebase grows.

Runners vs. @PostConstruct vs. ApplicationListener

Spring gives you several hooks. Here is when to use each one:

  • @PostConstruct — runs immediately after a single bean is constructed, before other beans that depend on it are fully initialised. Use it for bean-local setup (initialising an internal cache, validating a configuration property). Do not use it for anything that requires other beans to be fully started (e.g. starting a background thread that touches the DB).
  • CommandLineRunner / ApplicationRunner — run after the entire context is refreshed and the server is up. Use for startup tasks that touch multiple beans or the outside world: database migrations, cache warming, health checks, CLI-style commands.
  • ApplicationListener<ApplicationReadyEvent> — fires at exactly the same point as runners, but via the event system. Useful when the startup logic lives in an infrastructure layer that should not know about the Runner interfaces. Runners are generally simpler and preferred for application-level code.

Practical Pattern: Conditional Seeding

A common real-world pattern is seeding data only when a Spring profile is active, avoiding accidental data insertion in production:

import org.springframework.boot.CommandLineRunner; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; @Component @Profile("dev") // only runs when the 'dev' profile is active public class DevDataSeeder implements CommandLineRunner { private final ProductRepository productRepository; public DevDataSeeder(ProductRepository productRepository) { this.productRepository = productRepository; } @Override public void run(String... args) { productRepository.saveAll(SampleData.products()); System.out.println("Dev data seeded."); } }
Watch out for exceptions in runners. If a runner throws an unchecked exception, Spring Boot catches it, logs the error, and calls System.exit(1). This is usually what you want for a fatal startup check, but surprising if the exception is accidental. Handle expected errors explicitly and only let truly unrecoverable problems propagate.

Testing Runners

You can exclude runners from specific tests by excluding the bean class or using @SpringBootTest with a selective component scan. Alternatively, test the runner directly by constructing it with mock dependencies:

import org.junit.jupiter.api.Test; import static org.mockito.Mockito.*; class DataSeedRunnerTest { @Test void seedsAdminWhenEmpty() throws Exception { UserRepository repo = mock(UserRepository.class); when(repo.count()).thenReturn(0L); DataSeedRunner runner = new DataSeedRunner(repo); runner.run(); // no args needed for this logic verify(repo, times(1)).save(any(User.class)); } }

Summary

Spring Boot's startup sequence is deterministic and well-ordered. CommandLineRunner is the go-to interface when you just need to run code after startup and have no structured command-line arguments. ApplicationRunner is the better choice whenever you want to parse --option=value style arguments cleanly. Both are plain Spring beans: inject what you need, annotate with @Order when sequencing matters, and gate with @Profile to keep production safe. In the next lesson you will look at Spring Boot's built-in logging system and how to configure it without touching logback.xml.