mcpField
J
jpa-patterns

jpa-patterns

github.com/piomin開発1.4k71 回インストール1 日前に更新
リポジトリ

jpa-patterns について

jpa-patternsは、SpringアプリケーションでJPAとHibernateを使う際の実践的なリファレンススキルです。フェッチ結合、プロジェクション、ページネーション、楽観ロックなどを用いて、クエリ、読み込み、トランザクション、並行処理の問題を調べます。

主な機能

  1. 01N+1クエリ問題の診断
  2. 02遅延読み込みとフェッチ戦略の解説
  3. 03トランザクション管理の注意点
  4. 04クエリ最適化とページネーションの指針
  5. 05楽観ロックと最新Hibernate機能の例

活用シーン

  1. 01過剰なデータベースクエリの削減
  2. 02LazyInitializationExceptionの解決
  3. 03遅い読み取りクエリの最適化
  4. 04エンティティの同時更新への対応

インストール

コマンドをコピーしてターミナルで実行します。

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

インストール後、Claude Code で/jpa-patternsを入力して読み込まれたか確認します。

詳細

JPA パターンのスキル

Spring アプリケーションにおける JPA/Hibernate のベストプラクティスとよくある落とし穴です。

使用する場面

  • ユーザーが「N+1 問題」/「クエリが多すぎる」と言及した場合
  • LazyInitializationException エラーが発生した場合
  • フェッチ戦略(EAGER と LAZY)について質問された場合
  • トランザクション管理に問題がある場合
  • エンティティのリレーション設計
  • クエリの最適化

クイックリファレンス:よくある問題

問題症状解決策
N+1 クエリSELECT 文が多数実行されるJOIN FETCH、@EntityGraph
LazyInitializationExceptionトランザクション外でエラーが発生するOpen Session in View、DTO プロジェクション、JOIN FETCH
遅いクエリパフォーマンスの問題ページネーション、プロジェクション、インデックス
ダーティーチェックのオーバーヘッド更新が遅い読み取り専用トランザクション、DTO
更新の消失同時変更楽観的ロック(@Version)

N+1 問題

JPA のパフォーマンスを最も損なう要因

問題
// ❌ 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)
解決策 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();
解決策 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 {
    // ...
}
解決策 3:バッチフェッチ
// ✅ 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
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

遅延読み込み

FetchType の基本
@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;
}
ベストプラクティス:デフォルトを 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!
LazyInitializationException の解決策

解決策 1:クエリで JOIN FETCH を使用する

// ✅ 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);

解決策 2:サービスメソッドに @Transactional を付ける

// ✅ 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);
    }
}

解決策 3:DTO プロジェクション(読み取り専用の場合に最適)

// ✅ 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);
}

トランザクション管理

読み取り専用の最適化
@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
}

// 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");
        }
    }
}

最新の Hibernate 機能

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 の生成
@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 {
    // ...
}
クエリで DTO として Record を使用する
// 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())
);
Spring Data プロジェクション
// Interface-based projection - fetches only declared fields
public interface UserNameOnly {
    String getFirstName();
    String getLastName();
}

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

クイック判断ガイド

状況解決策
ループ内で N+1 が発生するJOIN FETCH または @EntityGraph
LazyInitializationExceptionクエリで JOIN FETCH を使用する、またはサービスに @Transactional を付ける
読み取り専用クエリが遅い@Transactional(readOnly = true)
同時更新@Version + 楽観的ロック
件数に上限のない結果セットPageRequest によるページネーション
必要な列が少数だけDTO プロジェクションまたはインターフェースプロジェクション
論理削除が必要@SoftDelete
UUID 主キー@GeneratedValue(strategy = GenerationType.UUID)

コメント