Skip to content

Java & Spring Boot Patterns

Java and Spring Boot recipes for AI coding agents work in three moves: pin Spring Boot 4.1, Hibernate 7 and Spring Security 7 in the agent’s context so it stops writing removed APIs, prompt for failure behavior rather than “CRUD”, and let Testcontainers, a query-count test and ArchUnit rules prove the result instead of a line-by-line review.

This recipe is for Java developers who let Claude Code, Codex or Cursor write Spring Boot services. The agent’s controller compiles and its mocked tests pass. Then the build breaks on @Where, which Hibernate 7 no longer ships, the orders list fires one query per row, and a payment timeout turns into a 500 because the circuit breaker was never wired.

  • A version pin block that stops the agent from writing Spring Boot 3, Hibernate 6 and Security 6 code
  • Copy-paste prompts for a REST resource, soft-deleting JPA entities, a resilient downstream client, JWT resource-server security, and a Testcontainers suite
  • A query-count test that fails the build on an N+1 regression
  • ArchUnit rules that reject field injection, repository access from controllers and entity leaks without anyone reading the diff
  • A failure-mode list with the recovery for each one

Pin the Spring versions in the agent’s context first

Section titled “Pin the Spring versions in the agent’s context first”

Models were trained on years of Spring Boot 2 and 3 code, so an unpinned prompt produces APIs that no longer exist. Current versions, from the spring-boot-dependencies 4.1.1 BOM on Maven Central (released 2026-08-20, checked 2026-09-26): Spring Framework 7.0.9, Spring Security 7.1.1, Spring Kafka 4.1.1, Hibernate ORM 7.4.5, Jackson 3.1.5 and Testcontainers 2.0.5. Spring Boot 4.1 needs Java 17 or later.

Put this block in the file your agent reads on every run:

## Stack (do not downgrade, do not use older APIs)
- Spring Boot 4.1.x, Java 25, Maven wrapper (./mvnw)
- Spring Framework 7, Spring Security 7 (lambda DSL only), Hibernate ORM 7, Jackson 3, Testcontainers 2
- Starters: spring-boot-starter-webmvc (not -web, deprecated), spring-boot-starter-security-oauth2-resource-server
- Banned: @Where (removed in Hibernate 7; use @SoftDelete or @SQLRestriction), WebSecurityConfigurerAdapter,
.and() and authorizeRequests() in security config, ListenableFuture/addCallback on KafkaTemplate,
@MockBean (use @MockitoBean), org.testcontainers.containers.PostgreSQLContainer (use org.testcontainers.postgresql)
## Conventions
- Constructor injection only; no @Autowired fields
- Controllers return DTO records, never entities; no business logic or try/catch in controllers
- One @RestControllerAdvice returning ProblemDetail (application/problem+json)
- @Transactional on public service methods only; readOnly = true for reads
- Every endpoint ships with a Testcontainers integration test

Save the block in CLAUDE.md at the repository root. Claude Code loads it at the start of every session. See project initialization for the rest of that file.

Scaffold a Spring Boot 4.1 service with an agent

Section titled “Scaffold a Spring Boot 4.1 service with an agent”

Spring Initializr at start.spring.io is the source of truth for the dependency tree. Have the agent download from it rather than write a pom.xml from memory, because a hand-written POM is where old starter names and stale versions come back. The prompt is the same in every tool; only where you run it differs.

Terminal window
claude "Download a Spring Boot 4.1 Maven project for Java 25 from start.spring.io with web, data-jpa, validation, security, oauth2-resource-server, actuator, postgresql, flyway and testcontainers. Then check pom.xml against the Stack section of CLAUDE.md and fix any starter or version that differs. Run ./mvnw -q verify and show me the result."

The run is done when ./mvnw verify passes and pom.xml names spring-boot-starter-webmvc rather than the deprecated spring-boot-starter-web.

Generate a REST resource that fails correctly

Section titled “Generate a REST resource that fails correctly”

Ask for the failure behavior, not “CRUD”. Without it the agent returns entities, scatters try/catch blocks that log and return null, and answers a validation error with a stack trace.

A correct controller stays thin. If the generated one maps fields, catches exceptions or injects an EntityManager, send it back with “move mapping and error handling out of the controller; rely on the advice”:

@RestController
@RequestMapping("/api/v1/products")
class ProductController {
private final ProductService products;
ProductController(ProductService products) { // constructor injection, no @Autowired
this.products = products;
}
@GetMapping
Page<ProductResponse> list(@PageableDefault(size = 20) Pageable pageable,
@RequestParam(required = false) String search) {
return products.search(search, pageable); // mapping lives in the service
}
}

Ask for soft delete and agents often emit the Hibernate 5 pair @SQLDelete plus @Where. @Where is not in Hibernate ORM 7.4.5 at all (checked in the published JAR on 2026-09-26), so that code does not compile. Hibernate’s managed @SoftDelete replaces both annotations and filters deleted rows from every query.

