Spring Boot is an opinionated layer on top of the Spring Framework. The Framework gives you a dependency-injection container, a web stack, transaction management and data access; Boot decides how to assemble them so that a single main() method produces a running service with an embedded web server, sensible defaults and production endpoints. Most teams use Boot daily without knowing what it is doing, and that is fine until a bean is missing, a property is ignored, or a pod is killed mid-request. Then the magic becomes a liability.

This article explains the machinery from first principles: the startup sequence, how auto-configuration decides what to create, how configuration values are resolved and bound, what Actuator exposes, how to test without starting everything, and which settings matter in production. A small order service runs through it as a worked example. Version notes reflect Spring Boot 4.x: 4.0 shipped in November 2025 on Spring Framework 7 with a Java 17 baseline, 4.1.0 on 10 June 2026 and 4.1.1 on 20 August 2026. The concepts apply equally to 3.x.

Advertisement

What Boot adds to the Framework

Without Boot, a Spring application declares every infrastructure bean itself: the DataSource, the JSON mapper, the transaction manager, the servlet container and its connectors. Boot replaces that boilerplate with four things. Starters are dependency bundles that put a coherent set of libraries on the classpath. Dependency management pins compatible versions of hundreds of libraries through a bill of materials, so you rarely write a version number. Auto-configuration creates infrastructure beans when the classpath and configuration suggest you want them. Production features, mostly Actuator, add health, metrics and runtime introspection.

The important word is defaults. Every bean Boot creates is conditional, and almost all of them step aside when you define your own. Learning Boot is mostly learning how to see which defaults applied and how to override them cleanly.

In 4.0 the auto-configuration code was split from a few large jars into smaller per-technology modules, and some starters were renamed to match. The web MVC starter, for example, is now spring-boot-starter-webmvc. For migration there are spring-boot-starter-classic and spring-boot-starter-test-classic, which restore something close to the old all-in-one classpath while you move to targeted starters. Jackson 3 is the default JSON library, with Jackson 2 support kept in deprecated form.

The startup sequence

Calling SpringApplication.run(App.class, args) runs a fixed sequence. Knowing the order explains most startup errors.

SpringApplication.run(): from main() to a ready, serving application1. Bootstraplisteners, initializers2. Environmentproperty sources, profiles3. Create contextservlet / reactive / none4. Load sourcesyour @Configuration5. refresh(): bean definitionsUser beanscomponent scanAuto-configurationonly if conditions passparse6. Instantiatesingletons, wiring7. Web serverTomcat / Jetty / Netty8. RunnersApplicationRunner9. Readyreadiness = ACCEPTINGFails fast heremissing bean, bad property binding, port in use: the context closes and the JVM exits non-zeroAuto-configuration runs after your own definitions, so a bean you declare makes the default back off.
The steps of SpringApplication.run(). Your own bean definitions are registered before auto-configuration, which is what lets conditions such as @ConditionalOnMissingBean see them.
  1. Bootstrap. Boot decides the application type (servlet, reactive or none) from the classpath and loads listeners and initializers registered by libraries.
  2. Environment. It builds the Environment: command-line arguments, environment variables, system properties and configuration files, in a defined order, then activates profiles.
  3. Context creation and refresh. It creates the application context, registers your configuration classes, runs component scanning, then processes auto-configuration classes. During refresh the container instantiates singletons and wires dependencies.
  4. Web server. For web applications the embedded server starts as part of refresh, and the port opens.
  5. Runners and readiness. ApplicationRunner and CommandLineRunner beans execute, then the application publishes its ready event and the readiness state switches to accepting traffic.

Any exception before the ready event closes the context and exits the JVM with a non-zero code. That fail-fast behaviour is a feature: a misconfigured instance should die in a deployment, not serve wrong answers. The common startup failures are a missing or ambiguous bean, a property that cannot be bound to its type, and a port already in use, and each one prints a failure analysis block that names the cause before the stack trace. Read that block first.

Advertisement

Auto-configuration and conditions

An auto-configuration class is an ordinary @Configuration class marked @AutoConfiguration and listed in a file named META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports inside its jar. Boot reads every such file on the classpath, but registers a class's beans only if its conditions pass. The conditions you will meet most are:

ConditionPasses whenTypical use
@ConditionalOnClassA class is on the classpathConfigure a client only when its library is present
@ConditionalOnMissingBeanNo bean of that type is defined yetLet the application override the default
@ConditionalOnPropertyA property has a given valueFeature switches
@ConditionalOnBeanAnother bean existsBuild on something already configured
@ConditionalOnWebApplicationThe app is a web applicationWeb-only infrastructure

The pattern is easiest to understand by writing one. Suppose your platform team ships an HTTP client for an inventory service that every product team uses. Instead of asking each team to wire it, publish a starter with an auto-configuration:

