Spring Boot Internals

Boot is opinionated wiring on top of the same ApplicationContext you learned in IoC & DI. This chapter explains what happens between SpringApplication.run() and "Started in 2.1 seconds": auto-configuration selection, property binding order, Actuator endpoints, and how Tomcat lands inside your fat JAR.

mid senior Spring Boot 3.x

Auto-configuration deep dive

Auto-configuration is a set of @Configuration classes registered on the classpath, each guarded by @ConditionalOn* annotations. Boot loads candidates, evaluates conditions, and registers only the beans your app didn't already define.

The magic is not runtime classpath scanning of every class—it's a curated list of configuration classes (hundreds in full Boot) filtered aggressively so startup stays fast and your explicit beans always win.

sequenceDiagram
  participant App as SpringApplication
  participant Sel as AutoConfigurationImportSelector
  participant CP as Classpath
  participant Ctx as ApplicationContext
  App->>Sel: process EnableAutoConfiguration
  Sel->>CP: read AutoConfiguration.imports
  Sel->>Sel: filter excluded auto-config classes
  Sel->>Ctx: register candidate configurations
  Ctx->>Ctx: evaluate ConditionalOnClass
  Ctx->>Ctx: evaluate ConditionalOnMissingBean
  Ctx->>Ctx: register matching beans

@SpringBootApplication

A composed annotation—three concerns in one declaration on your main class.

Meta-annotationWhat it enables
@Configuration Main class can declare @Bean methods (lite mode by default in Boot 2.2+)
@EnableAutoConfiguration Imports AutoConfigurationImportSelector — triggers auto-config loading
@ComponentScan Scans current package and sub-packages for @Component stereotypes
Equivalent explicit setup
@SpringBootApplication(
    scanBasePackages = "com.acme",
    exclude = { DataSourceAutoConfiguration.class }
)
public class OrderApplication {
  public static void main(String[] args) {
    SpringApplication.run(OrderApplication.class, args);
  }
}

// Same as:
@Configuration
@EnableAutoConfiguration(exclude = DataSourceAutoConfiguration.class)
@ComponentScan("com.acme")
class OrderApplicationExplicit {}
🔬 Under the Hood

@EnableAutoConfiguration imports AutoConfigurationImportSelector and AutoConfigurationPackages.Registrar — the latter registers the package of @SpringBootApplication for JPA entity scanning and other package-sensitive auto-config.

How candidates load — spring.factories → AutoConfiguration.imports

Boot 2.x and 3.x use the same selection pipeline; only the registration file moved.

VersionRegistration fileKey
Boot 2.x META-INF/spring.factories org.springframework.boot.autoconfigure.EnableAutoConfiguration
Boot 3.x META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports One fully qualified class name per line

Selection pipeline

  1. AutoConfigurationImportSelector.getAutoConfigurationEntry() loads all candidate class names from imports files across the classpath
  2. Remove exclusions from @SpringBootApplication(exclude=...) and spring.autoconfigure.exclude property
  3. Apply AutoConfigurationImportFilter beans (e.g. respect @ConditionalOnClass early)
  4. Register surviving configurations; full @Conditional evaluation happens during context refresh
AutoConfiguration.imports (Boot 3)
org.springframework.boot.autoconfigure.admin.SpringApplicationAdminJmxAutoConfiguration
org.springframework.boot.autoconfigure.aop.AopAutoConfiguration
org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration
# ... ~130+ entries in spring-boot-autoconfigure.jar

@ConditionalOnMissingBean — user bean wins

The most important pattern in every auto-config class: register a default only if the application hasn't already provided one. This is how you override DataSource, ObjectMapper, or SecurityFilterChain by simply declaring your own @Bean.

DataSourceAutoConfiguration pattern
@AutoConfiguration
@ConditionalOnClass(DataSource.class)
@ConditionalOnProperty(name = "spring.datasource.url")
@EnableConfigurationProperties(DataSourceProperties.class)
public class DataSourceAutoConfiguration {

  @Bean
  @ConditionalOnMissingBean
  DataSource dataSource(DataSourceProperties properties) {
    return properties.initializeDataSourceBuilder().build();
  }
}
⚠️ Pitfall

@ConditionalOnMissingBean checks the bean factory at registration time for that configuration pass. Defining two competing @Bean methods of the same type without @Primary still causes NoUniqueBeanDefinitionException — auto-config backing off doesn't fix duplicate user beans.

