Skip to main content

Spring Data JDBC で読み書きする

前の章で作ったテーブルに、Kotlin のコードから読み書きします。使うのは Spring Data JDBC です。テーブルの行と Kotlin のクラスを対応付け、save や findById のような決まった操作を、SQL を書かずに使えるようにする仕組みです。この章では、注文を注文明細ごと保存して読み戻すところまでを作り、それを確かめる最初のテストを書きます。ID を自分で決めて保存するときに踏みやすい落とし穴も、テストで動かして確かめます。

この章のコードは tag api-09 にあります。

集約を 1 つの単位で読み書きする​

Spring Data JDBC は、集約 を読み書きの単位にします。集約は、いつも一緒に読み書きするオブジェクトのまとまりで、外からはまとまりの入り口 (集約のルート) だけを通して扱います。このガイドの注文は、注文明細を含めた 1 つの集約です。注文明細だけを取り出して書き換えることはなく、いつも注文と一緒に保存します。

注文の集約を、Kotlin のクラスで書くと次のようになります。

app/src/main/kotlin/com/example/shop/order/Order.kt (抜粋)
@Table("orders")
data class Order(
@Id val id: UUID,
val contactEmail: String,
val status: OrderStatus,
val orderedAt: Instant,
@MappedCollection(idColumn = "order_id", keyColumn = "line_index")
val lines: List<OrderLine>,
@Version val version: Long? = null,
)

enum class OrderStatus { PENDING, CANCELLED }

/** 注文に含まれる 1 行。単価と税率は、注文した時点の商品の値を写したもの。 */
@Table("order_lines")
data class OrderLine(
val productId: UUID,
val quantity: Int,
val unitPrice: Int,
val taxRate: Int,
)

@Table は、クラスに対応するテーブルの名前です。プロパティの名前は、キャメルケースを _ 区切りに変えた名前の列に対応します (contactEmail は contact_email)。@Id は主キーのプロパティです。

lines に付けた @MappedCollection が、注文と注文明細のつながりを表します。idColumn は注文明細のテーブルの側で注文を指す列、keyColumn はリストの中の位置を入れる列です。前の章で order_lines の主キーを (order_id, line_index) にしたのは、この対応のためです。注文明細のクラスは、自分の ID を持ちません。

状態の OrderStatus は enum class で、列には名前の文字列 (PENDING) が入ります。日時の orderedAt は java.time.Instant で、timestamp with time zone の列に対応します。

プロパティはすべて val です。Spring Data JDBC は、値を変えたインスタンスが要るとき、data class の copy を呼んで新しいインスタンスを作ります1。

repository​

集約を読み書きする操作は、repository と呼ぶインターフェースを宣言するだけで使えます。

app/src/main/kotlin/com/example/shop/order/OrderRepository.kt
package com.example.shop.order

import org.springframework.data.repository.ListCrudRepository
import java.util.UUID

interface OrderRepository : ListCrudRepository<Order, UUID>

型引数の Order が集約のルート、UUID が ID の型です。ListCrudRepository を継承すると、save・findById・findAll・deleteById などの操作が、この interface に入ります。実装のクラスは書きません。起動のときに Spring Data JDBC が実装を作り、bean にします。ListCrudRepository は、複数件を返す操作の戻り値を List にした版です。

Kotlin からは、findById の代わりに findByIdOrNull を使います。Spring Data が用意している拡張関数で、見つからなければ Optional でなく null を返すので、第 1 部の null 安全の書き方がそのまま使えます。

save は、集約のルートと注文明細を 1 つのトランザクションの中で書きます。repository のメソッドは、既定でトランザクションの中で動くためです2。注文の行だけが入って、注文明細の行が入らない、という途中の状態は残りません。

既にある注文を save し直すときは、注文の行を更新したうえで、注文明細の行をすべて消してから入れ直します3。Spring Data JDBC は読んだときの状態を覚えていないので、どの明細が変わったかを調べずに、いまの状態を丸ごと書きます。集約を小さく保つ理由の 1 つです。

ID をアプリで作る​

前の章で決めたとおり、主キーの UUID はアプリが作ります。ID の作り方は、1 つのクラスにまとめて bean にします。

app/src/main/kotlin/com/example/shop/shared/IdGenerator.kt
package com.example.shop.shared

import java.util.UUID

/** 集約の ID を作る。テストでは、決まった並びの ID を返すものに差し替える。 */
fun interface IdGenerator {
fun newId(): UUID
}
app/src/main/kotlin/com/example/shop/shared/UuidV7Generator.kt
package com.example.shop.shared

