コンテンツにスキップ

Kotlinでの値オブジェクトの実装例

Kotlin で値オブジェクトを実装するときは、次の点を押さえます。

  • 不変 … プロパティは val のみとし、生成後に変更しない
  • 値による等価性 … 中身が同じなら同じものとして扱う
  • 生成時の検証 … 不正な値でインスタンスが作られないようにする
  • ファクトリ経由の生成companion objectof などで、生成意図や変換処理を明確にする

プロパティが 1 つか複数かで、選びやすいパターンが少し変わります。以下ではそれぞれの実装例を示します。


単一の値をラップする値オブジェクトのなかでも、エンティティの識別子(ID) はよく使われるパターンです。
UUID をそのまま渡すと UserIdOrderId を取り違えやすいため、専用の型でラップして区別します。

単一プロパティの実装には @JvmInline value classdata class の 2 パターンがあり、どちらも識別子の値オブジェクトに使えます。
以下では同じ UserId を題材に、それぞれの書き方を示します(実際のプロジェクトではどちらか一方を選びます)。

2 つの違いについては次の記事を参照してください。

識別子の値オブジェクトでは、用途に応じてファクトリを分けるのがポイントです。

ファクトリ使うタイミング
generate()新しいエンティティをドメイン内で生成するとき
of(UUID)リポジトリから UUID を読み出して復元するとき
fromString(String)API リクエストやシリアライズ済みデータから復元するとき

単一プロパティの識別子では、まずこちらを検討するケースが多いです。

import java.util.UUID
@JvmInline
value 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 classequals / hashCode がラップした値に基づいて自動的に定義されるため、同じ UUID なら同じ ID として扱われます

ファクトリの構成は 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 と取り違えやすい

複数の属性をまとめて 1 つの概念として表すときは、data class が定番です。
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 ブロックで プロパティ間の整合性 も検証できます。
たとえば「海外住所では郵便番号の形式が異なる」といったルールがあれば、ここにまとめて書けます。

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 インスタンスが返る

観点単一プロパティ複数プロパティ
よく使う型@JvmInline value class または data class(識別子など)data class
等価性ラップした値が同じなら等しい全プロパティが同じなら等しい
生成方法generate() / of() / fromString() など用途別ファクトリinit でプロパティ間のルールも検証
状態の変更新しいインスタンスへの交換新しいインスタンスへの交換

値オブジェクトはエンティティの属性として使われることが多いです。
エンティティ側で住所を変更するときも、Address インスタンスを 新しいものに差し替える 形にすると、ドメインモデルとして自然に表現できます。