🔖 Version Note

Boot 3.0 introduced @AutoConfiguration as a replacement meta-annotation for ordering auto-config classes via @AutoConfigureBefore / @AutoConfigureAfter (replacing the older @AutoConfigureOrder patterns in some modules).

Writing custom auto-configuration

Internal platform teams and library authors package auto-config in starter JARs—the same mechanism Boot itself uses.

Starter module structure

  1. autoconfigure module@AutoConfiguration classes + META-INF/spring/...AutoConfiguration.imports
  2. starter module — thin POM depending on autoconfigure + required libraries (what users add to Initializr)
  3. core module — actual integration code with no Spring dependency if possible
Custom starter auto-config
@AutoConfiguration
@ConditionalOnClass(NotificationClient.class)
@EnableConfigurationProperties(NotificationProperties.class)
public class NotificationAutoConfiguration {

  @Bean
  @ConditionalOnMissingBean
  NotificationClient notificationClient(NotificationProperties props) {
    return new NotificationClient(props.getApiKey(), props.getBaseUrl());
  }
}

@ConfigurationProperties(prefix = "acme.notification")
record NotificationProperties(
    @NotBlank String apiKey,
    @NotBlank String baseUrl
) {}
META-INF/spring/com.acme.autoconfigure.AutoConfiguration.imports
com.acme.autoconfigure.NotificationAutoConfiguration

Consumers add acme-notification-spring-boot-starter to their POM and configure:

application.yml
acme:
  notification:
    api-key: ${NOTIFICATION_API_KEY}
    base-url: https://notify.acme.internal
📦 Real World

Company-internal starters wrap observability, auth, and DB routing. Keep conditions tight: @ConditionalOnClass for optional deps, @ConditionalOnProperty for feature flags, and always @ConditionalOnMissingBean so product teams can override.

💡 Pro Tip

Test auto-config with @AutoConfigureTest / slice tests or ApplicationContextRunner — assert beans present/absent under different classpath and property combinations without booting the full app.

Spring Boot startup sequence

SpringApplication.run() is more than "create context and go"—it's a staged bootstrap with extension points at every layer.

  1. SpringApplication.run() — Create SpringApplication instance, infer web application type (SERVLET / REACTIVE / NONE)
  2. Run listeners fireApplicationStartingEvent → prepare Environment
  3. Load ApplicationContextInitializers — programmatic context customization before refresh (libraries use this for secrets, cloud bindings)
  4. Prepare environment — Load application.properties/.yml, profile-specific files, env vars, command-line args
  5. Print bannerspring.main.banner-mode (console / log / off)
  6. Create ApplicationContextAnnotationConfigServletWebServerApplicationContext for typical MVC apps
  7. context.refresh() — Parse config, register bean definitions, run BeanFactoryPostProcessors, instantiate singletons, start embedded web server
  8. Call runners — All ApplicationRunner and CommandLineRunner beans, ordered by @Order
  9. Publish ready eventsApplicationStartedEventApplicationReadyEvent (app accepting traffic)
ApplicationRunner for startup tasks
@Component
@Order(1)
class CacheWarmupRunner implements ApplicationRunner {
  private final CacheManager cacheManager;

  CacheWarmupRunner(CacheManager cacheManager) {
    this.cacheManager = cacheManager;
  }

  @Override
  public void run(ApplicationArguments args) {
    cacheManager.getCache("rates").put("USD", loadRates());
  }
}
⚠️ Pitfall

Heavy work in ApplicationRunner blocks readiness—Kubernetes probes fail if startup takes too long. Long migrations belong in init containers or Flyway; cache warmup should be async or lazy with readiness gated separately.

🔬 Under the Hood

Embedded server start happens inside context.refresh() via ServletWebServerApplicationContext.onRefresh() — it calls createWebServer() before the context marks itself refreshed. That's why port-in-use errors fail startup before ApplicationReadyEvent.

Full event timeline

Hook into startup with ApplicationListener or @EventListener. Order matters—some events fire before the context exists.

sequenceDiagram
  participant SA as SpringApplication
  participant Env as Environment
  participant Ctx as ApplicationContext
  SA->>SA: ApplicationStartingEvent
  SA->>Env: ApplicationEnvironmentPreparedEvent
  SA->>Ctx: ApplicationContextInitializedEvent
  SA->>Ctx: ApplicationPreparedEvent
  Ctx->>Ctx: ContextRefreshedEvent
  SA->>SA: ApplicationStartedEvent
  SA->>SA: ApplicationReadyEvent
  Note over SA: Shutdown path
  SA->>SA: ContextClosedEvent
