HomeSpring BootSpring Boot Internal Working
Spring Boot

Spring Boot Internal Working

How Spring Boot starts internally: SpringApplication.run(), auto-configuration, context refresh, embedded server, startup events and debugging, step by step.

The Entry Point — SpringApplication.run()

Everything starts with one line: SpringApplication.run(). It creates a SpringApplication instance, decides the application type, prepares the Environment (properties, profiles), creates the ApplicationContext, refreshes it, and finally runs your CommandLineRunner and ApplicationRunner beans. Why this matters: nearly every "magic" behavior in Spring Boot happens in this one call, so knowing its phases tells you where to hook in. Use a SpringApplication instance instead of the static run() when you need to change behavior before startup, such as turning off the banner, enabling lazy initialization or adding listeners. Properties set with setDefaultProperties have the lowest priority, so application.properties, environment variables and command-line arguments all override them.
Java
@SpringBootApplication
public class MyApplication {
    public static void main(String[] args) {
        SpringApplication.run(MyApplication.class, args);
        // This single line, in order:
        // 1. Creates a SpringApplication instance
        // 2. Determines application type (SERVLET / REACTIVE / NONE)
        // 3. Loads listeners and initializers from spring.factories
        // 4. Prepares the Environment (application.properties, profiles, env vars)
        // 5. Creates the ApplicationContext and refreshes it
        //    (bean definitions are loaded, beans are created, web server is created)
        // 6. Starts accepting web requests
        // 7. Runs CommandLineRunners and ApplicationRunners
    }
}

// You can customize SpringApplication before run():
@SpringBootApplication
public class MyApplication {
    public static void main(String[] args) {
        SpringApplication app = new SpringApplication(MyApplication.class);
        app.setBannerMode(Banner.Mode.OFF);      // disable startup banner
        app.setLazyInitialization(true);         // lazy bean initialization
        app.setDefaultProperties(Map.of(         // lowest-priority default properties
            "server.port", "9090"
        ));
        app.run(args);
    }
}

How the Executable JAR Starts

When you run java -jar app.jar, the JVM does not start your main class directly. The Spring Boot Maven or Gradle plugin builds a "fat JAR" whose manifest points to a launcher class. The launcher builds a class loader that can read the nested JARs inside BOOT-INF/lib, then calls your real main method. This is why your classes live under BOOT-INF/classes and your dependencies under BOOT-INF/lib, and why code that assumes a normal flat classpath (for example scanning the file system for resources) can behave differently inside a JAR. Use classpath: resources and Spring's Resource abstraction, not java.io.File.
Java
// Inside app.jar:
//
// META-INF/MANIFEST.MF
//   Main-Class: org.springframework.boot.loader.launch.JarLauncher   (Boot 3.2+)
//               org.springframework.boot.loader.JarLauncher          (older versions)
//   Start-Class: com.example.MyApplication      ← your real main class
//
// BOOT-INF/classes/        ← your compiled code and application.properties
// BOOT-INF/lib/            ← all dependency JARs (nested)
// org/springframework/boot/loader/   ← the launcher classes
//
// Startup flow:
// java -jar app.jar
//   → JVM runs JarLauncher.main()
//   → JarLauncher builds a class loader that understands BOOT-INF/classes and BOOT-INF/lib
//   → JarLauncher reads Start-Class and calls MyApplication.main()
//   → SpringApplication.run() begins

// Inspect it yourself:
// jar tf target/app.jar | head -30
// unzip -p target/app.jar META-INF/MANIFEST.MF

Step 1 — Application Type Detection

Spring Boot first decides what kind of application it is running: a Servlet web app, a reactive web app, or a non-web app. This decides which ApplicationContext class is created and whether a web server is started at all. The check is based on which classes are on the classpath, so it is driven by your dependencies. A common surprise: adding both spring-boot-starter-web and spring-boot-starter-webflux gives you a Servlet app, because Servlet wins when both are present. Set spring.main.web-application-type=reactive (or none) to force it.
Java
// Spring Boot checks the classpath (WebApplicationType.deduceFromClasspath):

