Kotlinでの値オブジェクトの実装例
Kotlin で値オブジェクトを実装するときは、次の点を押さえます。
- 不変 … プロパティは
valのみとし、生成後に変更しない - 値による等価性 … 中身が同じなら同じものとして扱う
- 生成時の検証 … 不正な値でインスタンスが作られないようにする
- ファクトリ経由の生成 …
companion objectのofなどで、生成意図や変換処理を明確にする
プロパティが 1 つか複数かで、選びやすいパターンが少し変わります。以下ではそれぞれの実装例を示します。
単一プロパティの場合
Section titled “単一プロパティの場合”単一の値をラップする値オブジェクトのなかでも、エンティティの識別子(ID) はよく使われるパターンです。
UUID をそのまま渡すと UserId と OrderId を取り違えやすいため、専用の型でラップして区別します。
単一プロパティの実装には @JvmInline value class と data class の 2 パターンがあり、どちらも識別子の値オブジェクトに使えます。
以下では同じ UserId を題材に、それぞれの書き方を示します(実際のプロジェクトではどちらか一方を選びます)。
2 つの違いについては次の記事を参照してください。
識別子の値オブジェクトでは、用途に応じてファクトリを分けるのがポイントです。
| ファクトリ | 使うタイミング |
|---|---|
generate() | 新しいエンティティをドメイン内で生成するとき |
of(UUID) | リポジトリから UUID を読み出して復元するとき |
fromString(String) | API リクエストやシリアライズ済みデータから復元するとき |
@JvmInline value class を使う場合
Section titled “@JvmInline value class を使う場合”単一プロパティの識別子では、まずこちらを検討するケースが多いです。
import java.util.UUID
@JvmInlinevalue class UserId private constructor(val value: UUID) { companion object { /** 新規エンティティ作成時に ID を生成する */ fun generate(): UserId = UserId(UUID.randomUUID())
/** DB や外部 API から取得した UUID を復元する */ fun of(value: UUID): UserId = UserId(value)
/** 文字列(JSON や URL パスなど)から復元する */ fun fromString(value: String): UserId = UserId(UUID.fromString(value)) }
override fun toString(): String = value.toString()}value class は equals / hashCode がラップした値に基づいて自動的に定義されるため、同じ UUID なら同じ ID として扱われます。
data class を使う場合
Section titled “data class を使う場合”ファクトリの構成は value class と同様にできます。
import java.util.UUID
data class UserId private constructor(val value: UUID) { companion object { fun generate(): UserId = UserId(UUID.randomUUID()) fun of(value: UUID): UserId = UserId(value) fun fromString(value: String): UserId = UserId(UUID.fromString(value)) }}どちらの実装でも、呼び出し側のコードは同じ形になります。
// 新規ユーザーの作成val newUserId = UserId.generate()
// DB から読み出した UUID の復元val persistedId = UserId.of(row.userId)
// API パスなど文字列からの復元val idFromRequest = UserId.fromString("550e8400-e29b-41d4-a716-446655440000")
// 型の混同を防げるfun findUser(id: UserId): User { ... }// fun findUser(id: UUID): User { ... } // OrderId と取り違えやすい複数プロパティの場合
Section titled “複数プロパティの場合”複数の属性をまとめて 1 つの概念として表すときは、data class が定番です。
Address のように、郵便番号・都道府県・市区町村・番地をひとまとめにするケースが典型です。
例: Address
Section titled “例: Address”data class Address private constructor( val postalCode: String, val prefecture: String, val city: String, val street: String,) { init { require(POSTAL_CODE_PATTERN.matches(postalCode)) { "郵便番号の形式が不正です" } require(prefecture.isNotBlank()) { "都道府県は必須です" } require(city.isNotBlank()) { "市区町村は必須です" } require(street.isNotBlank()) { "番地は必須です" } }
companion object { private val POSTAL_CODE_PATTERN = Regex("""^\d{3}-?\d{4}$""")
fun of( postalCode: String, prefecture: String, city: String, street: String, ): Address = Address( postalCode = postalCode, prefecture = prefecture, city = city, street = street, ) }
fun fullAddress(): String = "〒$postalCode $prefecture$city$street"}複数プロパティの値オブジェクトでは、init ブロックで プロパティ間の整合性 も検証できます。
たとえば「海外住所では郵便番号の形式が異なる」といったルールがあれば、ここにまとめて書けます。
例: Money(単位を含む金額)
Section titled “例: Money(単位を含む金額)”enum class Currency { JPY, USD}
data class Money private constructor( val amount: Long, val currency: Currency,) { init { require(amount >= 0) { "金額は0以上である必要があります" } }
companion object { fun yen(amount: Long): Money = Money(amount, Currency.JPY) fun usd(amount: Long): Money = Money(amount, Currency.USD) }
fun add(other: Money): Money { require(currency == other.currency) { "通貨が異なる金額は加算できません" } return Money(amount + other.amount, currency) }}金額の加算のように 振る舞いを持たせたい 場合も、値オブジェクトのメソッドとして実装します。
状態を書き換えるのではなく、新しいインスタンスを返す ことで不変性を保ちます。
val address = Address.of( postalCode = "150-0041", prefecture = "東京都", city = "渋谷区", street = "神南1-2-3",)
val price = Money.yen(1500)val tax = Money.yen(150)val total = price.add(tax) // 新しい Money インスタンスが返る実装時のポイントまとめ
Section titled “実装時のポイントまとめ”| 観点 | 単一プロパティ | 複数プロパティ |
|---|---|---|
| よく使う型 | @JvmInline value class または data class(識別子など) | data class |
| 等価性 | ラップした値が同じなら等しい | 全プロパティが同じなら等しい |
| 生成方法 | generate() / of() / fromString() など用途別ファクトリ | init でプロパティ間のルールも検証 |
| 状態の変更 | 新しいインスタンスへの交換 | 新しいインスタンスへの交換 |
値オブジェクトはエンティティの属性として使われることが多いです。
エンティティ側で住所を変更するときも、Address インスタンスを 新しいものに差し替える 形にすると、ドメインモデルとして自然に表現できます。