Skills for creating, testing, reviewing, migrating and guarding Spring Boot 4 projects with Java 25: project scaffolding with architecture patterns, Boot 4 test templates, code review, Boot 3 to 4 migration, and a team harness of build gates and agent hooks.
93%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Low
Low-risk findings worth noting
Spring Data JPA performance on Boot 4 (Hibernate 7), plus the few Jakarta Persistence 3.2 / Hibernate 7 facts that differ from older guidance.
One query loads N rows, then each row triggers another query for an association:
List<Order> orders = orderRepository.findAll(); // 1 query
for (Order order : orders) {
order.getCustomer().getName(); // +1 query per order
order.getItems().size(); // +1 more per order
}@ManyToOne and @OneToOne are EAGER by default in JPA. Set fetch = FetchType.LAZY on every association, then fetch what each use case needs:
| Fix | Use when |
|---|---|
JOIN FETCH in @Query | You need entities with their associations |
| DTO projection | Read-only views (best performance) |
@EntityGraph(attributePaths = {"customer", "items"}) | Same as JOIN FETCH on a derived query method |
hibernate.default_batch_fetch_size: 50 | Global safety net. Loads lazy associations in batches with IN (...) |
@Query("""
SELECT o FROM Order o
JOIN FETCH o.customer
WHERE o.status = :status
""")
List<Order> findWithCustomer(@Param("status") OrderStatus status);Gotchas:
JOIN FETCH of a collection + Pageable: Hibernate loads all rows and pages in memory. It logs "firstResult/maxResults specified with collection fetch; applying in memory". Set hibernate.query.fail_on_pagination_over_collection_fetch=true to fail instead. Fix: page the IDs first, then fetch the entities for those IDs, or use a DTO projection.List collections in one fetch throws MultipleBagFetchException. Fetch one collection per query in the same transaction (the persistence context fills the second collection on the entities the first query loaded), or rely on batch fetching. Don't switch to Set to silence it: the join still returns every child row multiplied by every other, for example 50 parents × 20 × 10 = 10,000 rows.LazyInitializationException. Fetch in the query. Do not enable open-in-view to hide it.findAll() for tables that grow. Use Pageable (PageRequest.of(0, 20, Sort.by("createdAt").descending())).Pageable even if that changes the response shape. Say so in the change summary rather than leaving the list unbounded (Haiku left it unbounded without this, tests/baseline-results/scenario-24.md).UPDATE rather than loading the rows in batches, unless each row needs entity logic or lifecycle callbacks.Page runs an extra count query. Use Slice when the UI does not need a total.SELECT new com.example.OrderSummary(o.id, c.name, o.total) ...) or an interface projection. See dto-projections.md.spring:
jpa:
properties:
hibernate:
jdbc:
batch_size: 50
order_inserts: true
order_updates: truesaveAll() loops over save(). It batches only when batch_size is set.GenerationType.IDENTITY disables insert batching. Hibernate needs each generated ID right away. Use SEQUENCE with allocationSize > 1, or application-generated IDs (TSID, UUID).for (int i = 0; i < products.size(); i++) {
entityManager.persist(products.get(i));
if ((i + 1) % 50 == 0) {
entityManager.flush();
entityManager.clear();
}
}@Modifying JPQL query. It skips loading entities.@Transactional(readOnly = true) on query services. Hibernate skips dirty checking and flushing. Override with @Transactional on write methods.@QueryHints(@QueryHint(name = HINT_FETCH_SIZE, value = "50"))
@Query("SELECT p FROM Product p WHERE p.status = 'ACTIVE'")
Stream<Product> streamAllActive();
try (Stream<Product> stream = repository.streamAllActive()) {
stream.forEach(this::process);
}JdbcClient with plain SQL is often simpler and faster than JPA.Boot uses HikariCP.
spring:
datasource:
hikari:
maximum-pool-size: 10 # start small; measure
connection-timeout: 30000
leak-detection-threshold: 60000 # logs connections held longer than 60 scores * 2 + 1 (the HikariCP pool-sizing guidance). Large pools (100+) add contention and can exceed the database's connection limit.max_connections.Index columns in WHERE, JOIN, and ORDER BY clauses of frequent queries. Put the most selective column first in composite indexes. Add indexes in Flyway/Liquibase migrations:
CREATE INDEX idx_orders_customer_created ON orders (customer_id, created_at DESC);Development:
spring:
jpa:
properties:
hibernate:
format_sql: true
generate_statistics: true # logs query counts per session
log_slow_query: 10 # ms; logs slower queries
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.orm.jdbc.bind: TRACE # bound parameters on Hibernate 6/7hibernate.session.events.log.LOG_QUERIES_SLOWER_THAN_MS. Use hibernate.log_slow_query.org.hibernate.type.descriptor.sql.BasicBinder is ignored on Hibernate 6/7.Statistics, or a datasource proxy) on hot paths to catch N+1 regressions.Production:
hikaricp.connections.pending and hikaricp.connections.usage (Actuator + Micrometer).pg_stat_statements for real query cost.LAZY; needed ones fetched per queryJOIN FETCH of collections with paginationbatch_size set; no IDENTITY IDs where batch inserts matter@Transactional(readOnly = true) on query servicesNEW constructor expressions in SELECT, and Spring Data's class-based projections don't nest. Select a flat record and assemble nested views in the service, or use Hibernate's non-portable extension knowingly.@ManyToMany is first-class in Jakarta Persistence 3.2 with a LAZY default. Preferring an explicit join entity is a design choice (the link has attributes or lifecycle), not a spec rule.@ManyToOne and @OneToOne are still EAGER by default. Flag every one without fetch = FetchType.LAZY.-hibernate-70, -71 and -73 modules (no -72 or -74 as of October 2026). -hibernate-73 is the closest fit; verify it against 7.4 before relying on it.