// Reactive classes present (DispatcherHandler) AND no Servlet MVC (DispatcherServlet)
// AND no Jersey ServletContainer:
//   → WebApplicationType.REACTIVE
//   → Creates AnnotationConfigReactiveWebServerApplicationContext
//   → Starts embedded Netty (by default)

// Servlet API or Spring's web context class missing:
//   → WebApplicationType.NONE
//   → Creates AnnotationConfigApplicationContext
//   → No web server started (batch jobs, CLI tools, etc.)

// Otherwise (jakarta.servlet.Servlet and ConfigurableWebApplicationContext present):
//   → WebApplicationType.SERVLET
//   → Creates AnnotationConfigServletWebServerApplicationContext
//   → Starts embedded Tomcat (or Jetty/Undertow)

// You can force the type in code:
SpringApplication app = new SpringApplication(MyApplication.class);
app.setWebApplicationType(WebApplicationType.NONE);   // force non-web
app.run(args);

// Or with a property:
// spring.main.web-application-type=none

Step 2 — @SpringBootApplication Unpacked

@SpringBootApplication is a shortcut for three annotations, and each one does a different job: @SpringBootConfiguration (a @Configuration), @ComponentScan, and @EnableAutoConfiguration. Two practical consequences. First, component scanning starts at the package of your main class and covers its sub-packages, so keep the main class in your root package, otherwise your controllers and services in sibling packages are silently not found. Second, @EnableAutoConfiguration also registers your main class package as the base package for things like JPA entity and repository scanning, which is why entities need no extra configuration when they live under it. Since Spring Boot 3.0 the auto-configuration list is read only from META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports. Listing auto-configurations in spring.factories was deprecated in 2.7 and removed in 3.0.
Java
// @SpringBootApplication =
//   @SpringBootConfiguration  (a specialized @Configuration)
// + @EnableAutoConfiguration
// + @ComponentScan

// @ComponentScan scans the main class package and all sub-packages:
// Finds: @Component, @Service, @Repository, @Controller, @RestController,
//        @Configuration, @Aspect, and other stereotype annotations
// Registers found classes as bean definitions in the BeanFactory

// @SpringBootConfiguration marks the class as a bean definition source:
// Any @Bean methods in the class produce beans

// @EnableAutoConfiguration is the key Spring Boot addition:
// It imports AutoConfigurationImportSelector
// Which reads META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
// (Boot 2.7 deprecated, and Boot 3.0 removed, the spring.factories way)
// That file, inside spring-boot-autoconfigure, lists well over 100 auto-configuration classes

// Example entries in AutoConfiguration.imports:
// org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration
// org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration
// org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration
// org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration
// ... and many more

// Package layout that works:
// com.example.myapp.MyApplication          ← main class (root package)
// com.example.myapp.controller.UserController   ← scanned
// com.example.myapp.service.UserService         ← scanned
// com.example.other.Something                   ← NOT scanned

Step 3 — Auto-Configuration Decision Making

Each auto-configuration class is a normal configuration class guarded by @Conditional annotations. Spring Boot evaluates those conditions against the classpath, properties and existing beans, and only applies the configuration if they pass. The detail that makes "your bean overrides the default" work is ordering: auto-configurations are imported last, after your own @Configuration classes and component scan results have registered their beans. So when @ConditionalOnMissingBean is checked, your bean is already there and the default one is skipped. The example below is simplified to show the idea. The real DataSourceAutoConfiguration has more nested classes and changes between versions, so read it in your own Boot version's source when you need exact details.
Java
// Simplified idea of DataSourceAutoConfiguration
// (real class differs between Boot versions):

@AutoConfiguration(before = SqlInitializationAutoConfiguration.class)
@ConditionalOnClass({ DataSource.class, EmbeddedDatabaseType.class })
@ConditionalOnMissingBean(type = "io.r2dbc.spi.ConnectionFactory")
@EnableConfigurationProperties(DataSourceProperties.class)
public class DataSourceAutoConfiguration {

