Part 1: Spring Boot 4 Advanced Practice
Is it Spring Boot 4?
| tips | environment the article states | Boot 4-specific code in it |
|---|---|---|
| 3, 4, 5, 6, 7, 9, 10, 11, 12, 13, 16, 22 | Boot 3.2.x, 3.3.x or "3.x"; JDK 17 in most | none |
| 18, 19, 23 | Boot 3.3 or 3.x; JDK 21 (18, 19) or JDK 17 (23) | none |
| 8, 14, 15, 17, 20, 21, 24 | "Spring Boot 4" with Java 21 | @MockitoBean (17) dates from Boot 3.4 and Framework 6.2, so it is not Boot 4-specific. Nothing uses Jackson 3, the modular starters, @ImportHttpServices or API versioning. |
The engineering advice is mostly sound for either major version. Where Boot 4 changes it, the change is noted below. The series never mentions those changes.
What Spring Boot 4.0 actually changed
Spring Boot 4.0 went GA in November 2025. Everything in this table except the Java baseline was read from
spring-boot-dependencies-4.0.8.pom and the 4.0.8 jars in ~/.m2. The baseline (Java 17, with 25 recommended) is from the release notes and was not re-checked.| area | Boot 3.3.5 (search-platform today) | Boot 4.0.8 |
|---|---|---|
| Spring Framework / Security | 6.1 / 6.3 | 7.0.9 / 7.0.7 |
| Jakarta Servlet / Tomcat | 6.0 / 10.1 | 6.1.0 / 11.0.24 (Jakarta EE 11) |
| Jackson | 2.17.2 | 3.1.5 (tools.jackson), plus a managed jackson-2-bom 2.21.5 for libraries that still need 2.x |
| Hibernate / Flyway / Liquibase | 6.5.3 / 10.10.0 / 4.x | 7.2.24 / 11.14.1 / 5.0.3 |
| Testcontainers / JUnit Jupiter | 1.19.8 / 5.10 | 2.0.5 / 6.0.3 |
| Elasticsearch Java client / Spring Data ES | 8.13.4 / 5.3 | 9.2.9 / 6.0.7 |
| Micrometer / Micrometer Tracing / OpenTelemetry | 1.13.6 / 1.3 / 1.37 | 1.16.7 / 1.6.7 / 1.55.0 |
| auto-configuration | one spring-boot-autoconfigure jar | modular. The core jar holds 12 auto-configurations. MVC, Flyway, Liquibase, HTTP clients, Elasticsearch, health and so on are separate spring-boot-* modules with matching spring-boot-starter-* starters (-webmvc, -flyway, -restclient, -opentelemetry, -elasticsearch). spring-boot-starter-classic keeps the old all-in-one behaviour. |
| health API | org.springframework.boot.actuate.health.* | org.springframework.boot.health.contributor.* (the old package is absent from actuator 4.0.8) |
| EnvironmentPostProcessor | org.springframework.boot.env | org.springframework.boot.EnvironmentPostProcessor. The old interface still ships, and Boot's own spring.factories registers under the new key. check whether the old key is still read. |
| test annotations | @MockBean (deprecated in 3.4) | @MockBean is gone. @WebMvcTest lives in org.springframework.boot.webmvc.test.autoconfigure and needs spring-boot-starter-webmvc-test. |
| JSON message converter | MappingJackson2HttpMessageConverter | JacksonJsonHttpMessageConverter; MappingJackson2… is @Deprecated in Framework 7.0.9 |
| HTTP interface clients | HttpServiceProxyFactory, wired by hand | Framework 7 adds @ImportHttpServices. Boot 4 configures the groups. check the property prefix (spring.http.serviceclient.<group>.*). |
| server.shutdown default | immediate | graceful (the default changed in 3.4) |
| management.endpoint.health.probes.enabled default | false; auto-enabled only when Kubernetes is detected | true |
| structured logging | needs logstash-logback-encoder | `logging.structured.format.console=ecs |
Startup and auto-configuration (tips 3, 4, 5, 22)
| tip | the series says | on Boot 4 | AWS mapping |
|---|---|---|---|
| 3 启动链 ("the startup chain") | Know the phases (Environment → context → bean definitions → beans → web server → ApplicationReadyEvent) so a failure can be placed in one of them. The root package is the scan boundary. | Holds. In Boot 4 a starter missing from the classpath fails earlier, because the auto-configuration is not on the classpath at all. | Classify a pod's CrashLoopBackOff with the same four phases. In Container Insights, a port conflict and a missing bean read alike unless the log names the phase. |
| 4 版本谁控制 ("who controls versions") | Let the BOM decide. An inline version reopens the whole compatibility matrix. Use dependency:tree and help:effective-pom. | Holds, and matters more, because Boot 4 moves Jackson, Hibernate, Flyway and Testcontainers by a major version at once. | Turn on Amazon Inspector's ECR enhanced scanning. A pinned old version is what it flags. |
| 5 自动配置 ("auto-configuration is not magic") | Read the --debug condition report. @ConditionalOnMissingBean lets your bean win. | The mechanism holds. Three claims are wrong (see the corrections). In Boot 4, also check that the module is present: a missing spring-boot-flyway produces no condition entry at all. | none |
| 22 自己写 AutoConfiguration ("write your own") | Separate package, AutoConfiguration.imports, @ConditionalOnMissingBean, configuration processor. | Holds. Add @AutoConfiguration(after = …) ordering and a spring-boot-test ApplicationContextRunner test. | The natural shared starter here is the AWS one. Spring Cloud AWS already is one for Parameter Store and Secrets Manager. check that its 4.x line supports Boot 4. |
search-platform.
AwsSecretsEnvironmentPostProcessor is a hand-rolled version of Tip 22's idea: it fetches Secrets Manager values before the context exists (:119 calls GetSecretValue). On Boot 4 it needs the new interface package (:6) and the new spring.factories key (spring.factories:3). It also uses Jackson 2's ObjectMapper (:3-4), which Boot 4 still manages through jackson-2-bom.Configuration and profiles (tips 6, 7)
| the series says | AWS mapping |
|---|---|
| One immutable jar. Environment differences arrive at runtime. Precedence is command line > environment variables > files. Use @ConfigurationProperties with @Validated to fail at startup. A secret is not committed and has no default. | Plain configuration goes in Parameter Store (GetParametersByPath with Path, Recursive, WithDecryption). Secrets go in Secrets Manager (GetSecretValue, BatchGetSecretValue, RotateSecret with RotationRules.ScheduleExpression). The pod reads them through IRSA or EKS Pod Identity (CreatePodIdentityAssociation). |
| Profiles name environments, not modules. Keep defaults minimal. Profile groups (spring.profiles.group.production). No if (prod) in code. | One profile per kind of environment. Per-cluster values come from Parameter Store paths such as /search-platform/prod/…, not from application-prod.yml. |
| "Next: dynamic refresh with Spring Cloud Config or a ConfigMap plus @RefreshScope" | Use AWS AppConfig for values that must change without a deploy, such as feature flags and thresholds. A configuration profile (CreateConfigurationProfile with Validators) is rolled out by StartDeployment under a CreateDeploymentStrategy (GrowthFactor, FinalBakeTimeInMinutes). An environment Monitors.AlarmArn rolls it back automatically. The app reads it through the AppConfig agent sidecar or through appconfigdata StartConfigurationSession and GetLatestConfiguration. Neither needs @RefreshScope: poll and swap an immutable holder. |
search-platform. It already follows the core of both tips:
SearchProperties is a validated record, and application.yml:17 gives the password no default. One thing the series gets wrong matters here. Its /actuator/env examples print values, but since Boot 3.0 management.endpoint.env.show-values defaults to never, which is confirmed in 4.0.8 metadata. The app exposes env (application.yml:136) to ROLE_ADMIN only (SecurityConfig.java:53), so masking is the remaining safeguard. Leave it at never.Web layer and security (tips 8, 9)
| the series says | holds? | AWS mapping |
|---|---|---|
| Return DTOs, not entities. Map errors to RFC 9457 ProblemDetail in one @RestControllerAdvice. | Yes. But its premise, that a type mismatch is a 500 by default, is wrong: Spring maps it to 400. Its catch-all @ExceptionHandler(Exception.class) would turn Spring's own 404 and 405 into 500. Use spring.mvc.problemdetails.enabled or extend ResponseEntityExceptionHandler. | An ALB or API Gateway in front passes application/problem+json through unchanged. Count 5xx at the target with HTTPCode_Target_5XX_Count, not at the edge. |
| Declare the SecurityFilterChain explicitly. Protect /actuator/** with ADMIN. Keep CSRF for forms. | The V2 rule "/actuator/**" hasRole("ADMIN") also locks /actuator/health/liveness and /readiness. A kubelet has no credential, so on EKS every pod would fail its probes. | Permit the two probe paths. The ALB and VPC Lattice health checks need them anonymous too. |
search-platform does it right:
SecurityConfig.java:52 permits exactly the two probe paths and :53 puts the rest of /actuator/** behind ADMIN. application.yml:64 enables problem details.Data, transactions and migrations (tips 10, 11, 13)
| tip | key advice | AWS mapping |
|---|---|---|
| 10 H2 → MySQL | The pool is the database's client-side rate limiter. Never ddl-auto=update in production. Use strict types. | pods × maximum-pool-size must stay under the RDS or Aurora max_connections for the instance class. RDS Proxy absorbs pod churn during rollouts. Use IAM database authentication or a Secrets Manager rotated secret, not a static password. |
| 11 where @Transactional applies | Proxies, self-invocation, rollback rules. Keep remote calls out of the transaction. | A remote call inside a transaction holds an RDS connection for the whole remote timeout. The outbox, not "call first, then write", is how the app makes DB-then-search consistent. |
| 13 no ddl-auto | A Flyway version chain, validate in production, forward fixes rather than undo scripts, expand/contract for large tables. | Run Flyway once, as a Job or Argo CD PreSync hook, not in every pod. For major engine upgrades and large DDL, use RDS Blue/Green Deployments or Aurora's instant DDL. On Boot 4, flyway-core alone no longer runs migrations; add spring-boot-starter-flyway. |
search-platform.
application.yml:33 sets ddl-auto to validate (:34 turns off open-in-view), and k8s/deployment.yaml runs a migrate Job as a Helm pre-upgrade hook. The pool math is at application.yml:19-21. Two loose ends:- RDS is MySQL
8.0(terraform/main.tf:128). check whether 8.0 is now past RDS standard support (expected 31 July 2026), which would mean paid Extended Support. - The test profile runs H2 with
create-dropand Flyway off (application-test.yml:7,12,19), which is the "H2 false green" Tip 18 warns about. The app's Tip 13 write-up covers the rest.
HTTP clients (tip 12)
The advice holds: set connect and read timeouts first, map exception types to degradations, and keep remote exceptions out of the domain. The code does not compile, though.
requestFactory(new ClientHttpRequestFactorySettings()…) passes a settings object where a factory is required, and new JdkClientHttpRequestFactory(settings) is not a real constructor. The article also says spring-web "already includes RestClient". The class does, but in Boot 4 the injectable, observed RestClient.Builder comes from spring-boot-restclient, which spring-boot-starter-webmvc 4.0.8 does not pull in. On Boot 4, prefer @HttpExchange interfaces registered with @ImportHttpServices, with timeouts set per group (check the property names).| concern | AWS mapping |
|---|---|
| per-call timeout below the caller's own budget | ALB idle timeout (60 s default) and API Gateway's 29 s integration limit are ceilings, not targets. The client timeout must be shorter than both. |
| retries | Retry only idempotent calls. VPC Lattice adds no outlier ejection (Spring on EKS). |
| AWS SDK calls (Secrets Manager, CloudWatch) | Configure the SDK's own apiCallTimeout and apiCallAttemptTimeout. They are not RestClient settings. |
search-platform separates search timeouts (800 ms connect, 3 s socket) from indexing timeouts (5 s and 30 s) in two clients (
OpenSearchClients.java:61-76). That is exactly the "one budget per workload" point; see the Tip 12 write-up.Caching (tip 14)
The advice holds: local caches diverge across replicas, keys must contain every parameter, TTL is a backstop rather than consistency, and a cache outage must fall back to the source. Three details are wrong:
/actuator/cachedoes not exist. The endpoint is/actuator/caches, and it lists cache names, not keys.- The hand-written Redis fallback in V2 reads the cache but never writes on a miss, so it never warms. Spring's answer is a
CacheErrorHandlerthroughCachingConfigurer. GenericJackson2JsonRedisSerializeris the Jackson 2 serializer. On Boot 4, check for its Jackson 3 counterpart in Spring Data Redis 4.
| AWS choice | when |
|---|---|
| ElastiCache Serverless for Valkey (CreateServerlessCache with Engine=valkey, CacheUsageLimits.DataStorage.Maximum, ECPUPerSecond.Maximum) | the default for a read-mostly cache; no node sizing. In transit it is TLS, so set spring.data.redis.ssl.enabled=true (check that TLS cannot be turned off). |
| node-based replication group (CreateReplicationGroup with TransitEncryptionEnabled, TransitEncryptionMode=required, ClusterMode) | predictable heavy load, or when you need data tiering or reserved nodes |
| Caffeine only | a single replica, or data that is safe to be stale for the TTL |
The tenant must be part of the key; that is NoSQL on AWS. search-platform deliberately has no cache yet (Tip 14 write-up).
Async, scheduling and virtual threads (tip 15)
The advice holds: one scheduler thread is a bug, bound your queues, and virtual threads help blocking I/O, not CPU. Two claims are wrong:
- "@Async uses
SimpleAsyncTaskExecutorby default" is true of plain Spring, not of Boot. Boot auto-configures aThreadPoolTaskExecutor(applicationTaskExecutor) with an unbounded queue. Withspring.threads.virtual.enabled=true, it becomes a virtual-threadSimpleAsyncTaskExecutor. - The V2 executor runs one thread.
ThreadPoolTaskExecutordefaults tocorePoolSize=1(read from the Framework 7.0.9 bytecode). WithsetQueueCapacity(200)and no core size, only one virtual thread runs until 200 tasks are queued, so the article'snotification-vt-1 … -20output cannot happen. Pooling virtual threads is the wrong shape anyway. UseSimpleAsyncTaskExecutorwithsetVirtualThreads(true)andsetConcurrencyLimit(n).
| concern | AWS mapping |
|---|---|
| a job that must run once per cluster | a single-replica Deployment (what the app does), or EventBridge Scheduler → SQS → any replica, idempotently |
| virtual threads on EKS | Run JDK 21 or later. JDK 21 pins carrier threads inside synchronized, which JDK 24 fixed (JEP 491). Turning virtual threads on makes server.tomcat.threads.max (application.yml:69) stop bounding concurrency. The Hikari pool and the OpenSearch client pools become the limit. |
search-platform. It sets four scheduler threads (
application.yml:52) and leaves virtual threads off by default (:61) on a JRE 21 image (Dockerfile:10). The single drainer is k8s/deployment.yaml:135-136 (replicas: 1, Recreate). See the Tip 15 write-up.Testing with Testcontainers and Compose (tips 17, 18, 19)
| tip | key advice | wrong or outdated | AWS mapping |
|---|---|---|---|
| 17 test pyramid | pure unit, @WebMvcTest, @DataJpaTest, one smoke @SpringBootTest; context-cache keys decide the cost | -Dspring.test.context.cache.diagnostics=always is not a property, and the "Context cache key mismatch" log line is invented. Use logging.level.org.springframework.test.context.cache=DEBUG. | CodeBuild: cache ~/.m2 in S3 or local cache. |
| 18 Testcontainers | real MySQL and Redis in CI, @ServiceConnection | Boot 4 manages Testcontainers 2.0.5. check the 2.x artifact names (testcontainers-mysql) and the moved MySQLContainer package before copying the 1.x coordinates. | CodeBuild needs privileged mode for Docker. Use valkey/valkey to match ElastiCache; it may need @ServiceConnection(name = "redis") (check). For OpenSearch, the opensearch-testcontainers module has no Boot service connection, so use @DynamicPropertySource. |
| 19 Docker Compose dev services | spring-boot-docker-compose starts compose.yml and injects connection details | "Ports are only for host debugging; the app connects by service name" is wrong: the app runs on the host and connects through the published ports. Remove ports: and the connection fails. The default stop is docker compose stop, not down (check spring.docker.compose.stop.command). Bitnami moved most images to bitnamilegacy in 2025, so do not copy bitnami/kafka. | none |
search-platform has the four layers (33 tests; the surefire reports in
app/target add up to 33 with 0 failures) but no Testcontainers yet. Its docker-compose.yml is run by make up, not by Boot dev services. That is fine, because Boot has no OpenSearch service connection to inject.Observability with Actuator: health groups as EKS probes (tip 20)
Tip 20's semantics are right: liveness is "restart me", readiness is "send me traffic", and liveness never checks dependencies. One claim is wrong. Turning on probes does not put
db or redis into readiness. The default groups are livenessState and readinessState only, and a dependency joins readiness only through management.endpoint.health.group.readiness.include. Its show-details: always is also the wrong production setting.| Actuator | Kubernetes on EKS | load balancer | search-platform |
|---|---|---|---|
| /actuator/health/liveness: livenessState only | livenessProbe | never | application.yml:145-148; deployment.yaml:120-122 |
| /actuator/health/readiness: readinessState plus what can make this pod useless | readinessProbe; a startupProbe instead of a long initialDelaySeconds for slow starts | ALB target group health check path; with the AWS Load Balancer Controller, the pod readiness gate (elbv2.k8s.aws/pod-readiness-gate-inject) | readinessState,indexBootstrap (application.yml:143-144); the Lattice TargetGroupPolicy checks the same path |
| show-details | none | none | when_authorized (:138) |
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article