import org.springframework.stereotype.Component
import java.util.UUID
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
import kotlin.uuid.toJavaUuid

/**
* 時刻の順に並ぶ UUID (version 7) を作る。主キーの索引への追加が末尾に寄るので、ランダムな version 4 より
* 索引のページが散らばりにくい。
*
* Kotlin の Uuid は実験的 API なので、opt-in をこのクラスに閉じ込め、外へは java.util.UUID で渡す。
* Java の UUID が version 7 を作れるのは Java 26 から。
*/
@Component
@OptIn(ExperimentalUuidApi::class)
class UuidV7Generator : IdGenerator {
override fun newId(): UUID = Uuid.generateV7().toJavaUuid()
}

UUID にはいくつかの版があります。よく使われる version 4 はすべてがランダムな値です。version 7 は、頭の 48 ビットが作った時刻のミリ秒で、後ろがランダムな値です。後から作った ID ほど大きくなるので、主キーの索引に新しい行を足す位置が、いつも索引の末尾に寄ります。ランダムな ID だと、索引のあちこちのページに行が入り、書き込みのたびに触るページが散らばります。

Java 25 の java.util.UUID は version 7 を作れません (作る関数が入るのは Java 26 です)。Kotlin 2.3 の標準ライブラリには、version 7 を作る Uuid.generateV7() があります4。ただし、Kotlin の Uuid はまだ 実験的 API で、使う側で @OptIn(ExperimentalUuidApi::class) を書いて、実験的であることを承知したと宣言する必要があります。この宣言を ID を作るクラス 1 つに閉じ込め、外へは toJavaUuid() で java.util.UUID に変えて渡しています。集約の ID の型も java.util.UUID です。Spring Data JDBC は java.util.UUID を、変換を書かずに PostgreSQL の uuid の列に読み書きできます5。

IdGenerator を fun interface にして、実装を bean にしているのは、テストで差し替えるためです。ID を受け取る側は IdGenerator の型だけを知っていて、どの実装が来るかは知りません。後の章の、記事の curl の例を照合するテストでは、決まった並びの ID を返す実装に差し替えます。

時計も bean にする​

注文を受け付けた日時も、同じ考え方で bean から取ります。

app/src/main/kotlin/com/example/shop/shared/ClockConfiguration.kt
package com.example.shop.shared

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.time.Clock
import java.time.Duration
import java.time.temporal.ChronoUnit

@Configuration(proxyBeanMethods = false)
class ClockConfiguration {

/**
* 現在の日時の出所。日時が要るクラスは Instant.now() を呼ばずにこの bean を受け取る。
*
* PostgreSQL の timestamp with time zone はマイクロ秒までしか持たないので、時計もマイクロ秒で刻む。
* ナノ秒まで返す時計のままだと、保存する前の値 (作成の応答に返す値) と、読み戻した値が食い違う。
*/
@Bean
fun clock(): Clock = Clock.tick(Clock.systemUTC(), Duration.of(1, ChronoUnit.MICROS))
}

java.time.Clock は、現在の日時を返す部品です。Instant.now() を直接呼ぶと、テストのたびに違う日時になり、日時を含む値を決まった値と比べられません。日時が要るクラスがこの bean を受け取って clock.instant() を呼ぶようにしておけば、テストでは決まった日時を返す時計に差し替えられます。

Clock.systemUTC() は、この環境ではナノ秒の桁まで値を持つ時刻を返します。PostgreSQL の timestamp with time zone はマイクロ秒までしか持たないので、そのまま保存すると、読み戻した値はマイクロ秒より下の桁が落ちたものになります。次の章で作る注文の API は、作った注文を保存する前の値で返します。そのため、作ったときの応答と後で取得したときの応答とで、同じ注文の日時が食い違ってしまいます。Clock.tick は、元の時計の時刻を指定した単位に切り捨てる時計を作ります。単位をマイクロ秒にしておけば、時計が返す値はそのままデータベースに収まります。この性質は、OrderRepositoryTest の 2 つ目のテスト (時計の時刻で作った注文を、読み戻しても日時が変わらない) で確かめています。

@Bean メソッドが返した値は、メソッドの名前 (clock) の bean になります。proxyBeanMethods = false は、このクラスの @Bean メソッドどうしが呼び合わないので、設定のクラスのプロキシを作らなくてよい、という指定です。

ID を入れた save が何もしないことがある​

save は、渡された集約が新しいかどうかを見て、INSERT と UPDATE を出し分けます。新しいかどうかの見分け方は、次の順で決まります6。

  1. @Version を付けたプロパティがあれば、それが null (基本型なら 0) のとき新しい
  2. 無ければ、@Id のプロパティが null (基本型なら 0) のとき新しい

