EntityのIDってどこで採番するのがいいの?
はじめに
Clean Architecture や DDD を勉強して (趣味で) 実践してたんですが、Entity の ID ってどこで採番するべきなんですかね。
自分の考えを整理したのでまとめてみます。
マサカリお待ちしてます。
結論
- 使っている RDB で連番の方がパフォーマンス的な有利なことを測定して、それが必要なほどシビアなビジネス要求があるなら、DB で採番する
- ID は直和で表現する
- そうでないなら、ドメイン内で採番する
- UUID v7 は優秀
前提
- サンプルコードは Kotlin で書きます。サーバーサイド Kotlin エンジニアなので...
- typed error を使いたいので arrow-core の Either 型を使います
考察
パフォーマンス要件はどうか
具体的なベンチマークは先人がやってるので避けますが、一般的な話をします
- キャッシュヒットやインデックスの構築を考えると、UUID (v4) よりも連番の方がパフォーマンスがよいことが多い
- UUID v7 は時刻ベースなので、v4 と違いパフォーマンスは十分によい (Serial と変わらないどころかむしろ早いという話もある[1])
- そもそもこの差が致命的になることは少ないのでは?
- 先にもっと見直すべき点ないですか
ですので、よっぽどのことがない限りは UUID v7 を使ってドメイン内で採番するのがよさそうです
UUID v7 を使ってドメイン内で採番する
UUID v7 はランダムではありながらも時刻ベースでソートできます。
INSERT のパフォーマンスも十分によいですし、作成順でソートした SELECT を行いたいときも期待できます。
また、UUID は一般的な規格なので、大抵の言語では UUID を生成するライブラリが提供されているはずです。
stdlib には v4 しかないこともあります (Kotlin もそう) が、サードパーティを探せば流石にどこかにあるでしょう。
サンプルコード
Entity を初期化する際に ID を採番します。与えられた場合はそれを使います。
kotlin 純正でいいのが見つからなかったので com.fasterxml.uuid:java-uuid-generator
を使っています。
今回の本題とは関係ありませんが、private constructor と operator fun invoke を使うことで、通常のコンストラクタを隠蔽し Either を返す初期化を提供できます。
data class User private constructor(
val id: String,
val name: String,
) {
companion object {
operator fun invoke(
id: String?,
name: String,
) = either {
ensure(name.isNotEmpty()) { IllegalArgumentException("Name must not be empty") }
User(
id = id ?: Generators.timeBasedEpochGenerator().generate().toString(),
name = name,
)
}
}
}
連番を使う
連番を使うなら選択肢は2つです。
- 次の ID を DB に問い合わせる
- 直和型で ID を表現する
ですが先に述べた通り、この手法を選ぶならパフォーマンスを最大限高めたいはずです。
問い合わせは避けた方がいいでしょう。
ということで、PostgreSQL の Serial や MySQL の Auto Increment を使って自動採番させましょう。
その場合の Entity の ID は、直和で表現することになります。
表現方法はいくつか考えられますが、原理としては同じですね。
羃等性は崩れますが仕方ないです。
- ID の型を Nullable Int や
Option<Int>
,Result<Int>
などにする - Save の Input boundary として ID を含まない DTO を作る
サンプルコード
本題からずれるので省略しますが、連番の ID をそのまま外部に公開するのは推測難易度が低いのでよくないです。
Interface Adapter layer で AES とかを使い適当に暗号化しましょう。
Entity には Either 型の ID を持たせます。
interface EntityBase<T : EntityBase<T>> {
val id: Either<IllegalStateException, Int>
fun persist(id: Int): T
}
data class User private constructor(
override val id: Either<IllegalStateException, Int>,
val name: String,
) : EntityBase<User> {
companion object {
operator fun invoke(
id: Int?,
name: String,
) = either {
ensure(name.isNotEmpty()) { IllegalArgumentException("Name must not be empty") }
User(
id = id?.right() ?: IllegalStateException("User entity is not persisted").left(),
name = name,
)
}
}
override fun persist(id: Int): User = copy(id = id.right())
}
ORM ライブラリに依存したコード載せるのもアレなので、インメモリのリポジトリを作ってみます。
やることは変わりません。ID のない Entity を受け取って、ID を採番して返します。
interface UserRepository {
fun save(user: User): User
}
class InMemoryUserRepository : UserRepository {
private val users = mutableMapOf<Int, User>()
override fun save(user: User): User {
val id = user.id.fold({ users.size }, { it })
val persisted = user.persist(id)
users[id] = persisted
return persisted
}
}
呼び出しはこんな感じですね。
class UserCreateUsecase(
private val userRepository: UserRepository,
) {
fun execute(input: UserCreateInput) = either {
input.toEntity().bind().let { user ->
// まだ ID にアクセスしてはいけない
// val userId = user.id.bind()
userRepository.save(user)
}.let { user ->
// ここなら正常に ID を取得できる
val userId = user.id.bind()
user.toCreateUserOutput().bind()
}
}
}
data class UserCreateInput(
val name: String,
) {
fun toEntity() = User(null, name)
}
data class UserCreateOutput(
val id: Int,
val name: String,
) {
companion object {
fun User.toCreateUserOutput() = either {
UserCreateOutput(id.bind(), name)
}
}
}
まとめ
UUID v7 最強!
Discussion