AWS Builder Center

Spring Boot 4 and Elasticsearch on AWS

Spring Boot 4 and Elasticsearch on AWS

Part 1: Spring Boot 4 Advanced Practice

Is it Spring Boot 4?

tipsenvironment the article statesBoot 4-specific code in it
3, 4, 5, 6, 7, 9, 10, 11, 12, 13, 16, 22Boot 3.2.x, 3.3.x or "3.x"; JDK 17 in mostnone
18, 19, 23Boot 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.
areaBoot 3.3.5 (search-platform today)Boot 4.0.8
Spring Framework / Security6.1 / 6.37.0.9 / 7.0.7
Jakarta Servlet / Tomcat6.0 / 10.16.1.0 / 11.0.24 (Jakarta EE 11)
Jackson2.17.23.1.5 (tools.jackson), plus a managed jackson-2-bom 2.21.5 for libraries that still need 2.x
Hibernate / Flyway / Liquibase6.5.3 / 10.10.0 / 4.x7.2.24 / 11.14.1 / 5.0.3
Testcontainers / JUnit Jupiter1.19.8 / 5.102.0.5 / 6.0.3
Elasticsearch Java client / Spring Data ES8.13.4 / 5.39.2.9 / 6.0.7
Micrometer / Micrometer Tracing / OpenTelemetry1.13.6 / 1.3 / 1.371.16.7 / 1.6.7 / 1.55.0
auto-configurationone spring-boot-autoconfigure jarmodular. 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 APIorg.springframework.boot.actuate.health.*org.springframework.boot.health.contributor.* (the old package is absent from actuator 4.0.8)
EnvironmentPostProcessororg.springframework.boot.envorg.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 converterMappingJackson2HttpMessageConverterJacksonJsonHttpMessageConverter; MappingJackson2… is @Deprecated in Framework 7.0.9
HTTP interface clientsHttpServiceProxyFactory, wired by handFramework 7 adds @ImportHttpServices. Boot 4 configures the groups. check the property prefix (spring.http.serviceclient.<group>.*).
server.shutdown defaultimmediategraceful (the default changed in 3.4)
management.endpoint.health.probes.enabled defaultfalse; auto-enabled only when Kubernetes is detectedtrue
structured loggingneeds logstash-logback-encoder`logging.structured.format.console=ecs

Startup and auto-configuration (tips 3, 4, 5, 22)

tipthe series sayson Boot 4AWS 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 saysAWS 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 saysholds?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)

tipkey adviceAWS mapping
10 H2 → MySQLThe 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 appliesProxies, 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-autoA 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-drop and 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).
concernAWS mapping
per-call timeout below the caller's own budgetALB 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.
retriesRetry 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/cache does 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 CacheErrorHandler through CachingConfigurer.
  • GenericJackson2JsonRedisSerializer is the Jackson 2 serializer. On Boot 4, check for its Jackson 3 counterpart in Spring Data Redis 4.
AWS choicewhen
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 onlya 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 SimpleAsyncTaskExecutor by default" is true of plain Spring, not of Boot. Boot auto-configures a ThreadPoolTaskExecutor (applicationTaskExecutor) with an unbounded queue. With spring.threads.virtual.enabled=true, it becomes a virtual-thread SimpleAsyncTaskExecutor.
  • The V2 executor runs one thread. ThreadPoolTaskExecutor defaults to corePoolSize=1 (read from the Framework 7.0.9 bytecode). With setQueueCapacity(200) and no core size, only one virtual thread runs until 200 tasks are queued, so the article's notification-vt-1 … -20 output cannot happen. Pooling virtual threads is the wrong shape anyway. Use SimpleAsyncTaskExecutor with setVirtualThreads(true) and setConcurrencyLimit(n).
concernAWS mapping
a job that must run once per clustera single-replica Deployment (what the app does), or EventBridge Scheduler → SQS → any replica, idempotently
virtual threads on EKSRun 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)

tipkey advicewrong or outdatedAWS mapping
17 test pyramidpure 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 Testcontainersreal MySQL and Redis in CI, @ServiceConnectionBoot 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 servicesspring-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.
ActuatorKubernetes on EKSload balancersearch-platform
/actuator/health/liveness: livenessState onlylivenessProbeneverapplication.yml:145-148; deployment.yaml:120-122
/actuator/health/readiness: readinessState plus what can make this pod uselessreadinessProbe; a startupProbe instead of a long initialDelaySeconds for slow startsALB 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-detailsnonenonewhen_authorized (:138)

Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article