EventContext available?Typical use
ApplicationStartingEvent No Very early init — logging config, property source registration
ApplicationEnvironmentPreparedEvent Environment only Modify properties before context creation
ApplicationPreparedEvent Created, not refreshed Register beans programmatically before refresh
ContextRefreshedEvent Yes — fully refreshed All beans ready; danger of acting before runners on some setups
ApplicationStartedEvent Yes Runners completed; use for metrics "startup duration"
ApplicationReadyEvent Yes Traffic-ready signal — register with service discovery, enable load balancer
ApplicationFailedEvent Maybe partial Alerting on startup failure, cleanup
Listen for readiness
@Component
class ReadinessHook {
  @EventListener(ApplicationReadyEvent.class)
  void onReady() {
    log.info("Accepting traffic");
  }
}
🎯 Interview Tip

Difference: ContextRefreshedEvent is a Spring Framework event (fires on every refresh, including tests). ApplicationReadyEvent is Boot-specific and fires once when the app is fully started including runners. For K8s readiness, use Actuator /actuator/health/readiness or listen to ApplicationReadyEvent.

Configuration properties

Boot's externalized configuration lets the same JAR run in dev, staging, and prod with different property sources— no rebuild required.

application.properties vs application.yml

Both load from classpath:/, classpath:/config/, and working directory. YAML supports nesting; properties use dot notation. Don't mix both for the same keys—last wins per source ordering.

application.properties
server.port=8080
spring.application.name=order-service
spring.datasource.url=jdbc:postgresql://localhost:5432/orders
spring.datasource.username=${DB_USER:postgres}
management.endpoints.web.exposure.include=health,info,prometheus
application.yml — equivalent
server:
  port: 8080
spring:
  application:
    name: order-service
  datasource:
    url: jdbc:postgresql://localhost:5432/orders
    username: ${DB_USER:postgres}
management:
  endpoints:
    web:
      exposure:
        include: health,info,prometheus

Profile-specific configuration

Files named application-{profile}.yml load when profile is active. Multiple profiles: spring.profiles.active=dev,local.

application-prod.yml
spring:
  datasource:
    url: ${DATABASE_URL}
logging:
  level:
    root: WARN
    com.acme: INFO

@Value vs @ConfigurationProperties

@Value injects individual placeholders. @ConfigurationProperties binds a prefix to a type-safe object—preferred for structured config.

@Value with defaults and SpEL
@Service
class PricingService {
  @Value("${pricing.markup:1.15}")
  private BigDecimal markup;

  @Value("#{${pricing.tiers}}")
  private List<Integer> tiers;

  @Value("${pricing.enabled:true}")
  private boolean enabled;
}
@ConfigurationProperties — type-safe binding
@ConfigurationProperties(prefix = "pricing")
@Validated
record PricingProperties(
    @NotNull @Positive BigDecimal markup,
    @NotEmpty List<Integer> tiers,
    boolean enabled
) {}

@Configuration
@EnableConfigurationProperties(PricingProperties.class)
class PricingConfig {}

@Service
class PricingService {
  private final PricingProperties props;
  PricingService(PricingProperties props) { this.props = props; }
}

@ConfigurationPropertiesScan

Alternative to @EnableConfigurationProperties on each class—scan a package for properties records:

Scan multiple property classes
@SpringBootApplication
@ConfigurationPropertiesScan("com.acme.config")
public class OrderApplication {}
⚠️ Pitfall

@Value("${key}") on a @Bean method parameter resolves at bean-definition time—placeholders in @Bean method signatures can surprise you if property sources aren't ready. Prefer @ConfigurationProperties injection into the method instead.

💡 Pro Tip

IDE support: add spring-boot-configuration-processor as optional dependency—generates spring-configuration-metadata.json for autocomplete on application.properties keys.

Externalized configuration order

Later property sources override earlier ones. Knowing precedence explains "I set it in application.yml but prod env var wins."

Spring Boot 2.4+ refined property source ordering. Highest precedence (wins) at top:

