- 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.
```bash
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.
```java
// ❌ WRONG — five distinct money bugs
double amount = 19.99; // binary float. never.
BigDecimal bad = new BigDecimal(0.1); // 0.10000000000000000555111512...
new BigDecimal("10.00").equals(new BigDecimal("10.0000")); // false. SQL says true.
new BigDecimal("10.00").divide(new BigDecimal("3")); // ArithmeticException
Map<BigDecimal, String> m = new TreeMap<>(); // merges 10.00 and 10.0000
```
```java
// ✅ CORRECT — normalise scale in the constructor, then compareTo for equality.
// Full class (plus/minus, percent, currency guard) in references/data-access.md.
public final class Money {
public static final int SCALE = 4; // matches NUMERIC(19,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); // canonical on the way in
this.currency = Objects.requireNonNull(currency);
}
public static Money of(String amount, String currency) { // String ctor, never double
return new Money(new BigDecimal(amount), currency);
}
// Safe ONLY because every instance is scale-normalised by the constructor.
@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.
```java
// ❌ WRONG — three silent failures in one class
@Service
public class TransferService {
public void handle(TransferCommand cmd) {
applyTransfer(cmd); // SELF-INVOCATION: proxy bypassed, NO transaction
}
@Transactional
public void applyTransfer(TransferCommand cmd) { /* runs in autocommit */ }
@Transactional
private void alsoBroken(TransferCommand cmd) { /* private is NEVER proxied */ }
@Transactional // pins a pooled connection across 2s of network
public void capture(UUID id, BigDecimal amt) { this.psp.capture(id, amt); }
}
```
```java
// ✅ CORRECT — the orchestrator holds no transaction; the boundary is a collaborator bean
@Service
public class TransferService {
private final LedgerWriter ledger; // separate bean -> the call goes through the proxy
private final PspClient psp; // ... constructor injection omitted
/** NOT @Transactional. The slow part runs with no DB connection held. */
public void capture(UUID paymentId, BigDecimal amount) {
this.ledger.markPending(paymentId, amount); // tx 1: short
PspResult result = this.psp.capture(paymentId, amount); // I/O, no connection held
this.ledger.recordResult(paymentId, result); // tx 2: state + outbox row
}
}
@Component
public class LedgerWriter {
@Transactional(timeout = 5) // public, external call -> actually intercepted
public void markPending(UUID id, BigDecimal amount) { /* one or two statements */ }
@Transactional(timeout = 5) // state change + outbox row, atomically
public void recordResult(UUID id, PspResult r) { /* see Rule #7 */ }
}
```
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*:
```java
import static org.springframework.transaction.annotation.RollbackOn.ALL_EXCEPTIONS;
@Configuration(proxyBeanMethods = false)
@EnableTransactionManagement(rollbackOn = ALL_EXCEPTIONS) // 6.2+; default RUNTIME_EXCEPTIONS
public class TxConfig {
/** Makes non-public @Transactional an ERROR rather than a silent no-op. */
@Bean TransactionAttributeSource transactionAttributeSource() {
// org.springframework.transaction.annotation.AnnotationTransactionAttributeSource
// -- NOT ...transaction.interceptor, which holds the interface and the other impls.
return new AnnotationTransactionAttributeSource(true); // publicMethodsOnly
}
}
```
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.
```java
// ✅ Two statements, deliberately. READ COMMITTED is load-bearing here.
@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); // we are the owner
}
// SEPARATE statement => fresh snapshot under READ COMMITTED => sees the winner's row
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);
}
```
```sql
عرض على GitHub