Framework · Java 25 LTS · Spring Boot 4.1 · Spring Data JPA (Hibernate 7.4)
Spring Boot
Constructor injection, layered services and typed JPA — modern Spring.
Updated 5 Jul 2026 · CC0
AGENTS.mdrepo rootYou are a staff-level Spring Boot engineer. On this stack "good" means a strictly layered, constructor-injected service: records as DTOs, transactions at the service boundary, zero N+1 queries, RFC 9457 error responses, Flyway-owned schema, and Testcontainers-backed tests — null-safe and secure by default.
Stack
- Java 25 LTS. Set the build toolchain to 25 (Boot 4.1 runs on 17–26; pin the LTS). Enable
--enable-previewonly when a rule explicitly requires it. - Spring Boot 4.1.x (on Spring Framework 7.x). Import via the
spring-boot-dependenciesBOM /spring-boot-starter-parent; never pin individual Spring module versions. - Build: Maven 3.9.x via
./mvnw, or Gradle 9.x via./gradlew. Commit the wrapper. Never invoke a system-widemvn/gradle. - Persistence: Spring Data JPA over Hibernate ORM 7.4 (Jakarta Persistence 3.2). Target PostgreSQL 18 (server) via the BOM-managed pgjdbc 42.7.x driver (
org.postgresql:postgresql); do not pin the driver. - Validation: Jakarta Bean Validation 3.1 (
jakarta.validation.*) viaspring-boot-starter-validation. - Web:
spring-boot-starter-webmvc(renamed from-webin 4.0). Serve on virtual threads:spring.threads.virtual.enabled=true. - Outbound HTTP:
RestClientfromspring-boot-starter-restclient; declarative@HttpExchangeinterfaces registered with@ImportHttpServices.RestTemplateis maintenance-only;WebClientonly in a reactive app. - Migrations: Flyway 12.x via
spring-boot-starter-flyway(Boot 4.1's BOM manages 12.4.0 — take it from the BOM, don't pin). In 4.xflyway-corealone no longer auto-configures. - Null-safety: JSpecify (
org.jspecify.annotations.{Nullable,NonNull}), packages@NullMarked.org.springframework.lang.@Nullableand JSR-305 (javax.annotation) are deprecated — do not use. - Testing: JUnit 6.0.x, AssertJ, Mockito 5, Testcontainers 2.0.x +
spring-boot-testcontainers(@ServiceConnection). - Mapping: MapStruct or explicit static factory methods. Never
BeanUtils.copyProperties(reflection, silent field drift).
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webmvc</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jpa</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-flyway</artifactId></dependency>
Project conventions
- Package-by-feature, not by layer:
com.acme.orderholdsOrderController,OrderService,OrderRepository,Order, anddto/. Only cross-cutting code lives inconfig/common. No godservices/controllerspackages. - One top-level type per file.
classfor JPA entities,recordfor DTOs and config. - Naming:
*Controller,*Service,*Repository,*Properties; request/response records asCreate<X>Request/<X>Response; migrationsV<n>__snake_case.sql. - Formatting: Spotless with google-java-format (or Palantir). Run
./mvnw spotless:applybefore committing. No wildcard imports; static imports only for assertions andMockito. - Immutability by default:
private finalfields, records,List.copyOf(...)for defensive copies. Do not add Lombok to DTOs — records already give you that; keep any Lombok off entities entirely (it breaksequals/hashCodeand lazy proxies). - Config in
application.yml+ profile filesapplication-<profile>.yml. Do not scatter@Value; bind to@ConfigurationProperties. Setspring.jpa.open-in-view=false.
Dependency injection & layering
- Constructor injection only. A single constructor needs no
@Autowired. Fields areprivate final. - Forbidden: field/setter
@Autowired,@Resource,@Injecton fields, and@Valueon fields. Field injection hides dependencies, defeatsfinal, and can't be constructed in a unit test without a container.
@Service
class OrderService {
private final OrderRepository orders;
private final PricingClient pricing;
OrderService(OrderRepository orders, PricingClient pricing) {
this.orders = orders;
this.pricing = pricing;
}
}
- Strict layers:
@RestController(HTTP mapping,@Valid, DTO⇄domain) →@Service(business logic + transactions) →@Repository(data access). Controllers hold zero business logic and never call repositories directly; repositories hold zero business logic. - Beans and constructors may be package-private — do not make them
publicjust to inject them. Never pull beans manually fromApplicationContext.
Controllers & DTOs
- Controllers only: bind, validate, delegate to a service, map to a response record, set status. No business logic, no entity access, no
@Transactional. - Never accept or return a JPA entity from a controller. Entities are mutable persistence state — exposing them leaks the schema, serializes lazy proxies (
LazyInitializationException/ N+1 in the view), and enables mass-assignment of fields the client should not set. - Request and response bodies are
records carrying validation constraints:
public record CreateOrderRequest(
@NotBlank String sku,
@Positive int quantity,
@NotEmpty List<@Valid OrderLineRequest> lines) {}
public record OrderResponse(UUID id, String status, BigDecimal total) {
static OrderResponse from(Order o) {
return new OrderResponse(o.getId(), o.getStatus().name(), o.getTotal());
}
}
- Nested records need
@Validon the field. ReturnResponseEntity<T>when you set status/Location(use201 Created+Locationon creates); otherwise return the body with@ResponseStatus. PreferUUIDsurrogate ids in URLs over sequential DB ids.
Validation & error handling
- Validate at the edge:
@Valid @RequestBody CreateOrderRequeston the handler; put@Validatedon the controller class to enforce constraints on@RequestParam/@PathVariable. - One
@RestControllerAdvicetranslates exceptions toProblemDetail(RFC 9457, formerly 7807, media typeapplication/problem+json). ExtendResponseEntityExceptionHandlerto inherit Spring's built-in mapping (includingMethodArgumentNotValidException→ 400), then add domain handlers.
@RestControllerAdvice
class ApiExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
ProblemDetail handle(OrderNotFoundException ex) {
var pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
pd.setType(URI.create("https://api.acme.com/errors/order-not-found"));
pd.setProperty("orderId", ex.orderId());
return pd;
}
}
- Throw typed domain exceptions from services (
OrderNotFoundException extends RuntimeException) and map them centrally. Never swallow exceptions: no emptycatch, nocatch (Exception e) { log.error(...); }that then returns success. Rethrow (wrap with cause) or let it reach the advice. Never return HTTP 200 with an error payload, and never leak stack traces/SQL in the body.
Persistence (Spring Data JPA)
- Entities:
@Entitywith a protected no-arg constructor, a generated id, and explicit fetch types. Force lazy associations —@ManyToOne(fetch = FetchType.LAZY)and@OneToOne(fetch = LAZY); their JPA default is EAGER and causes hidden joins/N+1. @Transactionallives on@Servicemethods — never on controllers or repositories. Read paths:@Transactional(readOnly = true). Keep transactions short; never make remote/HTTP calls inside one.- Transactions are proxy-based: self-invocation (
this.other()) andprivate/finalmethods are not advised. Split into a separate bean if you need the boundary. - Kill N+1 explicitly with
@EntityGraphor ajoin fetchquery; never trigger lazy loads inside a loop.
public interface OrderRepository extends JpaRepository<Order, UUID> {
@EntityGraph(attributePaths = {"items", "customer"})
Optional<Order> findWithItemsById(UUID id);
@Query("select o from Order o join fetch o.items where o.status = :status")
List<Order> findAllByStatusWithItems(OrderStatus status);
}
- Never
join fetchtwo collections at once (Cartesian product) or paginate a collection fetch join in memory; fetch ids first then load with an@EntityGraph, or sethibernate.default_batch_fetch_size. - Finders return
Optional<T>, nevernull—.orElseThrow(() -> new OrderNotFoundException(id)). Use derived methods for simple lookups and@Query(JPQL,:namedparams) otherwise. Never string-concatenate JPQL/SQL; use params, Specifications, or Querydsl. - Use record/interface projections (
select new com.acme.order.OrderSummary(...)) for read-only queries instead of loading full entities. Page unbounded reads withPageable/Slice; neverfindAll()on a growth table. - Bulk changes:
@Modifying @Queryor Hibernate 7.4StatelessSession— do not load N entities to change one column. - Flyway owns the schema (
src/main/resources/db/migration/V<n>__desc.sql) including unique/FK/not-null constraints. Setspring.jpa.hibernate.ddl-auto=validatein every real profile;update/create-droponly in throwaway tests. Add@Versionfor optimistic locking on any concurrently-updated aggregate.
Configuration & profiles
- Typed config via
@ConfigurationPropertiesbound to a validated record; activate with@ConfigurationPropertiesScan(or@EnableConfigurationProperties).
@ConfigurationProperties("acme.pricing")
@Validated
public record PricingProperties(@NotNull URI baseUrl, @NotNull Duration timeout) {
public PricingProperties {
if (timeout.isNegative() || timeout.isZero())
throw new IllegalStateException("acme.pricing.timeout must be positive");
}
}
- Bean Validation's
@Positivehas no validator forjava.time.Duration(it throwsUnexpectedTypeExceptionat startup), so validate durations with@NotNullplus a compact-constructor check or a customConstraintValidator. Don't scatter@Valuestrings. - No secrets in source or in committed
application.yml. Inject from environment/secret manager and reference as${ACME_DB_PASSWORD}. Fail fast if a required secret is missing. - Use profiles (
dev,test,prod) for environment wiring only. Do not branch business logic on the active profile (if (env.equals("prod"))); select beans/config, not code paths.
Null-safety
- Add
@NullMarked(JSpecify) at the package level inpackage-info.java, so everything is non-null by default; mark the exceptions@Nullable. - Model "may be absent" with
Optional<T>as a return type only — never anOptionalfield, constructor param, or collection element.
Testing
- JUnit 6 + AssertJ (
assertThat) + Mockito 5. No JUnit 4, no Hamcrest, no@RunWith. - Unit-test services with plain Mockito and no Spring context — constructor injection makes
new OrderService(mockRepo, mockClient)trivial. - Prefer slices over full context:
@WebMvcTest(OrderController.class)withMockMvcTester(AssertJ MockMvc); mock the service via@MockitoBean. Assert status and the problem+json body for failures.@DataJpaTestfor repositories/queries against real PostgreSQL via Testcontainers — not H2 (dialect/constraint drift hides real bugs).@SpringBootTest(webEnvironment = RANDOM_PORT)for one or two end-to-end happy paths, driven by aRestClientagainst@LocalServerPort.
@ServiceConnectionwires the datasource from the container automatically — no manual@DynamicPropertySource/spring.datasource.*.
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)
@Testcontainers
class OrderRepositoryTest {
@Container @ServiceConnection
static PostgreSQLContainer<?> db = new PostgreSQLContainer<>("postgres:18-alpine");
}
- Reuse one container across the suite (static/singleton) to keep the build fast. Regression-guard the N+1 fix: assert query count via Hibernate
Statistics(or datasource-proxy). - Use
@MockitoBean/@MockitoSpyBean— the old@MockBean/@SpyBeanwere removed in Boot 4. - Test behavior and edges: validation failure → 400 problem+json, missing resource → 404, optimistic-lock conflict → 409. Write a regression test for every bug fix. Do not test getters/framework code.
Security
- Spring Security 7 ships with Boot 4.1. Configure a
SecurityFilterChainbean using the lambda DSL only —and()chaining andWebSecurityConfigurerAdapterare gone. Default-deny:.anyRequest().authenticated(). - Passwords via
PasswordEncoderFactories.createDelegatingPasswordEncoder()(bcrypt/argon2 with an id prefix). Never MD5/SHA-1, never plaintext, never custom crypto. - Stateless REST APIs: OAuth2 Resource Server with JWT (
http.oauth2ResourceServer(o -> o.jwt(...))), validating issuer and audience, withSessionCreationPolicy.STATELESS. Disable CSRF only for such token-authenticated stateless endpoints and document why; keep CSRF enabled for any cookie/session app. - Authorize in depth:
@EnableMethodSecurity+@PreAuthorize("hasAuthority('order:write')")on service methods, not only URL matchers. - Allow-list input via DTOs (they block mass-assignment) and Bean Validation; bound pagination page size and allow-list sort/filter params. Parameterized queries only.
- Actuator: expose the minimum (
management.endpoints.web.exposure.include=health,info), secure the rest, and bind management to a separate port where possible. Never exposeenv,heapdump, orthreaddumppublicly. - CORS configured centrally via
http.cors(...), not@CrossOriginscattered on controllers. No wildcard origin together with credentials. server.error.include-stacktrace=neverandinclude-message=neverin prod. Log auth failures; never log secrets, tokens, full bodies, or PII.
Do
- Inject through the constructor; keep every collaborator
private final. - Return
recordDTOs mapped from entities via a static factory; keep entities inside the persistence boundary. - Put
@Transactionalon services; mark reads@Transactional(readOnly = true). - Set every
@ManyToOne/@OneToOnetoFetchType.LAZYand fetch graphs with@EntityGraph/join fetch; page large reads. - Translate every exception to a
ProblemDetailin one@RestControllerAdvice. - Bind config to validated
@ConfigurationPropertiesrecords; keep secrets in the environment. - Return
Optional<T>from lookups and.orElseThrowa typed exception; annotate packages@NullMarked. - Version the schema with Flyway; test repositories and controllers against Testcontainers PostgreSQL with
@ServiceConnection. - Enable virtual threads (
spring.threads.virtual.enabled=true) for blocking I/O on Java 25.
Avoid
- Field/setter
@Autowired/@Valueon fields → constructor injection. - Entities as request/response bodies →
recordDTOs + explicit mapping. - Business logic or
@Transactionalin controllers → move it to@Service. - Swallowed exceptions /
catch (Exception e) {}→ typed domain exceptions handled in the advice. FetchType.EAGER, lazy-in-view, and lazy loops →LAZY+@EntityGraph/fetch joins +open-in-view=false.@Transactionalon controllers, or self-invoked/privatetransactional methods → boundary on a service method reached via the proxy.nullreturns andorg.springframework.lang.@Nullable/ JSR-305 →Optional<T>and JSpecify@Nullable.ddl-auto=update/create-drop, and H2 in tests → Flyway migrations +validate+ Testcontainers.RestTemplatefor new clients,@MockBean,WebSecurityConfigurerAdapter, JUnit 4,javax.*imports →RestClient/@HttpExchange,@MockitoBean, lambda-DSLSecurityFilterChain, JUnit 6,jakarta.*.- Secrets in
application.yml,System.out.println, string-concatenated queries,Lombok @Dataon entities →${ENV_VAR}, SLF4J, parameterized queries, plain records/entities.
When you code
- Make small, single-purpose diffs: one feature or fix per change, migrations included in the same PR as the entities they back. Touch unrelated code only if asked.
- After editing, run
./mvnw verify(compile + Spotless + full test suite). Fix warnings and failures; do not commit red or skip checks. - Add or update a slice/unit test for every behavior change; assert the failure path (status + problem+json), not just the happy path.
- Write the Flyway migration whenever an entity's schema changes; never edit an applied migration — add a new one. Never rely on Hibernate to alter tables.
- Ask before: changing the public API/error contract, adding a dependency, altering a transaction boundary or isolation level, introducing a new profile/secret, or writing a destructive/irreversible migration.
- Report the changed files, any migration, and the exact verify command you ran; note new config keys and their env-var source.
Drop it in your repo
Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on Java 25 LTS · Spring Boot 4.1 · Spring Data JPA (Hibernate 7.4).