【入門】Spring Data JDBC の始め方と使い方

はい、承知いたしました。Spring Data JDBC の始め方と使い方について、詳細な説明を含む約5000語の記事を作成します。


【入門】Spring Data JDBC の始め方と使い方

はじめに

アプリケーション開発において、データベースとの連携は避けて通れない重要な要素です。Javaの世界では、古くからJDBC(Java Database Connectivity)が標準的なAPIとして存在し、データベース操作の基盤を提供してきました。しかし、生JDBCはコネクション管理、SQLステートメントの準備、結果セットの処理など、多くの定型的なコード(ボイラープレートコード)を記述する必要があり、開発効率の低下を招きがちです。

このような課題を解決するために、Spring FrameworkはJDBCをより使いやすくするための抽象化レイヤーを提供してきました。特に、Spring Dataプロジェクトは、様々なデータアクセス技術に対して、一貫性のあるプログラミングモデルとリポジトリベースのアプローチを提供することを目的としています。

Spring Dataには、RDBMS向けのモジュールとして広く知られているSpring Data JPAがあります。これはJPA(Java Persistence API)という標準仕様に基づいたORM(Object-Relational Mapping)フレームワークであり、オブジェクトとリレーショナルデータベースの間のマッピングを強力にサポートします。しかし、ORMの持つ複雑さや、「永続化コンテキスト」「ダーティチェッキング」といった概念が、特にシンプルさを求める場合や、データベース構造をより直接的に制御したい場合にはオーバーヘッドとなることがあります。

そこで登場するのが Spring Data JDBC です。

Spring Data JDBCは、Spring Dataファミリーの一員でありながら、Spring Data JPAとは異なる哲学に基づいています。ORMのような複雑なマッピングやランタイムマジックを極力排除し、JDBCのシンプルさと直接性を保ちつつ、Spring Dataのリポジトリ抽象化の恩恵を受けることを目指しています。言い換えれば、Spring Data JDBCは「シンプルで、JDBCに近く、かつSpring Dataのリポジトリを使える」という、JPAと生JDBCの中間に位置するソリューションと言えます。

本記事では、Spring Data JDBC の基本的な概念、Spring Data JPA との違い、そして実際に Spring Boot アプリケーションで Spring Data JDBC を使い始める方法から、エンティティ定義、リポジトリ作成、CRUD操作、カスタムクエリ、リレーションシップの扱い方まで、網羅的に解説します。約5000語というボリュームで、初心者の方でも Spring Data JDBC を理解し、自分のプロジェクトで活用できるようになることを目指します。

Spring Data JDBC とは? Spring Data JPA との違い

Spring Data JDBC を理解する上で、まず Spring Data JPA との比較は避けて通れません。両者は同じ Spring Data ファミリーに属し、リポジトリパターンを提供するRDBMS向けモジュールですが、その設計思想は大きく異なります。

Spring Data JPA

  • 基盤: JPA仕様に基づいたORM(Object-Relational Mapping)フレームワークです。HibernateなどのJPA実装を利用します。
  • 目的: Javaオブジェクトとリレーショナルデータベースの間の「マッピング」に重点を置いています。データベース構造を意識せず、オブジェクト指向的な観点からデータアクセスを行うことを目指します。
  • 主な機能:
    • エンティティとテーブル間の複雑なマッピング(アノテーションやXMLで定義)。
    • 永続化コンテキストによるエンティティの管理、ダーティチェッキングによる自動更新。
    • トランザクション管理。
    • 関連性の自動的なローディング(Eager/Lazy Loading)。
    • JPQLやCriteria APIによるクエリ。
    • スキーマ自動生成機能(開発用途など)。
  • 利点: オブジェクト指向的にデータベースを扱える、開発効率が高い(特に複雑なドメインモデルの場合)、標準仕様に基づいている。
  • 欠点: 学習コストが高い、ランタイムマジック(ダーティチェッキングなど)が理解しにくい場合がある、パフォーマンスチューニングが難しい場合がある、生成されるSQLを完全に制御しにくい。

Spring Data JDBC

  • 基盤: Springの JdbcTemplate を内部的に利用しています。ORMではなく、「Object-Relational Conversion」や「Simple Object Access」と表現されることがあります。
  • 目的: JDBCのシンプルさと直接性を維持しつつ、Spring Dataのリポジトリ抽象化による定型コードの削減を目指します。SQLの実行をより直接的に制御できます。
  • 主な機能:
    • リポジトリインターフェースによる基本的なCRUD操作の提供。
    • シンプルなエンティティ(集約ルート)定義とテーブルマッピング。
    • 集約(Aggregate)という概念に基づくデータ操作。集約単位での読み込み・保存。
    • 関連性の扱い方がJPAとは大きく異なる(基本的には集約内の関連のみ自動で、集約間の関連はIDのみ保持し手動でロード)。
    • Derived Query(メソッド名規則によるクエリ)や @Query アノテーションによるSQL直接記述。
    • トランザクション管理はSpringの標準機能を利用。
  • 利点: シンプルで学習しやすい、ランタイムの挙動が予測しやすい、生成されるSQLを(@Queryなどで)直接記述・制御しやすい、パフォーマンス特性がJDBCに近いため予測しやすい、軽量。
  • 欠点: JPAに比べて機能が限定的(例: 複雑な関連性の自動ロードがない、スキーマ自動生成機能がない)、ドメインモデルが複雑な場合はJPAの方が適している場合がある。

哲学の違いの要約

特徴 Spring Data JPA Spring Data JDBC
アプローチ オブジェクト指向中心のORM(Object-Relational Mapping) シンプルなObject Access / Conversion
基盤技術 JPA実装(Hibernateなど) Spring JdbcTemplate
マッピング 複雑な関連性マッピング、永続化コンテキスト、ダーティチェッキング シンプルな集約ベース、関連性は集約内のみ自動
関連性(集約間) 自動ロード(Eager/Lazy)をサポート IDのみ保持、手動でロードが必要
クエリ JPQL, Criteria API, Native SQL (@Query) Derived Query, @Query (SQL直接記述)
ランタイム 永続化コンテキストによる状態管理 ステートレスなデータ読み書き
学習曲線 高い 低い
SQL制御 比較的間接的 直接的

Spring Data JDBC は、JPA の持つ ORM の複雑さや「マジック」を避けたい場合に特に適しています。シンプルさ、パフォーマンスの予測可能性、SQL の直接的な制御を重視するプロジェクトや、マイクロサービスのような軽量なアプリケーションにおいて、非常に良い選択肢となり得ます。

なぜ Spring Data JDBC を選ぶのか?

Spring Data JDBC を選択する理由はいくつかあります。

  1. シンプルさ: JPA の永続化コンテキストやセッションなどの複雑な概念がなく、非常にシンプルです。学習コストが低く、プロジェクトへの導入が容易です。
  2. パフォーマンスの予測可能性: 内部で JdbcTemplate を使用しているため、生の JDBC に近いパフォーマンス特性を持ちます。クエリがどのように実行されるか、いつデータがロードされるかが予測しやすく、パフォーマンス問題の原因特定が比較的容易です。
  3. SQL の直接的な制御: @Query アノテーションを使用することで、必要に応じて複雑な SQL クエリを直接記述できます。ORM が生成するクエリに頼らず、データベースの特性を活かした最適なクエリを実行できます。
  4. 集約ベースのアプローチ: ドメイン駆動設計(DDD)の集約(Aggregate)の概念と相性が良いです。データの整合性を保つべき単位(集約)ごとにデータを操作するという考え方が、Spring Data JDBC の設計に自然とフィットします。
  5. 軽量: JPA 実装(Hibernateなど)と比較して依存関係が少なく、ランタイムのオーバーヘッドも小さいため、軽量なアプリケーションに適しています。

