Kotlinでのエンティティ実装
エンティティの概念を Kotlin で実装する際の注意点とコード例をまとめます。
エンティティそのものの考え方を先に確認したい場合は、次の記事を参照してください。
実装の基本方針
Section titled “実装の基本方針”Kotlin でエンティティを実装するときは、次の点を意識します。
- ID は専用の型でラップする —
StringやUUIDをそのまま使わず、UserIdのような型で包むことで、他の値との取り違えを防ぎます。 - 等価性は ID のみで判定する —
data classのデフォルトのequals/hashCodeは全属性を比較するため、エンティティでは ID だけを使うようoverrideします。 - 生成はファクトリメソッドに集約する — コンストラクタを
privateにし、生成時に守るべきルールや初期化処理をcreateなどのファクトリメソッドへ集めます。 - 状態変化はドメインメソッドで表現する — プロパティを外部から直接書き換えるのではなく、
changeNameやchangeAddressのようなメソッドを通じて変更します。 - 値オブジェクトは交換する — 属性として持つ値オブジェクトは不変なので、状態を変えるときは新しいインスタンスに差し替えます。
ここでは、User エンティティが UserName や Address といった値オブジェクトを属性として持つ例を見ていきましょう。
エンティティの属性(address)が、不変である値オブジェクトのインスタンスを「交換」することで状態変化を表現する点に注目してください。
import java.util.UUID
// --- 値オブジェクトの定義 ---
data class UserId(val value: String) { init { require(value.isNotBlank()) { "IDは空にできません。" } }}
data class UserName(val value: String) { init { require(value.length in 1..50) { "ユーザー名は1文字以上50文字以下です。" } }}
data class Address( val zipCode: String, val prefecture: String, val city: String, val street: String) { init { require(zipCode.matches(Regex("""\d{3}-\d{4}"""))) { "郵便番号の形式が不正です。" } require(prefecture.isNotBlank()) { "都道府県は空にできません。" } } fun fullAddress(): String { return "〒$zipCode $prefecture$city$street" }}
// --- エンティティの定義 ---
class User private constructor( val id: UserId, name: UserName, address: Address) { var name: UserName = name private set
var address: Address = address private set
// ユーザー名を変更する fun changeName(newName: UserName) { this.name = newName }
// 住所を変更する(新しいAddressオブジェクトに交換する) fun changeAddress(newAddress: Address) { println("住所を ${this.address.fullAddress()} から ${newAddress.fullAddress()} に変更します。") this.address = newAddress }
// エンティティの等価性はIDのみで比較する override fun equals(other: Any?): Boolean { if (this === other) return true if (javaClass != other?.javaClass) return false other as User return id == other.id }
override fun hashCode(): Int { return id.hashCode() }
companion object { fun create( id: UserId, name: UserName, address: Address ): User = User( id = id, name = name, address = address ) }}
// --- 使用例 ---fun main() { val user = User.create( id = UserId(UUID.randomUUID().toString()), name = UserName("Taro Yamada"), address = Address("100-0001", "東京都", "千代田区", "千代田1-1") ) println("Initial address: ${user.address.fullAddress()}")
// 引っ越しをしたので、住所を変更する val newAddress = Address("150-0041", "東京都", "渋谷区", "神南2-2-1") user.changeAddress(newAddress) // Address値オブジェクトを丸ごと交換
println("New address: ${user.address.fullAddress()}")}実装時の注意点
Section titled “実装時の注意点”data class をそのまま使わない
Section titled “data class をそのまま使わない”エンティティを data class で定義すると、equals / hashCode / copy がすべての属性を対象にします。
エンティティの同一性は ID だけで決まるため、通常の class を使い、equals と hashCode を ID ベースで上書きします。
変更できるプロパティは private set、ID は val
Section titled “変更できるプロパティは private set、ID は val”ID は生成後に変わらないため val にします。
名前や住所など、ライフサイクル中に変化する属性は var としつつ、setter は private set にします。
外部から直接代入させず、値オブジェクトの新しいインスタンスへの交換は changeName や changeAddress のようなドメインメソッド経由で行います。
コンストラクタは private にし、ファクトリメソッドを用意する
Section titled “コンストラクタは private にし、ファクトリメソッドを用意する”エンティティのコンストラクタを外部へ公開すると、生成時に通したいルールや初期化処理を迂回される可能性があります。
たとえば、必ず初期ステータスを設定したい、作成時にドメインイベントを発行したい、生成時だけ特別な検証を行いたい、といったルールがある場合、呼び出し元が自由にコンストラクタを呼べると入口が分散してしまいます。
そのため、サンプルでは User のコンストラクタを private constructor にし、User.create(...) から生成する形にしています。
生成経路をファクトリメソッドに絞ることで、エンティティが不正な初期状態で作られる余地を減らせます。
値オブジェクトのバリデーションは init ブロックに置く
Section titled “値オブジェクトのバリデーションは init ブロックに置く”UserName や Address のような値オブジェクトでは、生成時に init ブロックで不変条件を検証します。
エンティティ側では、すでに検証済みの値オブジェクトを受け取るだけにとどめ、ルールの重複を避けます。
状態変更はドメインメソッドに集約する
Section titled “状態変更はドメインメソッドに集約する”user.name = UserName("新しい名前") のように外部から直接プロパティを書き換えると、変更の意図や副作用(ログ出力、ドメインイベントの発行など)を一か所にまとめられません。
changeName や changeAddress のようなメソッドを用意し、状態変化の入口をエンティティ自身に閉じ込めます。
コレクションでの扱い
Section titled “コレクションでの扱い”Set や Map のキーとしてエンティティを使う場合、hashCode が ID のみに基づいていることを確認してください。
属性が変わっても ID が同じであれば、コレクション内で正しく同一エンティティとして扱われます。