    // Applies only when an embedded DB (H2, HSQL, Derby) is on the classpath
    // and you have not defined your own DataSource
    @Configuration(proxyBeanMethods = false)
    @Conditional(EmbeddedDatabaseCondition.class)
    @ConditionalOnMissingBean({ DataSource.class, XADataSource.class })
    @Import(EmbeddedDataSourceConfiguration.class)
    protected static class EmbeddedDatabaseConfiguration { }

    // Applies when a connection pool (HikariCP by default) is available
    // and spring.datasource.url is set
    @Configuration(proxyBeanMethods = false)
    @Conditional(PooledDataSourceCondition.class)
    @ConditionalOnMissingBean({ DataSource.class, XADataSource.class })
    protected static class PooledDataSourceConfiguration { }
}

// The key @Conditional annotations:
// @ConditionalOnClass          — applies if a class IS on the classpath
// @ConditionalOnMissingClass   — applies if a class is NOT on the classpath
// @ConditionalOnBean           — applies if a bean IS already defined
// @ConditionalOnMissingBean    — applies if a bean is NOT already defined
// @ConditionalOnProperty       — applies if a property has a specific value
// @ConditionalOnWebApplication — applies in web applications only
// @ConditionalOnExpression     — applies based on a SpEL expression

// This is why defining your own bean OVERRIDES auto-configuration:
@Bean
public DataSource dataSource() {
    // Your custom DataSource — @ConditionalOnMissingBean on auto-config
    // sees your bean and skips the default one
    return myCustomDataSource();
}

Step 4 — ApplicationContext Refresh

The ApplicationContext refresh is the heart of startup. It is one method, refresh(), that runs a fixed series of phases. Knowing the order explains a lot of behavior: why @ConditionalOnMissingBean works, why BeanPostProcessors exist before your normal beans, and why the web server is created before your beans are all ready. Important detail: the embedded web server is created during refresh (in the onRefresh phase), and it starts accepting requests at the very end of refresh. It is not started after refresh finishes, as is sometimes described. Prefer constructor injection over @Autowired fields. It makes dependencies explicit, allows final fields, and makes classes easy to test without Spring. In Spring Boot 3 and later, @PostConstruct comes from jakarta.annotation, not javax.annotation.
Java
// The refresh() phases, in order:

// 1. prepareRefresh / obtainFreshBeanFactory
//    — Environment validated, empty BeanFactory ready

// 2. invokeBeanFactoryPostProcessors
//    — ConfigurationClassPostProcessor parses @Configuration classes,
//      runs @ComponentScan, processes @Import and @Bean methods
//    — Auto-configurations are imported LAST (DeferredImportSelector),
//      so your beans are registered first and @ConditionalOnMissingBean sees them
//    — At this point only bean DEFINITIONS exist, no objects yet

// 3. registerBeanPostProcessors
//    — e.g. AutowiredAnnotationBeanPostProcessor, AOP proxy creators

// 4. onRefresh
//    — Web applications: the embedded server (Tomcat/Netty) is created here

// 5. finishBeanFactoryInitialization
//    — Every non-lazy singleton bean is created, in this order per bean:
//      a. Instantiate (call constructor, resolving constructor arguments)
//      b. Inject dependencies (@Autowired fields and setters)
//      c. Aware callbacks (BeanNameAware, ApplicationContextAware, ...)
//      d. BeanPostProcessor before-init
//      e. @PostConstruct, then InitializingBean.afterPropertiesSet(), then custom init
//      f. BeanPostProcessor after-init (AOP proxies for @Transactional, @Async, etc.
//         are created here)

// 6. finishRefresh
//    — Lifecycle beans start; the web server starts accepting requests
//    — ContextRefreshedEvent and WebServerInitializedEvent are published

// Hook points:

// Run code after a specific bean's dependencies are injected:
@Component
public class StartupValidator {
    @PostConstruct   // jakarta.annotation.PostConstruct (Boot 3+)
    public void validate() {
        System.out.println("Validating configuration...");
    }
}

// Run code after the context is started, before ApplicationReadyEvent:
@Component
@Order(1)   // control the order of multiple runners
public class DataSeeder implements CommandLineRunner {
    @Override
    public void run(String... args) {
        System.out.println("Seeding initial data...");
    }
}