ID をデータベースが振る作りでは、保存する前の ID は null なので、2 の判定で新しい集約だと分かります。このガイドのようにアプリが ID を入れてから save すると、ID は null ではありません。@Version のプロパティが無ければ、Spring Data JDBC はその集約を既にあるものとみなし、UPDATE を出します。テーブルにはまだその行が無いので、UPDATE は 1 行も書き換えません。それでも例外は出ません7。save は何も知らせずに戻り、行は入らないままです。

この動きをテストで確かめます。Product と同じテーブルに対応付けた、@Version を持たないクラスを、テストの中に用意しました。

app/src/test/kotlin/com/example/shop/product/SaveWithAssignedIdTest.kt (抜粋)
/** Product と同じテーブルに対応付けた、version のプロパティを持たないクラス。 */
@Table("products")
data class ProductWithoutVersion(
@Id val id: UUID,
val name: String,
val price: Int,
val taxRate: Int,
)
app/src/test/kotlin/com/example/shop/product/SaveWithAssignedIdTest.kt (抜粋)
@Test
fun `version の無いクラスは、ID があると既存の行とみなされ、行が入らない`() {
template.save(ProductWithoutVersion(id = id, name = "ノート", price = 330, taxRate = 10))

assertEquals(0L, countRows())
}

@Test
fun `version が null なら新しい行とみなされ、行が入る`() {
val saved = template.save(Product(id = id, name = "ノート", price = 330, taxRate = 10))

assertEquals(1L, countRows())
assertEquals(0L, saved.version)
}

template は Spring Data JDBC の JdbcAggregateTemplate で、repository の save と同じ判定で保存します8。countRows() は、その ID の行の数を SQL で数える関数です。1 つ目のテストは、save を呼んだのに行が 1 つも無いことを確かめ、2 つ目は、@Version を持つ Product なら同じ ID で行が入ることを確かめています。保存した後の saved には、Spring Data JDBC が version の最初の値を入れて返します。

このガイドの集約は、すべて @Version val version: Long? = null を持ちます。新しく作る集約の version は null なので INSERT になり、読み出した集約は version に値を持つので UPDATE になります。

古い version の保存は失敗する​

@Version には、もう 1 つの働きがあります。UPDATE のときに、読み出したときの version を条件に入れ、書き換えたら 1 つ増やします。

app/src/test/kotlin/com/example/shop/product/SaveWithAssignedIdTest.kt (抜粋)
@Test
fun `読んだ後に書き換えられた行を、古い version のまま保存すると失敗する`() {
val saved = template.save(Product(id = id, name = "ノート", price = 330, taxRate = 10))
template.save(saved.copy(price = 350))

assertThrows<OptimisticLockingFailureException> {
template.save(saved.copy(price = 360))
}
}

2 回目の save で、データベースの行の version は 1 つ進みます。3 回目の save は、saved が持つ古い version を条件にした UPDATE になり、条件に合う行が無いので、OptimisticLockingFailureException が投げられます9。2 人が同じ在庫を読んで、それぞれ書き換えたとき、後から書いた側が失敗するのはこの仕組みによります。この失敗をどう扱うかは、エラーレスポンスの章で決めます。

集約をまたいで読む​

repository は集約を単位に読み書きします。一覧の画面に出す「商品と在庫の数」のように 2 つの集約の値を並べて読みたいだけのときは、集約を 2 つ読んでつなぐより、SQL で 1 回に読むほうが速く素直です。そのための道具が、Spring Framework の JdbcClient です。

app/src/main/kotlin/com/example/shop/product/ProductSummaryQuery.kt (抜粋)
@Repository
class ProductSummaryQuery(private val jdbcClient: JdbcClient) {

fun findAll(): List<ProductSummary> =
jdbcClient.sql(
"""
select p.id, p.name, p.price, p.tax_rate, i.quantity
from products p
join inventories i on i.product_id = p.id
order by p.id
""".trimIndent(),
)
.query { rs, _ ->
ProductSummary(
id = rs.getObject("id", UUID::class.java),
name = rs.getString("name"),
price = rs.getInt("price"),
taxRate = rs.getInt("tax_rate"),
inventoryQuantity = rs.getInt("quantity"),
)
}
.list()
}

JdbcClient は Spring Boot が bean として用意するので、コンストラクタで受け取るだけで使えます。sql に SQL を渡し、query に行を値に変える関数を渡して、list で結果のリストを受け取ります。query に渡したラムダは 1 行ごとに呼ばれ、rs (ResultSet) から列の値を読みます。@Repository は、データベースを読み書きする bean であることを表す注釈で、@Component と同じく bean として読み込まれます。

