Spring Boot Evolution: 1.x to 3.4 — What Every EM Needs to Know
Spring Boot is the backbone of most Java microservice ecosystems. As an EM, you’re not expected to know every annotation — but you should be able to drive the architectural decisions: MVC vs WebFlux vs virtual threads, Boot 2 vs 3 migration, observability strategy, and testing approach. Here’s the full evolution with the trade-offs that matter.
The auto-configuration model (@SpringBootApplication scanning the classpath) replaced XML config, starters eliminated manual dependency management, and embedded Tomcat killed the WAR file deployment model. If your org still builds WARs, that’s a conversation worth having.
The programming model was simple and synchronous: @RestController → dispatcher servlet → blocking thread per request. This works fine up to a few hundred concurrent requests per instance.
Spring Framework 5 introduced WebFlux — a fully non-blocking web stack built on Project Reactor (Mono<T> for one item, Flux<T> for a stream). Instead of blocking a thread while waiting for I/O, the thread is released and a callback fires when data is ready.
The promise: Handle more concurrent connections with fewer threads. A service doing thousands of concurrent outbound HTTP calls — e.g., a fan-out aggregator — can run on a handful of threads.
The cost: The reactive programming model is genuinely harder. Stack traces become nearly useless (they show reactor internals, not your code). Debugging requires understanding Reactor’s execution model. Onboarding new engineers takes longer. Libraries that aren’t reactive-native (legacy JDBC, certain clients) block carrier threads and undermine the model.
The honest EM take: Most teams adopted WebFlux for the wrong reasons — “it’s faster” is not sufficient. WebFlux shines when you have true backpressure requirements or when you’re doing high-concurrency I/O aggregation and can’t go to Java 21 virtual threads. For everything else, the complexity cost outweighs the throughput gain.
Actuator overhaul: Health endpoints, metrics via Micrometer. Micrometer is a vendor-neutral metrics facade — your code emits metrics once, and you plug in Prometheus, Datadog, CloudWatch, or anything else via a dependency. This is the right abstraction; use it.
Layered JARs and Buildpacks (2.3): Docker image optimization. Layered JARs separate dependencies from app classes, so rebuilds only push the changed layer. Cloud Native Buildpacks (spring-boot:build-image) produce OCI images without writing a Dockerfile. For teams struggling with Docker image maintenance, this reduces friction significantly.
Graceful shutdown: Added in 2.3. When the app receives SIGTERM, it stops accepting new requests but finishes in-flight ones. Essential for Kubernetes zero-downtime deploys. Default is disabled — enable it: server.shutdown=graceful.
Config import (spring.config.import): Replaced the bootstrap.yml / Spring Cloud Config bootstrap context with a cleaner import mechanism. If your team uses Spring Cloud Config Server, this changes how config is loaded and can break existing setups on upgrade. Test this carefully.
Profile YAML documents: A single application.yml can contain multiple profile-specific sections using --- separators.
Volume-mounted config trees: Reads Kubernetes ConfigMap and Secret key-value pairs from mounted filesystem paths — clean integration without custom bootstrap code.
This is the release where “check it against your dependency list first” became mandatory advice.
No more Java 8 or Java 11. If you’re on Boot 2.x with Java 11, the Boot 3 migration forces a Java upgrade. Usually fine, but plan for it.
Every javax.* import becomes jakarta.*. This sounds mechanical but it’s pervasive:
javax.servlet.http.HttpServletRequest→jakarta.servlet.http.HttpServletRequestjavax.persistence.*→jakarta.persistence.*javax.validation.*→jakarta.validation.*
Any library that hasn’t published a Jakarta-compatible version is a blocker. This is the primary reason Boot 2 → 3 migrations stall. Run a dependency audit before planning the migration timeline.
Ahead-of-time compilation to a native executable: no JVM startup, sub-100ms startup time, ~10x less memory than JVM. Sounds transformative.
Trade-offs that matter:
- Build times are long (minutes, not seconds). CI pipelines need adjustment.
- Reflection, dynamic proxies, and classpath scanning require configuration hints. Spring provides many automatically, but third-party libraries may not.
- Dynamic features (some Hibernate behaviors, certain Spring Data queries) may fail at runtime if not configured correctly in AOT mode.
- Best fit: Serverless functions, scale-to-zero workloads, CLI tools. For always-on services, startup time doesn’t matter — CDS (Class Data Sharing) is a better middle ground.
Spring Cloud Sleuth (distributed tracing) is dead — replaced by Micrometer Tracing which builds on the Micrometer Observation API. The unified model: one @Observed annotation or Observation API call instruments metrics, traces, and logs together. OpenTelemetry is supported natively.
Why this matters architecturally: Your observability stack in Boot 3 should be Micrometer + OpenTelemetry exporter → your backend (Tempo, Jaeger, Zipkin, or a commercial APM). Don’t fight the framework.
Declarative HTTP clients, similar to Feign but built into the framework:
@HttpExchange("https://api.example.com")
interface UserClient {
@GetExchange("/users/{id}")
User getUser(@PathVariable String id);
}
Generated by a proxy, no implementation needed. Works with the new RestClient and WebClient. For internal service-to-service calls, this is cleaner than manual RestTemplate or Feign configuration.
Standard error response format: type, title, status, detail, instance. Enabled via spring.mvc.problemdetails.enabled=true. Useful when your API consumers are external or need machine-readable errors.
Docker Compose support: Add spring-boot-docker-compose and Boot auto-starts your compose.yml on startup in development. No more “remember to start your local Postgres before running the app.”
Testcontainers integration (@ServiceConnection): Define a Testcontainers container in test config and Spring Boot auto-wires the connection properties. Real database, real Redis, real Kafka — in tests, with zero manual URL configuration.
@SpringBootTest
class OrderServiceTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
// Spring Boot reads connection details automatically — no @DynamicPropertySource needed
}
This is the single best improvement to Spring Boot testing in years. Use it.
The headline feature: one configuration property to run Tomcat on virtual threads:
spring:
threads:
virtual:
enabled: true
All request handling moves to virtual threads. Each blocking call — database query, external HTTP call — parks the virtual thread instead of blocking an OS thread. You get WebFlux-level concurrency with Spring MVC’s straightforward programming model.
RestClient: New synchronous HTTP client, modern replacement for RestTemplate (which is in maintenance mode, not removed). Fluent API:
RestClient client = RestClient.create();
User user = client.get()
.uri("https://api.example.com/users/{id}", id)
.retrieve()
.body(User.class);
JdbcClient: Fluent JDBC API that makes the JdbcTemplate API much less verbose.
Structured logging: JSON logs out of the box with logging.structured.format.console=ecs or logstash. In Kubernetes where logs go to ELK/Loki, JSON is far better than text — no log parsing regex needed.
CDS (Class Data Sharing) polish: Improved tooling for creating class data archives, reducing JVM startup time by 20–40% without going full native image. Good middle ground for teams that want faster startup without GraalVM complexity.
Spring Security: Lambda DSL is now the only way (WebSecurityConfigurerAdapter was removed in 6.x). If you have legacy security config, it must be rewritten:
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.build();
}
| Spring MVC | WebFlux | MVC + Virtual Threads (3.2+) | |
|---|---|---|---|
| Programming model | Imperative, simple | Reactive, complex | Imperative, simple |
| Concurrency model | Thread per request | Event loop + callbacks | Virtual thread per request |
| Debugging | Normal stack traces | Reactor internals | Normal stack traces |
| Throughput (I/O bound) | Good | Excellent | Excellent |
| Backpressure | No | Yes | No |
| Hire for | Easy | Hard | Easy |
| Best fit | Most services | High-concurrency I/O, streaming | Most services on Java 21 |
The recommendation today: If you’re on Spring Boot 3.2+ and Java 21, enable virtual threads and stay with Spring MVC. You get most of WebFlux’s throughput benefits without its complexity. Only choose WebFlux if you specifically need backpressure or are already invested in the reactive stack.
- Dependency audit first. Identify every
javax.*import and every third-party library. Check if Jakarta EE 9-compatible versions exist. - Java 17 upgrade as a separate step from Boot upgrade.
- Upgrade to Boot 2.7.x (last 2.x release) — it includes deprecation warnings for things removed in Boot 3.
- Fix deprecated usages —
WebSecurityConfigurerAdapter, old config bootstrap, removed APIs. - Upgrade to Boot 3.0 — expect
javax.*→jakarta.*compile errors. Use IntelliJ’s “Migrate to Jakarta EE 9” refactoring. - Run full test suite. Testcontainers integration tests will catch runtime issues native compilation might not.
- Enable virtual threads (3.2+) and validate no
synchronizedpinning issues.
Spring Data JDBC matured as a lighter alternative to JPA. It’s explicit — no lazy loading, no transparent dirty checking, no session cache. What you call is what executes. For teams burned by Hibernate surprises (N+1 queries, LazyInitializationException), Spring Data JDBC is worth considering.
R2DBC (Reactive Relational Database Connectivity) is the non-blocking database driver layer for WebFlux apps. If you’re committed to the reactive stack, it’s the right tool. Otherwise, JDBC + virtual threads is simpler.
Spring Cloud components worth knowing:
- Config Server: Centralized externalized config. Viable but many teams migrate to Kubernetes ConfigMaps/Secrets + Vault.
- Gateway: API gateway built on WebFlux. Solid.
- Resilience4j: Replaced Hystrix for circuit breakers. Framework-agnostic; can use standalone or with Spring Boot starters.
- Service discovery (Eureka/Consul): Many teams moved to service mesh (Istio) or rely on Kubernetes DNS instead.
EM trade-off discussion: “Do you need Spring Cloud or does your infrastructure solve it?” Kubernetes + Istio handles service discovery, traffic management, mTLS, and circuit breaking at the infrastructure layer — no application library changes needed. Spring Cloud still makes sense when you need application-level awareness (e.g., client-side load balancing with routing logic) or when you’re not on Kubernetes.