// ApplicationRunner — same, but receives parsed ApplicationArguments:
@Component
public class AppStartup implements ApplicationRunner {
    @Override
    public void run(ApplicationArguments args) {
        System.out.println("App started with args: " + args.getOptionNames());
    }
}

Step 5 — Embedded Server Startup

For web applications the embedded server is just another part of the context, built from a WebServerFactory bean chosen by auto-configuration (ServletWebServerFactoryAutoConfiguration picks Tomcat by default). Because it is a normal bean, you can customize it with a WebServerFactoryCustomizer or with server.* properties. Prefer properties for simple settings such as the port, thread pool or compression, and a customizer bean only for things properties cannot express. Note that the Tomcat thread settings (server.tomcat.threads.max, default 200) and server.tomcat.max-connections matter for load: a request thread is held for the whole request, so slow downstream calls can exhaust the pool.
Java
// What happens for the embedded server:
// 1. During refresh (onRefresh), TomcatServletWebServerFactory creates the server
//    — port from server.port (default 8080)
//    — connector, compression and SSL settings applied
// 2. Spring's DispatcherServlet is registered with the server
//    — it routes incoming HTTP requests to your controllers
// 3. At the end of refresh (finishRefresh), the server starts accepting requests
//    and WebServerInitializedEvent is published
// 4. ApplicationStartedEvent is published
// 5. CommandLineRunners and ApplicationRunners execute
// 6. ApplicationReadyEvent is published — app is ready

// Customize the embedded server in code:
@Configuration
public class ServerConfig {
    @Bean
    public WebServerFactoryCustomizer<TomcatServletWebServerFactory> tomcatCustomizer() {
        return factory -> {
            factory.setPort(9090);
            factory.addConnectorCustomizers(connector -> {
                connector.setMaxPostSize(10 * 1024 * 1024);  // 10MB max POST body
            });
        };
    }
}

// Or via application.properties (preferred for simple settings):
// server.port=8080
// server.tomcat.connection-timeout=10s
// server.tomcat.max-connections=10000
// server.tomcat.threads.max=200
// server.tomcat.threads.min-spare=10
// server.compression.enabled=true
// server.ssl.key-store=classpath:keystore.p12

Spring Boot Application Events

Spring Boot publishes events at each stage of startup, so you can run code at an exact point in the lifecycle. The order matters when choosing a hook: use ApplicationReadyEvent for things that need the whole application up (warming caches, calling other services, starting schedulers), and the earlier events only for infrastructure work. Key pitfall: the first four events are published before your beans exist, so a normal @EventListener on a @Component cannot receive them. Register those listeners with SpringApplication.addListeners(...) or in META-INF/spring.factories. The same applies to ApplicationFailedEvent, because the context may never have started.
Java
// Application events in order of publication:

// 1. ApplicationStartingEvent            — very early, before anything is prepared
// 2. ApplicationEnvironmentPreparedEvent — Environment ready, context not created yet
// 3. ApplicationContextInitializedEvent  — context created, bean definitions not loaded
// 4. ApplicationPreparedEvent            — bean definitions loaded, context not refreshed
// 5. ContextRefreshedEvent               — context refreshed, beans created
// 6. WebServerInitializedEvent           — embedded web server is accepting requests
// 7. ApplicationStartedEvent             — context refreshed, before runners
// 8. AvailabilityChangeEvent (LivenessState.CORRECT)           — app is live
// 9. CommandLineRunners and ApplicationRunners are called
// 10. ApplicationReadyEvent              — runners finished, app fully started
// 11. AvailabilityChangeEvent (ReadinessState.ACCEPTING_TRAFFIC) — ready for traffic
//
// ApplicationFailedEvent is published instead if startup fails at any point.

// Normal listener beans — fine for events 5 to 11:
@Component
public class StartupListener {

    @EventListener(ApplicationReadyEvent.class)
    public void onReady(ApplicationReadyEvent event) {
        System.out.println("Application fully started and ready!");
        // Safe to make external connections, start schedulers, etc.
    }
}