ただし、Spring Data JDBC は万能ではありません。複雑な関連性を持つ大規模なドメインモデルや、ORM の強力な自動マッピング機能、複雑なキャッシング機能などを必要とする場合は、Spring Data JPA の方が適している可能性もあります。Spring Data JDBC は「ORM がオーバースペックだが、生の JDBC や JdbcTemplate だけでは定型コードが多い」と感じる場合に、非常に有効な選択肢となります。

Spring Data JDBC を始めるための準備

Spring Data JDBC を使ったアプリケーション開発を始めるために必要なものを確認しましょう。

  1. Java Development Kit (JDK): Java 8 以降(推奨は Java 11 以降)。
  2. ビルドツール: Maven または Gradle。
  3. Spring Boot: Spring Data JDBC は Spring Boot と組み合わせて使うのが最も一般的です。Spring Boot の基本的な知識があるとスムーズです。
  4. リレーショナルデータベース: PostgreSQL, MySQL, H2, H2 Database, SQL Server, Oracle など、JDBCドライバーが提供されているデータベースが必要です。開発や学習目的であれば、インメモリデータベースである H2 Database が手軽でおすすめです。
  5. JDBC ドライバー: 使用するデータベースに対応した JDBC ドライバーの依存関係が必要です。

本記事では、ビルドツールとして Maven を使用し、データベースとして H2 Database を使用する例を中心に解説します。

Spring Boot プロジェクトのセットアップ

Spring Initializr を使用して、Spring Boot プロジェクトをセットアップするのが最も簡単です。ブラウザで https://start.spring.io/ にアクセスし、以下の設定を行います。

  • Project: Maven Project または Gradle Project
  • Language: Java
  • Spring Boot: 最新の安定板を選択
  • Project Metadata:
    • Group: com.example (任意のグループ名)
    • Artifact: spring-data-jdbc-example (任意のプロジェクト名)
    • Name: spring-data-jdbc-example
    • Package name: com.example.springdatajdbcexample
    • Packaging: Jar
    • Java: 11 (または使用したいJDKバージョン)
  • Dependencies: 以下の依存関係を追加します。
    • Spring Data JDBC: 検索ボックスに「JDBC」と入力し、「Spring Data JDBC」を選択します。
    • H2 Database: 検索ボックスに「H2」と入力し、「H2 Database」を選択します。
    • Spring Boot DevTools (オプション): 開発中のアプリケーションの自動再起動などに便利です。
    • Lombok (オプション): ボイラープレートコード(getter, setter, constructorなど)を削減できます。使う場合は依存関係を追加し、IDEにLombokプラグインをインストールしてください。

設定後、「Generate」ボタンをクリックしてプロジェクトをダウンロードします。ダウンロードしたZIPファイルを解凍し、お好みのIDE(IntelliJ IDEA, Eclipse, VS Codeなど)で開きます。

Mavenプロジェクトの場合、pom.xml には以下のような依存関係が追加されているはずです(バージョンは異なる場合があります)。

“`xml


4.0.0 org.springframework.boot
spring-boot-starter-parent
3.2.5
com.example
spring-data-jdbc-example
0.0.1-SNAPSHOT
spring-data-jdbc-example
Demo project for Spring Data JDBC 11

org.springframework.boot
spring-boot-starter-data-jdbc


com.h2database
h2
runtime



org.springframework.boot
spring-boot-devtools
runtimetrue


org.projectlombok
lomboktrue



org.springframework.boot
spring-boot-starter-test
test

<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
            <configuration>
                <excludes>
                    <exclude>
                        <groupId>org.projectlombok</groupId>
                        <artifactId>lombok</artifactId>
                    </exclude>
                </excludes>
            </configuration>
        </plugin>
    </plugins>
</build>

“`

データベース設定

Spring Boot は、application.properties または application.yml ファイルでデータソースの設定を行うことで、自動的にデータソースを構成してくれます。H2 Database を使用する場合の設定は以下のようになります。

src/main/resources/application.properties

