mcpField
J
jpa-patterns

jpa-patterns

github.com/piominDevelopment1.4k71 installsUpdated 1d ago
Repository

About jpa-patterns

jpa-patterns is a practical reference skill for JPA and Hibernate in Spring applications. It helps developers diagnose query, loading, transaction, and concurrency problems with patterns such as fetch joins, projections, pagination, and optimistic locking.

Key features

  1. 01Diagnoses N+1 query patterns
  2. 02Explains lazy loading and fetch strategies
  3. 03Covers transaction management pitfalls
  4. 04Guides query optimization and pagination
  5. 05Shows optimistic locking and modern Hibernate features

Use cases

  1. 01Reducing excess database queries
  2. 02Resolving LazyInitializationException
  3. 03Optimizing slow read queries
  4. 04Handling concurrent entity updates

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/piomin/claude-ai-spring-boot --skill jpa-patterns

After installing, type/jpa-patternsin Claude Code to confirm it loaded.

Details

From the project README

JPA Patterns Skill

Best practices and common pitfalls for JPA/Hibernate in Spring applications.

When to Use

  • User mentions "N+1 problem" / "too many queries"
  • LazyInitializationException errors
  • Questions about fetch strategies (EAGER vs LAZY)
  • Transaction management issues
  • Entity relationship design
  • Query optimization

Quick Reference: Common Problems

ProblemSymptomSolution
N+1 queriesMany SELECT statementsJOIN FETCH, @EntityGraph
LazyInitializationExceptionError outside transactionOpen Session in View, DTO projection, JOIN FETCH
Slow queriesPerformance issuesPagination, projections, indexes
Dirty checking overheadSlow updatesRead-only transactions, DTOs
Lost updatesConcurrent modificationsOptimistic locking (@Version)

N+1 Problem

The #1 JPA performance killer

The Problem
// ❌ 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)
Solution 1: JOIN FETCH (JPQL)
// ✅ 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();
Solution 2: @EntityGraph
// ✅ 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 {
    // ...
}
Solution 3: Batch Fetching
// ✅ 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
Detecting N+1
# 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

Lazy Loading

FetchType Basics
@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;
}
Best Practice: Default to LAZY
// ✅ 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;
}
LazyInitializationException
// ❌ 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!
Solutions for 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);
}

Transaction Management

Read-Only Optimization
@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);
    }
}
Self-Invocation Pitfall
// ❌ 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
    }
}

Optimistic Locking

@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
}

// Handling optimistic lock failure
@Service
public class ProductService {
    private final ProductRepository repo;

    public ProductService(ProductRepository repo) {
        this.repo = repo;
    }

    @Transactional
    public Product updatePrice(Long id, BigDecimal newPrice) {
        Product product = repo.findById(id)
            .orElseThrow(() -> new EntityNotFoundException("Product not found"));
        product.setPrice(newPrice);
        try {
            return repo.save(product);
        } catch (OptimisticLockingFailureException e) {
            throw new ConflictException("Product was modified by another user");
        }
    }
}

Modern Hibernate Features

Jakarta Persistence
// Always use jakarta.persistence.* (not javax.persistence.*)
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
UUID Generation
@Entity
public class Event {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)  // Native UUID generation
    private UUID id;

    private String name;

    // getters/setters
}
Soft Delete
// 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 {
    // ...
}
Record as DTO in Queries
// 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);
}

Query Best Practices

Pagination
// ✅ 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())
);
Spring Data Projections
// Interface-based projection - fetches only declared fields
public interface UserNameOnly {
    String getFirstName();
    String getLastName();
}

public interface UserRepository extends JpaRepository<User, Long> {
    List<UserNameOnly> findAllByActiveTrue();
}

Quick Decision Guide

SituationSolution
N+1 in loopJOIN FETCH or @EntityGraph
LazyInitializationExceptionJOIN FETCH in query or @Transactional on service
Read-only queries slow@Transactional(readOnly = true)
Concurrent updates@Version + optimistic locking
Unbounded result setPagination with PageRequest
Need only few columnsDTO projection or interface projection
Soft delete required@SoftDelete
UUID primary key@GeneratedValue(strategy = GenerationType.UUID)

Comments

Markdown supported