// A library you ship to other teams: inventory-client-spring-boot-starter
@AutoConfiguration
@ConditionalOnClass(InventoryClient.class)
@EnableConfigurationProperties(InventoryProperties.class)
public class InventoryClientAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean          // back off if the application defines its own
    InventoryClient inventoryClient(InventoryProperties props, RestClient.Builder builder) {
        RestClient http = builder.baseUrl(props.baseUrl().toString()).build();
        return new InventoryClient(http, props.timeout());
    }

    @Bean
    @ConditionalOnProperty(prefix = "inventory", name = "cache.enabled", havingValue = "true")
    InventoryCache inventoryCache(InventoryClient client) {
        return new InventoryCache(client);
    }
}
# src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
com.example.inventory.InventoryClientAutoConfiguration

Any application that adds the starter and sets inventory.base-url gets a configured InventoryClient. An application that needs something special declares its own InventoryClient bean, and @ConditionalOnMissingBean makes the default disappear. The RestClient.Builder parameter is itself auto-configured by Boot, so your client inherits the application's message converters and observability instrumentation.

To see what happened at startup, run with --debug to print the condition evaluation report, or query Actuator's conditions endpoint on a running instance. The report lists each auto-configuration as a positive match, a negative match with the failed condition, or excluded. When a bean is mysteriously missing, this report answers the question in seconds. To switch off an auto-configuration entirely, use spring.autoconfigure.exclude or the exclude attribute of @SpringBootApplication.

Externalized configuration and binding

Boot resolves every property from an ordered list of sources, and a higher source wins. Simplified, from highest to lowest: command-line arguments, Java system properties, operating-system environment variables, profile-specific configuration files outside the jar, plain configuration files outside the jar, the same two kinds of files packaged inside the jar, and finally defaults in code. The practical consequence is that the jar carries safe defaults and each environment overrides them from outside, without a rebuild.

Environment variables use relaxed binding: INVENTORY_BASEURL (dots become underscores, dashes are dropped, letters are uppercased) sets inventory.base-url, which is how container platforms usually inject configuration. Profiles group overrides; a document in application.yaml guarded by spring.config.activate.on-profile: prod applies only when the prod profile is active. spring.config.import pulls in extra files or external sources such as a config tree mounted from Kubernetes secrets.

Read configuration through a typed, validated object rather than scattered @Value annotations. A record annotated with @ConfigurationProperties binds a whole prefix, converts strings such as 800ms to Duration, and with @Validated fails startup when a required value is missing:

@ConfigurationProperties(prefix = "inventory")
@Validated
public record InventoryProperties(
        @NotNull URI baseUrl,
        @DefaultValue("2s") Duration timeout,
        @DefaultValue Cache cache) {

    public record Cache(@DefaultValue("false") boolean enabled) {}
}

The worked example's application.yaml ships defaults inside the jar and tightens the timeout in production. It also contains the production settings discussed below, so the whole service's behaviour is visible in one file:

# application.yaml: defaults that ship inside the jar
inventory:
  base-url: http://inventory.internal:8080
  timeout: 2s
server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 20s
  threads:
    virtual:
      enabled: true
management:
  server:
    port: 8081
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus
  endpoint:
    health:
      probes:
        enabled: true
---
spring:
  config:
    activate:
      on-profile: prod
inventory:
  timeout: 800ms

Worked example: one request through the order service

The order service exposes POST /orders. Follow one request. The embedded Tomcat accepts the connection; because spring.threads.virtual.enabled is true, the request runs on a virtual thread rather than a pooled platform thread, so a blocking call to the inventory service does not pin a scarce OS thread (see virtual threads for the scheduling model and its pinning caveats). The DispatcherServlet routes to OrderController, Jackson deserialises the JSON body, and the controller calls InventoryClient, the bean our starter created, with the 800 ms production timeout.

If inventory answers in time, the service writes the order inside a transaction and returns 201. If inventory times out, the client throws, and an @ExceptionHandler maps the exception to a 503 with a problem-detail body, so callers can retry. Meanwhile Micrometer records a timer for the HTTP request and another for the outbound client call, both tagged with outcome and URI template, which is how you will later find that the p99 latency is dominated by inventory, not by your database.

Actuator: health, probes and metrics

Actuator adds management endpoints. Only health is exposed over HTTP by default; expose others deliberately with management.endpoints.web.exposure.include, and put them on a separate port with management.server.port so they are not reachable through the public load balancer.

Health is a tree of contributors: the database, disk space, any message broker, plus your own. A custom indicator turns a dependency's state into a status:

@Component
class InventoryHealthIndicator implements HealthIndicator {
    private final InventoryClient client;
    InventoryHealthIndicator(InventoryClient client) { this.client = client; }

    @Override
    public Health health() {
        return client.ping()
                ? Health.up().build()
                : Health.down().withDetail("inventory", "unreachable").build();
    }
}

