📊

ID の値オブジェクト化を考える

2022/08/21に公開1

この記事を書くきっかけ

なぜプリミティブな値の ID を値オブジェクトにするのか。どのようなメリットを期待しているのか、ということが、よくわからなくなったため記事にしました。
個人で開発するアプリでも、関わっているプロジェクトなどでも、 プリミティブな値の ID は思考停止で値オブジェクトにしがち で、なんのためにしているんだっけ?ということの確認をしたいと思いました。
ご意見いただければ幸いです。

そもそも値オブジェクトとは

ID を値オブジェクトとして扱う以前に、値オブジェクトとは以下のような特徴を持つものと認識しています。

  • 不変性
  • 交換可能性
  • 等価性

「値をラップしたオブジェクト」のことだと考える人もいるようですが、「値として扱えるオブジェクト」と考えるのが良いのではないかと思います。「」であり、「オブジェクト」でもあるものが「値オブジェクト」です。上記特徴は 値の特徴 でもあります。

これらの特徴を持った値オブジェクトは、システムに 値として扱える新たな型 を提供してくれます。整数型や浮動小数点型、文字列型などと同様に、システムにとってあって当たり前であるはずの値を作ることができるものです。

演算

プリミティブな型が演算を行うことができるように、 値オブジェクトも必要であれば演算を行えるようにできます。値オブジェクトの内部にアクセスしなくても、オブジェクトそのものを演算可能にしておくことで、より値らしく扱うことができます。

kotlin
// Vector という値オブジェクトが定義されている前提
val current = Vector(0f, 0f)
val move = Vector(100f, 100f)

val next = Vector(current.x + move.x, current.y + move.y) // Bad
val next = current + move // Good

演算子のオーバーロードがない言語では、メソッドで代替します。

java
Vector next = current.add(move);

(current.add(move) と書くと current への副作用が疑われる(?)ので別のメソッド名の方が良いかもしれません)

ID を値オブジェクト化する

値オブジェクトがどのようなものか簡単に説明したところで、本題の ID を値オブジェクトにすることについて考えていきたいと思います。

例えば、 Int の値を持つユーザー ID は以下のようなクラスになります。

Int 値を持つユーザー ID クラス
data class UserId(val value: Int) {
    init {
        // 生成時のルールがあれば記述する
    }
}

このような値オブジェクトはどのような機能やメリットを持つのか考えてみたいと思います。

不正な値の検査

値オブジェクトは、インスタンス生成時に値のチェックを行うことができます。そこで値が不正であればエラーとすることで、値オブジェクトのインスタンスは 必ず正しい状態を維持する ことができます。
これはメリットではありますが、値オブジェクト特有のメリットというわけではありません。例えば、前述の 等価性がなかったとしても 同様の制約を持たせることは可能です。
また、 ID を扱う場合においてはデータベースなどで自動的に振られた番号などを扱う場合が多いのではないでしょうか。不正な値を検査するメリットが受けられる機会はそう多くないように思います。

不変

値オブジェクトの内部の値は不変です。別名参照問題が起こらなくなります。
「値オブジェクトは不変である」ならば「不変は当然」のことのように思いますが、メリットとしてあげておきます。
ただし、 プリミティブ型も値 であるため、不変性に関しては「値オブジェクトにしたからプリミティブ型より優れている」ということにはなりません。

演算

ID で演算を必要とすることは多くないでしょう。
演算を行うとしても、せいぜい比較演算子の == くらいかと思います。

List から同一 ID のオブジェクトを探す例
val user = users.find { it.id == targetId }

上記例においても、使用箇所は限られていると考えています。
ID は基本的にリポジトリ側に渡して利用されるもので、アプリケーション層やドメイン層で利用されることは少ないという認識です。

交換の必要性

値オブジェクトとして定義されれば、交換可能性を備えています。
しかし、 ID に交換可能性は必要でしょうか。ID に対して演算することもないので、全く別の ID を代入することしか思い浮かびません。次のようなコードを書くことはないと思います。

var userId = UserId(100)
userId = otherUserId

前述の Vector のような値オブジェクトであれば、値が変化するため交換可能性に意味が見出せますが、基本的に ID に交換可能性は不要でしょう。

ふるまい

ID は基本的にふるまいを持たないでしょう。
ID の数値や文字の並びに意味があり、何かしらの情報(生成日時など)を取り出せる場合があるかもしれません。

ID が何らかの情報を持つ例
val userId = UserId(20220801)
val createdDate = userId.getCreatedAt() // 2022/08/01

実際にはデータベースなどで自動的に振られた番号を利用していることが多いのではないかと思います。

タイプセーフ(その1)

値オブジェクトのメリットとして、 タイプセーフ について挙げられていることがあります。
複数種類の ID がそれぞれ型を持てば、間違った ID を渡すことがなくなるというものです。
以下のように getUser() に間違った taskId を渡してもエラーに気付くことができます。

fun getUser(userId: UserId): User { ... }

val userId = UserId(100)
val taskId = TaskId(9999)

val user1 = getUser(userId) // OK
val user2 = getUser(taskId) // エラー

これはメリットとして挙げられると思いますが、やはり、値オブジェクトの特有のメリットとは言えません。
「不正な値の検査」でも述べたように、値オブジェクトの条件を満たさなかったとしても上記コードはエラーを検出することができます。 タイプセーフのメリットはオブジェクトであれば得られるものです
java のように hashCode()equals() を実装しなければ等価性を得られない言語で、それらを実装せずに ID をオブジェクト化して利用している方もそれなりにいるのではないでしょうか。

