jpa-patterns について
jpa-patternsは、SpringアプリケーションでJPAとHibernateを使う際の実践的なリファレンススキルです。フェッチ結合、プロジェクション、ページネーション、楽観ロックなどを用いて、クエリ、読み込み、トランザクション、並行処理の問題を調べます。
主な機能
- 01N+1クエリ問題の診断
- 02遅延読み込みとフェッチ戦略の解説
- 03トランザクション管理の注意点
- 04クエリ最適化とページネーションの指針
- 05楽観ロックと最新Hibernate機能の例
活用シーン
- 01過剰なデータベースクエリの削減
- 02LazyInitializationExceptionの解決
- 03遅い読み取りクエリの最適化
- 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) |
コメント
Markdown 対応