jpa-patterns について
jpa-patternsは、Springアプリ開発者向けのJPA/Hibernate実践スキルです。性能問題、トランザクション管理、エンティティ関連を扱い、永続化層の確認に役立つクエリ最適化の例を示します。
主な機能
- 01N+1クエリの検出と最適化
- 02遅延ロードとトランザクション問題への対応
- 03ページネーションとDTOプロジェクションの利用
- 04エンティティ関連とカスケード設定の設計
- 05楽観ロックによる更新消失の防止
- 06インデックスと性能上の問題の確認
活用シーン
- 01アプリのN+1クエリや遅いクエリを調査
- 02LazyInitializationExceptionを解消
- 03エンティティ関連とトランザクション境界をレビュー
- 04大量の検索結果や一括更新を最適化
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ decebals/ claude-code-java --skill jpa-patternsインストール後、Claude Code で/jpa-patternsを入力して読み込まれたか確認します。
同梱ファイルREADME.md
詳細
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.type.descriptor.sql.BasicBinder: 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
int itemCount = order.getItems().size();
return new OrderDTO(order, itemCount);
}
}
解決策 3:DTO プロジェクション(推奨)
// ✅ BEST: Return only what you need
public interface OrderSummary {
Long getId();
String getStatus();
int getItemCount();
}
@Query("SELECT o.id as id, o.status as status, SIZE(o.items) as itemCount " +
"FROM Order o WHERE o.id = :id")
Optional<OrderSummary> findOrderSummary(@Param("id") Long id);
解決策 4:Open Session in View(非推奨)
# Keeps session open during view rendering
# ⚠️ Can mask N+1 problems, use with caution
spring:
jpa:
open-in-view: true # Default is true
トランザクション
基本的なトランザクション管理
@Service
public class OrderService {
// Read-only: Optimized, no dirty checking
@Transactional(readOnly = true)
public Order findById(Long id) {
return orderRepository.findById(id).orElseThrow();
}
// Write: Full transaction with dirty checking
@Transactional
public Order createOrder(CreateOrderRequest request) {
Order order = new Order();
// ... set properties
return orderRepository.save(order);
}
// Explicit rollback
@Transactional(rollbackFor = Exception.class)
public void processPayment(Long orderId) throws PaymentException {
// Rolls back on any exception, not just RuntimeException
}
}
トランザクションの伝播
@Service
public class OrderService {
@Autowired
private PaymentService paymentService;
@Transactional
public void placeOrder(Order order) {
orderRepository.save(order);
// REQUIRED (default): Uses existing or creates new
paymentService.processPayment(order);
// If paymentService throws, entire order is rolled back
}
}
@Service
public class PaymentService {
// REQUIRES_NEW: Always creates new transaction
// If this fails, order can still be saved
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processPayment(Order order) {
// Independent transaction
}
// MANDATORY: Must run within existing transaction
@Transactional(propagation = Propagation.MANDATORY)
public void updatePaymentStatus(Order order) {
// Throws if no transaction exists
}
}
よくあるトランザクションの誤り
// ❌ BAD: Calling @Transactional method from same class
@Service
public class OrderService {
public void processOrder(Long id) {
updateOrder(id); // @Transactional is IGNORED!
}
@Transactional
public void updateOrder(Long id) {
// Transaction not started because called internally
}
}
// ✅ GOOD: Inject self or use separate service
@Service
public class OrderService {
@Autowired
private OrderService self; // Or use separate service
public void processOrder(Long id) {
self.updateOrder(id); // Now transaction works
}
@Transactional
public void updateOrder(Long id) {
// Transaction properly started
}
}
エンティティの関連付け
OneToMany / ManyToOne
// ✅ GOOD: Bidirectional with proper mapping
@Entity
public class Author {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(mappedBy = "author", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Book> books = new ArrayList<>();
// Helper methods for bidirectional sync
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 {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private Author author;
}
ManyToMany
// ✅ GOOD: ManyToMany with Set (not List) to avoid duplicates
@Entity
public class Student {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@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<>();
public void addCourse(Course course) {
courses.add(course);
course.getStudents().add(this);
}
public void removeCourse(Course course) {
courses.remove(course);
course.getStudents().remove(this);
}
}
@Entity
public class Course {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToMany(mappedBy = "courses")
private Set<Student> students = new HashSet<>();
}
エンティティの equals() と hashCode()
// ✅ GOOD: Use business key or ID carefully
@Entity
public class Book {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NaturalId // Hibernate annotation for business key
@Column(unique = true, nullable = false)
private String isbn;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Book book)) return false;
return isbn != null && isbn.equals(book.isbn);
}
@Override
public int hashCode() {
return Objects.hash(isbn); // Use business key, not ID
}
}
クエリの最適化
ページネーション
// ✅ GOOD: Always paginate large result sets
public interface OrderRepository extends JpaRepository<Order, Long> {
Page<Order> findByStatus(OrderStatus status, Pageable pageable);
// With sorting
@Query("SELECT o FROM Order o WHERE o.status = :status")
Page<Order> findByStatusSorted(
@Param("status") OrderStatus status,
Pageable pageable
);
}
// Usage
Pageable pageable = PageRequest.of(0, 20, Sort.by("createdAt").descending());
Page<Order> orders = orderRepository.findByStatus(OrderStatus.PENDING, pageable);
DTO プロジェクション
// ✅ GOOD: Fetch only needed columns
// Interface-based projection
public interface OrderSummary {
Long getId();
String getCustomerName();
BigDecimal getTotal();
}
@Query("SELECT o.id as id, o.customer.name as customerName, o.total as total " +
"FROM Order o WHERE o.status = :status")
List<OrderSummary> findOrderSummaries(@Param("status") OrderStatus status);
// Class-based projection (DTO)
public record OrderDTO(Long id, String customerName, BigDecimal total) {}
@Query("SELECT new com.example.dto.OrderDTO(o.id, o.customer.name, o.total) " +
"FROM Order o WHERE o.status = :status")
List<OrderDTO> findOrderDTOs(@Param("status") OrderStatus status);
一括操作
// ✅ GOOD: Bulk update instead of loading entities
public interface OrderRepository extends JpaRepository<Order, Long> {
@Modifying
@Query("UPDATE Order o SET o.status = :status WHERE o.createdAt < :date")
int updateOldOrdersStatus(
@Param("status") OrderStatus status,
@Param("date") LocalDateTime date
);
@Modifying
@Query("DELETE FROM Order o WHERE o.status = :status AND o.createdAt < :date")
int deleteOldOrders(
@Param("status") OrderStatus status,
@Param("date") LocalDateTime date
);
}
// Usage
@Transactional
public void archiveOldOrders() {
LocalDateTime threshold = LocalDateTime.now().minusYears(1);
int updated = orderRepository.updateOldOrdersStatus(
OrderStatus.ARCHIVED,
threshold
);
log.info("Archived {} orders", updated);
}
楽観的ロック
更新の消失を防ぐ
// ✅ GOOD: Use @Version for optimistic locking
@Entity
public class Order {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Version
private Long version;
private OrderStatus status;
private BigDecimal total;
}
// When two users update same order:
// User 1: loads order (version=1), modifies, saves → version becomes 2
// User 2: loads order (version=1), modifies, saves → OptimisticLockException!
OptimisticLockException の処理
@Service
public class OrderService {
@Transactional
public Order updateOrder(Long id, UpdateOrderRequest request) {
try {
Order order = orderRepository.findById(id).orElseThrow();
order.setStatus(request.getStatus());
return orderRepository.save(order);
} catch (OptimisticLockException e) {
throw new ConcurrentModificationException(
"Order was modified by another user. Please refresh and try again."
);
}
}
// Or with retry
@Retryable(value = OptimisticLockException.class, maxAttempts = 3)
@Transactional
public Order updateOrderWithRetry(Long id, UpdateOrderRequest request) {
Order order = orderRepository.findById(id).orElseThrow();
order.setStatus(request.getStatus());
return orderRepository.save(order);
}
}
よくある誤り
1. Cascade の誤用
// ❌ BAD: CascadeType.ALL on @ManyToOne
@Entity
public class Book {
@ManyToOne(cascade = CascadeType.ALL) // Dangerous!
private Author author;
}
// Deleting a book could delete the author!
// ✅ GOOD: Cascade only from parent to child
@Entity
public class Author {
@OneToMany(mappedBy = "author", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Book> books;
}
2. インデックスの不足
// ❌ BAD: Frequent queries on non-indexed column
@Query("SELECT o FROM Order o WHERE o.customerEmail = :email")
List<Order> findByCustomerEmail(@Param("email") String email);
// ✅ GOOD: Add index
@Entity
@Table(indexes = @Index(name = "idx_order_customer_email", columnList = "customerEmail"))
public class Order {
private String customerEmail;
}
3. toString() で遅延フィールドを使用する
// ❌ BAD: toString includes lazy collection
@Entity
public class Author {
@OneToMany(mappedBy = "author", fetch = FetchType.LAZY)
private List<Book> books;
@Override
public String toString() {
return "Author{id=" + id + ", books=" + books + "}"; // Triggers lazy load!
}
}
// ✅ GOOD: Exclude lazy fields from toString
@Override
public String toString() {
return "Author{id=" + id + ", name='" + name + "'}";
}
パフォーマンスのチェックリスト
JPA コードをレビューする際は、次の点を確認してください。
- N+1 クエリがない(JOIN FETCH または @EntityGraph を使用)
- フェッチのデフォルトが LAZY(特に @ManyToOne)
- 大規模な結果セットにページネーションを使用
- 読み取り専用クエリに DTO プロジェクションを使用
- 一括更新/削除に一括操作を使用
- 同時アクセスがあるエンティティに @Version を使用
- 頻繁にクエリされる列にインデックスを設定
- toString() で遅延フィールドを使用していない
- 該当する場合、読み取り専用トランザクションを使用
関連スキル
spring-boot-patterns- Spring Boot のコントローラー/サービスのパターンjava-code-review- 一般的なコードレビューのチェックリストclean-code- コード品質の原則
コメント
Markdown 対応