Kotlinでの集約実装例
注文を表す Order と注文明細を表す OrderItem を使って、集約をKotlinで実装する方法を紹介します。
この記事では、エンティティのID、等価性、ファクトリメソッドなどの基本的な実装は繰り返さず、集約ルートを通して子エンティティを操作し、注文全体の不変条件を守る方法に焦点を当てます。
先に次の記事を確認しておくと、コードの役割を理解しやすくなります。
実装の基本方針
Section titled “実装の基本方針”Kotlinで集約を実装するときは、次の点を意識します。
- 集約ルートだけを操作の入口にする — 子エンティティの追加・変更・削除は、集約ルートのメソッドから行います。
- 集約全体の不変条件を検証する — 一件の明細だけでなく、注文の状態や明細全体を確認してから変更します。
- 可変コレクションを外部に公開しない — 内部では
MutableListを使っても、外部には変更できないListを返します。 - 別の集約はIDで参照する —
CustomerやProductのオブジェクトではなく、CustomerIdやProductIdを保持します。 - 集約単位で取得・保存する — リポジトリは集約ルートに対して用意し、子エンティティ専用のリポジトリは作りません。
注文集約のルール
Section titled “注文集約のルール”今回の注文集約では、次の不変条件を扱います。
- 注文には明細が一つ以上必要
- 同じ商品を重複して追加できない
- 明細は20件まで追加できる
- 数量は1以上99以下
- 確定済みの注文は変更できない
数量単体の範囲は Quantity 値オブジェクトが検証します。明細数、商品の重複、注文ステータスのように集約全体を見なければ判断できないルールは、Order が検証します。
IDと値オブジェクト
Section titled “IDと値オブジェクト”サンプルで使用するIDと値オブジェクトを定義します。
import java.util.UUID
@JvmInlinevalue class OrderId(val value: UUID)
@JvmInlinevalue class OrderItemId(val value: UUID)
@JvmInlinevalue class CustomerId(val value: UUID)
@JvmInlinevalue class ProductId(val value: UUID)
@JvmInlinevalue class Quantity(val value: Int) { init { require(value in 1..99) { "数量は1以上99以下で指定してください。" } }}
@JvmInlinevalue class Money(val value: Long) { init { require(value >= 0) { "金額は0円以上で指定してください。" } }
operator fun times(quantity: Quantity): Money = Money(Math.multiplyExact(value, quantity.value.toLong()))}CustomerやProductをそのまま保持せず、それぞれのIDだけを使います。これにより、注文集約の境界へ別の集約が入り込むことを防ぎます。
子エンティティ
Section titled “子エンティティ”OrderItemは注文集約の内部にある子エンティティです。
class OrderItem internal constructor( val id: OrderItemId, val productId: ProductId, val unitPrice: Money, val quantity: Quantity) { fun subtotal(): Money = unitPrice * quantity}プロパティはすべてvalにし、OrderItem自身を外部から書き換えられないようにしています。数量を変更するときは、Orderが同じOrderItemIdを持つ新しいインスタンスへ置き換えます。
ここで扱うOrderItemはIDを持つため、値オブジェクトではなくエンティティです。IDに基づくequals / hashCodeなど、エンティティ共通の実装は冒頭の「Kotlinでのエンティティ実装」カードを参照してください。このサンプルでは集約固有のコードへ焦点を絞るため、明示的な等価性の実装は省略しています。
Orderを集約ルートとして実装します。
enum class OrderStatus { DRAFT, CONFIRMED}
class Order private constructor( val id: OrderId, val customerId: CustomerId, initialItems: List<OrderItem>, status: OrderStatus) { private val mutableItems = initialItems.toMutableList()
val items: List<OrderItem> get() = mutableItems.toList()
var status: OrderStatus = status private set
init { require(initialItems.size in 1..MAX_ITEM_COUNT) { "注文の明細数は1件以上${MAX_ITEM_COUNT}件以下にしてください。" } require(initialItems.map { it.productId }.distinct().size == initialItems.size) { "同じ商品を重複して追加できません。" } }
fun addItem( orderItemId: OrderItemId, productId: ProductId, unitPrice: Money, quantity: Quantity ) { ensureDraft() check(mutableItems.size < MAX_ITEM_COUNT) { "注文明細は${MAX_ITEM_COUNT}件まで追加できます。" } check(mutableItems.none { it.productId == productId }) { "同じ商品を重複して追加できません。" }
mutableItems += OrderItem( id = orderItemId, productId = productId, unitPrice = unitPrice, quantity = quantity ) }
fun changeItemQuantity( orderItemId: OrderItemId, newQuantity: Quantity ) { ensureDraft()
val index = mutableItems.indexOfFirst { it.id == orderItemId } check(index >= 0) { "指定された注文明細が見つかりません。" }
val current = mutableItems[index] mutableItems[index] = OrderItem( id = current.id, productId = current.productId, unitPrice = current.unitPrice, quantity = newQuantity ) }
fun removeItem(orderItemId: OrderItemId) { ensureDraft()
val index = mutableItems.indexOfFirst { it.id == orderItemId } check(index >= 0) { "指定された注文明細が見つかりません。" } check(mutableItems.size > 1) { "注文には明細が一つ以上必要です。" }
mutableItems.removeAt(index) }
fun totalAmount(): Money = Money( mutableItems.sumOf { it.subtotal().value } )
fun confirm() { ensureDraft() status = OrderStatus.CONFIRMED }
private fun ensureDraft() { check(status == OrderStatus.DRAFT) { "確定済みの注文は変更できません。" } }
companion object { private const val MAX_ITEM_COUNT = 20
fun create( orderId: OrderId, customerId: CustomerId, firstItemId: OrderItemId, productId: ProductId, unitPrice: Money, quantity: Quantity ): Order = Order( id = orderId, customerId = customerId, initialItems = listOf( OrderItem( id = firstItemId, productId = productId, unitPrice = unitPrice, quantity = quantity ) ), status = OrderStatus.DRAFT ) }}集約ルートであるOrderに、明細の追加、数量変更、削除、注文確定の入口を集めています。
changeItemQuantityはOrderItemを外部から直接変更せず、集約内部で新しいインスタンスへ置き換えます。その前にOrderが注文ステータスを確認するため、「確定済みの注文は変更できない」というルールをすべての変更処理で守れます。
集約の利用者は、OrderItemを取り出して変更するのではなく、Orderのメソッドを呼び出します。
val order = Order.create( orderId = OrderId(UUID.randomUUID()), customerId = CustomerId(UUID.randomUUID()), firstItemId = OrderItemId(UUID.randomUUID()), productId = ProductId(UUID.randomUUID()), unitPrice = Money(1_200), quantity = Quantity(2))
val secondItemId = OrderItemId(UUID.randomUUID())order.addItem( orderItemId = secondItemId, productId = ProductId(UUID.randomUUID()), unitPrice = Money(800), quantity = Quantity(1))
order.changeItemQuantity(secondItemId, Quantity(3))order.confirm()confirmを呼び出した後にaddItemやchangeItemQuantityを呼ぶと、ensureDraftの検証によって変更が拒否されます。
リポジトリは集約ルートに用意する
Section titled “リポジトリは集約ルートに用意する”注文集約にはOrderRepositoryを用意します。
interface OrderRepository { fun findById(orderId: OrderId): Order? fun save(order: Order)}OrderItemRepositoryを別に作って明細だけを保存すると、Orderの不変条件を通らずに明細を変更できてしまいます。取得と保存は集約ルートを単位にします。
リポジトリ自体の実装方法は、次の記事で解説しています。
実装時の注意点
Section titled “実装時の注意点”MutableListを外部へ公開しない
Section titled “MutableListを外部へ公開しない”次のように可変コレクションをそのまま公開すると、Orderのメソッドを通らずに明細を追加・削除できます。
// 避けたい例val items: MutableList<OrderItem>内部のコレクションはprivateにし、外部には変更できないスナップショットを返します。
private val mutableItems = initialItems.toMutableList()
val items: List<OrderItem> get() = mutableItems.toList()KotlinのListは読み取り専用インターフェースですが、元のMutableListを同じ参照のまま渡すと、別の場所に保持された参照から変更される可能性があります。コンストラクタでコピーし、公開時にもコピーすることで、集約外からの変更を防ぎます。
子エンティティの変更メソッドを外部へ公開しない
Section titled “子エンティティの変更メソッドを外部へ公開しない”order.items.first().changeQuantity(...)のような操作を許すと、Orderが注文ステータスを確認できません。
子エンティティを不変にして集約ルートが置き換える、子エンティティの変更操作を集約内部に限定するなど、必ず集約ルートを通る構造にします。
子エンティティはIDで指定する
Section titled “子エンティティはIDで指定する”変更対象のOrderItemそのものを外部から受け取ると、別の注文に属する明細が渡される可能性があります。
// 避けたい例fun changeItemQuantity(item: OrderItem, quantity: Quantity)
// 集約内部から対象を探すfun changeItemQuantity(orderItemId: OrderItemId, quantity: Quantity)子エンティティのIDを受け取り、集約が自身の内部から対象を探すことで、その集約に属する明細だけを変更できます。
別の集約をオブジェクトのまま保持しない
Section titled “別の集約をオブジェクトのまま保持しない”注文集約がCustomerやProductを直接保持すると、注文を取得するだけで別の集約まで読み込む設計になりやすくなります。
// 避けたい例class Order( val customer: Customer, val items: List<Product>)
// IDで参照するclass Order( val customerId: CustomerId, // 明細はProductIdを保持する)注文時の商品名や価格を後から変えてはいけない場合は、Product集約を保持するのではなく、注文時点の値を注文集約の値として保持します。
集約を大きくしすぎない
Section titled “集約を大きくしすぎない”顧客、商品、配送、支払いなどをすべて注文集約に含めると、一部の変更でも大量のデータを取得・保存することになります。
同じトランザクションで不変条件を守る必要がある範囲だけを集約に含め、それ以外はIDで参照します。
集約単位の同時更新を考慮する
Section titled “集約単位の同時更新を考慮する”同じ注文を複数の処理が同時に変更すると、後から保存した処理が先の変更を上書きする可能性があります。
実際の永続化では、集約ルートにバージョンを持たせる、DBのバージョン列を使うなど、楽観的ロックによって更新競合を検出する方法があります。集約内のルールだけでなく、集約を保存するまでの一貫性も考慮します。
DBからの復元で不正な状態を作らない
Section titled “DBからの復元で不正な状態を作らない”ORMやマッパーから集約を復元するときも、可変コレクションやsetterを公開して少しずつ組み立てる方法は避けます。
生成用のcreateとは別に復元専用のファクトリを用意する場合でも、復元後の集約が不変条件を満たしていることを確認します。復元処理の都合で、通常のアプリケーションコードから不正な状態を作れるようにしてはいけません。
エンティティ実装例との役割分担
Section titled “エンティティ実装例との役割分担”エンティティ実装と集約実装では、どちらも状態変更をドメインメソッドに集めるため、コードの形が似ることがあります。ただし、注目する範囲が異なります。
| エンティティ実装で扱う内容 | 集約実装で扱う内容 |
|---|---|
| IDによる同一性 | 集約ルートを操作の入口にする |
equals / hashCode | 子エンティティを直接変更させない |
| ファクトリメソッドによる生成 | 集約全体の不変条件を守る |
| プロパティの直接変更を防ぐ | 可変コレクションを外部へ公開しない |
| 値オブジェクトの交換 | 別集約をIDで参照する |
| 一つのエンティティのライフサイクル | 集約単位で取得・保存する |
一つのエンティティだけで構成される集約では、エンティティ実装の記事だけで十分な場合もあります。複数のエンティティを一つの整合性境界として扱う場合に、この記事の実装が必要になります。
Kotlinで集約を実装するときは、集約ルートからしか状態を変更できない構造を作ります。
ポイントは次の5つです。
- 子エンティティの操作を集約ルートに集める
- 集約全体の状態を確認して不変条件を守る
- 可変コレクションを外部へ公開しない
- 別集約はIDで参照する
- 集約ルートを単位に取得・保存する
エンティティのIDや等価性といった共通部分はエンティティ実装の記事に任せ、集約の記事では複数のオブジェクトを一つの整合性境界として扱う方法に焦点を当てます。