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
- What
SpringApplication.run(...)actually does - The startup phases, step by step
- The banner and how to customise it
- Reading CicloUrbana's startup log
- The lifecycle events
- An
ApplicationReadyEventlistener CommandLineRunnerandApplicationRunner- Customising startup with
SpringApplicationBuilder - Graceful shutdown and closing hooks
- Common Mistakes and Tips
- Exercises
- What
SpringApplication.run(...) actually does
SpringApplication.run(...) actually doesLet 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.
- 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:
- Command-line arguments (
--server.port=9090) - Java system properties (
-Dserver.port=9090) - Environment variables (
SERVER_PORT=9090) application-{profile}.propertiesapplication.properties- 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 theTomcatServletWebServerFactoryget 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
RentalServiceneedsStationService, 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.
- 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.txtIn production the usual choice is off or log: the banner clutters structured log aggregators.
- 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
killor for locating the process. - Application name: comes from
spring.application.name, which we set in the previous lesson. - Thread:
restartedMainindicates that DevTools is active. Without DevTools it would bemain. When an HTTP request arrives you will seehttp-nio-8080-exec-1. - Abbreviated class:
o.s.b.w.embedded.tomcat.TomcatWebServerisorg.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
- 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 theCommandLineRunners 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.
- An
ApplicationReadyEvent listener
ApplicationReadyEvent listenerLet 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 theApplicationListenerinterface, 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 inspring-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.
CommandLineRunner and ApplicationRunner
CommandLineRunner and ApplicationRunnerBoth 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.
- Customising startup with
SpringApplicationBuilder
SpringApplicationBuilderSpringApplicationBuilder 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.
- 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=20sWith 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 completeThe 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+Cin 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—:
Common Mistakes and Tips
- Using
ApplicationStartedEventwhen you meantApplicationReadyEvent. With the former, theCommandLineRunners have not run yet, so the data you expect to be preloaded is not there. When in doubt, useApplicationReadyEvent. - 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
@EventListenerto events that precede the context.ApplicationStartingEventandApplicationEnvironmentPreparedEventare published while the container does not yet exist: an annotated bean will never receive them. Register them withSpringApplicationBuilder.listeners(...). - Confusing the
Started ... in X secondsfigure 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=gracefulin 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
StartupNoticesummarising 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 networkVerification:
./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.propertiesNote: ${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 == nullcheck covers the case of a failed startup:ContextClosedEventcan be published withoutApplicationReadyEventever having been reached. toMinutesPart()andtoSecondsPart()(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=INFOVerification:
# 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 completeTry 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
- What Is Spring Boot?
- Setting Up Your Development Environment
- Building Your First Spring Boot Application
- Understanding the Project Structure
- Application Startup and Lifecycle
Module 2: Spring Boot Core Concepts
- Spring Boot Annotations
- Dependency Injection in Spring Boot
- Bean Scope and Lifecycle
- Spring Boot Configuration
- Spring Boot Properties
- Auto-Configuration and Starters from the Inside
Module 3: Building RESTful Web Services
- Introduction to RESTful Web Services
- Creating REST Controllers
- Handling HTTP Methods
- Validating Input Data
- DTOs and Mapping Between Layers
- Exception Handling in REST
- Documenting the API with OpenAPI
Module 4: Data Access with Spring Boot
- Introduction to Spring Data JPA
- Configuring Data Sources
- Creating JPA Entities
- Relationships Between Entities
- Using Spring Data Repositories
- Query Methods in Spring Data JPA
- Transactions and Persistence Management
- Schema Migrations with Flyway
Module 5: Security in Spring Boot
- Introduction to Spring Security
- Configuring Spring Security
- User Authentication and Authorization
- Implementing JWT Authentication
- Method-Level Security and API Hardening
Module 6: Testing in Spring Boot
- Introduction to Testing
- Unit Testing with JUnit
- Mocking with Mockito
- Integration Testing
- Testing with Testcontainers
Module 7: Advanced Spring Boot Features
- Spring Boot Actuator
- Spring Boot Profiles
- Scheduled Tasks and Asynchronous Execution
- Spring Boot with Docker
- Spring Boot and Microservices
- Service Communication and Fault Tolerance
Module 8: Deploying Spring Boot Applications
- Introduction to Deployment
- Deploying to Heroku
- Deploying to AWS
- Deploying to Kubernetes
- Continuous Integration and Delivery
Module 9: Performance and Monitoring
- Performance Tuning
- Caching with Spring Cache
- Monitoring with Spring Boot Actuator
- Using Prometheus and Grafana
- Logging and Log Management
- Distributed Tracing
