JPA/Hibernate patterns and common pitfalls (N+1, lazy loading, transactions, queries). Use when user has JPA performance issues, LazyInitializationException, or asks about entity relationships and fetching strategies.
80%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Passed
No findings from the security scan
Fix and improve this skill with Tessl
tessl review fix ./skills/jpa-patterns/SKILL.mdBest practices and common pitfalls for JPA/Hibernate in Spring applications.
| Problem | Symptom | Solution |
|---|---|---|
| N+1 queries | Many SELECT statements | JOIN FETCH, @EntityGraph |
| LazyInitializationException | Error outside transaction | DTO projection, JOIN FETCH (not Open Session in View) |
| Slow queries | Performance issues | Pagination, projections, indexes |
| Dirty checking overhead | Slow updates | Read-only transactions, DTOs |
| Lost updates | Concurrent modifications | Optimistic locking (@Version), version from the client compared explicitly |
Entity lost from HashSet after persist | equals/hashCode on generated id | Natural key, or id-based with constant hashCode |
| Stale entities after bulk update | Old values read in same transaction | @Modifying(clearAutomatically = true) |
| Schema drift | Entities and DB out of sync across environments | Flyway migrations + ddl-auto: validate (see conventions) |
| 500 after a successful update of a relation | LazyInitializationException on an uninitialized proxy | @Transactional write, map the response inside it |
The #1 JPA performance killer
// ❌ BAD: N+1 queries
@Entity
public class Author {
@Id private Long id;
private String name;
@OneToMany(mappedBy = "author", fetch = FetchType.LAZY)
private List<Book> books;
}
// This innocent code...
List<Author> authors = authorRepository.findAll(); // 1 query
for (Author author : authors) {
System.out.println(author.getBooks().size()); // N queries!
}
// Result: 1 + N queries (if 100 authors = 101 queries)// ✅ GOOD: Single query with JOIN FETCH
public interface AuthorRepository extends JpaRepository<Author, Long> {
@Query("SELECT a FROM Author a JOIN FETCH a.books")
List<Author> findAllWithBooks();
}
// Usage - single query
List<Author> authors = authorRepository.findAllWithBooks();// ✅ GOOD: EntityGraph for declarative fetching
public interface AuthorRepository extends JpaRepository<Author, Long> {
@EntityGraph(attributePaths = {"books"})
List<Author> findAll();
// Or with named graph
@EntityGraph(value = "Author.withBooks")
List<Author> findAllWithBooks();
}
// Define named graph on entity
@Entity
@NamedEntityGraph(
name = "Author.withBooks",
attributeNodes = @NamedAttributeNode("books")
)
public class Author {
// ...
}// ✅ GOOD: Batch fetching (Hibernate-specific)
@Entity
public class Author {
@OneToMany(mappedBy = "author")
@BatchSize(size = 25) // Fetch 25 at a time
private List<Book> books;
}
// Or globally in application.properties
spring.jpa.properties.hibernate.default_batch_fetch_size=25# Enable SQL logging to detect N+1
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.orm.jdbc.bind: TRACE@Entity
public class Order {
// LAZY: Load only when accessed (default for collections)
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items;
// EAGER: Always load immediately (default for @ManyToOne, @OneToOne)
@ManyToOne(fetch = FetchType.EAGER) // ⚠️ Usually bad
private Customer customer;
}// ✅ GOOD: Always use LAZY, fetch when needed
@Entity
public class Order {
@ManyToOne(fetch = FetchType.LAZY) // Override EAGER default
private Customer customer;
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items;
}// ❌ BAD: Accessing lazy field outside transaction
@Service
public class OrderService {
public Order getOrder(Long id) {
return orderRepository.findById(id).orElseThrow();
}
}
// In controller (no transaction)
Order order = orderService.getOrder(1L);
order.getItems().size(); // 💥 LazyInitializationException!Solution 1: JOIN FETCH in query
// ✅ Fetch needed associations in query
@Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.id = :id")
Optional<Order> findByIdWithItems(@Param("id") Long id);Solution 2: @Transactional on service method
// ✅ Keep transaction open while accessing
@Service
public class OrderService {
@Transactional(readOnly = true)
public OrderDTO getOrderWithItems(Long id) {
Order order = orderRepository.findById(id).orElseThrow();
// Access within transaction - lazy collection loads here
int itemCount = order.getItems().size();
return new OrderDTO(order, itemCount);
}
}Solution 3: DTO projection (best for read-only)
// ✅ Avoid lazy loading entirely - query only what you need
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT new pl.piomin.services.dto.OrderSummary(o.id, o.status, SIZE(o.items)) " +
"FROM Order o WHERE o.id = :id")
Optional<OrderSummary> findSummaryById(@Param("id") Long id);
}Spring Boot enables OSIV by default (and logs a warning at startup). It keeps the persistence context and a DB connection open until the response is written, so lazy loads in controllers or JSON serialization "work" while hiding N+1 queries.
spring:
jpa:
open-in-view: false # fetch what the response needs in the service layerWith open-in-view: false, a write that loads the record outside a transaction and then calls save merges a detached entity; a newly set @ManyToOne becomes an uninitialized proxy, and mapping the response throws LazyInitializationException. The change is already committed, but the client gets a 500, and its next save meets a stale version.
@Transactional // load, change, save and map in one persistence context
public TaskResponse update(Long id, TaskUpdateRequest request) {
Task task = find(id);
Person assignee = request.assigneeId() == null ? null : personRepository.findById(request.assigneeId())
.orElseThrow(() -> unknownReference("assigneeId")); // 400, not 404: the request is invalid
checkVersion(task, request.version());
task.replaceWith(request, assignee);
return TaskResponse.from(repository.saveAndFlush(task)); // map inside the transaction
}@Transactional, and the response is mapped inside the transaction.@EntityGraph(attributePaths = "assignee") on findById and findAll(Specification, Pageable), so a page does not issue one query per row.@WebMvcTest never sees a JPA proxy, and A → B is the case that fails.@Service
@Transactional(readOnly = true) // Default for all methods
public class ProductService {
private final ProductRepository repo;
public ProductService(ProductRepository repo) {
this.repo = repo;
}
public List<Product> findAll() {
return repo.findAll(); // Read-only: skips dirty checking
}
@Transactional // Override: read-write for mutations
public Product create(ProductRequest request) {
var product = new Product();
product.setName(request.name());
return repo.save(product);
}
}// ❌ BAD: @Transactional ignored on self-invocation
@Service
public class OrderService {
public void processOrders(List<Long> ids) {
for (Long id : ids) {
processOrder(id); // Calls via 'this', not proxy - no transaction!
}
}
@Transactional
public void processOrder(Long id) {
// This @Transactional is ignored when called from processOrders()
}
}
// ✅ GOOD: Extract to separate bean
@Service
public class OrderService {
private final OrderProcessor processor;
public OrderService(OrderProcessor processor) {
this.processor = processor;
}
public void processOrders(List<Long> ids) {
for (Long id : ids) {
processor.processOrder(id); // Calls via proxy
}
}
}
@Service
public class OrderProcessor {
@Transactional
public void processOrder(Long id) {
// Transaction works correctly
}
}@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private BigDecimal price;
@Version
private Long version; // Hibernate checks this on update
// getters/setters
}
// The client sends the version it read; compare it explicitly
@Service
public class ProductService {
private final ProductRepository repo;
public ProductService(ProductRepository repo) {
this.repo = repo;
}
@Transactional
public ProductResponse update(Long id, ProductUpdateRequest request) {
Product product = repo.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Product " + id + " not found"));
if (!request.version().equals(product.getVersion())) {
throw new ObjectOptimisticLockingFailureException(Product.class, id);
}
product.setName(request.name());
product.setPrice(request.price());
return ProductResponse.from(repo.saveAndFlush(product)); // flushed: the response has the new version
}
}@Version only protects the window between this transaction's read and its commit. Across HTTP requests, the client sends the version it read and the service compares it: Hibernate ignores a version set on a managed entity, so product.setVersion(request.version()) does not detect a stale update.save() in try/catch (OptimisticLockingFailureException) inside a @Transactional method: the UPDATE runs at flush or commit, after the try block has ended. Let the exception propagate and map ObjectOptimisticLockingFailureException to 409 in the @RestControllerAdvice (Problem Details without errors, so a client can tell it from a field conflict).version in every response, and require it in the update request.// ✅ Business key: stable before and after persist
@Entity
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NaturalId // org.hibernate.annotations.NaturalId
@Column(nullable = false, unique = true, updatable = false)
private String isbn;
@Override
public boolean equals(Object o) {
if (this == o) return true;
return o instanceof Book other && isbn != null && isbn.equals(other.isbn);
}
@Override
public int hashCode() {
return Objects.hashCode(isbn);
}
}
// ✅ No natural key: id-based equality, constant hashCode
@Override
public boolean equals(Object o) {
if (this == o) return true;
return o instanceof Order other && id != null && id.equals(other.id);
}
@Override
public int hashCode() {
return getClass().hashCode(); // unchanged when the id is assigned on persist
}instanceof (not getClass()) in equals so Hibernate proxies compare equal@Entity
public class Author {
@OneToMany(mappedBy = "author", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Book> books = new ArrayList<>();
// ✅ Keep both sides in sync - the @ManyToOne side owns the FK
public void addBook(Book book) {
books.add(book);
book.setAuthor(this);
}
public void removeBook(Book book) {
books.remove(book);
book.setAuthor(null);
}
}
@Entity
public class Book {
@ManyToOne(fetch = FetchType.LAZY) // ✅ No cascade on the child side
@JoinColumn(name = "author_id")
private Author author;
}// ✅ @ManyToMany: Set (a List makes Hibernate delete and re-insert the whole join table)
// and PERSIST/MERGE only - never REMOVE or ALL
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@JoinTable(name = "student_course",
joinColumns = @JoinColumn(name = "student_id"),
inverseJoinColumns = @JoinColumn(name = "course_id"))
private Set<Course> courses = new HashSet<>();If the link carries data (enrolment date, grade), map the join table as its own entity with two @ManyToOne.
// ❌ BAD: CascadeType.ALL on @ManyToOne - removing a book removes its shared author
@ManyToOne(cascade = CascadeType.ALL)
private Author author;
// ❌ BAD: toString() touching a lazy collection - extra query,
// LazyInitializationException outside a transaction, StackOverflowError on bidirectional links
@Override
public String toString() {
return "Author{id=" + id + ", books=" + books + "}";
}
// ✅ GOOD: basic columns only
@Override
public String toString() {
return "Author{id=" + id + ", name='" + name + "'}";
}// Always use jakarta.persistence.* (not javax.persistence.*)
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;@Entity
public class Event {
@Id
@GeneratedValue(strategy = GenerationType.UUID) // Native UUID generation
private UUID id;
private String name;
// getters/setters
}// Built-in soft delete support
@Entity
@SoftDelete // Adds 'deleted' boolean column, filters automatically
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
// getters/setters
}
// Custom column name
@Entity
@SoftDelete(columnName = "is_active", converter = NumericBooleanConverter.class)
public class Product {
// ...
}// Records work natively in JPQL constructor expressions
public record ProductSummary(Long id, String name, BigDecimal price) {}
public interface ProductRepository extends JpaRepository<Product, Long> {
@Query("SELECT new pl.piomin.services.dto.ProductSummary(p.id, p.name, p.price) " +
"FROM Product p WHERE p.price > :minPrice")
List<ProductSummary> findExpensiveProducts(@Param("minPrice") BigDecimal minPrice);
}// ✅ Always paginate unbounded queries
public interface UserRepository extends JpaRepository<User, Long> {
Page<User> findByActiveTrue(Pageable pageable);
Slice<User> findByRole(String role, Pageable pageable); // No COUNT query
}
// Usage
Page<User> page = userRepository.findByActiveTrue(
PageRequest.of(0, 20, Sort.by("createdAt").descending())
);// Interface-based projection - fetches only declared fields
public interface UserNameOnly {
String getFirstName();
String getLastName();
}
public interface UserRepository extends JpaRepository<User, Long> {
List<UserNameOnly> findAllByActiveTrue();
}Bulk JPQL bypasses the persistence context, lifecycle callbacks, cascades and @Version checks.
public interface OrderRepository extends JpaRepository<Order, Long> {
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("UPDATE Order o SET o.status = :status WHERE o.createdAt < :before")
int archiveOlderThan(@Param("status") OrderStatus status, @Param("before") Instant before);
}flushAutomatically writes pending changes before the query; clearAutomatically detaches stale entities after it@Transactional service methodSET o.version = o.version + 1) if concurrent writers rely on optimistic locking| Situation | Solution |
|---|---|
| N+1 in loop | JOIN FETCH or @EntityGraph |
| LazyInitializationException | JOIN FETCH in query or @Transactional on service |
| Read-only queries slow | @Transactional(readOnly = true) |
| Concurrent updates over HTTP | @Version, client sends version, compare, 409 on mismatch |
| Entity equality | @NaturalId, or id-based equals with constant hashCode |
| Bidirectional association | add*/remove* helper methods; Set for @ManyToMany |
| Bulk UPDATE/DELETE | @Modifying(clearAutomatically = true, flushAutomatically = true) |
| Lazy loads in web layer | spring.jpa.open-in-view=false |
| Unbounded result set | Pagination with PageRequest |
| Need only few columns | DTO projection or interface projection |
| Soft delete required | @SoftDelete |
| UUID primary key | @GeneratedValue(strategy = GenerationType.UUID) |
piomin/claude-ai-spring-boot
31fe1d5
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.