PrecedenceSource
1 (highest)Command-line args (--server.port=9090)
2Java System properties (-Dserver.port=9090)
3OS environment variables (SERVER_PORT)
4SPRING_APPLICATION_JSON inline JSON
5ServletConfig / ServletContext init params (WAR deployment)
6JNDI attributes
7Test @TestPropertySource / @DynamicPropertySource
8application-{profile}.properties|yml
9application.properties|yml (jar + file:./config/)
10@PropertySource on @Configuration classes
11Default properties (SpringApplication.setDefaultProperties())

Spring Cloud Config Server integration

With spring-cloud-starter-config, a ConfigDataLocation loads remote property sources during bootstrap (before main context). Typical flow: app starts → fetches from Config Server → merges with local overrides.

application.yml for Config Server
spring:
  application:
    name: order-service
  config:
    import: optional:configserver:https://config.internal:8888
  cloud:
    config:
      fail-fast: true
📦 Real World

12-factor apps: store secrets in env vars or secret managers (Vault, AWS Secrets Manager), not in git. Config Server holds non-secret tunables (timeouts, feature flags); K8s Secret mounts become env vars with highest practical precedence.

Spring Boot Actuator

Production operations built in: health checks, metrics, environment introspection, and debugging endpoints— all behind management.* configuration.

Add dependency spring-boot-starter-actuator. Base path defaults to /actuator.

Expose endpoints
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus,env,beans,conditions,loggers
      base-path: /actuator
  endpoint:
    health:
      show-details: when_authorized
      probes:
        enabled: true
EndpointPathPurpose
health/actuator/healthAggregate health — DB, disk, custom indicators; K8s liveness/readiness sub-paths
info/actuator/infoApp metadata from info.* properties or InfoContributor
metrics/actuator/metricsMicrometer metric names and measurements
prometheus/actuator/prometheusPrometheus scrape format (add micrometer-registry-prometheus)
env/actuator/envProperty sources and resolved values (sanitize secrets in prod)
beans/actuator/beansAll beans in context — debugging wiring issues
conditions/actuator/conditionsAuto-config match report — why a bean is missing
loggers/actuator/loggersView/change log levels at runtime (POST)
threaddump/actuator/threaddumpJVM thread dump JSON
heapdump/actuator/heapdumpHPROF download — lock down in prod
scheduledtasks/actuator/scheduledtasksRegistered @Scheduled tasks
httpexchanges/actuator/httpexchangesRecent HTTP request/response audit (Boot 3, replaces httptrace)

Custom HealthIndicator

Downstream dependency health
@Component
class PaymentGatewayHealthIndicator implements HealthIndicator {
  private final PaymentGatewayClient client;

  PaymentGatewayHealthIndicator(PaymentGatewayClient client) {
    this.client = client;
  }

  @Override
  public Health health() {
    try {
      client.ping();
      return Health.up().withDetail("provider", "stripe").build();
    } catch (Exception ex) {
      return Health.down(ex).withDetail("provider", "stripe").build();
    }
  }
}

Boot 2.3+ splits K8s probes: /actuator/health/liveness and /actuator/health/readiness when management.endpoint.health.probes.enabled=true.

⚠️ Pitfall

Never expose env, heapdump, or beans publicly. management.endpoints.web.exposure.include=* in prod has caused credential leaks. Default exposure is only health (Boot 2+).

Custom metrics with Micrometer

Actuator metrics are powered by Micrometer — a vendor-neutral facade exporting to Prometheus, Datadog, CloudWatch, etc.

Meter typeUse forExample
CounterMonotonically increasing countorders placed, errors total
GaugeCurrent value up/downqueue depth, cache size
TimerDuration + count of eventsAPI latency percentiles
DistributionSummaryNon-timing distributionspayload sizes, batch sizes
Programmatic metrics
@Service
class OrderMetrics {
  private final Counter ordersCreated;
  private final Timer checkoutTimer;

  OrderMetrics(MeterRegistry registry) {
    this.ordersCreated = registry.counter("orders.created", "channel", "web");
    this.checkoutTimer = registry.timer("orders.checkout.duration");
  }

  void recordOrder() {
    ordersCreated.increment();
  }

  void timeCheckout(Runnable checkout) {
    checkoutTimer.record(checkout);
  }
}
Declarative @Timed / @Counted (AspectJ)
@Timed(value = "payment.charge", percentiles = {0.5, 0.95, 0.99})
@Counted("payment.charge.calls")
public PaymentResult charge(PaymentRequest request) {
  return gateway.charge(request);
}
⚠️ Pitfall