@Entity
@Table(name = "orders")
@SoftDelete // Hibernate 6.4+: replaces @SQLDelete + @Where on every query
@EntityListeners(AuditingEntityListener.class)
class Order {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
private Customer customer;
// items, status, auditing fields
}

For deeper JPA work, including lazy-loading traps across ORMs, see ORM best practices. For the Flyway side, see database migration patterns.

N+1 queries are a frequent performance defect in generated JPA: a LAZY association read in a loop fires one query per row, and it looks fine with five rows of test data. A test that counts statements turns it into a red build. Enable statistics in the test profile with spring.jpa.properties.hibernate.generate_statistics=true, then assert on the count:

@Test
void listingOrdersDoesNotScaleQueriesWithRows() {
fixtures.createOrders(50); // enough rows for an N+1 to show
var stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
stats.clear();
mockMvc.perform(get("/api/v1/orders?size=50")).andExpect(status().isOk());
assertThat(stats.getPrepareStatementCount()).isLessThanOrEqualTo(3);
}

When the test goes red, paste the failure back: “the list endpoint runs 51 statements; fix it with an @EntityGraph on to-one associations and batch fetching for collections, not by raising the limit.” Do not accept a JOIN FETCH of a collection on a paged query: Hibernate then pages in memory (see the failure modes below).

Make downstream calls resilient with Resilience4j

Section titled “Make downstream calls resilient with Resilience4j”

A service that calls payment or inventory synchronously needs timeouts and a circuit breaker. Spring Framework 7.0 ships @Retryable and @ConcurrencyLimit in org.springframework.resilience.annotation (enabled with @EnableResilientMethods), but it has no circuit breaker, so use Resilience4j’s resilience4j-spring-boot4 2.4.0 for that. On Kafka, KafkaTemplate.send() returns a CompletableFuture; the old ListenableFuture.addCallback(...) does not compile.

The publisher should look like this: whenComplete, and only after commit, so a rolled-back order never produces an event:

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
void onOrderCreated(OrderCreatedEvent event) {
var message = OrderEvent.from(event);
kafkaTemplate.send("order-events", message.orderId(), message).whenComplete((result, ex) -> {
if (ex != null) log.error("Failed to publish {}", message.eventId(), ex);
});
}

For retries, idempotency keys and compensation across services, see recovery patterns.

Secure the API with Spring Security 7 and JWT

Section titled “Secure the API with Spring Security 7 and JWT”

Spring Security 7.1.1 has no WebSecurityConfigurerAdapter, no and() and no authorizeRequests(): every HttpSecurity method takes a lambda Customizer (checked in the published spring-security-config JAR on 2026-09-26). Agents also like to hand-write a OncePerRequestFilter that parses JWTs. Do not accept that. The OAuth2 resource server validates signature, expiry, issuer and audience for you.

For security testing beyond the filter chain, see security testing with AI.

Prove it on real Postgres with Testcontainers 2

Section titled “Prove it on real Postgres with Testcontainers 2”

Mocked repositories pass while the real query is broken, so the conventions require a real database. Testcontainers 2 moved the Postgres module to the testcontainers-postgresql artifact and the org.testcontainers.postgresql package; the old org.testcontainers.containers.PostgreSQLContainer is deprecated. With @ServiceConnection, Spring Boot wires the datasource from the container, so the test needs no @DynamicPropertySource.

@TestConfiguration(proxyBeanMethods = false)
class PostgresTestConfig {
@Bean
@ServiceConnection
PostgreSQLContainer postgres() { // org.testcontainers.postgresql, not .containers
return new PostgreSQLContainer("postgres:17");
}
}

For Testcontainers patterns beyond the database layer, see integration testing with AI.

Give the agent the live schema through a Postgres MCP server

Section titled “Give the agent the live schema through a Postgres MCP server”

For index and query work the agent does better when it reads real indexes and EXPLAIN output than when it guesses from your entity classes. Postgres MCP Pro (PyPI postgres-mcp 0.3.0, from Crystal DBA) provides that. Its last PyPI release was in May 2025, so check the project’s activity before you standardise on it; database MCP servers covers the alternatives, DBHub and MCP Toolbox. Point it at a development database with a read-only role:

Terminal window
claude mcp add -s project postgres -e 'DATABASE_URI=${DEV_DATABASE_URL}' \
-- uvx postgres-mcp --access-mode=restricted

The single quotes keep the placeholder literal, so .mcp.json stores ${DEV_DATABASE_URL} and Claude Code expands it from your environment at launch; the credentials never reach the repository.

Codex (codex mcp add … --env …) and Cursor take the same command and arguments in their own MCP configuration. Database MCP servers has all three configurations and the read-only role to create first.

How to verify agent-written Spring code without reading every line