Be careful where that indicator is counted. Kubernetes uses two probes. Liveness asks whether the process should be restarted; readiness asks whether it should receive traffic. With management.endpoint.health.probes.enabled (switched on automatically when Boot detects Kubernetes) you get /actuator/health/liveness and /actuator/health/readiness groups. Never put an external dependency into the liveness group: if inventory goes down, every pod fails liveness, Kubernetes restarts them all, and a partial outage becomes a total one. Put it in readiness, or in neither, and let the circuit breaker and the 503 handle it.

Metrics flow through Micrometer. Adding the Prometheus registry exposes /actuator/prometheus in the scrape format, and the Observation API produces traces and metrics from the same instrumentation.

Testing without starting everything

@SpringBootTest starts the whole application context, which is accurate and slow. Test slices start only the part under test: @WebMvcTest loads the MVC layer and the controllers you name, @DataJpaTest loads repositories against a test database, @JsonTest loads JSON mapping. Collaborators outside the slice are replaced with mocks using @MockitoBean, which replaced the older @MockBean:

@WebMvcTest(OrderController.class)       // MVC slice: no database, no full context
class OrderControllerTest {

    @Autowired MockMvc mvc;
    @MockitoBean InventoryClient inventory;   // replaces the bean in the slice context

    @Test
    void rejectsOrderWhenOutOfStock() throws Exception {
        given(inventory.available("sku-42")).willReturn(0);

        mvc.perform(post("/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"sku\":\"sku-42\",\"qty\":1}"))
           .andExpect(status().isConflict());
    }
}

For integration tests, Testcontainers with @ServiceConnection starts a real database or broker in a container and points Boot's connection properties at it, so you test against PostgreSQL rather than an in-memory imitation. Spring caches application contexts between tests that share the same configuration; every distinct set of mocks or properties creates a new context, so standardise them to keep the suite fast. JUnit 5 underpins all of this.

Production settings that matter

  • Graceful shutdown. With server.shutdown=graceful the server stops accepting new requests on SIGTERM and lets in-flight ones finish within spring.lifecycle.timeout-per-shutdown-phase. Recent versions default to graceful, but set it explicitly and keep the timeout below the platform's termination grace period.
  • Fail fast on configuration. Validate every properties record so a missing secret stops the rollout at the first pod.
  • Separate management port and an explicit exposure list for Actuator. heapdump and threaddump leak memory contents, including secrets, if exposed publicly, and env reveals every property key and source even with values masked.
  • Startup time. Large contexts start in seconds, which hurts autoscaling and serverless. Options, roughly in order of effort: trim starters, class data sharing, CRaC checkpoint and restore, and ahead-of-time processing with a GraalVM native image, which trades build time and reflection restrictions for millisecond startup.
  • Know your thread model. Virtual threads remove the thread-pool ceiling but not the database connection-pool ceiling. Size the pool for the database, not the request rate.

Failure modes

SymptomUsual causeFix
Bean you expected is missingA condition failed: library absent, property off, or another bean presentRead the condition report with --debug
Your override is ignoredTwo beans of the type, or yours is in a package component scan never reachesCheck scan base package; use @Primary or remove the duplicate
Property value not appliedTypo, wrong profile, or a higher source overriding itQuery Actuator's env endpoint on a non-public port to see which source won
Pods restart in a cascadeDownstream dependency in the liveness groupMove it to readiness or out of health groups
502s during every deployNo graceful shutdown, or grace period shorter than drain timeEnable graceful shutdown; align timeouts
Upgrade breaks imports4.0 moved auto-configuration into new modules and packagesUse the classic starters as a bridge, then migrate module by module

Trade-offs

Boot's defaults make the common path fast and the uncommon path opaque. You gain consistency across services, curated dependency versions and production endpoints for free; you pay with a large dependency graph, startup cost, and behaviour that depends on what happens to be on the classpath. A transitive dependency can activate a new auto-configuration without your code changing. Pin starters deliberately, review dependency diffs on upgrade, and treat the condition report as part of code review for infrastructure changes. Lighter frameworks start faster and hide less; Boot wins when the team values the ecosystem and shared conventions more than raw startup time.

What to do next

  1. Run your service with --debug once and read the positive and negative matches for the auto-configurations you rely on.
  2. Replace scattered @Value fields with validated @ConfigurationProperties records.
  3. Move Actuator to a separate management port and list exposed endpoints explicitly.
  4. Audit health groups: nothing external in liveness.
  5. Enable graceful shutdown and check the timeout against your platform's grace period with a deploy under load.
  6. Convert the slowest @SpringBootTest tests into slices, and use Testcontainers for real integration tests.
  7. If you share client libraries across teams, package them as a starter with a conditional auto-configuration.
Key takeaway: Spring Boot is a set of conditional defaults applied in a fixed startup sequence. Your beans are registered first, auto-configuration fills the gaps only when its conditions pass, and configuration flows from an ordered list of sources into typed, validated objects. Learn to read the condition report, bind configuration through properties records, keep external dependencies out of liveness, test with slices, and shut down gracefully, and the magic becomes predictable engineering.