query には、ラムダの代わりにクラスを渡す書き方 (query(ProductSummary::class.java)) もあり、列の名前とプロパティの名前を合わせて値を入れます。ただし、Spring Framework 7 はこの書き方の結果の要素を @Nullable と宣言しているので10、Kotlin からは List<ProductSummary?> に見えます。7 章で見た JSpecify の注釈の働きです。ラムダで書けば、ラムダが返す型 (ProductSummary) がそのまま要素の型になります。

この問い合わせは、一覧 API の章で、ページングと並べ替えを足して使います。

最初のテスト​

ここまでのコードを、テストで確かめます。テストの置き場は app/src/test/kotlin/ で、書き方は JUnit (Jupiter) です。

app/src/test/kotlin/com/example/shop/order/OrderRepositoryTest.kt (抜粋)
@Import(TestcontainersConfiguration::class)
@SpringBootTest
@Transactional
class OrderRepositoryTest(
@Autowired private val orderRepository: OrderRepository,
@Autowired private val productRepository: ProductRepository,
@Autowired private val clock: Clock,
) {

private fun saveNotebook(): Product =
productRepository.save(
Product(id = UUID.fromString("0199f3a8-0000-7000-8000-000000000001"), name = "ノート", price = 330, taxRate = 10),
)

private fun newOrder(product: Product, orderedAt: Instant) = Order(
id = UUID.fromString("019a1c00-0000-7000-8000-000000000001"),
contactEmail = "taro@example.com",
status = OrderStatus.PENDING,
orderedAt = orderedAt,
lines = listOf(OrderLine(productId = product.id, quantity = 2, unitPrice = 330, taxRate = 10)),
)

@Test
fun `保存した注文を、注文明細ごと読み戻せる`() {
val order = newOrder(saveNotebook(), orderedAt = Instant.parse("2026-10-24T01:00:00Z"))

val saved = orderRepository.save(order)
val found = orderRepository.findByIdOrNull(order.id)

assertEquals(order.copy(version = 0), saved)
assertEquals(saved, found)
}
// ...
}

クラスに付けた 3 つの注釈が、テストの動く環境を決めます。

  • @SpringBootTest: アプリと同じ設定で DI コンテナを作り、bean を用意します
  • @Import(TestcontainersConfiguration::class): 前の章で見た、テストの DB を Testcontainers で立てる設定を読み込みます
  • @Transactional: テストのメソッドを 1 つのトランザクションで包み、メソッドが終わったらロールバックします。テストが入れた行は残らないので、ほかのテストに影響しません

テストのクラスのコンストラクタの引数に @Autowired を付けると、JUnit と Spring が、その型の bean を渡します。テストの名前はバッククォートで囲んで、日本語の文で書いています。Kotlin は、バッククォートで囲めば空白を含む名前を関数に付けられます。

1 つ目のテストは、注文明細の外部キーが指す商品を先に保存してから、注文を保存し、ID で読み戻します。1 つ目の assertEquals は、save の戻り値が保存したものと同じであること (version に最初の値が入ったほか) を確かめます。2 つ目は、読み戻した注文が保存したものと等しいことを確かめます。data class の == はすべてのプロパティを比べます。注文明細のリストや Instant の日時、enum の状態まで、データベースを通っても値が変わらないことを 1 行で確かめられます。

テストは次のコマンドで動かします。Docker が要ります。

./gradlew :app:test

:app:test は app のテストをすべて動かします。1 つのクラスだけを動かすときは、第 1 部と同じく --tests で絞り込みます。

本番で効く注意点​

ID を入れて保存する集約には @Version を付ける​

アプリが ID を作る作りで @Version を付け忘れると、新しい集約の save が、例外を出さずに何も書かない UPDATE になります。テストで行が入ることを確かめていなければ、本番で注文が保存されないまま、応答だけは成功を返すことになります。集約のルートに @Version を付けることと、保存した後に読み戻せることをテストで確かめることを、組にしておきます。@Version を使いたくない集約では、Persistable を実装して、新しいかどうかを自分で答える方法もあります6。

集約を大きくしない​

Spring Data JDBC は、既にある集約の save で、子のエンティティの行をすべて消してから入れ直します。明細が数件の注文なら問題になりませんが、1 つの集約に数千の子を持たせると、1 か所を変えるだけで数千の DELETE と INSERT が出ます。いつも一緒に書き換えるものだけを 1 つの集約にし、別々に書き換えるものは別の集約に分ける、というのがこのガイドの分け方です (商品と在庫を別の集約にしているのはそのためです)。