Section titled “How to verify agent-written Spring code without reading every line”

Read the migration SQL, the security rules and the test names. Let gates check the rest. ArchUnit 1.5.1 turns the conventions into tests, so a convention the agent ignores fails the build instead of waiting for a reviewer to notice:

@AnalyzeClasses(packages = "com.example.shop")
class ArchitectureTest {
@ArchTest
static final ArchRule noFieldInjection = GeneralCodingRules.NO_CLASSES_SHOULD_USE_FIELD_INJECTION;
@ArchTest
static final ArchRule controllersNeverTouchRepositories = noClasses()
.that().areAnnotatedWith(RestController.class)
.should().dependOnClassesThat()
// Spring Data's interface, not the @Repository annotation: with the annotation,
// areAssignableTo never matches and the rule passes vacuously
.areAssignableTo(org.springframework.data.repository.Repository.class);
@ArchTest
static final ArchRule controllersNeverReturnEntities = noMethods()
.that().areDeclaredInClassesThat().areAnnotatedWith(RestController.class)
.should().haveRawReturnType(annotatedWith(Entity.class));
}

The entity rule checks the raw return type only. A controller returning Page<Order> or List<Order> passes it, so cover generic returns with a custom ArchCondition that inspects the type arguments.

Add the banned-API check from the pin block to CI as one step. It exits non-zero when any match is found:

Terminal window
! git grep -nE '@Where\b|WebSecurityConfigurerAdapter|authorizeRequests\(|\.addCallback\(|@MockBean\b|org\.testcontainers\.containers\.PostgreSQLContainer' -- '*.java'
GateWhat it provesRuns in
./mvnw verify with TestcontainersEndpoints, validation, 409/404 paths and soft delete work on real PostgresPre-push and CI
Query-count testsNo N+1 regression on list endpointsCI
ArchUnit rulesNo field injection, no repository access from controllers, no entity returned from a controllerCI
The banned-API git grep aboveNo removed API slipped back inCI
Security slice tests401, 403 and role mapping behave as specifiedCI
Circuit-breaker testThe fallback fires and 4xx is never retriedCI
A human read of the Flyway SQL and SecurityFilterChainLocks, drops and access rules are intendedPR review

The developer who opens the pull request owns the test names and the invariants they encode. The reviewer signs off on the migration, the security configuration and the gate results, not on every generated line. To package that evidence for review, see the evidence bundle. For why this replaces reading the diff, see evidence, not diffs.

When agent-written Spring Boot code breaks

Section titled “When agent-written Spring Boot code breaks”
  • The code targets an unsupported major version (Spring Boot 3 / Hibernate 6). Symptoms: cannot find symbol: class Where, WebSecurityConfigurerAdapter not found, addCallback undefined on CompletableFuture, or com.fasterxml.jackson.databind imports failing under Jackson 3. Recovery: add the pin block to CLAUDE.md or AGENTS.md, paste the compiler error back with “this is Spring Boot 4.1 / Hibernate 7”, then add the banned-API git grep step above to CI.
  • Test slices do not compile. Symptom: @WebMvcTest or @DataJpaTest cannot be resolved after the upgrade. Recovery: Spring Boot 4 split test auto-configuration into modules; add spring-boot-starter-webmvc-test or spring-boot-starter-data-jpa-test rather than letting the agent guess package names.
  • @Transactional does nothing. Symptom: a partial write survives an exception. Cause: the annotation sits on a private method or on a method called from inside the same class, so Spring’s proxy never sees the call. Recovery: move it to a public service method (and keep it off controllers: transactions belong to the service layer), and add a test that throws mid-write and asserts no row exists.
  • Validation never runs. Symptom: blank names reach the database. Cause: @Valid is missing on the @RequestBody. Recovery: the 422 test from the REST prompt should already be red; if it is green, the test posts a valid body.
  • An @Async method swallows its exception. Symptom: a job silently stops. Recovery: return CompletableFuture or register an AsyncUncaughtExceptionHandler, and assert the failure path in a test.
  • Events for rolled-back orders. Symptom: downstream services see orders that do not exist. Recovery: publish from @TransactionalEventListener(phase = AFTER_COMMIT) or move to a transactional outbox.
  • Hibernate pages the list in memory. Symptom: the log shows warning HHH90003004 (firstResult/maxResults specified with collection fetch; applying in memory), and memory grows with table size rather than page size. Cause: a JOIN FETCH or @EntityGraph on the items collection of a paged query, so Hibernate loads every matching row and pages in the JVM. Recovery: fetch only to-one associations in the paged query and load collections with default_batch_fetch_size or a second query by id; set hibernate.query.fail_on_pagination_over_collection_fetch=true in tests to turn the warning into an exception.
  • The N+1 returns after a refactor. Symptom: latency grows with page size. Recovery: the query-count test should be red; if it is green, the fixture has too few rows, so seed more.