“`properties

H2 Database Configuration

spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=password

Enable H2 Console (Optional, useful for development)

spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

Initialize schema and data (Optional, but highly recommended for development/testing)

Spring Boot will execute schema.sql and data.sql from classpath on startup

spring.sql.init.mode=always
“`

  • spring.datasource.url: H2 データベースの接続URLです。jdbc:h2:mem:testdb は、testdb という名前のインメモリデータベースを作成することを意味します。永続化したい場合は jdbc:h2:file:./data/testdb のようにファイルパスを指定します。
  • spring.datasource.driverClassName: 使用するJDBCドライバーのクラス名を指定します。H2 の場合は org.h2.Driver です。
  • spring.datasource.username, spring.datasource.password: データベースのユーザー名とパスワードです。H2 のデフォルトは sa / (パスワードなし)ですが、ここでは例としてパスワードを設定しています。
  • spring.h2.console.enabled: true に設定すると、開発時に便利な H2 Console が有効になります。
  • spring.h2.console.path: H2 Console にアクセスするためのパスを指定します。この例では /h2-console でアクセスできます(例: http://localhost:8080/h2-console)。
  • spring.sql.init.mode=always: アプリケーション起動時にクラスパス直下の schema.sql および data.sql ファイルを探し、自動的に実行します。これは開発やテストのためにテーブル構造を作成したり、初期データを投入したりするのに非常に便利です。

本記事ではこの spring.sql.init.mode=always を活用し、schema.sql ファイルを使ってテーブル構造を定義します。

src/main/resources/schema.sql

“`sql
— Drop tables if they exist (useful for development/testing with in-memory DB)
DROP TABLE IF EXISTS CUSTOMER;
DROP TABLE IF EXISTS CUSTOMER_ADDRESSES;
DROP TABLE IF EXISTS CUSTOMER_ORDERS;
DROP TABLE IF EXISTS ORDER_ITEMS; — Assuming Order has items

— Create CUSTOMER table
CREATE TABLE CUSTOMER (
ID INT PRIMARY KEY AUTO_INCREMENT,
FIRST_NAME VARCHAR(255) NOT NULL,
LAST_NAME VARCHAR(255) NOT NULL,
EMAIL VARCHAR(255) UNIQUE,
AGE INT,
CREATED_DATE TIMESTAMP,
LAST_MODIFIED_DATE TIMESTAMP
);

— Create ADDRESS table linked to CUSTOMER (Example of a nested collection/value object)
— Spring Data JDBC maps this automatically if defined within the Customer aggregate
— Default table name is PARENT_CHILDREN (e.g., CUSTOMER_ADDRESSES)
— Default foreign key is PARENT_ID (e.g., CUSTOMER_ID)
— Ordering column (key_column) is added for List/Map
CREATE TABLE CUSTOMER_ADDRESSES (
CUSTOMER_ID INT NOT NULL, — Foreign key referencing CUSTOMER
ADDRESS_KEY INT NOT NULL, — For List ordering or Map key
STREET VARCHAR(255) NOT NULL,
CITY VARCHAR(255) NOT NULL,
POSTAL_CODE VARCHAR(255),
COUNTRY VARCHAR(255),
PRIMARY KEY (CUSTOMER_ID, ADDRESS_KEY), — Composite primary key
FOREIGN KEY (CUSTOMER_ID) REFERENCES CUSTOMER(ID) ON DELETE CASCADE
);

— Create ORDER table linked to CUSTOMER (Example of another nested collection)
CREATE TABLE CUSTOMER_ORDERS (
CUSTOMER_ID INT NOT NULL, — Foreign key referencing CUSTOMER
ORDERS_KEY INT NOT NULL, — For List ordering or Map key
ID INT PRIMARY KEY AUTO_INCREMENT, — ID for the Order entity itself
ORDER_DATE TIMESTAMP NOT NULL,
TOTAL_AMOUNT DECIMAL(10, 2) NOT NULL,
PRIMARY KEY (CUSTOMER_ID, ORDERS_KEY), — Composite primary key
FOREIGN KEY (CUSTOMER_ID) REFERENCES CUSTOMER(ID) ON DELETE CASCADE
);

— Create ORDER_ITEM table linked to ORDER (Example of a nested collection within a nested collection)
CREATE TABLE ORDER_ITEMS (
ORDER_ID INT NOT NULL, — Foreign key referencing ORDER
ORDER_ITEMS_KEY INT NOT NULL, — For List ordering or Map key
ID INT PRIMARY KEY AUTO_INCREMENT, — ID for the OrderItem entity itself
PRODUCT_NAME VARCHAR(255) NOT NULL,
QUANTITY INT NOT NULL,
PRICE DECIMAL(10, 2) NOT NULL,
PRIMARY KEY (ORDER_ID, ORDER_ITEMS_KEY), — Composite primary key
FOREIGN KEY (ORDER_ID) REFERENCES CUSTOMER_ORDERS(ID) ON DELETE CASCADE — Note: References CUSTOMER_ORDERS.ID
);

“`

この schema.sql ファイルは、顧客(CUSTOMER)テーブルとその集約内の関連テーブル(顧客の住所 CUSTOMER_ADDRESSES、顧客の注文 CUSTOMER_ORDERS、注文のアイテム ORDER_ITEMS)を定義しています。Spring Boot が起動する際にこのスクリプトが実行され、データベーススキーマが準備されます。

エンティティ(集約)の定義

Spring Data JDBC では、データベースのレコードにマッピングされるクラスを「エンティティ」と呼びますが、特にデータを変更する際の整合性の単位となる一連の関連オブジェクトを「集約(Aggregate)」として扱います。集約のルートとなるエンティティ(集約ルート)に @Id アノテーションを付け、その集約に含まれるオブジェクトやコレクションをフィールドとして定義します。

Spring Data JDBC は、特に設定がなければ、以下のルールでエンティティとテーブルをマッピングします。

  • クラス名はテーブル名に変換されます(例: Customer -> CUSTOMER)。キャメルケースはスネークケースに変換されます。
  • フィールド名はカラム名に変換されます(例: firstName -> first_name)。
  • @Id アノテーションが付与されたフィールドが主キーとして扱われます。主キーは必須です。
  • プリミティブ型やStringなどのフィールドは、対応するカラムに直接マッピングされます。
  • 他のクラス型のフィールドは、デフォルトではネストされたオブジェクトとして扱われます。
  • Collection (List, Setなど) や Map 型のフィールドは、デフォルトでは別のテーブル(子テーブル)にマッピングされます。この子テーブルは、親テーブルのIDを外部キーとして持ちます。テーブル名は通常 親テーブル名_フィールド名 となります(例: customer テーブルの addresses フィールド -> CUSTOMER_ADDRESSES テーブル)。外部キーカラム名は通常 親テーブル名の単数形_id となります(例: customer_id)。順序(List/Mapのキー)を保持するために、別途 フィールド名_key のようなカラムが追加されます(例: addresses_key)。

では、schema.sql で定義したテーブルに対応するJavaクラス(集約ルート Customer およびその内部の Address, Order, OrderItem)を定義しましょう。Lombok を使用するとコードが簡潔になります。

src/main/java/com/example/springdatajdbcexample/domain/Customer.java

“`java
package com.example.springdatajdbcexample.domain;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Transient; // Example of transient field
import org.springframework.data.relational.core.mapping.MappedCollection;
import org.springframework.data.relational.core.mapping.Table;

import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;
import java.util.ArrayList; // Example using List
import java.util.List;

// Lombok annotations for boilerplate code
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table(“CUSTOMER”) // Optional: Explicitly map to table name if different from class name
public class Customer {

// @Id marks this field as the primary key and the Aggregate Root identifier
@Id
private Long id; // Use Long for AUTO_INCREMENT

private String firstName;
private String lastName;
private String email;
private Integer age;

// Spring Data JDBC can handle simple date/time types
private LocalDateTime createdDate;
private LocalDateTime lastModifiedDate;

// Nested collection: Addresses
// Spring Data JDBC maps this to a separate table (default: CUSTOMER_ADDRESSES)
// with a foreign key back to CUSTOMER (default: CUSTOMER_ID).
// @MappedCollection can customize column names if needed.
// By default, the child table name is derived from the parent table and field name.
// The foreign key column name is derived from the parent table name.
// The ordering column name is derived from the field name.
// Example: CUSTOMER_ADDRESSES table, CUSTOMER_ID foreign key, ADDRESSES_KEY order column.
// Set is common for collections where order doesn't matter logically,
// but List/Set/Map are all supported. List/Map require an ordering column in the child table.
private Set<Address> addresses = new HashSet<>(); // Initialize to avoid NullPointerException

// Another nested collection: Orders
// This will also be mapped to a separate table (default: CUSTOMER_ORDERS)
// with foreign key CUSTOMER_ID and order column ORDERS_KEY.
// Note: Each Order itself can be an entity with its own ID, but it's part of the Customer aggregate here.
private List<Order> orders = new ArrayList<>(); // Using List requires an ordering column

// @Transient: Fields marked with this annotation are ignored by Spring Data JDBC.
@Transient
private String fullName; // Example: Calculated field, not stored in DB

// Helper method to add an address
public void addAddress(Address address) {
    this.addresses.add(address);
}

// Helper method to add an order
public void addOrder(Order order) {
    this.orders.add(order);
}

}
“`

src/main/java/com/example/springdatajdbcexample/domain/Address.java

“`java
package com.example.springdatajdbcexample.domain;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.relational.core.mapping.Table;

// Note: Address doesn’t have its own @Id because it’s part of the Customer aggregate.
// It’s a value object or an entity whose lifecycle is managed by the Customer aggregate root.
// Spring Data JDBC treats entities within an aggregate differently than the root.
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
// @Table annotation can be used here if the default table name derived from class name
// (ADDRESS) is not the same as the one used for the collection mapping (CUSTOMER_ADDRESSES).
// However, for nested collections mapped by default conventions (Parent_Children table),
// the @Table annotation on the child class is often not strictly necessary for the mapping itself,
// but good for clarity if this class were ever used as a root entity.
// In this case, the mapping is primarily driven by the collection field in Customer.
public class Address {

// No @Id here, as it's part of the Customer aggregate.
// Spring Data JDBC will implicitly create a composite key on the join table
// using the parent ID and the order column/key.

private String street;
private String city;
private String postalCode;
private String country;

// Example: A simple value object within Address (optional)
// private GeoLocation location; // Would also be mapped to a sub-table if complex

}
“`

src/main/java/com/example/springdatajdbcexample/domain/Order.java

“`java
package com.example.springdatajdbcexample.domain;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.MappedCollection;
import org.springframework.data.relational.core.mapping.Table;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

// Although part of the Customer aggregate, Order has its own ID.
// This is common for entities within an aggregate that need their own identity
// or serve as a root for a sub-aggregate (like OrderItems here).
// Spring Data JDBC handles this correctly: it saves Order to the CUSTOMER_ORDERS
// table (as part of the Customer aggregate’s collection) but uses Order’s ID
// for its own identity and for mapping its children (OrderItems).
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table(“CUSTOMER_ORDERS”) // Map to the table used for the collection
public class Order {

// Order itself has an ID
@Id
private Long id;

private LocalDateTime orderDate;
private BigDecimal totalAmount;

// Nested collection within Order: Items
// This will be mapped to ORDER_ITEMS table with foreign key ORDER_ID
// and order column ORDER_ITEMS_KEY.
private List<OrderItem> orderItems = new ArrayList<>();

// Example: Reference to another aggregate (e.g., a Product aggregate)
// In Spring Data JDBC, you *only* store the ID of the referenced aggregate.
// private Long productId; // Store ID only
// You would *not* do: private Product product; // This would not be automatically loaded/saved

// Helper method to add an item
public void addOrderItem(OrderItem item) {
    this.orderItems.add(item);
}

}
“`

src/main/java/com/example/springdatajdbcexample/domain/OrderItem.java

“`java
package com.example.springdatajdbcexample.domain;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;

import java.math.BigDecimal;

// Part of the Order aggregate, which is part of the Customer aggregate.
// It has its own ID, but its lifecycle is managed by the Order entity,
// which is in turn managed by the Customer aggregate root.
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table(“ORDER_ITEMS”) // Map to the table used for the collection
public class OrderItem {

// OrderItem itself has an ID
@Id
private Long id;

private String productName;
private Integer quantity;
private BigDecimal price;

// Note: No need for a reference back to Order entity within the OrderItem class
// for Spring Data JDBC mapping within the aggregate. The mapping is handled
// by the collection field in the parent (Order) and the foreign key in the DB table.

}
“`

これらのクラス定義と schema.sql によって、Spring Data JDBC がデータベーステーブルとJavaオブジェクトの間でどのようにデータをやり取りするかを理解できます。特に、Customer 集約ルート、その中のコレクション(addresses, orders)、そして orders の中のコレクション(orderItems)が、それぞれ独立したテーブルにマッピングされ、外部キーによって関連付けられる点が重要です。

リポジトリの作成

Spring Data の核心はリポジトリパターンです。データアクセス操作をカプセル化するインターフェースを定義するだけで、Spring Data がその実装を自動生成してくれます。Spring Data JDBC では、CrudRepository または ListCrudRepository インターフェースを拡張してリポジトリを作成します。

  • CrudRepository<T, ID>: 基本的な CRUD 操作(作成、読み取り、更新、削除)を提供します。
  • ListCrudRepository<T, ID>: CrudRepository に加えて、結果を List で返す findAll() や、複数のIDを指定して取得する findAllById() などを提供します。通常はこちらを使う方が便利です。

Customer エンティティのリポジトリを作成しましょう。

src/main/java/com/example/springdatajdbcexample/repository/CustomerRepository.java

“`java
package com.example.springdatajdbcexample.repository;

import com.example.springdatajdbcexample.domain.Customer;
import org.springframework.data.repository.ListCrudRepository;
import org.springframework.stereotype.Repository;

// Extend ListCrudRepository, specifying the Aggregate Root type (Customer)
// and the type of its ID field (Long).
// Spring Data will automatically provide implementations for basic CRUD methods.
@Repository // Although not strictly necessary as Spring Data finds repositories, it’s good practice
public interface CustomerRepository extends ListCrudRepository {

// Custom query methods can be added here (covered later)

}
“`

これで、CustomerRepository インターフェースをアプリケーションの他のコンポーネント(サービス層など)に @Autowired でインジェクションして使用できるようになります。Spring Data がインターフェースの実行時に、必要な JDBC コードを生成してくれます。

基本的な CRUD 操作

作成した CustomerRepository を使用して、顧客データの作成、読み取り、更新、削除といった基本的な CRUD 操作を実行してみましょう。ここでは、アプリケーション起動時に実行される CommandLineRunner を使ってサンプルコードを示します。

src/main/java/com/example/springdatajdbcexample/SpringDataJdbcExampleApplication.java に以下の CommandLineRunner bean を追加します。

“`java
package com.example.springdatajdbcexample;

import com.example.springdatajdbcexample.domain.Address;
import com.example.springdatajdbcexample.domain.Customer;
import com.example.springdatajdbcexample.domain.Order;
import com.example.springdatajdbcexample.domain.OrderItem;
import com.example.springdatajdbcexample.repository.CustomerRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.data.jdbc.core.JdbcAggregateOperations;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;

@SpringBootApplication
public class SpringDataJdbcExampleApplication {

private static final Logger log = LoggerFactory.getLogger(SpringDataJdbcExampleApplication.class);

public static void main(String[] args) {
    SpringApplication.run(SpringDataJdbcExampleApplication.class, args);
}

@Autowired
private CustomerRepository customerRepository;

// Inject JdbcAggregateOperations for more detailed save/load process visualization (optional)
@Autowired
private JdbcAggregateOperations aggregateOperations;


@Bean
public CommandLineRunner demoCrudOperations(CustomerRepository repository) {
    return (args) -> {
        log.info("--- Starting CRUD Operations Demo ---");

        // --- CREATE ---
        log.info("Creating a new customer...");
        Customer newCustomer = Customer.builder()
                .firstName("Alice")
                .lastName("Smith")
                .email("[email protected]")
                .age(30)
                .createdDate(LocalDateTime.now())
                .build();

        // Add addresses (part of the aggregate)
        Address address1 = Address.builder().street("123 Main St").city("Anytown").postalCode("12345").country("USA").build();
        Address address2 = Address.builder().street("456 Oak Ave").city("Otherville").postalCode("67890").country("USA").build();
        newCustomer.addAddress(address1);
        newCustomer.addAddress(address2);

        // Add orders (part of the aggregate)
        OrderItem item1 = OrderItem.builder().productName("Laptop").quantity(1).price(new BigDecimal("1200.00")).build();
        OrderItem item2 = OrderItem.builder().productName("Mouse").quantity(2).price(new BigDecimal("25.00")).build();
        Order order1 = Order.builder().orderDate(LocalDateTime.now()).totalAmount(new BigDecimal("1250.00")).build();
        order1.addOrderItem(item1);
        order1.addOrderItem(item2);

        OrderItem item3 = OrderItem.builder().productName("Keyboard").quantity(1).price(new BigDecimal("75.00")).build();
        Order order2 = Order.builder().orderDate(LocalDateTime.now().minusDays(5)).totalAmount(new BigDecimal("75.00")).build();
        order2.addOrderItem(item3);

        newCustomer.addOrder(order1);
        newCustomer.addOrder(order2);


        // Save the customer aggregate
        // Spring Data JDBC saves the Customer, and automatically saves its nested collections (addresses, orders, orderItems)
        Customer savedCustomer = repository.save(newCustomer);
        log.info("Customer saved: {}", savedCustomer);
        log.info("Saved Customer with ID: {}", savedCustomer.getId());
        // Check if nested items were saved with IDs
        if (!savedCustomer.getOrders().isEmpty()) {
             log.info("Saved Order 1 with ID: {}", savedCustomer.getOrders().get(0).getId());
             if (!savedCustomer.getOrders().get(0).getOrderItems().isEmpty()) {
                 log.info("Saved Order Item 1 (of Order 1) with ID: {}", savedCustomer.getOrders().get(0).getOrderItems().get(0).getId());
             }
        }


        // --- READ ---
        log.info("Finding customer by ID: {}", savedCustomer.getId());
        Optional<Customer> foundCustomerOpt = repository.findById(savedCustomer.getId());

        if (foundCustomerOpt.isPresent()) {
            Customer foundCustomer = foundCustomerOpt.get();
            log.info("Customer found: {}", foundCustomer);
            // Note: When loading an aggregate root, Spring Data JDBC automatically loads its nested collections
            log.info("Found Customer's Addresses: {}", foundCustomer.getAddresses());
            log.info("Found Customer's Orders: {}", foundCustomer.getOrders());
            if (!foundCustomer.getOrders().isEmpty()) {
                 log.info("Found Order 1's Items: {}", foundCustomer.getOrders().get(0).getOrderItems());
            }
        } else {
            log.info("Customer with ID {} not found.", savedCustomer.getId());
        }

        log.info("Finding all customers:");
        Iterable<Customer> allCustomers = repository.findAll();
        allCustomers.forEach(customer -> log.info("  {}", customer));


        // --- UPDATE ---
        if (foundCustomerOpt.isPresent()) {
            Customer customerToUpdate = foundCustomerOpt.get();
            log.info("Updating customer with ID: {}", customerToUpdate.getId());

            customerToUpdate.setAge(31); // Update a field
            customerToUpdate.setLastModifiedDate(LocalDateTime.now());

            // Add a new address to the collection
            Address address3 = Address.builder().street("789 Pine Ln").city("Village").postalCode("98765").country("USA").build();
            customerToUpdate.addAddress(address3); // Add to the Set

            // Add a new order
            Order order3 = Order.builder().orderDate(LocalDateTime.now()).totalAmount(new BigDecimal("50.00")).build();
            OrderItem item4 = OrderItem.builder().productName("Book").quantity(1).price(new BigDecimal("50.00")).build();
            order3.addOrderItem(item4);
            customerToUpdate.addOrder(order3); // Add to the List

            // Spring Data JDBC handles updates:
            // - Simple fields are updated.
            // - For nested collections (Set, List, Map):
            //   - By default, the *entire* collection is typically deleted from the child table for this parent ID,
            //     and then the *entire* current collection from the object is inserted again.
            //   - This "delete all and insert all" strategy is simple and robust, especially with List/Map ordering.
            //   - For very large collections, this might be inefficient, but it's the default behavior.
            //   - More advanced strategies require custom converters or operations.
            Customer updatedCustomer = repository.save(customerToUpdate);
            log.info("Customer updated: {}", updatedCustomer);
            log.info("Updated Customer's Addresses: {}", updatedCustomer.getAddresses()); // Should include address3
            log.info("Updated Customer's Orders: {}", updatedCustomer.getOrders()); // Should include order3
        }


        // --- DELETE ---
        log.info("Deleting customer with ID: {}", savedCustomer.getId());
        repository.deleteById(savedCustomer.getId());
        log.info("Customer deleted.");

        // Verify deletion
        Optional<Customer> deletedCustomerCheck = repository.findById(savedCustomer.getId());
        log.info("Customer exists after deletion? {}", deletedCustomerCheck.isPresent());

        log.info("--- CRUD Operations Demo Finished ---");

        // Example of JdbcAggregateOperations (optional)
        // log.info("--- Using JdbcAggregateOperations ---");
        // Customer simpleCustomer = Customer.builder().firstName("Test").lastName("User").email("[email protected]").age(99).build();
        // log.info("Saving simple customer using aggregateOperations.save: {}", aggregateOperations.save(simpleCustomer));
        // log.info("--- JdbcAggregateOperations Finished ---");
    };
}

}
“`

このコードを実行すると、アプリケーションが起動し、CommandLineRunner bean が実行されます。

  1. 新しい Customer オブジェクトを作成し、addresses (Set) と orders (List) に子オブジェクトを追加します。
  2. repository.save(newCustomer) を呼び出して保存します。Spring Data JDBC は Customer レコードを CUSTOMER テーブルに挿入し、生成された ID を取得します。次に、addressesorders コレクションを処理します。addressesCUSTOMER_ADDRESSES テーブルに、ordersCUSTOMER_ORDERS テーブルに挿入されます。orders の各要素 (Order) も @Id を持っているので、それ自体も ID が生成され、その ID を使って orderItems コレクションが ORDER_ITEMS テーブルに挿入されます。子テーブルには、親(集約ルートまたは中間エンティティ)の ID と、コレクション内の順序を示すキーカラムが追加されます。
  3. repository.findById(id) で顧客を読み込みます。Spring Data JDBC は CUSTOMER テーブルからレコードを取得するだけでなく、自動的に関連する CUSTOMER_ADDRESSES, CUSTOMER_ORDERS, ORDER_ITEMS テーブルからもデータをロードし、対応するコレクションにマッピングして Customer オブジェクトを再構築します。これは、集約内の関連性のみが自動でロードされる点に注意してください。
  4. repository.findAll() ですべての顧客を取得します。
  5. 読み込んだ顧客オブジェクトを変更し、repository.save(customerToUpdate) で更新します。Spring Data JDBC は、変更された単純なフィールドを更新します。コレクションに関しては、デフォルトでは関連する子テーブルから該当する親のレコード(ここでは顧客IDに対応する行)を すべて削除 し、現在のオブジェクトに含まれるコレクションの要素を すべて再挿入 するという戦略を取ります。
  6. repository.deleteById(id) で顧客を削除します。外部キーに ON DELETE CASCADE が設定されている場合、関連する子テーブルのレコードもデータベースによって自動的に削除されます。Spring Data JDBC 自体も、子テーブルのレコードを先に削除してから親レコードを削除する処理を行います。

この例から、Spring Data JDBC が集約ルート (Customer) を save または load する際に、その集約に含まれるネストされたオブジェクトやコレクションを自動的に処理してくれることがわかります。

カスタムクエリの定義

Spring Data JDBC では、標準の CRUD メソッド以外に、独自のデータアクセスロジックを定義する方法がいくつかあります。

  1. Derived Query Methods (派生クエリメソッド): リポジトリインターフェースに特定の命名規則に従ったメソッドを定義することで、Spring Data JDBC が自動的にクエリを生成します。
  2. @Query アノテーション: メソッドに @Query アノテーションを付けて、実行したい SQL ステートメントを直接記述します。

1. Derived Query Methods

メソッド名を findBy...countBy... などのプレフィックスに続けて、エンティティのプロパティ名や組み合わせを記述します。Spring Data JDBC はメソッド名から意図を解釈し、対応する SQL クエリを生成します。

CustomerRepository.java に以下のメソッドを追加してみましょう。

“`java
package com.example.springdatajdbcexample.repository;

import com.example.springdatajdbcexample.domain.Customer;
import org.springframework.data.repository.ListCrudRepository;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
public interface CustomerRepository extends ListCrudRepository {

// Find customers by last name
List<Customer> findByLastName(String lastName);

// Find customers older than a certain age
List<Customer> findByAgeGreaterThan(Integer age);

// Find customer by first name and last name
Optional<Customer> findByFirstNameAndLastName(String firstName, String lastName);

// Count customers by age
long countByAge(Integer age);

// Check if a customer exists by email
boolean existsByEmail(String email);

// Using OrderBy keyword
List<Customer> findByLastNameOrderByFirstNameAsc(String lastName);

// Limiting results (example: find first 5 customers by last name)
List<Customer> findFirst5ByLastName(String lastName);

// ... and many more keywords like Between, LessThan, Like, StartingWith, etc.
// See Spring Data documentation for a full list of supported keywords.

}
“`

これらのメソッドは、メソッド名から自動的に SQL クエリが生成されます。例えば、findByLastName(String lastName)SELECT * FROM CUSTOMER WHERE LAST_NAME = ? のようなクエリを生成します(実際には関連する集約データもロードするためのより複雑なクエリになります)。

2. @Query アノテーション

より複雑なクエリや、Derived Query Methods で表現できないクエリを実行したい場合は、@Query アノテーションを使用します。ここに直接 SQL クエリを記述します。

CustomerRepository.java に以下のメソッドを追加してみましょう。

“`java
package com.example.springdatajdbcexample.repository;

import com.example.springdatajdbcexample.domain.Customer;
import org.springframework.data.jdbc.repository.query.Query;
import org.springframework.data.repository.ListCrudRepository;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
public interface CustomerRepository extends ListCrudRepository {

List<Customer> findByLastName(String lastName);
List<Customer> findByAgeGreaterThan(Integer age);
Optional<Customer> findByFirstNameAndLastName(String firstName, String lastName);
long countByAge(Integer age);
boolean existsByEmail(String email);
List<Customer> findByLastNameOrderByFirstNameAsc(String lastName);
List<Customer> findFirst5ByLastName(String lastName);

// Example using @Query annotation
// Positional parameters (?) are supported, but named parameters (:paramName) are recommended
@Query("SELECT ID, FIRST_NAME, LAST_NAME, EMAIL, AGE, CREATED_DATE, LAST_MODIFIED_DATE FROM CUSTOMER WHERE EMAIL = :email")
Optional<Customer> findByEmail(@Param("email") String email);

// Select specific columns (e.g., only first name and last name)
// Note: If you select only a subset of columns, Spring Data JDBC might not be able to map
// the result back to the full Customer aggregate, especially if the ID is missing or collections are involved.
// This is more suitable for projecting into a different type or when you know the limitations.
@Query("SELECT FIRST_NAME, LAST_NAME FROM CUSTOMER WHERE LAST_NAME = :lastName")
List<CustomerName> findCustomerNamesByLastName(@Param("lastName") String lastName); // Requires a CustomerName interface/class

// Example of a more complex query (might involve joins, though stay within aggregate where possible)
// This query attempts to find customers who have placed an order with a total amount greater than X.
// Note: Loading the *full* Customer aggregate *with* nested collections via a JOIN can be tricky
// and sometimes requires manual mapping or specific Spring Data JDBC features not covered in basic intro.
// A simpler approach often is to find Customer IDs first, then load Customers.
@Query("SELECT DISTINCT c.ID, c.FIRST_NAME, c.LAST_NAME, c.EMAIL, c.AGE, c.CREATED_DATE, c.LAST_MODIFIED_DATE " +
       "FROM CUSTOMER c JOIN CUSTOMER_ORDERS o ON c.ID = o.CUSTOMER_ID " +
       "WHERE o.TOTAL_AMOUNT > :minAmount")
List<Customer> findCustomersWithOrdersGreaterThan(@Param("minAmount") BigDecimal minAmount);


// Example demonstrating that @Query does NOT automatically load nested collections UNLESS you select all columns
// and the query structure allows Spring Data JDBC's default mapper to understand the aggregate structure.
// In simple cases selecting all columns works, but complex joins might break it.
@Query("SELECT * FROM CUSTOMER WHERE AGE < :maxAge")
List<Customer> findCustomersYoungerThan(@Param("maxAge") Integer maxAge); // This might load nested collections if * is used and mapping is default

}
“`

@Query アノテーションを使う場合は、以下の点に注意が必要です。

  • SQL ステートメントを正確に記述する必要があります。データベースの種類(方言)に依存します。
  • パラメータは :parameterName の形式で指定し、メソッドの引数には @Param("parameterName") を付けて対応させます。
  • SELECT クエリの場合、Spring Data JDBC は結果セットを戻り値の型(エンティティクラスや他の型)にマッピングしようとします。
  • 集約ルートを返す @Query メソッドで、ネストされたコレクションを自動的にロードさせるには、通常、ルートテーブルの 全てのカラム を選択する必要があります(SELECT * または全てのカラムを列挙)。 これは Spring Data JDBC がデフォルトのマッピング処理で集約構造を再構築するために必要となる情報だからです。複雑な JOIN を含むクエリで集約全体をロードしようとすると、期待通りに動作しない場合があります。そのような場合は、JOIN を使って ID だけを取得し、その ID を元に findById で個別にロードする、または結果を別の DTO にマッピングする、あるいは JdbcAggregateOperations を使って手動でマッピングを行うなどの検討が必要になります。

CustomerName インターフェースを定義する例:

“`java
package com.example.springdatajdbcexample.repository;

// Interface-based projection
public interface CustomerName {
String getFirstName();
String getLastName();
}

// Or Class-based projection (needs constructor matching selected columns)
/*
package com.example.springdatajdbcexample.repository;

public class CustomerName {
private final String firstName;
private final String lastName;

public CustomerName(String firstName, String lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
}

public String getFirstName() { return firstName; }
public String getLastName() { return lastName; }

}
*/
“`

CommandLineRunner でこれらのカスタムクエリを使用する例:

“`java
// Add to CommandLineRunner demo method
log.info(“— Starting Custom Queries Demo —“);

// Derived Query Methods
List smiths = repository.findByLastName(“Smith”);
log.info(“Customers with last name ‘Smith’: {}”, smiths);

List olderThan25 = repository.findByAgeGreaterThan(25);
log.info(“Customers older than 25: {}”, olderThan25);

Optional alice = repository.findByFirstNameAndLastName(“Alice”, “Smith”);
log.info(“Customer Alice Smith: {}”, alice);

long count30 = repository.countByAge(30);
log.info(“Number of customers aged 30: {}”, count30);

boolean exists = repository.existsByEmail(“[email protected]”);
log.info(“Customer with email [email protected] exists? {}”, exists);

// @Query Methods
Optional customerByEmail = repository.findByEmail(“[email protected]”);
log.info(“Customer found by email using @Query: {}”, customerByEmail);

// Using the method that might load nested collections (if selected all columns)
List youngerThan100 = repository.findCustomersYoungerThan(100);
log.info(“Customers younger than 100 (using @Query SELECT *): {}”, youngerThan100);
if (!youngerThan100.isEmpty()) {
log.info(” First customer’s orders: {}”, youngerThan100.get(0).getOrders()); // Check if collections loaded
}

List highOrderCustomers = repository.findCustomersWithOrdersGreaterThan(new BigDecimal(“100.00”));
log.info(“Customers with orders > 100: {}”, highOrderCustomers);

log.info(“— Custom Queries Demo Finished —“);
“`

リレーションシップの扱い方 (Spring Data JDBC の特徴)

Spring Data JDBC におけるリレーションシップの扱いは、JPA との最も大きな違いの一つであり、その哲学を理解する上で非常に重要です。

Spring Data JDBC は、デフォルトではオブジェクト間のリレーションシップをデータベースの外部キーとして自動的に「管理」しません。代わりに、集約内の関連性集約間の参照 を明確に区別します。

1. 集約内の関連性 (Within an Aggregate)

前述の例のように、集約ルート(Customer)が持つ他のエンティティや値オブジェクト(Address, Order)への参照は、Spring Data JDBC によって自動的に処理されます。

  • ネストされたオブジェクト: フィールドとして他のクラスのオブジェクトを持つ場合(例: Customer クラスに Address オブジェクトが Address homeAddress; のように一つだけある場合)、Spring Data JDBC はデフォルトでそのオブジェクトのフィールドを親テーブル(CUSTOMER)のカラムとしてフラットにマッピングしようとします。ただし、これはシンプルな値オブジェクトの場合に限られます。複雑なオブジェクトや @Id を持つオブジェクトの場合は、別途 @Embedded (JPAとは意味が異なる) や @MappedCollection などのアノテーション、あるいはカスタムコンバーターが必要になる場合があります。一般的には、ネストされたオブジェクトはコレクションとして扱うか、明示的なマッピングを定義する方がクリアです。
  • ネストされたコレクション: Set<Address> addresses;List<Order> orders; のようにコレクションを持つ場合、Spring Data JDBC はデフォルトで別のテーブル(子テーブル)にマッピングします。親テーブルの ID を外部キーとして持ち、必要に応じて順序カラム(ListやMapの場合)が追加されます。集約ルートをロードする際、これらのネストされたコレクションは自動的にロードされます。 集約ルートを保存する際、これらのネストされたオブジェクト/コレクションも一緒に保存/更新/削除されます(デフォルトは「親 ID に紐づく子を全て削除してから再挿入」戦略)。

例: Customer -> addresses (Set), Customer -> orders (List) -> orderItems (List)
これらの関係は全て Customer 集約内にあり、Customer をロード/保存する際に自動的に処理されます。

2. 集約間の参照 (References Between Aggregates)

Spring Data JDBC の重要な原則は、集約間の参照は ID のみで行うべき ということです。集約ルート(例: Customer)が、別の集約のルート(例: Product 集約の Product エンティティ)を参照する場合、Customer エンティティは Product エンティティのインスタンスをフィールドとして持つべきではなく、Product の ID (例: Long productId;) だけを持つべきです。

“`java
// In Customer.java (BAD – don’t do this with Spring Data JDBC for referencing another aggregate)
// private Product favoriteProduct;

// In Customer.java (GOOD – reference another aggregate by ID)
private Long favoriteProductId; // Store the ID of the Product aggregate root
“`

Spring Data JDBC は、このように ID でのみ参照されている他の集約を自動的にロードしません。 例えば、顧客の注文(Order エンティティ)が集約ルートである Product を参照している場合、Order オブジェクトは Product オブジェクト自体ではなく、Long productId を持つべきです。Customer 集約をロードしたときに Order がロードされますが、その Order 内の productId に対応する Product は自動的にはロードされません。

これは JPA の Eager/Lazy Loading と大きく異なります。JPA は関連が定義されていれば、設定に応じて関連エンティティを自動的にロードしようとします。これは便利な反面、意図しない N+1 クエリ問題を引き起こしたり、メモリ上のオブジェクトグラフが巨大になったり、ランタイムの挙動がわかりにくくなったりする原因にもなります。

Spring Data JDBC のアプローチは、明示的なロードを強制することで、データアクセスの制御と予測可能性を高めます。顧客の注文に含まれる製品情報が必要な場合は、以下の手順で取得する必要があります。

  1. CustomerRepository を使って Customer 集約をロードします。
  2. ロードされた Customer オブジェクトから、orders コレクションを取得します。
  3. Order オブジェクトから productId を取得します。
  4. ProductRepository (Product 集約用の別のリポジトリ) を使って、取得した productId を元に Product 集約を個別にロードします。

“`java
// Example of loading a referenced aggregate manually
public Customer getCustomerWithFavoriteProduct(Long customerId, ProductRepository productRepository) {
// 1. Load the Customer aggregate
Optional customerOpt = customerRepository.findById(customerId);

if (customerOpt.isPresent()) {
    Customer customer = customerOpt.get();

    // 2. Get the ID of the referenced aggregate
    Long favoriteProductId = customer.getFavoriteProductId(); // Assuming Customer had this field

    if (favoriteProductId != null) {
        // 4. Load the referenced aggregate using its own repository
        Optional<Product> favoriteProductOpt = productRepository.findById(favoriteProductId);

        // Now you have both the customer and their favorite product
        // You might combine them in a DTO or use them separately
        if (favoriteProductOpt.isPresent()) {
            Product favoriteProduct = favoriteProductOpt.get();
            // Do something with customer and favoriteProduct
            log.info("Customer {}'s favorite product is {}", customer.getFirstName(), favoriteProduct.getName());
            // Note: You cannot just set `customer.setFavoriteProduct(favoriteProduct)`
            // if Customer class only stores the ID, unless you add a @Transient field for it.
        }
    }
    return customer; // Return the loaded customer (without the Product object field)
}
return null; // Or throw exception

}
“`

この「ID による参照と手動ロード」のアプローチは、最初は手間に感じるかもしれませんが、集約境界を明確にし、データ整合性の単位を明確にし、不要なデータロードを防ぐというメリットがあります。これは、特にマイクロサービスアーキテクチャや、パフォーマンスがクリティカルな場面で力を発揮します。

まとめ:
* 集約内の関連性: Spring Data JDBC が自動処理 (ロード/保存)。
* 集約間の参照: ID のみ保持。ロードは手動で行う必要がある。

この違いを理解することが、Spring Data JDBC を効果的に使用するための鍵となります。

トランザクション管理

Spring Data JDBC は Spring Framework の標準的なトランザクション管理機能と完全に連携します。通常、サービス層のメソッドに @Transactional アノテーションを付与することで、そのメソッド内のデータベース操作が単一のトランザクションとして実行されるようになります。

src/main/java/com/example/springdatajdbcexample/service/CustomerService.java

“`java
package com.example.springdatajdbcexample.service;

import com.example.springdatajdbcexample.domain.Customer;
import com.example.springdatajdbcexample.repository.CustomerRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.Optional;

@Service
public class CustomerService {

private final CustomerRepository customerRepository;

@Autowired
public CustomerService(CustomerRepository customerRepository) {
    this.customerRepository = customerRepository;
}

// The @Transactional annotation ensures that all operations within this method
// are executed within a single transaction. If any operation fails,
// the entire transaction will be rolled back.
@Transactional
public Customer createNewCustomer(Customer customer) {
    // Business logic before saving
    customer.setCreatedDate(LocalDateTime.now());
    // customer.setLastModifiedDate(LocalDateTime.now()); // Often set by auditing

    // Save the customer aggregate
    Customer savedCustomer = customerRepository.save(customer);

    // Example: Perform another operation that should be part of the same transaction
    // e.g., saving an audit log, updating a related status in another table (if necessary, carefully)

    return savedCustomer;
}

@Transactional(readOnly = true) // readOnly=true is a hint for optimization
public Optional<Customer> getCustomerById(Long id) {
    // Reading data within a transaction
    return customerRepository.findById(id);
}

@Transactional
public Customer updateCustomer(Customer customer) {
    // Business logic before updating
    customer.setLastModifiedDate(LocalDateTime.now());

    // Save the customer aggregate - this handles updates to the root and collections
    return customerRepository.save(customer);
}

@Transactional
public void deleteCustomer(Long id) {
    // Delete the customer aggregate
    customerRepository.deleteById(id);
}

// Example of a transaction spanning multiple repository calls (often bad practice across aggregates)
// @Transactional
// public void processOrderForCustomer(Long customerId, Long orderId, OrderRepository orderRepository) {
//    Customer customer = customerRepository.findById(customerId).orElseThrow();
//    Order order = orderRepository.findById(orderId).orElseThrow(); // Assuming Order has its own repository (i.e., Order is an Aggregate Root)
//    // Process logic...
//    customerRepository.save(customer); // Save customer changes
//    orderRepository.save(order); // Save order changes
//    // Note: Calling save on two different aggregate roots within one transaction
//    // couples them. This violates the principle that changes should be confined
//    // within an aggregate boundary. While technically possible with Spring's tx mgmt,
//    // in DDD/Spring Data JDBC philosophy, operations should ideally be within a single aggregate.
//    // Cross-aggregate consistency is typically handled by eventual consistency mechanisms or domain events.
// }

}
“`

@Transactional アノテーションをサービス層に適用することで、ビジネスロジック単位でトランザクションを管理できます。Spring Data JDBC の save, delete などの操作は、内部的にトランザクション境界を尊重して実行されます。save メソッドは、集約ルートとその集約内の子オブジェクト/コレクションの保存/更新/削除を一連のデータベース操作として実行し、これらが単一のトランザクション内でコミットまたはロールバックされることを保証します。

スキーマ管理

Spring Data JDBC は、Spring Data JPA のようにエンティティ定義に基づいてデータベーススキーマを自動生成する機能は持っていません(開発用途の一時的な利用を除き、JPAでもプロダクションでの自動生成は推奨されません)。これは Spring Data JDBC がデータベース構造をより直接的に扱う哲学に基づいているためです。

したがって、データベーススキーマの定義と変更は、別途管理する必要があります。一般的には、以下のようなツールや方法が推奨されます。

  1. schema.sql / data.sql (Spring Boot の初期化機能): 開発やテスト用途には、src/main/resources ディレクトリに schema.sql および data.sql を配置し、spring.sql.init.mode=always (または embedded) を設定することで、アプリケーション起動時にスキーマ作成や初期データ投入を自動で行わせることができます。これはシンプルで手軽ですが、複雑なスキーマ変更(マイグレーション)の管理には向きません。
  2. Flyway または Liquibase: これらのデータベースマイグレーションツールは、バージョン管理された SQL スクリプト(または XML/YAML/Java など)を使用して、データベーススキーマの変更履歴を管理し、様々な環境に適用することを可能にします。プロダクション環境では、これらのツールを使用することが強く推奨されます。プロジェクトに依存関係を追加し、適切な設定を行えば、Spring Boot は起動時に Flyway または Liquibase を自動的に実行してくれます。

schema.sql を使用する例は既に示しました。Flyway を使う場合は、pom.xml に Flyway の依存関係を追加し、src/main/resources/db/migration ディレクトリに Vバージョン__説明.sql という形式の SQL ファイル(例: V1__create_customer_table.sql)を配置します。Spring Boot は自動的にこのディレクトリのスクリプトを検出して実行します。

その他の考慮事項

  • Auditing: 作成日時や更新日時、作成者、更新者などの情報を自動的に記録したい場合、Spring Data JDBC は Spring Data Commons の Auditing 機能に対応しています。@EnableJdbcAuditing アノテーションを付与し、エンティティフィールドに @CreatedDate, @LastModifiedDate, @CreatedBy, @LastModifiedBy などのアノテーションを付けます。日付/時刻の場合は LocalDateTime などを使用します。
  • Value Object: データベースに独立したテーブルとして存在しない、値の集合を表すオブジェクト(例: Address が常に Customer に紐づく場合)は、Spring Data JDBC では通常、ネストされたオブジェクトまたはコレクションとして扱います。@Embedded アノテーションを使って親テーブルにフラットにマッピングすることも可能ですが、コレクションの場合は別のテーブルになります。
  • カスタムコンバーター: 特定の Java 型(例: 列挙型、独自の複雑な型)をデータベースの型にマッピングしたり、その逆を行ったりする場合、Converter インターフェースを実装したカスタムコンバーターを作成し、Spring コンテキストに Bean として登録することで対応できます。
  • LOB (Large Object): BLOB や CLOB のような大きなデータを扱う場合、Spring Data JDBC はバイト配列 (byte[]) や String などで基本的なマッピングをサポートしますが、データベース固有の型や高度なストリーミングが必要な場合は、JdbcTemplate を直接使用したり、カスタムコンバーターを検討したりする必要があるかもしれません。
  • N+1 問題: 集約内のネストされたコレクションは自動的にロードされるため、集約ルートをロードする際に N+1 問題が発生する可能性は低いです(JOIN クエリなどで効率的にロードされるため)。しかし、集約間の参照を ID で行い、それを手動で一つずつロードする場合には、潜在的に N+1 問題が発生する可能性があります。これを避けるためには、特定のユースケースに合わせて @Query で適切な JOIN クエリを記述して複数の集約を一度にロードする、またはバッチ処理機能(Spring Data JDBC には JPAのような組み込みバッチロード機能は基本的にありませんが、JdbcTemplate を使って効率的なバッチ操作を実装することは可能です)を検討する必要があります。

まとめと次のステップ

本記事では、Spring Data JDBC の基本的な概念、Spring Data JPA との違い、プロジェクトのセットアップ、エンティティ定義、リポジトリによる CRUD 操作、カスタムクエリの記述、そして Spring Data JDBC の重要な特徴であるリレーションシップ(集約内の関連性と集約間の参照)の扱い方について詳しく解説しました。

Spring Data JDBC は、JPA の ORM 機能がオーバースペックだと感じる場合や、JDBC のシンプルさと SQL の直接的な制御を重視したい場合に非常に強力な選択肢となります。集約ベースのアプローチは、DDD の概念と組み合わせることで、より堅牢で保守しやすいドメインモデルを構築するのに役立ちます。

Spring Data JDBC の主な特徴の再確認:

  • Spring Data リポジトリパターンを提供し、定型コードを削減。
  • JDBC および JdbcTemplate を基盤とする、シンプルなObject Accessレイヤー。
  • 永続化コンテキストやダーティチェッキングのようなランタイムマジックがない。
  • 「集約(Aggregate)」単位でのデータ操作を重視。
  • 集約内のネストされたオブジェクト/コレクションは自動でロード/保存される。
  • 集約間の参照は ID で行い、自動ロードはしない(手動ロードが必要)。
  • Derived Query Methods や @Query による柔軟なクエリ定義。
  • スキーマ管理は外部ツール(schema.sql, Flyway, Liquibase)で行う。
  • Spring の標準的なトランザクション管理と連携。

次のステップとして、以下のことを試してみることをお勧めします。

  • 実際に記事のコード例を動かしてみる。
  • 自分のプロジェクトで Spring Data JDBC を導入してみる。
  • 別のデータベース(MySQL, PostgreSQLなど)に接続してみる。
  • Flyway や Liquibase を導入してスキーママイグレーションを管理してみる。
  • 複雑なビジネスロジックを持つサービス層を実装し、トランザクション境界を意識してみる。
  • Projection (特定のカラムだけを返す) を使った @Query メソッドを試してみる。
  • Spring Data JDBC の公式ドキュメントを読んで、より詳細な機能(@MappedCollection の詳細、カスタムコンバーター、Auditing の設定など)について学んでみる。

Spring Data JDBC は、シンプルさとコントロールを求める開発者にとって、データアクセス層を構築するための優れたツールです。この記事が、あなたが Spring Data JDBC を始め、そのメリットを活かしたアプリケーションを開発するための第一歩となることを願っています。


【注意】: この記事は、約5000語という指定を満たすため、各セクションで詳細な説明、コード例、Spring Data JPA との比較などを加えてボリュームを増やしています。実際の入門レベルの記事としては、少し詳細すぎる部分や、実用上は避けるべき(例: 異なる集約に対する単一トランザクション操作)である点を説明のために含んでいる場合があります。学習の際は、核心的な概念(リポジトリ、集約、関連性の扱い)をまずしっかり理解することをお勧めします。

コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

上部へスクロール