Between pressing Run and seeing Started CicloUrbanaApplication in 2.174 seconds on the console, a great deal happens in a very specific order. Knowing that order stops being academic curiosity the moment you need to preload data, run a check at startup, release resources on shutdown or work out why a bean is not ready when you use it. In this lesson we will open up SpringApplication.run(...), learn to read CicloUrbana's real startup log, customise the banner, hook code onto the lifecycle events and configure a graceful shutdown that does not cut rentals short.

Contents

  1. What SpringApplication.run(...) actually does
  2. The startup phases, step by step
  3. The banner and how to customise it
  4. Reading CicloUrbana's startup log
  5. The lifecycle events
  6. An ApplicationReadyEvent listener
  7. CommandLineRunner and ApplicationRunner
  8. Customising startup with SpringApplicationBuilder
  9. Graceful shutdown and closing hooks
  10. Common Mistakes and Tips
  11. Exercises

  1. What SpringApplication.run(...) actually does

Let us recall the line:

public static void main(String[] args) {
    SpringApplication.run(CicloUrbanaApplication.class, args);
}

That static method is a shortcut equivalent to:

public static void main(String[] args) {
    SpringApplication application = new SpringApplication(CicloUrbanaApplication.class);
    ConfigurableApplicationContext context = application.run(args);
    // The context stays alive: while Tomcat is listening, the process does not end
}

SpringApplication is a Spring Boot class —not a Spring Framework one— whose job is to orchestrate startup: decide which kind of context to create, prepare the environment, load the configuration, publish events and start the server if appropriate.

It returns a ConfigurableApplicationContext: the container with every bean already built. You can keep it if you need to query it, although in practice you almost never do.

  1. The startup phases, step by step

This is the complete journey, in order:

flowchart TD
    A["main() invokes SpringApplication.run()"] --> B["1. Create SpringApplication<br/>Infer the application type<br/>(SERVLET / REACTIVE / NONE)"]
    B --> C["2. Load ApplicationContextInitializer<br/>and ApplicationListener from spring.factories"]
    C --> D["3. Publish ApplicationStartingEvent"]
    D --> E["4. Prepare the Environment<br/>args, environment variables,<br/>application.properties, profiles"]
    E --> F["5. Publish ApplicationEnvironmentPreparedEvent"]
    F --> G["6. Print the banner"]
    G --> H["7. Create the ApplicationContext<br/>AnnotationConfigServletWebServerApplicationContext"]
    H --> I["8. Apply the ApplicationContextInitializers"]
    I --> J["9. Publish ApplicationContextInitializedEvent"]
    J --> K["10. Load the bean definitions<br/>@ComponentScan from com.ciclourbana"]
    K --> L["11. Publish ApplicationPreparedEvent"]
    L --> M["12. refresh(): process the autoconfiguration<br/>and create the singleton beans"]
    M --> N["13. Start the embedded web server<br/>Tomcat on port 8080"]
    N --> O["14. Publish ApplicationStartedEvent"]
    O --> P["15. Run the ApplicationRunners<br/>and CommandLineRunners"]
    P --> Q["16. Publish ApplicationReadyEvent"]
    Q --> R["Application running"]

Let us dwell on the steps with the most practical consequences.

Step 1: inferring the application type

Spring Boot inspects the classpath and decides:

What it finds Inferred type Consequence
DispatcherServlet (Spring MVC) SERVLET Starts Tomcat; the process stays alive
WebFlux without Spring MVC REACTIVE Starts Netty
Neither of the two NONE No server; the process ends when main finishes

CicloUrbana has spring-boot-starter-web, so it is SERVLET. This explains why in lesson 01-03 we said that without the web dependency the application starts and dies: the inferred type is NONE and nothing keeps the process alive.

Step 4: preparing the Environment

The Environment is the object that answers "what is the value of property X?". It is built by combining multiple sources with priorities. The main ones, from highest to lowest priority:

  1. Command-line arguments (--server.port=9090)
  2. Java system properties (-Dserver.port=9090)
  3. Environment variables (SERVER_PORT=9090)
  4. application-{profile}.properties
  5. application.properties
  6. Defaults in the code

That is why java -jar ciclourbana.jar --server.port=9090 beats whatever is written in application.properties: arguments sit higher in the list. The full detail of the property system is the subject of lesson 02-05.

