コンテンツにスキップ

Kotlinでのエンティティ実装

エンティティの概念を Kotlin で実装する際の注意点とコード例をまとめます。
エンティティそのものの考え方を先に確認したい場合は、次の記事を参照してください。

Kotlin でエンティティを実装するときは、次の点を意識します。

  • ID は専用の型でラップするStringUUID をそのまま使わず、UserId のような型で包むことで、他の値との取り違えを防ぎます。
  • 等価性は ID のみで判定するdata class のデフォルトの equals / hashCode は全属性を比較するため、エンティティでは ID だけを使うよう override します。
  • 生成はファクトリメソッドに集約する — コンストラクタを private にし、生成時に守るべきルールや初期化処理を create などのファクトリメソッドへ集めます。
  • 状態変化はドメインメソッドで表現する — プロパティを外部から直接書き換えるのではなく、changeNamechangeAddress のようなメソッドを通じて変更します。
  • 値オブジェクトは交換する — 属性として持つ値オブジェクトは不変なので、状態を変えるときは新しいインスタンスに差し替えます。

ここでは、User エンティティが UserNameAddress といった値オブジェクトを属性として持つ例を見ていきましょう。 エンティティの属性(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()}")
}

エンティティを data class で定義すると、equals / hashCode / copy がすべての属性を対象にします。 エンティティの同一性は ID だけで決まるため、通常の class を使い、equalshashCode を ID ベースで上書きします。

変更できるプロパティは private set、ID は val

Section titled “変更できるプロパティは private set、ID は val”

ID は生成後に変わらないため val にします。 名前や住所など、ライフサイクル中に変化する属性は var としつつ、setter は private set にします。
外部から直接代入させず、値オブジェクトの新しいインスタンスへの交換は changeNamechangeAddress のようなドメインメソッド経由で行います。

コンストラクタは private にし、ファクトリメソッドを用意する

Section titled “コンストラクタは private にし、ファクトリメソッドを用意する”

エンティティのコンストラクタを外部へ公開すると、生成時に通したいルールや初期化処理を迂回される可能性があります。

たとえば、必ず初期ステータスを設定したい、作成時にドメインイベントを発行したい、生成時だけ特別な検証を行いたい、といったルールがある場合、呼び出し元が自由にコンストラクタを呼べると入口が分散してしまいます。

そのため、サンプルでは User のコンストラクタを private constructor にし、User.create(...) から生成する形にしています。
生成経路をファクトリメソッドに絞ることで、エンティティが不正な初期状態で作られる余地を減らせます。

値オブジェクトのバリデーションは init ブロックに置く

Section titled “値オブジェクトのバリデーションは init ブロックに置く”

UserNameAddress のような値オブジェクトでは、生成時に init ブロックで不変条件を検証します。 エンティティ側では、すでに検証済みの値オブジェクトを受け取るだけにとどめ、ルールの重複を避けます。

状態変更はドメインメソッドに集約する

Section titled “状態変更はドメインメソッドに集約する”

user.name = UserName("新しい名前") のように外部から直接プロパティを書き換えると、変更の意図や副作用(ログ出力、ドメインイベントの発行など)を一か所にまとめられません。 changeNamechangeAddress のようなメソッドを用意し、状態変化の入口をエンティティ自身に閉じ込めます。

SetMap のキーとしてエンティティを使う場合、hashCode が ID のみに基づいていることを確認してください。 属性が変わっても ID が同じであれば、コレクション内で正しく同一エンティティとして扱われます。