High-cardinality tags (user ID, order ID as metric tags) explode Prometheus cardinality and crash monitoring. Use low-cardinality tags: status, region, endpoint. Put request IDs in logs/traces, not metric labels.

HTTP vs JMX exposure

Default: HTTP on management.server.port (same as app unless separated). JMX still available for local JVM tools—disable in containers where JMX isn't used: spring.jmx.enabled=false.

Securing Actuator endpoints

Health may be public; everything else should require authentication or network isolation.

Spring Security 6 — separate actuator rules
@Bean
SecurityFilterChain actuatorSecurity(HttpSecurity http) throws Exception {
  return http
      .securityMatcher(EndpointRequest.toAnyEndpoint())
      .authorizeHttpRequests(auth -> auth
          .requestMatchers(EndpointRequest.to("health", "info")).permitAll()
          .requestMatchers(EndpointRequest.toAnyEndpoint()).hasRole("OPS")
      )
      .httpBasic(Customizer.withDefaults())
      .build();
}

@Bean
@Order(2)
SecurityFilterChain appSecurity(HttpSecurity http) throws Exception {
  return http
      .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
      .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
      .build();
}

Alternative: bind management to internal port only:

Separate management port
management.server.port=8081
management.server.address=127.0.0.1
📦 Real World

K8s pattern: liveness/readiness probes hit /actuator/health/* on app port (no auth from kubelet). Prometheus scrapes /actuator/prometheus on internal network or via service mesh mTLS. Never forward actuator paths through public API gateways.

Embedded server

Spring Boot embeds the servlet container in your JAR—no WAR deployment to external Tomcat required. Default is Tomcat; swap to Jetty or Undertow by changing dependencies.

ServerDependency swapNotes
Tomcat (default) spring-boot-starter-web Mature, default in docs; virtual threads in Boot 3.2+
Jetty Exclude Tomcat, add spring-boot-starter-jetty Lower memory in some workloads; good for microservices
Undertow Exclude Tomcat, add spring-boot-starter-undertow Non-blocking XNIO; popular in reactive-adjacent servlet apps
pom.xml — switch to Jetty
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
  <exclusions>
    <exclusion>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-tomcat</artifactId>
    </exclusion>
  </exclusions>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-jetty</artifactId>
</dependency>

TomcatServletWebServerFactory customization

Programmatic Tomcat tuning
@Bean
WebServerFactoryCustomizer<TomcatServletWebServerFactory> tomcatCustomizer() {
  return factory -> {
    factory.addConnectorCustomizers(connector -> {
      connector.setProperty("relaxedQueryChars", "|{}[]");
    });
    factory.addContextCustomizers(context ->
        context.addMimeMapping("json", "application/json")
    );
  };
}

Common server.* properties

PropertyEffect
server.portListen port (default 8080)
server.servlet.context-pathApp root path (e.g. /api)
server.compression.enabledGzip responses above threshold
server.tomcat.threads.maxWorker thread pool cap
server.tomcat.connection-timeoutSocket accept/read timeout
server.shutdowngraceful — drain in-flight requests on SIGTERM
spring.lifecycle.timeout-per-shutdown-phaseMax wait during graceful shutdown (default 30s)
Graceful shutdown
server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

SSL/TLS and HTTP/2

Embedded TLS (often terminated at load balancer instead)
server:
  port: 8443
  ssl:
    enabled: true
    key-store: classpath:keystore.p12
    key-store-password: ${KEYSTORE_PASSWORD}
    key-store-type: PKCS12
  http2:
    enabled: true
🔬 Under the Hood

Boot creates a WebServer bean (e.g. TomcatWebServer) wrapping the embedded container. DispatcherServlet is registered as a ServletRegistrationBean on startup. Virtual threads (Boot 3.2 + Java 21): spring.threads.virtual.enabled=true — Tomcat uses virtual threads per request.

🔖 Version Note

Boot 3.2: virtual thread support for embedded Tomcat and Jetty. Boot 3.1: Testcontainers @ServiceConnection for integration tests (see Testing chapter). HTTP/2 requires TLS in most browsers; typically enabled at ingress, not app container.

🎯 Interview Tip

Explain executable JAR layout: BOOT-INF/classes (your code), BOOT-INF/lib (dependencies), JarLauncher starts embedded server. WAR deployment still supported via SpringBootServletInitializer for legacy ops models.