// Early events (1 to 4, and ApplicationFailedEvent) need special registration:
public class EarlyListener implements ApplicationListener<ApplicationEnvironmentPreparedEvent> {
    @Override
    public void onApplicationEvent(ApplicationEnvironmentPreparedEvent event) {
        System.out.println("Active profiles: "
            + Arrays.toString(event.getEnvironment().getActiveProfiles()));
    }
}

// Option 1 — register in code:
SpringApplication app = new SpringApplication(MyApplication.class);
app.addListeners(new EarlyListener());
app.run(args);

// Option 2 — register in META-INF/spring.factories:
// org.springframework.context.ApplicationListener=com.example.EarlyListener

Debugging Auto-Configuration

When auto-configuration behaves unexpectedly, Spring Boot can tell you exactly what was applied and why. Start the app with --debug (or debug=true) to print the conditions evaluation report, or use the Actuator conditions endpoint on a running app. Read the report from the bottom up. Positive matches show what was applied and which condition passed, negative matches show what was skipped and which condition failed, and the second list is usually where the answer is. For example, if your DataSource is not configured, check the negative matches for DataSourceAutoConfiguration and look for a missing dependency or a missing spring.datasource.url.
Java
// Enable the conditions report (choose one):
//   java -jar app.jar --debug
//   debug=true   in application.properties
//   GET /actuator/conditions   (with spring-boot-starter-actuator, endpoint exposed)

// The report shows these sections:
// POSITIVE MATCHES — auto-configs that were applied and why:
//   DataSourceAutoConfiguration matched:
//     - @ConditionalOnClass found required classes 'javax.sql.DataSource'
//     - @ConditionalOnMissingBean did not find any beans of type ConnectionFactory

// NEGATIVE MATCHES — auto-configs that were skipped and why:
//   MongoAutoConfiguration:
//     Did not match:
//       - @ConditionalOnClass did not find required class 'com.mongodb.client.MongoClient'

// EXCLUSIONS — auto-configs you explicitly disabled
// UNCONDITIONAL CLASSES — auto-configs applied with no conditions

// Exclude specific auto-configurations in code:
@SpringBootApplication(exclude = {
    SecurityAutoConfiguration.class,
    DataSourceAutoConfiguration.class
})
public class Application { }

// Or in application.properties (comma-separated, keep it on one line):
// spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration

// See all beans in the context:
// GET /actuator/beans (with Actuator enabled)
// Or programmatically:
@Component
public class BeanPrinter implements CommandLineRunner {
    private final ApplicationContext context;

    public BeanPrinter(ApplicationContext context) {
        this.context = context;
    }

    @Override
    public void run(String... args) {
        Arrays.stream(context.getBeanDefinitionNames())
            .sorted()
            .forEach(System.out::println);
    }
}

Measuring and Speeding Up Startup

Once you understand the phases you can measure them. Most slow startups come from three places: too many beans created eagerly, heavy work inside @PostConstruct or runners, and slow external calls such as a database connection or schema migration at startup. Start by finding the slow part, not guessing. The startup Actuator endpoint records how long each phase and bean takes. Lazy initialization is the quickest win, but it moves the cost to the first request and hides wiring errors until a bean is first used, so use it deliberately.
Java
// 1. Record startup steps — pass a BufferingApplicationStartup:
public static void main(String[] args) {
    SpringApplication app = new SpringApplication(MyApplication.class);
    app.setApplicationStartup(new BufferingApplicationStartup(2048));
    app.run(args);
}
// Then expose the endpoint and call: GET /actuator/startup
// management.endpoints.web.exposure.include=startup

// 2. Lazy initialization — beans are created when first needed:
// spring.main.lazy-initialization=true
// Trade-off: faster startup, slower first request, wiring errors found later.
// Opt a single bean out of it:
@Component
@Lazy(false)
public class CacheWarmer { }

// 3. Cut work done at startup:
//    - Remove unused starters (each one adds auto-configurations to evaluate)
//    - Move slow initialization out of @PostConstruct into async or ApplicationReadyEvent
//    - Exclude auto-configurations you never use (see previous section)

// 4. Read the "Started MyApplication in X seconds" log line as a baseline
//    and compare after every change.