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.
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-annotation | What 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 |
@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 {}
@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.
| Version | Registration file | Key |
|---|---|---|
| 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
- AutoConfigurationImportSelector.getAutoConfigurationEntry() loads all candidate class names from imports files across the classpath
- Remove exclusions from @SpringBootApplication(exclude=...) and spring.autoconfigure.exclude property
- Apply AutoConfigurationImportFilter beans (e.g. respect @ConditionalOnClass early)
- Register surviving configurations; full @Conditional evaluation happens during context refresh
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.
@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();
}
}
@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.
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
- autoconfigure module — @AutoConfiguration classes + META-INF/spring/...AutoConfiguration.imports
- starter module — thin POM depending on autoconfigure + required libraries (what users add to Initializr)
- core module — actual integration code with no Spring dependency if possible
@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
) {}
com.acme.autoconfigure.NotificationAutoConfiguration
Consumers add acme-notification-spring-boot-starter to their POM and configure:
acme:
notification:
api-key: ${NOTIFICATION_API_KEY}
base-url: https://notify.acme.internal
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.
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.
- SpringApplication.run() — Create SpringApplication instance, infer web application type (SERVLET / REACTIVE / NONE)
- Run listeners fire — ApplicationStartingEvent → prepare Environment
- Load ApplicationContextInitializers — programmatic context customization before refresh (libraries use this for secrets, cloud bindings)
- Prepare environment — Load application.properties/.yml, profile-specific files, env vars, command-line args
- Print banner — spring.main.banner-mode (console / log / off)
- Create ApplicationContext — AnnotationConfigServletWebServerApplicationContext for typical MVC apps
- context.refresh() — Parse config, register bean definitions, run BeanFactoryPostProcessors, instantiate singletons, start embedded web server
- Call runners — All ApplicationRunner and CommandLineRunner beans, ordered by @Order
- Publish ready events — ApplicationStartedEvent → ApplicationReadyEvent (app accepting traffic)
@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());
}
}
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.
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
| Event | Context 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 |
@Component
class ReadinessHook {
@EventListener(ApplicationReadyEvent.class)
void onReady() {
log.info("Accepting traffic");
}
}
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.
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
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.
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.
@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(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:
@SpringBootApplication
@ConfigurationPropertiesScan("com.acme.config")
public class OrderApplication {}
@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.
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:
| Precedence | Source |
|---|---|
| 1 (highest) | Command-line args (--server.port=9090) |
| 2 | Java System properties (-Dserver.port=9090) |
| 3 | OS environment variables (SERVER_PORT) |
| 4 | SPRING_APPLICATION_JSON inline JSON |
| 5 | ServletConfig / ServletContext init params (WAR deployment) |
| 6 | JNDI attributes |
| 7 | Test @TestPropertySource / @DynamicPropertySource |
| 8 | application-{profile}.properties|yml |
| 9 | application.properties|yml (jar + file:./config/) |
| 10 | @PropertySource on @Configuration classes |
| 11 | Default 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.
spring:
application:
name: order-service
config:
import: optional:configserver:https://config.internal:8888
cloud:
config:
fail-fast: true
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.
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
| Endpoint | Path | Purpose |
|---|---|---|
| health | /actuator/health | Aggregate health — DB, disk, custom indicators; K8s liveness/readiness sub-paths |
| info | /actuator/info | App metadata from info.* properties or InfoContributor |
| metrics | /actuator/metrics | Micrometer metric names and measurements |
| prometheus | /actuator/prometheus | Prometheus scrape format (add micrometer-registry-prometheus) |
| env | /actuator/env | Property sources and resolved values (sanitize secrets in prod) |
| beans | /actuator/beans | All beans in context — debugging wiring issues |
| conditions | /actuator/conditions | Auto-config match report — why a bean is missing |
| loggers | /actuator/loggers | View/change log levels at runtime (POST) |
| threaddump | /actuator/threaddump | JVM thread dump JSON |
| heapdump | /actuator/heapdump | HPROF download — lock down in prod |
| scheduledtasks | /actuator/scheduledtasks | Registered @Scheduled tasks |
| httpexchanges | /actuator/httpexchanges | Recent HTTP request/response audit (Boot 3, replaces httptrace) |
Custom HealthIndicator
@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.
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 type | Use for | Example |
|---|---|---|
| Counter | Monotonically increasing count | orders placed, errors total |
| Gauge | Current value up/down | queue depth, cache size |
| Timer | Duration + count of events | API latency percentiles |
| DistributionSummary | Non-timing distributions | payload sizes, batch sizes |
@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);
}
}
@Timed(value = "payment.charge", percentiles = {0.5, 0.95, 0.99})
@Counted("payment.charge.calls")
public PaymentResult charge(PaymentRequest request) {
return gateway.charge(request);
}
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.
@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:
management.server.port=8081
management.server.address=127.0.0.1
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.
| Server | Dependency swap | Notes |
|---|---|---|
| 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 |
<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
@Bean
WebServerFactoryCustomizer<TomcatServletWebServerFactory> tomcatCustomizer() {
return factory -> {
factory.addConnectorCustomizers(connector -> {
connector.setProperty("relaxedQueryChars", "|{}[]");
});
factory.addContextCustomizers(context ->
context.addMimeMapping("json", "application/json")
);
};
}
Common server.* properties
| Property | Effect |
|---|---|
| server.port | Listen port (default 8080) |
| server.servlet.context-path | App root path (e.g. /api) |
| server.compression.enabled | Gzip responses above threshold |
| server.tomcat.threads.max | Worker thread pool cap |
| server.tomcat.connection-timeout | Socket accept/read timeout |
| server.shutdown | graceful — drain in-flight requests on SIGTERM |
| spring.lifecycle.timeout-per-shutdown-phase | Max wait during graceful shutdown (default 30s) |
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
SSL/TLS and HTTP/2
server:
port: 8443
ssl:
enabled: true
key-store: classpath:keystore.p12
key-store-password: ${KEYSTORE_PASSWORD}
key-store-type: PKCS12
http2:
enabled: true
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.
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.
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.