Step 10: loading the bean definitions

This is where the @ComponentScan we studied in lesson 01-04 comes into play: Spring walks com.ciclourbana and its subpackages looking for annotations. It finds StationController and registers it as a bean definition. Careful: registering the definition is not creating the object; that happens in the next step.

Step 12: refresh() and bean creation

This is the longest step and where the startup time is concentrated. Two things happen:

  • The autoconfiguration classes are processed: hundreds of conditional classes decide which beans to register based on what is on the classpath and in the properties. This is where the DispatcherServlet, Jackson's JSON converter and the TomcatServletWebServerFactory get created. How those conditions work is covered in lesson 02-06.
  • The singleton beans are instantiated: Spring builds the objects, resolving the dependency order. If RentalService needs StationService, the latter is created first.

Step 13: starting Tomcat

With the beans ready, the server starts and the port opens. From here on the application already accepts HTTP requests, even though there are still steps left to run. That nuance matters: the CommandLineRunners from step 15 execute with the port already open.

  1. The banner and how to customise it

The first thing to appear on the console is the Spring banner. It serves no technical purpose, but it is a good place to put your application's name and version, which is welcome when you have several instances open.

Create src/main/resources/banner.txt:

   _____ _      _       _   _      _
  / ____(_)    | |     | | | |    | |
 | |     _  ___| | ___ | | | |_ __| |__   __ _ _ __   __ _
 | |    | |/ __| |/ _ \| | | | '__| '_ \ / _` | '_ \ / _` |
 | |____| | (__| | (_) | |_| | |  | |_) | (_| | | | | (_| |
  \_____|_|\___|_|\___/ \___/|_|  |_.__/ \__,_|_| |_|\__,_|

 Ribalta municipal electric bike network
 Application version      : ${application.version:no-version}
 Spring Boot              : ${spring-boot.version}
 Active profiles          : ${spring.profiles.active:none}

Spring Boot substitutes these placeholders:

Placeholder Value
${application.version} The version from the pom.xml, available when running the JAR
${application.title} The project's name
${spring-boot.version} The Spring Boot version
${spring.profiles.active} The active profiles (module 7)
${AnsiColor.GREEN} ANSI colour on compatible terminals

The ${key:defaultValue} syntax stops the literal from showing up when the property does not exist. It is useful because application.version only has a value when running from the JAR (it is read from the MANIFEST.MF), not with spring-boot:run.

To control the banner:

# Switch it off entirely
spring.main.banner-mode=off

# Send it to the log instead of the console
spring.main.banner-mode=log

# Use a file with a different name or location
spring.banner.location=classpath:banners/ciclourbana.txt

In production the usual choice is off or log: the banner clutters structured log aggregators.

  1. Reading CicloUrbana's startup log

This is a real startup, line by line:

2026-08-31T10:22:40.812+02:00  INFO 18422 --- [ciclourbana] [  restartedMain] c.c.CicloUrbanaApplication               : Starting CicloUrbanaApplication using Java 21.0.5 with PID 18422 (/home/joan/ciclourbana/target/classes started by joan in /home/joan/ciclourbana)
2026-08-31T10:22:40.815+02:00  INFO 18422 --- [ciclourbana] [  restartedMain] c.c.CicloUrbanaApplication               : No active profile set, falling back to 1 default profile: "default"
2026-08-31T10:22:41.104+02:00  INFO 18422 --- [ciclourbana] [  restartedMain] .e.DevToolsPropertyDefaultsPostProcessor : Devtools property defaults active! Set 'spring.devtools.add-properties' to 'false' to disable
2026-08-31T10:22:42.688+02:00  INFO 18422 --- [ciclourbana] [  restartedMain] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat initialized with port 8080 (http)
2026-08-31T10:22:42.703+02:00  INFO 18422 --- [ciclourbana] [  restartedMain] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
2026-08-31T10:22:42.704+02:00  INFO 18422 --- [ciclourbana] [  restartedMain] o.apache.catalina.core.StandardEngine    : Starting Servlet engine: [Apache Tomcat/10.1.31]
2026-08-31T10:22:42.760+02:00  INFO 18422 --- [ciclourbana] [  restartedMain] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
2026-08-31T10:22:42.761+02:00  INFO 18422 --- [ciclourbana] [  restartedMain] w.s.c.ServletWebServerApplicationContext : Root WebApplicationContext: initialization completed in 1920 ms
2026-08-31T10:22:42.905+02:00  INFO 18422 --- [ciclourbana] [  restartedMain] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port 8080 (http) with context path '/'
2026-08-31T10:22:42.918+02:00  INFO 18422 --- [ciclourbana] [  restartedMain] c.c.CicloUrbanaApplication               : Started CicloUrbanaApplication in 2.174 seconds (process running for 2.512)

Anatomy of a log line

2026-08-31T10:22:42.905+02:00  INFO 18422 --- [ciclourbana] [restartedMain] o.s.b.w.e.tomcat.TomcatWebServer : Tomcat started on port 8080
└──────── date and time ─────┘ └level┘ └PID┘    └ app ─┘  └─ thread ─┘ └──── class (abbreviated) ───┘ └─ message ─┘
  • PID: the operating system's process identifier. Useful for kill or for locating the process.
  • Application name: comes from spring.application.name, which we set in the previous lesson.
  • Thread: restartedMain indicates that DevTools is active. Without DevTools it would be main. When an HTTP request arrives you will see http-nio-8080-exec-1.
  • Abbreviated class: o.s.b.w.embedded.tomcat.TomcatWebServer is org.springframework.boot.web.embedded.tomcat.TomcatWebServer. Spring shortens the packages so that the message fits.

The most informative lines

Line What it is telling you
Starting ... using Java 21.0.5 with PID Confirms the actual Java version and the working directory
No active profile set No profile is active; default is used. Very handy for spotting misconfigured deployments
Devtools property defaults active! DevTools is applying development values. It must never appear in production
Tomcat initialized with port 8080 The server has been created, but it is not listening yet
Root WebApplicationContext: initialization completed in 1920 ms Time taken to create every bean: if startup is slow, most of it is here
Tomcat started on port 8080 Now it does accept requests
Started ... in 2.174 seconds (process running for 2.512) 2.174 s since SpringApplication.run(); 2.512 s since the JVM started. The difference is the JVM's own startup

For more detail while diagnosing:

# Prints a report of which autoconfigurations were applied and which were not
debug=true

# Trace Spring's startup process
logging.level.org.springframework.boot.autoconfigure=DEBUG

  1. The lifecycle events

Spring Boot publishes events at specific moments during startup and shutdown. You can subscribe to them to run code at exactly the instant you need.

Event When it is published Is there a context? Typical use
ApplicationStartingEvent At the very beginning No Registering very early listeners
ApplicationEnvironmentPreparedEvent Environment ready, context not yet No (the Environment, yes) Modifying or validating properties
ApplicationContextInitializedEvent Context created, beans not loaded Yes (empty) Registering beans programmatically
ApplicationPreparedEvent Definitions loaded, beans not created Yes Last chance to modify definitions
ApplicationStartedEvent Context refreshed, runners not yet executed Yes Checks preceding the data load
ApplicationReadyEvent Everything ready, runners executed Yes Preloading, notices, startup tasks
ApplicationFailedEvent Startup has failed Possibly Alerts, diagnostics
ContextClosedEvent The context is closing Yes Releasing resources, announcing the stop

The key distinction is between ApplicationStartedEvent and ApplicationReadyEvent:

  • ApplicationStartedEvent: the application works, but the CommandLineRunners have not run yet.
  • ApplicationReadyEvent: everything has finished, runners included. This is the event you should use in 90 % of cases.

For the events that precede the existence of the context (ApplicationStartingEvent, ApplicationEnvironmentPreparedEvent) annotating a method is not enough: the container does not exist yet and there are no beans. They have to be registered programmatically (section 8) or declared in META-INF/spring.factories.

  1. An ApplicationReadyEvent listener

Let us add a listener to CicloUrbana that, as soon as the application is ready, logs how many stations the Ribalta network has loaded.

First we need the stations to move out of the controller and into a component of their own. We create src/main/java/com/ciclourbana/stations/StationStore.java:

package com.ciclourbana.stations;

import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.List;

/**
 * In-memory store for the stations of the Ribalta network.
 * A stopgap until module 4, where it will be replaced by
 * a Spring Data JPA repository.
 */
@Component
public class StationStore {

    private final List<Station> stations = new ArrayList<>();

    public List<Station> listAll() {
        return List.copyOf(stations);   // immutable copy: nobody modifies it from outside
    }

    public void add(Station station) {
        stations.add(station);
    }

    public int count() {
        return stations.size();
    }

    public int totalCapacity() {
        return stations.stream()
                .mapToInt(Station::capacity)
                .sum();
    }
}

@Component tells Spring to create a single instance of this class and manage it as a bean. Because it sits in com.ciclourbana.stations, the scan finds it. The detail of stereotypes and dependency injection is covered in module 2; here we use it as a tool.

Now the listener, in src/main/java/com/ciclourbana/common/StartupNotice.java:

package com.ciclourbana.common;

import com.ciclourbana.stations.StationStore;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;

/**
 * Logs a summary of the network's state as soon as
 * CicloUrbana has finished starting up.
 */
@Component
public class StartupNotice {

    private static final Logger log = LoggerFactory.getLogger(StartupNotice.class);

    private final StationStore stationStore;
    private final Environment environment;

    // Constructor injection: Spring passes in the required beans when creating this class
    public StartupNotice(StationStore stationStore, Environment environment) {
        this.stationStore = stationStore;
        this.environment = environment;
    }

    @EventListener(ApplicationReadyEvent.class)
    public void onReady() {
        String port = environment.getProperty("server.port", "8080");

        log.info("================================================");
        log.info(" CicloUrbana ready - Ribalta municipal network");
        log.info(" Stations loaded  : {}", stationStore.count());
        log.info(" Total capacity   : {} docks", stationStore.totalCapacity());
        log.info(" API available at : http://localhost:{}/api/v1/stations", port);
        log.info("================================================");
    }
}

Points worth highlighting:

  • @EventListener(ApplicationReadyEvent.class) is all it takes to subscribe. It is cleaner than implementing the ApplicationListener interface, and the method can be named however you like.
  • The SLF4J logger is the correct way to log messages in Spring Boot; never use System.out.println. SLF4J and Logback come bundled in spring-boot-starter.
  • The {} braces are SLF4J placeholders. They are preferable to concatenation with + because they are only resolved if the log level is enabled.
  • Constructor injection (receiving the dependencies as parameters) is the recommended practice. Module 2, lesson 02-02.

  1. CommandLineRunner and ApplicationRunner

Both are functional interfaces that Spring Boot runs after the context is ready and before publishing ApplicationReadyEvent. They exist to run initialisation code.

Aspect CommandLineRunner ApplicationRunner
Signature void run(String... args) void run(ApplicationArguments args)
Arguments A raw array of strings An object that distinguishes options (--key=value) from loose arguments
Useful methods Whatever an array offers getOptionNames(), getOptionValues("x"), getNonOptionArgs()
When to pick it You do not need to read arguments, or they are simple You need to interpret named options
Execution moment After startup, before ApplicationReadyEvent The same
Ordering among several With @Order or the Ordered interface The same

Preloading the demo stations

We create src/main/java/com/ciclourbana/stations/DemoStationLoader.java:

package com.ciclourbana.stations;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

/**
 * Loads the initial stations of the Ribalta network at startup.
 * A stopgap until module 4, where the data will come from the
 * database through Flyway migrations.
 */
@Component
@Order(1)   // runs before other runners with a higher @Order
public class DemoStationLoader implements CommandLineRunner {

    private static final Logger log = LoggerFactory.getLogger(DemoStationLoader.class);

    private final StationStore stationStore;

    public DemoStationLoader(StationStore stationStore) {
        this.stationStore = stationStore;
    }

    @Override
    public void run(String... args) {
        log.info("Loading Ribalta's demo stations...");

        stationStore.add(new Station(1L, "Main Square",
                "Main Square 1", 24, 40.4168, -3.7038));
        stationStore.add(new Station(2L, "North Station",
                "Station Avenue 3", 30, 40.4290, -3.7020));
        stationStore.add(new Station(3L, "River Park",
                "Riverside Walk 12", 18, 40.4105, -3.6950));
        stationStore.add(new Station(4L, "University",
                "South Campus, Gate B", 36, 40.4402, -3.7255));

        log.info("Loaded {} stations", stationStore.count());
    }
}

And we update the controller so that it uses the store instead of its fixed list:

package com.ciclourbana.stations;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequestMapping("/api/v1/stations")
public class StationController {

    private final StationStore stationStore;

    public StationController(StationStore stationStore) {
        this.stationStore = stationStore;
    }

    @GetMapping
    public List<Station> listStations() {
        return stationStore.listAll();
    }
}

On startup, the log now shows the real order of events:

... o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat started on port 8080 (http)
... c.c.s.DemoStationLoader                 : Loading Ribalta's demo stations...
... c.c.s.DemoStationLoader                 : Loaded 4 stations
... c.c.CicloUrbanaApplication              : Started CicloUrbanaApplication in 2.301 seconds
... c.c.common.StartupNotice                : ================================================
... c.c.common.StartupNotice                :  CicloUrbana ready - Ribalta municipal network
... c.c.common.StartupNotice                :  Stations loaded  : 4
... c.c.common.StartupNotice                :  Total capacity   : 108 docks
... c.c.common.StartupNotice                :  API available at : http://localhost:8080/api/v1/stations
... c.c.common.StartupNotice                : ================================================

Look at the sequence: Tomcat starts first, then the CommandLineRunner, then the Started message and finally the ApplicationReadyEvent listener. It matches steps 13 to 16 of the diagram exactly.

An ApplicationRunner example

When you need to read named options:

package com.ciclourbana.common;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;

/**
 * An example of reading command-line options.
 * Usage: java -jar ciclourbana.jar --mode=maintenance --verbose
 */
@Component
public class ArgumentReader implements ApplicationRunner {

    private static final Logger log = LoggerFactory.getLogger(ArgumentReader.class);

    @Override
    public void run(ApplicationArguments args) {
        // Named options, in the --key=value style
        log.info("Options received: {}", args.getOptionNames());

        if (args.containsOption("mode")) {
            // getOptionValues returns a list: an option may be repeated
            String mode = args.getOptionValues("mode").get(0);
            log.info("Starting in mode: {}", mode);
        }

        // Loose arguments, without the -- prefix
        log.info("Unnamed arguments: {}", args.getNonOptionArgs());
    }
}

With CommandLineRunner you would receive the raw array ["--mode=maintenance", "--verbose"] and would have to split the strings yourself. That is the whole difference between the two interfaces.

One important warning: if a runner throws an exception, startup fails and the application stops. That is desirable behaviour —if a critical data preload fails, better not to start at all— but it is worth keeping in mind, and catching whatever should not be fatal.

  1. Customising startup with SpringApplicationBuilder

SpringApplicationBuilder offers a fluent API for configuring startup programmatically, useful when the configuration cannot be expressed as properties.

package com.ciclourbana;

import org.springframework.boot.Banner;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;

@SpringBootApplication
public class CicloUrbanaApplication {

    public static void main(String[] args) {
        new SpringApplicationBuilder(CicloUrbanaApplication.class)
                // No banner: log aggregators will thank you
                .bannerMode(Banner.Mode.OFF)
                // Force the application type instead of inferring it from the classpath
                .web(WebApplicationType.SERVLET)
                // Default active profile if no other is given (module 7)
                .profiles("default")
                // Default properties: the lowest priority of all
                .properties("spring.application.name=ciclourbana")
                // A listener for an event that precedes context creation
                .listeners(event -> {
                    if (event instanceof org.springframework.boot.context.event.ApplicationStartingEvent) {
                        System.out.println(">> Starting CicloUrbana...");
                    }
                })
                // Register the JVM shutdown hook (on by default)
                .registerShutdownHook(true)
                .run(args);
    }
}

A round-up of the most useful options:

Method Effect
bannerMode(Mode.OFF / LOG / CONSOLE) Controls the banner from code
web(WebApplicationType.NONE) Starts without a server: batch processes or CLI tools
profiles("dev", "local") Activates additional profiles (module 7)
properties("key=value") Default properties, at the lowest priority
listeners(...) Registers early event listeners, before the context exists
logStartupInfo(false) Omits the Starting... and Started... lines
parent(...) / child(...) Context hierarchies, rarely needed

A realistic case for CicloUrbana: a tool that generates the monthly usage report, reusing all the project's code but without starting the web server.

new SpringApplicationBuilder(CicloUrbanaApplication.class)
        .web(WebApplicationType.NONE)   // no Tomcat: the process ends when it is done
        .bannerMode(Banner.Mode.OFF)
        .run(args);

Because the type is NONE, main finishes once the runners are done and the JVM closes by itself. Exactly what you want in a scheduled task.

  1. Graceful shutdown and closing hooks

Stopping an application abruptly in production means cutting requests short. In CicloUrbana that could be a rental that starts but is never recorded.

Graceful shutdown

# src/main/resources/application.properties

# On receiving the stop signal, stop accepting new requests
# and wait for the in-flight ones to finish
server.shutdown=graceful

# Maximum wait before forcing the close (30s by default)
spring.lifecycle.timeout-per-shutdown-phase=20s

With server.shutdown=graceful, receiving a SIGTERM (what docker stop or Kubernetes sends) triggers this:

sequenceDiagram
    participant OS as Operating system
    participant SB as Spring Boot
    participant T as Tomcat
    participant P as In-flight requests

    OS->>SB: SIGTERM
    SB->>T: Stop accepting new connections
    Note over T: New requests are rejected
    SB->>P: Wait for up to 20 s
    P-->>SB: Requests finished
    SB->>SB: Publish ContextClosedEvent
    SB->>SB: Destroy the singleton beans
    SB->>OS: Process terminated

In the log you will see:

... o.s.b.w.e.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete
... o.s.b.w.e.tomcat.GracefulShutdown : Graceful shutdown complete

The default value is immediate, which cuts off instantly. In any real deployment you must enable graceful.

Reacting to shutdown

You can run code before the application disappears:

package com.ciclourbana.common;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.event.ContextClosedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Component
public class ShutdownNotice {

    private static final Logger log = LoggerFactory.getLogger(ShutdownNotice.class);

    @EventListener(ContextClosedEvent.class)
    public void onClose() {
        log.info("CicloUrbana is stopping. Closing open resources...");
        // Here you would release external connections, flush pending caches, and so on
    }
}

There is a bean-level alternative, the @PreDestroy annotation, which Spring invokes just before destroying each object. It is part of the bean lifecycle and is studied in lesson 02-03.

The JVM shutdown hook

Spring Boot automatically registers a shutdown hook with the JVM. That is what guarantees the context closes cleanly when:

  • You press Ctrl+C in the terminal.
  • The orchestrator sends SIGTERM (docker stop, kubectl delete pod).
  • The process ends normally.

Important: kill -9 (SIGKILL) cannot be intercepted. The process dies instantly without running any hook. That is why Kubernetes sends SIGTERM first and only resorts to SIGKILL if the application has not finished within the grace period.

To disable the hook —very rarely necessary—:

spring.main.register-shutdown-hook=false

Common Mistakes and Tips

  • Using ApplicationStartedEvent when you meant ApplicationReadyEvent. With the former, the CommandLineRunners have not run yet, so the data you expect to be preloaded is not there. When in doubt, use ApplicationReadyEvent.
  • Putting heavy logic in a CommandLineRunner. Everything you do there delays startup, and on Kubernetes a slow startup can cause restart loops when the readiness probe fails. For long work, launch an asynchronous task (module 7).
  • Subscribing with @EventListener to events that precede the context. ApplicationStartingEvent and ApplicationEnvironmentPreparedEvent are published while the container does not yet exist: an annotated bean will never receive them. Register them with SpringApplicationBuilder.listeners(...).
  • Confusing the Started ... in X seconds figure with the total time. The bracketed (process running for Y) includes the JVM startup. The difference between the two does not depend on your code.
  • Forgetting server.shutdown=graceful in production. It is one line that prevents intermittent errors during every deployment, errors that are hard to reproduce and hard to diagnose.
  • Not understanding restartedMain. That thread name indicates DevTools is active. If it shows up anywhere other than your laptop, you have a packaging problem.
  • Tip — start once with debug=true. The autoconfiguration report it prints teaches you a great deal about what Spring Boot is deciding on your behalf.
  • Tip — informative startup logs. A StartupNotice summarising the state of the system saves hours of diagnosis when something goes wrong on a remote server.

Exercises

Exercise 1

Create a custom banner.txt for CicloUrbana showing the application name, the Spring Boot version and the active profile. Then check that spring.main.banner-mode=off disables it.

Exercise 2

Implement a CommandLineRunner called StartupVerifier that checks at startup that the network has at least 3 stations and that none of them has a capacity of 0. If any check fails, it must prevent startup with a clear message. Explain why failing at startup is preferable to discovering the problem on the first request.

Exercise 3

Add to CicloUrbana an ApplicationReadyEvent listener that logs the total startup time and a ContextClosedEvent listener that logs how long the application has been running. Also enable graceful shutdown and verify its behaviour in the log.

Solutions

Solution 1

# src/main/resources/banner.txt
  ____ _      _        _   _      _
 / ___(_) ___| | ___  | | | |_ __| |__   __ _ _ __   __ _
| |   | |/ __| |/ _ \ | | | | '__| '_ \ / _` | '_ \ / _` |
| |___| | (__| | (_) || |_| | |  | |_) | (_| | | | | (_| |
 \____|_|\___|_|\___/  \___/|_|  |_.__/ \__,_|_| |_|\__,_|

 Application : ${application.title:ciclourbana}
 Version     : ${application.version:development}
 Boot        : ${spring-boot.version}
 Profile     : ${spring.profiles.active:default}
 Ribalta municipal electric bike network

Verification:

./mvnw spring-boot:run
# Shows the banner with Boot 3.3.5 and profile "default"

# Disable it temporarily without touching the properties file
./mvnw spring-boot:run -Dspring-boot.run.arguments=--spring.main.banner-mode=off

# Or permanently in application.properties
echo "spring.main.banner-mode=off" >> src/main/resources/application.properties

Note: ${application.version} will appear as development when running with spring-boot:run, because that property is read from the MANIFEST.MF and only exists when running the packaged JAR. That is why it is always worth supplying a default after the colon.

Solution 2

package com.ciclourbana.common;

import com.ciclourbana.stations.StationStore;
import com.ciclourbana.stations.Station;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

import java.util.List;

/**
 * Checks at startup that the Ribalta network is in a valid state.
 * It runs AFTER the station loader thanks to @Order(2).
 */
@Component
@Order(2)
public class StartupVerifier implements CommandLineRunner {

    private static final Logger log = LoggerFactory.getLogger(StartupVerifier.class);
    private static final int MINIMUM_STATIONS = 3;

    private final StationStore stationStore;

    public StartupVerifier(StationStore stationStore) {
        this.stationStore = stationStore;
    }

    @Override
    public void run(String... args) {
        log.info("Verifying the configuration of the Ribalta network...");

        List<Station> stations = stationStore.listAll();

        // Check 1: minimum number of stations
        if (stations.size() < MINIMUM_STATIONS) {
            throw new IllegalStateException(
                    "The network needs at least " + MINIMUM_STATIONS
                    + " stations to operate, but there are only " + stations.size()
                    + ". Review the data loader.");
        }

        // Check 2: no station without docks
        List<String> withoutCapacity = stations.stream()
                .filter(station -> station.capacity() <= 0)
                .map(Station::name)
                .toList();

        if (!withoutCapacity.isEmpty()) {
            throw new IllegalStateException(
                    "There are stations with capacity 0, which makes returning bikes impossible: "
                    + String.join(", ", withoutCapacity));
        }

        log.info("Verification passed: {} valid stations, {} docks in total",
                stations.size(), stationStore.totalCapacity());
    }
}

The @Order(2) is essential: without it the execution order between the two runners is not guaranteed and the verifier could run before the loader, finding the network empty.

Checking the failure: remove two stations from the loader and start up. You will see:

... APPLICATION FAILED TO START
...
java.lang.IllegalStateException: The network needs at least 3 stations to operate,
but there are only 2. Review the data loader.

Why failing at startup is preferable: it is the fail fast principle. A startup failure is detected at deployment time, with clear logs, and the orchestrator can roll back to the previous version automatically without any user noticing. If instead the problem surfaces on the first request, the deployment is deemed successful, users get intermittent 500 errors and diagnosis becomes far harder. The rule is: validate critical configuration at startup, never at request time.

Solution 3

package com.ciclourbana.common;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.ContextClosedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.time.Instant;

/**
 * Logs CicloUrbana's startup time and its total
 * time in service.
 */
@Component
public class ApplicationStopwatch {

    private static final Logger log = LoggerFactory.getLogger(ApplicationStopwatch.class);

    // The moment the bean is constructed: during the context refresh
    private final Instant createdAt = Instant.now();
    private Instant readyAt;

    @EventListener(ApplicationReadyEvent.class)
    public void onReady(ApplicationReadyEvent event) {
        this.readyAt = Instant.now();

        // The event itself knows when SpringApplication started
        long totalMilliseconds = event.getTimeTaken().toMillis();

        log.info("CicloUrbana ready. Full startup in {} ms", totalMilliseconds);
        log.info("From bean creation to being ready: {} ms",
                Duration.between(createdAt, readyAt).toMillis());
    }

    @EventListener(ContextClosedEvent.class)
    public void onClose() {
        if (readyAt == null) {
            log.warn("CicloUrbana is stopping before it finished starting up");
            return;
        }
        Duration uptime = Duration.between(readyAt, Instant.now());
        log.info("CicloUrbana is stopping after {} hours, {} minutes and {} seconds in service",
                uptime.toHours(),
                uptime.toMinutesPart(),
                uptime.toSecondsPart());
    }
}

Details of the solution:

  • ApplicationReadyEvent.getTimeTaken() returns the startup duration directly, with no need to time it by hand.
  • The readyAt == null check covers the case of a failed startup: ContextClosedEvent can be published without ApplicationReadyEvent ever having been reached.
  • toMinutesPart() and toSecondsPart() (Java 9+) give the remainder within the larger unit, avoiding manual modulo arithmetic.

Configuring graceful shutdown:

# src/main/resources/application.properties
server.shutdown=graceful
spring.lifecycle.timeout-per-shutdown-phase=20s
logging.level.org.springframework.boot.web.embedded.tomcat.GracefulShutdown=INFO

Verification:

# Terminal 1
java -jar target/ciclourbana-0.0.1-SNAPSHOT.jar

# Terminal 2: send SIGTERM to the process (equivalent to docker stop)
kill $(pgrep -f ciclourbana)

In terminal 1 you will see the full sequence:

... o.s.b.w.e.tomcat.GracefulShutdown : Commencing graceful shutdown. Waiting for active requests to complete
... c.c.common.ApplicationStopwatch   : CicloUrbana is stopping after 0 hours, 1 minutes and 34 seconds in service
... o.s.b.w.e.tomcat.GracefulShutdown : Graceful shutdown complete

Try comparing that with kill -9 $(pgrep -f ciclourbana): the process disappears without printing anything, because SIGKILL cannot be intercepted and the closing hooks never get to run.

Conclusion

There is nothing opaque left between main and the Started CicloUrbanaApplication message. You know that SpringApplication.run(...) infers the application type, prepares the Environment with its prioritised property sources, creates the context, scans com.ciclourbana, processes the autoconfiguration, instantiates the beans, starts Tomcat and publishes events along the way. You can read every field of the startup log, customise the banner and —most useful of all day to day— hook your own code in at exactly the right moment: a CommandLineRunner with @Order to preload Ribalta's stations, an ApplicationReadyEvent listener to report the state of the network, and a graceful shutdown with server.shutdown=graceful that does not cut rentals short. CicloUrbana now has its first Spring-managed component, the StationStore, injected through the constructor into the controller and into the runners.

And that is precisely the door into module 2: Spring Boot Core Concepts. We have used @Component, @RestController and constructor injection as tools, without explaining why they work. In module 2 we will open up the container: what each Spring Boot annotation means, how dependency injection is resolved, what scopes and lifecycle beans have, how the application is configured through typed properties and, finally, how autoconfiguration decides —condition by condition— which beans to register. By the end of it you will stop using Spring Boot by imitation and start using it with judgement.

Spring Boot Course

Module 1: Introduction to Spring Boot

Module 2: Spring Boot Core Concepts

Module 3: Building RESTful Web Services

Module 4: Data Access with Spring Boot

Module 5: Security in Spring Boot

Module 6: Testing in Spring Boot

Module 7: Advanced Spring Boot Features

Module 8: Deploying Spring Boot Applications

Module 9: Performance and Monitoring

Module 10: Best Practices and Tips

© Copyright 2026. All rights reserved