ID型はソフトウェア上で一意になるようにした方がいい
Last Updated on 2023年12月15日 by lemonade
概要
DBやその他のストレージを使用するソフトウェアでは、一般に連番、UUID、または任意の文字列をデータのキーとして使用します。強い静的型を持つ言語では、ID値を値オブジェクトでラップし、それぞれのエンティティに対するID型を作成することがよくあります。ID型を用意することで、型による安全性が高まり、永続化層やドメインサービス間でのデータの不整合を防ぐことができます。また、型を使用することで、単にid
という引数名でも、どのIDが必要かが明確になります。
IDの型を作成する例
以下は、User型にUser.ID型を使用したSwiftの例です。
import Foundation
/// ユーザー
struct User: Hashable, Sendable {
/// ユーザー識別子
var id: ID
struct ID: Hashable, Sendable {
var value: UUID
init(_ value: UUID = .init()) {
self.value = value
}
}
}
/// ユーザークエリサービス
final class UserQueryService {
func find(_ id: User.ID) async throws -> User? {
try await UserModel.find(id.value, on: db)?.toEntity
}
}
この方法では、リポジトリのfindメソッドのIDをUser.IDに制限することができます。
特定のエンティティに対する一意のIDの問題点
ソフトウェア内で一意でなく、特定のエンティティに対して一意なIDを使用すると、常にその親エンティティのIDも同時に使用する必要が生じます。
例えば、以下はユーザーに対して一意な画像IDを使用した場合のSwiftの例です。
import Foundation
struct Image: Hashable, Sendable {
var id: ID
var data: Data
struct ID: Hashable, Sendable {
var value: String
}
}
この場合、ユーザーAがImage.ID("hello")
を持ち、ユーザーBがImage.ID("hello")
を持つことが可能ですが、ユーザーAは同じIDの画像を2つ持つことはできません。
しかし、この実装には大きな問題があります。画像のIDを扱う際に常にユーザーIDを付随させる必要があり、扱いが煩雑になります。
例えば、以下のImageQueryServiceクラス、ImageRepositoryクラスでは、画像に関する各操作にユーザーIDが必要です。
final class ImageQueryService {
func find(_ imageID: Image.ID, of userID: User.ID) async throws -> Image? { ... }
}
final class ImageRepository {
func create(_ image: Image, of userID: User.ID) async throws { ... }
func update(_ image: Image, of userID: User.ID) async throws { ... }
func delete(_ imageID: Image.ID, of userID: User.ID) async throws { ... }
}
この実装では、複数ユーザーの画像を一括で扱うことが困難になります。たとえば、以下のようなメソッドは利用できません。
final class ImageQueryService {
func deleteAll(_ imageIDs: Set<Image.ID>) async throws
}
解決策
解決策としては、IDに親エンティティのIDも含めることが考えられます。
struct Image: Hashable, Sendable {
var id: ID
var data: Data
struct ID: Hashable, Sendable {
var userID: User.ID
var value: String
}
}
この方法により、IDがソフトウェア全体で一意になるため、集合として扱うことが容易になり、User.IDを常に渡す必要がなくなります。