Java and Spring Boot rules for AI coding agents
Spring Boot has changed enough between major versions that agents often mix eras: javax.* imports in a Boot 3 project, WebSecurityConfigurerAdapter and antMatchers that no longer exist, field injection with @Autowired. The Java and Spring rules target Spring Boot 3 on Java 17 or 21 and begin by having the agent check the Java release and Boot version in the build file.
The rest is about the parts of Spring that work through proxies and defaults: @Transactional is ignored on private methods and on calls within the same class, @ManyToOne is eager unless you say otherwise, and a LazyInitializationException should be fixed in the query, not by switching to EAGER. There is also guidance on virtual threads with Java 21 and on using test slices instead of booting the whole context.
Folder: rules/java-spring/ · java-spring.mdc, CLAUDE.md, AGENTS.md
What the Spring Boot rules tell your agent
- VersionsCheck the Java release in the build file (
<java.version>or the Gradle toolchain) and the Spring Boot version. On Spring Boot 3.x all validation, persistence and servlet imports arejakarta.*, neverjavax.*. - Dependency injectionUse constructor injection with
finalfields. A class with one constructor needs no@Autowired. Never use field injection. - Web layerControllers translate HTTP to service calls and back. No repository calls or business rules in controllers.
- TransactionsPut
@Transactionalon public service methods. It works through a proxy, so it is ignored on private methods and on calls from another method of the same class. - JPA and data accessMake
@ManyToOneand@OneToOnefetch = FetchType.LAZY; their default is EAGER.
First rule of each section in java-spring.mdc, quoted as shipped.
Mistakes these rules are written to prevent
Optional.get()without a check; useorElseThrow(() -> new NotFoundException(id)). Do not useOptionalfor fields or parameters.LazyInitializationException"fixed" by switching to EAGER. Fetch what the use case needs in the query instead.- Logging with string concatenation; use SLF4J placeholders:
log.info("order {} paid", id).
Source: “Pitfalls to avoid”.
Checks the agent runs before it says done
Example commands from the Spring Boot file (swap in your repo's own):
- Build and test:
./mvnw verify(or./gradlew check)
Run ./mvnw verify or ./gradlew check and report the result, including any test you skipped and why.
Before and after: Spring Data JPA: N+1 queries and entities in responses
Prompt card 23 · Stack: Java, Spring Boot 3, Hibernate 6 · Type: Performance and API review · Best model: a frontier reasoning model
What the card is for
Find endpoints that return JPA entities (triggering lazy loads during JSON serialization), relations loaded one query per row, and EAGER defaults. Replace them with DTO projections or fetch joins sized to the use case.
Worked example from the card
Before
@Entity
class Order {
@Id Long id;
@ManyToOne Customer customer; // EAGER by default
@OneToMany(mappedBy = "order") List<OrderLine> lines;
BigDecimal total;
}
@GetMapping("/orders")
Page<Order> list(Pageable pageable) { return orders.findAll(pageable); }
Expected AI output (abridged)
Page of 20: 1 count + 1 orders + up to 20 customer selects (EAGER ManyToOne, loaded per
row unless already in the persistence context) + 20 line selects when Jackson serializes
`lines` (open-in-view on) = up to 42 statements.
The response also serializes every Customer and OrderLine column and can recurse if
OrderLine has a back-reference to Order.
Client needs: id, customerName, total, lineCount.
Fix: DTO projection with a join and a subquery count: 2 statements.
After
public record OrderSummary(Long id, String customerName, BigDecimal total, long lineCount) {}
interface OrderRepository extends JpaRepository<Order, Long> {
@Query(value = """
select new com.acme.orders.OrderSummary(
o.id, c.name, o.total, (select count(l) from OrderLine l where l.order = o))
from Order o join o.customer c
where o.account.id = :accountId
""",
countQuery = "select count(o) from Order o where o.account.id = :accountId")
Page<OrderSummary> findSummaries(@Param("accountId") Long accountId, Pageable pageable);
}
@GetMapping("/orders")
Page<OrderSummary> list(@AuthenticationPrincipal AppUser user, Pageable pageable) {
return orders.findSummaries(user.accountId(), pageable);
}
@Entity
class Order {
@Id Long id;
@ManyToOne(fetch = FetchType.LAZY) Customer customer;
@OneToMany(mappedBy = "order") List<OrderLine> lines;
@ManyToOne(fetch = FetchType.LAZY) Account account;
BigDecimal total;
}
Excerpt from card 23; the prompt itself is in the pack.
More Spring Boot prompt cards in the pack
- Card 22: Spring: @Transactional boundaries and rollback rules
Using these rules in Cursor, Claude Code and AGENTS.md tools
Cursor
Put _base.mdc and java-spring.mdc in .cursor/rules/. Globs: pom.xml, build.gradle*, src/**/*.java, src/main/resources/application*.yml, src/main/resources/application*.properties.
Claude Code
Root CLAUDE.md, base first. Path-scoped variant .claude/rules/java-spring.md for src/**/*.java, src/main/resources/application*.{yml,properties}; PostToolUse hook: ./mvnw spotless:apply (if configured).
AGENTS.md tools (Codex, Gemini CLI, Aider)
Base then stack in one AGENTS.md. Gemini CLI needs context.fileName; Aider needs --read AGENTS.md. Put this file in the module directory with pom.xml or build.gradle. Agents use the AGENTS.md nearest to the file being edited, so a nested copy overrides a root one for that subtree.