GoでDDD① IDオブジェクトの共通化
はじめに
普段業務ではKotlinとSpringBootを使用してDDDで開発している。
というと聞こえはいいが、既存のコードはアプリケーション層にロジックが漏れまくっていて、とても「うちのプロジェクトはDDDできています!」と胸を張って言える状態ではない。
言ってしまえばDB駆動設計とDDDの間みたいな状態である。
最終的な目標は現在のプロジェクトの一番複雑なユースケースをリファクタすることであるが、まずは実践できていることも含めて改めて整理する。
しかし、Kotlinでそのまま書いても面白みがないので、普段個人開発で使用しているGoを用いてアウトプットしていく。
IDオブジェクトの共通化
DDDでエンティティを作成する際にまず必要になるのが一意の識別子だ。
DDDにおけるエンティティの定義をおさらいしておくと
- 値オブジェクトと並びドメインモデル (ドメインオブジェクト) の中心的な要素。ドメイン内のさまざまなビジネスの実体の概念をモデル化する。
- エンティティの同一性判定は一意の識別子によって行われる。
- エンティティは可変性がある。
この識別子=IDをどう実装するかが、今回扱う内容である。
この記事ではIDを値オブジェクトとして実装する。
では値オブジェクト(VO)についてもおさらいしておくと
- エンティティと並びドメインモデル (ドメインオブジェクト) の中心的な要素。ドメイン内のさまざまな値の概念をモデル化するのに用いられる。
- 値オブジェクトの同一性判定は、その属性の値によって行われる。つまり、属性の値が全て同じであれば、その値オブジェクトは同一と見なされる。
- 値オブジェクトは不変であることが多い。一度作成されたら、その値を変更するのではなく、新しい値オブジェクトを作成する。(Kotlinだと
copy()
を用いることが多い)
そもそもIDをint
やstring
などのプリミティブ型ではなく値オブジェクト扱うのはなぜか。
IDは多くの場合でULIDやUUIDなどが用いられる。エンティティを復元する際にIDが本当にULIDか、本当にUUIDかバリデーションを実装してしまうと、そのバリデーションロジックがエンティティの各所に散らばってしまうことになる。これは、ロジックの重複とメンテナンスの困難さを招くことになりかねない。一方で、IDを値オブジェクトとして実装することで、そのようなバリデーションロジックを一箇所に集約し、各エンティティで再利用できるようにすることができる。これは、コードの整理と保守性の向上に直結する。
また、IDを値オブジェクトとして扱うことで、IDの生成方法やフォーマットに関する詳細をカプセル化することができる。例えば、ULIDを使用する場合、その生成ロジックをIDクラス内に隠蔽し、外部からは単にIDオブジェクトを生成するだけで良くなる。これにより、将来的にIDの生成方法やフォーマットを変更したい場合にも、IDクラスの内部の変更のみで済み、他のエンティティに影響を与えることなく対応することが可能になる。
今回実装するID
今回は仮にProductID(商品ID)をULIDとして実装する。
あらかじめutilsなどにULID関連の関数を用意しておく。
import (
"github.com/oklog/ulid"
"math/rand"
"time"
)
// NewULID は ULID を生成
func NewULID() string {
t := time.Now()
entropy := rand.New(rand.NewSource(t.UnixNano()))
return ulid.MustNew(ulid.Timestamp(t), entropy).String()
}
// IsValidULID は ULID が有効かどうか
func IsValidULID(ulidStr string) bool {
_, err := ulid.Parse(ulidStr)
return err == nil
}
実装するモデルは以下。
Productエンティティも記載しているが、今回実装するのはProductIDまで。
実装
GenericID
// GenericID は汎用IDの型
type GenericID struct {
value string
}
// NewGenericID は新しいGenericIDを生成
func NewGenericID() *GenericID {
return &GenericID{value: utils.NewULID()}
}
// Value はIDの値を返す
func (id *GenericID) Value() string {
return id.value
}
// RestoreGenericID はGenericIDを復元
func RestoreGenericID(value string) (*GenericID, error) {
if !utils.IsValidULID(value) {
return nil, customerrors.NewInternalServerError(
fmt.Sprintf("IDが不正です: %s", value),
nil,
)
}
return &GenericID{value: value}, nil
}
ProductID
import (
id "path/to/your/id/package"
)
// ProductID は商品ID
type ProductID struct {
id *id.GenericID
}
// NewProductID は新しい商品IDを生成
func NewProductID() *ProductID {
return &ProductID{id: id.NewGenericID()}
}
// Value はIDの値を取得
func (p *ProductID) Value() string {
return p.id.Value()
}
// RestoreProductID は文字列からProductIDを復元
func RestoreProductID(value string) (*ProductID, error) {
gid, err := id.RestoreGenericID(value)
if err != nil {
return nil, err
}
return &ProductID{id: gid}, nil
}
上記の実装のポイントは以下。
- カプセル化
IDの生成と検証のロジックがGenericID内にカプセル化されており、これによりProductIDはその詳細を知る必要がない。外部から見て、IDの内部実装は隠蔽されている。 - 再利用性
GenericIDを使うことで、IDの生成と復元のロジックを異なるエンティティタイプ間で共有できる。これにより、コードの重複を減少させ、将来的なメンテナンスが容易になる。 - 一貫性
すべてのIDがGenericIDを使うことで、アプリケーション全体でIDの形式と操作が一貫する。これは、システムの予測可能性と整合性を向上させる。 - バリデーション
RestoreProductIDメソッドは、与えられた文字列が有効なIDであるかを検証する。 - 変更への柔軟性
将来的にIDの生成方法やフォーマットが変更される場合、GenericIDの実装のみを更新すれば良いため、変更が容易になる。
今回はシステム全体で同じIDを使う前提になっているが、ところによってUUIDを使ったり、数字を使ったりする場合は、IDインターフェースを用意してもいいかもしれない。
ちなみによくないパターンとして以下も記載しておく。
// ProductID は商品ID
type ProductID struct {
id.GenericID
}
// NewProductID は新しい商品IDを生成
func NewTravelEventID() *TravelEventID {
return &TravelEventID{
*id.NewGenericID(),
}
}
...
このコードは、Go言語における構造体の埋め込み(embedding)を用いた委譲の一形式だが、潜在的な問題がある。ProductIDがid.GenericIDを埋め込んでいるため、ProductIDのインスタンスはGenericIDの公開メソッドを直接公開してしまう。その結果以下が起こる。
- インターフェースの破綻
ProductIDがGenericIDのメソッドを直接公開することで、ProductID固有の振る舞いや制約を追加することが難しくなる。例えば、特定のバリデーションルールをProductIDに適用したい場合、これを実現するのが複雑になる。 - カプセル化の欠如
ProductIDの内部構造(つまり、GenericIDを使っていること)が外部に露出してしまう。これはオブジェクト指向の原則であるカプセル化に反する。
まぁGoでオブジェクト指向をしようとすると往々にしてハマる理由が、ここに見える気がする。継承と同じ気持ちで委譲をしてはいけない。
Discussion