まとめ​

  • Spring Data JDBC は集約を単位に読み書きします。注文明細は @MappedCollection で注文の一部として対応付けます
  • repository は interface を宣言するだけで使えます。Kotlin からは findByIdOrNull で読みます。save はトランザクションの中で集約をまとめて書きます
  • 主キーは、Kotlin の Uuid.generateV7() で作った version 7 の UUID です。実験的 API の opt-in は ID を作るクラスに閉じ込め、外へは java.util.UUID で渡します
  • ID の採番と時計を bean にしておくと、テストで決まった値を返すものに差し替えられます
  • ID を入れて save するとき、@Version が無いと UPDATE になり、行が入らないまま例外も出ません。@Version があれば、null のとき INSERT になり、古い version の保存は失敗します
  • 集約をまたいで読むだけの問い合わせは、JdbcClient で SQL を書きます
  • 最初のテストは @SpringBootTest と Testcontainers の DB で動かし、@Transactional でテストごとに書いた行を戻します

次に読む​

前の章は テーブル設計とマイグレーション です。

次章 ルーティングと最初のエンドポイント では、注文を作って取得する API を作り、curl で呼び出します。

練習問題​

在庫が 1 個以上ある商品だけを、在庫の数と一緒に読む問い合わせを書いてください

解答例

AllocatableProductQuery.kt (抜粋)
class AllocatableProductQuery(private val jdbcClient: JdbcClient) {

fun findAllocatable(): List<ProductSummary> =
jdbcClient.sql(
"""
select p.id, p.name, p.price, p.tax_rate, i.quantity
from products p
join inventories i on i.product_id = p.id
where i.quantity > 0
order by p.id
""".trimIndent(),
)
.query { rs, _ ->
ProductSummary(
id = rs.getObject("id", UUID::class.java),
name = rs.getString("name"),
price = rs.getInt("price"),
taxRate = rs.getInt("tax_rate"),
inventoryQuantity = rs.getInt("quantity"),
)
}
.list()
}

本文の ProductSummaryQuery の SQL に、where i.quantity > 0 を足しただけです。条件は在庫のテーブルの列なので、商品と在庫をつないだ後で絞り込みます。

商品をすべて読んでから Kotlin の filter で絞り込んでも、結果は同じです。ただし、在庫が 0 の商品もデータベースから読み出してから捨てることになります。絞り込みの条件を SQL に書けば、データベースは条件に合う行だけを返します。

PostgreSQL 18 には uuidv7() という関数があり、列の既定値にすれば、データベースが version 7 の UUID を振れます。このガイドがそうせずに、アプリで ID を作るのはなぜですか

解答例

理由は 2 つあります。

1 つ目は、保存する前に ID が決まることです。アプリで作れば、注文を組み立てた時点で ID があります。保存の前にその ID をログに残したり、同じ処理の中で作るほかのデータから ID で指したりするときに、保存の順番を気にせずに書けます。

2 つ目は、テストで ID を決まった値にできることです。ID の作り方を IdGenerator の bean にしているので、テストでは決まった並びの ID を返す実装に差し替えられます。データベースが振る ID は、テストのたびに変わります。

その代わり、新しい集約かどうかを Spring Data JDBC に知らせる手当てが必要です。このガイドでは、集約に @Version を付けてそれに答えています。


Footnotes​

  1. 出典: Object Mapping Fundamentals (Spring Data Relational)。 ↩

  2. 出典: Transactionality (Spring Data Relational)。 ↩

  3. 出典: Persisting Entities (Spring Data Relational)。 ↩

  4. 出典: What's new in Kotlin 2.3.0 (Kotlin のドキュメント)。Java の version 7 は JDK-8334015 (修正の版は 26)。 ↩

  5. 出典: Spring Data Relational 4.1.2 の JdbcSimpleTypes のソース。 ↩

  6. 出典: Entity State Detection Strategies (Spring Data Commons)。 ↩ ↩2

  7. 出典: Spring Data Relational 4.1.2 の JdbcAggregateChangeExecutionContext のソース。@Version の無い集約の UPDATE は件数を見ません。4.0.0 までは件数が 0 のときに例外を投げていましたが、4.0.1 で外れました (Spring Data Relational の issue 2176)。 ↩

  8. 出典: Persisting Entities: Template API (Spring Data Relational)。 ↩

  9. 出典: Spring Data Relational 4.1.2 の JdbcAggregateChangeExecutionContext と OptimisticLockingUtils のソース。 ↩

  10. 出典: Spring Framework 7.0.10 の JdbcClient のソース。query(Class<T>) は MappedQuerySpec<@Nullable T> を返します。 ↩