| name | spring-boot |
| description | Production-grade Spring Boot for Java services that own money and state. This skill MUST be loaded when ANY .java file, pom.xml, build.gradle, build.gradle.kts, application.yml, application.yaml, application.properties, or Flyway migration (V*__*.sql, R__*.sql) belonging to a Spring project is being read, reviewed, edited, or created. Also use when the user asks to "create a Spring Boot app", "scaffold a Spring service", "add a REST controller", "add an endpoint", "create a repository", "write a DAO", "use JdbcClient", "use JdbcTemplate", "write raw SQL in Spring", "add @Transactional", "fix a transaction that isn't rolling back", "make this endpoint idempotent", "handle the Idempotency-Key header", "implement the transactional outbox", "publish events reliably", "fix a race condition", "prevent duplicate charges", "prevent double-posting", "add a Flyway migration", "add liveness and readiness probes", "add Actuator", "graceful shutdown", "containerize a Spring Boot app", "write a Dockerfile for a jar", "write a Testcontainers test", "test with a real Postgres", "why isn't @Valid working", "return RFC 9457 problem details", "upgrade to Spring Boot 4", "migrate from Spring Boot 3", "review this Java service", or mentions Spring Boot, Spring Framework, JdbcClient, JdbcTemplate, NamedParameterJdbcTemplate, HikariCP, Flyway, Actuator, ProblemDetail, ResponseEntityExceptionHandler, MockMvcTester, Testcontainers, @MockitoBean, ON CONFLICT, SELECT FOR UPDATE, SKIP LOCKED, BigDecimal money, idempotency keys, outbox pattern, at-least-once delivery, Maven wrapper, spring-boot-starter-webmvc, or Spring Boot on Docker / Kubernetes / Azure Container Apps. |
Production-Grade Spring Boot
Weighted toward services that own money and state: plain JDBC over raw SQL, idempotent writes,
transactional outboxes, correctness under simultaneous requests. Focused on the failures that are
silent — a missed transaction boundary and a lost update do not throw, they produce wrong numbers.
Version reality — establish this before writing a line
Read from live sources on 2026-07-25. Re-verify at the URLs; this whole line moved in 2025-2026.
| Thing | Current | Source |
|---|
| Spring Boot GA (Initializr default) | 4.1.0 (June 2026) | https://start.spring.io/metadata/client |
| Java | min 17, max 26; Initializr default 17 | https://docs.spring.io/spring-boot/system-requirements.html |
| Spring Framework | 7.0.8+ | same page |
| Maven / Gradle floor | Maven 3.6.3+; Gradle 8.14+ or 9.x (8.0-8.13 unsupported) | same page |
| Servlet baseline | 6.1 — Tomcat 11.0.x / Jetty 12.1.x. Undertow dropped. | same page |
| Jackson | 3.1.4 default; Jackson 2 at 2.21.4, deprecated | BOM ↓ |
| PostgreSQL driver / Flyway | 42.7.11 / 12.4.0 | BOM |
| HikariCP / Micrometer / Tomcat | 7.0.2 / 1.17.0 / 11.0.22 | BOM |
| JUnit Jupiter / Testcontainers | 6.0.3 (JUnit 6, not 5) / 2.0.5 (breaking major) | BOM |
| PostgreSQL server | 18 (/docs/current) | https://www.postgresql.org/docs/current/ |
BOM, for re-reading any managed version — never pin these yourself:
https://repo1.maven.org/maven2/org/springframework/boot/spring-boot-dependencies/4.1.0/spring-boot-dependencies-4.1.0.pom
Boot 4.x is GA. Do not write a 3.5-shaped service: the Initializr offers no 3.x bootVersion at all,
and 3.5's OSS window closed mid-2026. Java: 17 is the floor and the Initializr default; nothing here
requires more. Prefer 25 for a long-lived money service — newest LTS, and where the one-step AOT
cache lives; 21 is fine. (The claim that 4.1 raised the baseline to 21 misreads a release note about
jOOQ 3.20; the system-requirements page says 17.)
If you see this in old code or an old tutorial
| Boot 3.x / pre-4 | Boot 4.1 | What happens if you keep the old one (almost all of them still resolve, which is why nobody notices) |
|---|
spring-boot-starter-web | spring-boot-starter-webmvc | Resolves fine; only the POM <description> says "deprecated in favor of spring-boot-starter-webmvc". Fully silent. |
org.flywaydb:flyway-core alone | spring-boot-starter-flyway + org.flywaydb:flyway-database-postgresql | Startup failure about an unsupported database, never naming the missing module. |
org.testcontainers:postgresql | org.testcontainers:testcontainers-postgresql | 404 at 2.0.5. Every module gained a testcontainers- prefix. |
org.testcontainers.containers.PostgreSQLContainer<?> | org.testcontainers.postgresql.PostgreSQLContainer — no type parameter | The old class still ships in the same jar as a deprecated shim, so 1.x code compiles forever. |
spring-boot-starter-test alone for MockMvc | add spring-boot-starter-webmvc-test | MockMvc/@WebMvcTest do not resolve; looks like a broken starter. |
@MockBean / @SpyBean | @MockitoBean / @MockitoSpyBean | Removed in 4.0 — compile error. |
…boot.test.autoconfigure.web.servlet.WebMvcTest | …boot.webmvc.test.autoconfigure.WebMvcTest | Unresolved import whose fix is not obvious. |
…boot.actuate.health.HealthIndicator | …boot.health.contributor.HealthIndicator | Unresolved import; people "fix" it by re-adding an old jar. |
com.fasterxml.jackson.databind.* | tools.jackson.databind.* — but annotations stay com.fasterxml.jackson.annotation | Jackson 2 is still on the classpath, so you silently develop against the deprecated path. Two serialization defaults also flipped: alphabetical property order is now on, dates now emit ISO-8601. |
org.springframework.lang.Nullable | org.jspecify.annotations.Nullable — TYPE_USE, so private @Nullable String x | Deprecated in Framework 7. A null-checker or any Kotlin can now fail the build. |
RestTemplate; Jackson2ObjectMapperBuilderCustomizer | RestClient; …boot.jackson.autoconfigure.JsonMapperBuilderCustomizer | RestTemplate is deprecated in Framework 7 at the docs level only — no @Deprecated, so zero compiler warnings. The renamed customizer means your bean is simply never applied. |
spring-retry @Retryable(maxAttempts=…) + @EnableRetry | org.springframework.resilience.annotation.@Retryable(maxRetries=…) + @EnableResilientMethods | maxAttempts does not exist on the new annotation. Total attempts = 1 + maxRetries. |
server.error.*, spring.http.client.* (singular) | spring.web.error.*, spring.http.clients.* (plural) | Silently ignored — Boot never errors on unknown properties. You leak stack traces, and outbound HTTP gets an infinite read timeout. |
More renames bite only in ops and container work, and are covered where you meet them:
spring-boot-starter-aop → spring-boot-starter-aspectj (a 404, so at least it fails loudly),
server.shutdown: graceful (now the default, so a no-op) and the probe properties in
references/production-ops.md; -Djarmode=layertools (removed in 4.1) and -DskipTests (no
longer skips AOT) in references/containerization.md. And do not build on the 4.0 shims
(spring-boot-starter-classic, spring-boot-jackson2, spring.jackson.use-jackson2-defaults): 4.1
already removed everything deprecated in 4.0, so they are scaffolding with a short fuse.
Rule #1: Package layout — slice by feature, not by layer
The single-file monolith is the top failure mode in any language. In Java it appears as a
controller/ service/ repository/ model/ tree where one behavioural change edits four directories and
every class must be public so nothing can be encapsulated.
src/main/java/com/example/ledger/
LedgerApplication.java # @SpringBootApplication — ROOT package, above everything
config/ # ResilienceConfig, TxConfig, JacksonConfig
money/Money.java # BigDecimal + currency, canonical scale. NO Spring imports
payment/ # a FEATURE slice
PaymentController.java # HTTP only: bind, delegate, map to a response
PaymentService.java # orchestration. NOT @Transactional if it does I/O
PaymentTx.java # the @Transactional boundary — a separate bean, on purpose
PaymentRepository.java # JdbcClient + raw SQL, package-private
Payment.java # record
idempotency/IdempotencyStore.java, outbox/{OutboxRepository,OutboxRelay}.java
support/{ApiExceptionHandler,PgErrors}.java # advice -> ProblemDetail; SQLSTATE helpers
src/main/resources/{application.yaml, db/migration/V1__baseline.sql}
@SpringBootApplication must sit in the root package — its package is the implicit component-scan
root. In the default package, @ComponentScan reads every class in every jar.
- A feature slice can be package-private.
PaymentRepository has no business being visible to the
outbox code, and here you can enforce that; a layer layout forces everything public. money/ imports
no Spring at all, because money arithmetic is the highest-value thing to unit-test at microsecond speed.
PaymentTx is a separate bean from PaymentService deliberately — see Rule #4. Not ceremony: it
is the only way @Transactional and @Retryable actually fire.
A class past ~200 lines has more than one responsibility; a controller method past ~20 lines is doing
service work.
Rule #2: Generate the build file; do not hand-write it
Three coordinates a 3.x-trained model produces are now wrong. Ask the Initializr, and pin every input —
type defaults to gradle-project, javaVersion to 17, and the default bootVersion moves when
4.2 ships.
curl -sS https://start.spring.io/starter.zip \
-d type=maven-project -d language=java \
-d bootVersion=4.1.0 -d javaVersion=25 -d packaging=jar \
-d groupId=com.example -d artifactId=ledger -d packageName=com.example.ledger \
-d dependencies=web,jdbc,validation,actuator,postgresql,flyway,testcontainers \
-o ledger.zip && unzip -q ledger.zip -d ledger && cd ledger && ./mvnw -q clean verify
The short ids expand into the renamed 4.x artifacts for you — that is the whole point. Prefer Maven:
one unambiguous way to express a dependency, spring-boot-starter-parent supplies the BOM and repackage
goal for free, and SBOM tooling assumes a POM. Commit mvnw, mvnw.cmd and
.mvn/wrapper/maven-wrapper.properties — there is no wrapper jar any more
(distributionType=only-script), so .gitignore entries for it are stale.
Rule #3: Money is BigDecimal, and the comparison is compareTo
double is the canonical error. The subtler one: BigDecimal.equals compares scale as well as value
while SQL numeric equality does not — so the same amount compares differently in Java and in the
database.
double amount = 19.99;
BigDecimal bad = new BigDecimal(0.1);
new BigDecimal("10.00").equals(new BigDecimal("10.0000"));
new BigDecimal("10.00").divide(new BigDecimal("3"));
Map<BigDecimal, String> m = new TreeMap<>();
public final class Money {
public static final int SCALE = 4;
public static final RoundingMode ROUNDING = RoundingMode.HALF_UP;
private final BigDecimal amount;
private final String currency;
private Money(BigDecimal amount, String currency) {
this.amount = amount.setScale(SCALE, ROUNDING);
this.currency = Objects.requireNonNull(currency);
}
public static Money of(String amount, String currency) {
return new Money(new BigDecimal(amount), currency);
}
@Override public boolean equals(Object o) {
return o instanceof Money m && this.currency.equals(m.currency)
&& this.amount.compareTo(m.amount) == 0;
}
@Override public int hashCode() {
return Objects.hash(this.currency, this.amount.stripTrailingZeros());
}
}
Column type is numeric(19,4) — never PostgreSQL's locale-dependent money. Postgres rounds on insert
(ties away from zero) and pads to the declared scale, so a Java value with more precision than the column
loses it silently, and an over-large value raises SQLSTATE 22003. In tests, isEqualByComparingTo.
Rule #4: The transaction boundary is a bean boundary
@Transactional is a proxy. Only calls arriving through the proxy are intercepted. Self-invocation
and private methods are not — no warning, no log, no error. You get autocommit-per-statement, which for
a two-write transfer means a half-applied transfer.
@Service
public class TransferService {
public void handle(TransferCommand cmd) {
applyTransfer(cmd);
}
@Transactional
public void applyTransfer(TransferCommand cmd) { }
@Transactional
private void alsoBroken(TransferCommand cmd) { }
@Transactional
public void capture(UUID id, BigDecimal amt) { this.psp.capture(id, amt); }
}
@Service
public class TransferService {
private final LedgerWriter ledger;
private final PspClient psp;
public void capture(UUID paymentId, BigDecimal amount) {
this.ledger.markPending(paymentId, amount);
PspResult result = this.psp.capture(paymentId, amount);
this.ledger.recordResult(paymentId, result);
}
}
@Component
public class LedgerWriter {
@Transactional(timeout = 5)
public void markPending(UUID id, BigDecimal amount) { }
@Transactional(timeout = 5)
public void recordResult(UUID id, PspResult r) { }
}
Make the mistake loud, and fix the default rollback rule — checked exceptions commit by default, so a
@Transactional void settle() throws SettlementException that throws after a partial write commits it:
import static org.springframework.transaction.annotation.RollbackOn.ALL_EXCEPTIONS;
@Configuration(proxyBeanMethods = false)
@EnableTransactionManagement(rollbackOn = ALL_EXCEPTIONS)
public class TxConfig {
@Bean TransactionAttributeSource transactionAttributeSource() {
return new AnnotationTransactionAttributeSource(true);
}
}
Read references/data-access.md before touching propagation, isolation, or retries:
UnexpectedRollbackException, REQUIRES_NEW vs NESTED, and why a retry must re-enter a new
transaction all live there.
Rule #5: Idempotent writes — ON CONFLICT, two statements, READ COMMITTED
A client-supplied Idempotency-Key reused across retries must produce exactly one effect. The mechanism is
a unique index plus ON CONFLICT, never a Java if (!exists) — check-then-act in application code is
a race that two concurrent requests both win.
@Transactional(isolation = Isolation.READ_COMMITTED)
public IdempotencyResult claim(String key, String requestHash) {
Optional<UUID> inserted = this.db.sql("""
insert into idempotency_record (key, request_hash) values (:key, :hash)
on conflict (key) do nothing returning id
""")
.param("key", key).param("hash", requestHash)
.query(UUID.class).optional();
if (inserted.isPresent()) {
return new IdempotencyResult(inserted.get(), true);
}
UUID existing = this.db.sql("select id from idempotency_record where key = :key")
.param("key", key).query(UUID.class).single();
return new IdempotencyResult(existing, false);
}