mcpField
S
spring-data-jpa

spring-data-jpa

github.com/rrezartprebreza開発334128 回インストール3 日前に更新
リポジトリ

spring-data-jpa について

Spring Data JPA を使う開発者向けの永続化ガイドです。エンティティ設計、クエリ、ページネーション、バッチ書き込みを扱い、N+1 問題や深いページの負荷などに触れます。

主な機能

  1. 01エンティティの項目と関連の規約
  2. 02JOIN FETCH、EntityGraph、プロジェクション
  3. 03派生クエリと JPQL のパターン
  4. 04ページネーションとキーセット方式
  5. 05Hibernate のバッチ挿入設定

活用シーン

  1. 01一貫性のある JPA エンティティ設計
  2. 02N+1 クエリの発見と削減
  3. 03一覧 API のページネーション選定
  4. 04バッチ書き込み設定と関連の確認

インストール

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

$npx skills add https://github.com/rrezartprebreza/spring-boot-skills --skill spring-data-jpa

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

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

agents/
openai.yaml
examples/
bad-entity.java
good-entity.java

詳細

Spring Data JPA

エンティティの規約

@Entity
@Table(name = "orders")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED) // JPA requires no-arg, hide from callers
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    @Column(updatable = false, nullable = false)
    private UUID id;

    @Column(nullable = false)
    private String customerEmail;

    @Enumerated(EnumType.STRING) // always STRING, never ORDINAL
    @Column(nullable = false)
    private OrderStatus status;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<OrderItem> items = new ArrayList<>();

    @CreationTimestamp
    @Column(updatable = false)
    private Instant createdAt;

    @UpdateTimestamp
    private Instant updatedAt;

    // Static factory, not public constructor
    public static Order create(String customerEmail) {
        Order order = new Order();
        order.customerEmail = customerEmail;
        order.status = OrderStatus.PENDING;
        return order;
    }

    // Behavior on entity, not in service
    public void addItem(Product product, int quantity) {
        items.add(OrderItem.create(this, product, quantity));
    }
}

ルール

  • 常に @Enumerated(EnumType.STRING) を使用します。列挙型の順序変更で ORDINAL は壊れます。
  • ID には GenerationType.UUID を使用します。自動採番の整数を公開してはいけません。
  • @NoArgsConstructor(access = PROTECTED) を使用します。JPA に必要ですが、アプリケーションコードからは隠します。
  • Lombok の @Getter を使用します。エンティティに @Setter は付けず、振る舞いを表すメソッドを使用します。
  • コレクションはインラインで初期化します(= new ArrayList<>())。null にしてはいけません。

N+1 の防止

特定方法: 注文の取得に 1 回、各注文の明細の取得に N 回のクエリが発行されると、N+1 になります。

JOIN FETCH で修正:

@Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.id = :id")
Optional<Order> findByIdWithItems(@Param("id") UUID id);

// For lists — use @EntityGraph to avoid duplicates
@EntityGraph(attributePaths = {"items", "items.product"})
List<Order> findByStatus(OrderStatus status);

読み取り専用ビューには Projection を使用して修正:

// Interface projection — no entity loaded
public interface OrderSummary {
    UUID getId();
    String getCustomerEmail();
    OrderStatus getStatus();
    Instant getCreatedAt();
}

List<OrderSummary> findByStatus(OrderStatus status); // fast, no lazy loading issues

クエリパターン

public interface OrderRepository extends JpaRepository<Order, UUID> {

    // Derived query — simple conditions
    List<Order> findByStatusAndCustomerEmail(OrderStatus status, String email);

    // JPQL — for joins and complex conditions
    @Query("SELECT o FROM Order o JOIN FETCH o.items WHERE o.status = :status")
    List<Order> findActiveOrdersWithItems(@Param("status") OrderStatus status);

    // Native SQL — only when JPQL can't do it
    @Query(value = "SELECT * FROM orders WHERE created_at > NOW() - INTERVAL '7 days'",
           nativeQuery = true)
    List<Order> findRecentOrders();

    // Exists check — faster than findById + isPresent
    boolean existsByCustomerEmailAndStatus(String email, OrderStatus status);

    // Projection
    List<OrderSummary> findByCustomerEmail(String email);
}

ページネーション

// Always use Pageable for list endpoints
Page<Order> findByStatus(OrderStatus status, Pageable pageable);

// In service
Page<Order> orders = orderRepository.findByStatus(status, PageRequest.of(page, size, Sort.by("createdAt").descending()));

双方向リレーションシップ

// Parent side (Order)
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();

// Child side (OrderItem) — owns the FK
@ManyToOne(fetch = FetchType.LAZY) // LAZY always on @ManyToOne
@JoinColumn(name = "order_id", nullable = false)
private Order order;

// Helper on parent to keep both sides in sync
public void addItem(OrderItem item) {
    items.add(item);
    item.setOrder(this);
}

深いページネーション — OFFSET より Keyset を使用

OFFSET によるページネーションでは、スキップする行をすべて読み込んで破棄します。5,000 ページ目では、DB は 20 件を返すために 100,000 行を読み込みます。大規模なデータセットや無限スクロールでは、最後に確認したキーを使ってページネーションします(「seek」方式)。

// ❌ Slow on deep pages — OFFSET grows linearly
Page<Order> findByStatus(OrderStatus status, Pageable pageable);

// ✅ Keyset — constant time regardless of depth. Pass the last row's createdAt + id.
@Query("""
    SELECT o FROM Order o
    WHERE o.status = :status
      AND (o.createdAt < :lastCreatedAt
           OR (o.createdAt = :lastCreatedAt AND o.id < :lastId))
    ORDER BY o.createdAt DESC, o.id DESC
    """)
List<Order> findNextPage(OrderStatus status, Instant lastCreatedAt, UUID lastId, Limit limit);

(createdAt, id) のタプルで同値を判別するため、タイムスタンプが重複してもカーソルは安定します。インデックスは (status, created_at DESC, id DESC) です。

バッチ挿入

リストを 1 行ずつ保存すると、ラウンドトリップが N 回発生します。JDBC バッチ処理を有効にすると、Hibernate がまとめて処理します。

spring:
  jpa:
    properties:
      hibernate:
        jdbc.batch_size: 50
        order_inserts: true
        order_updates: true

注意点:GenerationType.IDENTITY は挿入のバッチ処理を暗黙的に無効にします(Hibernate は行ごとに生成されたキーを必要とするためです)。GenerationType.UUID またはプーリングされたシーケンスならバッチ処理が維持されます。これも UUID を推奨する理由です。

注意点

  • エージェントは FetchType.EAGER を使用します。@ManyToOne と @ManyToMany では常に LAZY を使用します。
  • エージェントは @Enumerated(EnumType.ORDINAL) を使用します。常に STRING を使用します。
  • エージェントは Long の ID を使用します。UUID を使用します。
  • エージェントはリストのエンドポイントで findAll() を呼び出します。常に Pageable を使用します。
  • エージェントは巨大なテーブルで OFFSET によるページネーションを使用します。深いページでは keyset に切り替えます。
  • エージェントはエンティティにセッターを追加します。代わりに振る舞いを表すメソッドを使用します。
  • エージェントは @OneToMany の orphanRemoval = true を忘れます。子レコードが孤児になります。
  • エージェントは気付かずに N+1 を発生させます。ループ内での items へのアクセスを確認します。
  • エージェントは GenerationType.IDENTITY で挿入をバッチ処理します。バッチ処理は暗黙的に無効になるため、UUID/シーケンスを使用します。

コメント