タイプセーフ(その2)

複数の ID を同時に扱う場合に、取り違えることがなくなるという意見も目にします。
値オブジェクトは ドメインオブジェクト として作成されることが多いと思います。使用されるのは アプリケーション層(ユースケース層)ドメイン層 でしょう。

class GetTaskUseCase {
    ...
    
    fun execute(userId: Int, taskId: Int): TaskData {
        val userId = UserId(userId)
	val taskId = TaskId(taskId)
	
	val task = taskRepository.getTask(userId, taskId)
	...
    }
}

ドメインモデルをプレゼンテーション層に公開しない 設計であれば、外側から UseCase に渡されるのは値オブジェクト化された ID とは別のものでしょう。リポジトリが値オブジェクトとしての ID を要求するのであれば、 UseCase 内で UserIDTaskId を生成する必要があります。このとき、値を取り違えてインスタンスを生成する可能性は捨てきれません。

val userId = UserId(userId)
val taskId = TaskId(userId) // userId を渡してしまっている

このような間違いを値オブジェクトだけで防ぐことは難しいので、最終的には テストで防ぐ ことになるでしょう。このような複数種類の ID の取り違いは、値オブジェクトであっても、プリミティブ型であっても起こりうるものかと思います。

タイプセーフ(その3)

続いてはリポジトリ側の話になります。
引数として値オブジェクトを渡されたリポジトリの実装はどのようになるでしょうか。
具体的なデータベースや API にアクセスするために値オブジェクトの中の値を取り出すことになります。

fun getTask(userId: UserId, taskId: TaskId): Task {
    val uid = userId.value
    val tid = taskId.value
    
    // uid, tid を使って DB や API にアクセスする
    ...
}

この UserIdTaskId は、 UseCase の中で生成されたものでした。そして、リポジトリに渡された後はすぐに値を取り出されます。
このように リポジトリのインターフェースを乗り越えるためだけ に作成される値オブジェクトにどれほどのメリットがあるのでしょうか。

可読性

可読性が上がる、という意見も見ました。
型が記述されている箇所の可読性を見比べてみます。

値オブジェクト版
class User(
    val userId: UserId,
    ...
)

fun getUser(userId: UserId): User { ... }
プリミティブ版
class User(
    val userId: Int,
    ...
)

fun getUser(userId: Int): User { ... }

上記例では、個人的には可読性にあまり差を感じません。
プロパティ名や引数名で十分に意味が伝わってくるからです。

ただし、値オブジェクトの方が命名の自由度は高いのではないか、とは思います。
プリミティブ型では名前で確実にユーザー ID であることを伝えなければなりません。例えば、 id: Int では、何を指すID かわかりません。少なくとも userId: Int としなければならないでしょう。
値オブジェクトであれば、 id: UserId としていても、ユーザー ID ということが伝えられます。変数名以外に情報を持たせられるのはメリットだと感じました。
しかし、繰り返しになりますが、これも オブジェクトであれば得られるメリット ではあります。

まとめ

このように ID を値オブジェクト化したときの特徴を見てきてわかったことがあります。
値オブジェクトは「値」と「オブジェクト」の 2 つの特徴を持ちます。

  • 値の特徴
    • 不変
    • 交換可能性
    • 演算
  • オブジェクトの特徴
    • 不正な値の検査
    • ふるまい
    • タイプセーフ
    • 可読性

ID を値オブジェクトにした場合、値の特徴である「不変」「演算」ではプリミティブ型であったときと比べて大きなメリットが得られていません。「交換可能性」においては、 ID の持つ性質から必要性を感じません。
一方でオブジェクトの特徴である「タイプセーフ」「可読性」については、プリミティブ型であったときには得られなかったメリットを得られています。「不正な値の検査」や「ふるまい」に関しては、 ID という用途においてはあまりメリットがないように思います。
「タイプセーフ」はメリットではありますが、煩わしさも増しているため、手放しで取り入れた方が良いと言えるものではないように感じます。過剰包装のようなイメージがあります。
「可読性」のメリットは個人的にはわずかであると思いました。これについては個人差があるかもしれません。

このようにして見ると、 ID は 値として活躍の場がない ため、 値オブジェクト化する動機が弱い のかな、と感じました。特に交換の必要性を感じないという点が致命的だと思いました。
オブジェクト化することで型を得たい という動機はありますが、単一の値のみを持ったオブジェクトを作るというのは個人的には抵抗があります。そこで、単一の値のみを持っていてもおかしくない 値オブジェクトというパターンを用いて、その奇妙さを紛らわそうとした のが「 ID の値オブジェクト化」 なのではないか、とも感じました。

これまでは、あまりよく考えずに ID は値オブジェクト化していました。 kotlin では data class として宣言すれば簡単に実装できるというのも一因だと思います。
今回改めて考えてみて、 ID を値オブジェクト化するメリットはあまり大きくないように感じました。実際にコードを書いてみないとわからない部分はあるかと思いますが、これからは、ひとまず個人開発においては、プリミティブのまま扱ってみることを試してみようと思います。実際に試した上で、今回の印象がまた変わっていくのかどうか確認していきたいと思います。

本記事には色々と考慮漏れや認識間違いなどあるかもしれません。それらの指摘を含めて、ご意見いただければ幸いです。

Discussion

Kugiya JiroKugiya Jiro

IDはプラクティカルな例としてネット記事ではよく出てきますが、参照として使いたいときに相手方のパッケージへの依存が出てきてしまうので(構成にもよるけど)、私もイマイチなモデリングだなと思っています。