Goで汎用的な値オブジェクト(value object)の仕組みを作成した話
この記事ではドメイン駆動開発(DDD 以降DDDとします)に登場してくる値(value)オブジェクト(以降値オブジェクトとします)をGoで実装する方法を紹介します。クラスがないGoで完全な値オブジェクトを実装するには工夫が必要です。今回はGoのジェネリクスを使用してなるべく汎用的な作りにしてみました。
値オブジェクトについて
DDDの文脈で登場するドメインモデルの一種です。ドメインを表現するのにエンティティという概念があり、エンティティは一意性を持ちます。エンティティ以外のドメインモデルは値オブジェクトであり、以下のような特徴を持ちます。
- 一度作成したオブジェクトの状態は不変である。(基本的には)
- オブジェクトの状態が変わる時は、オブジェクト自体を完全に置き換える。
- 値が等しいかどうかを比較することができる。
値オブジェクトを採用する理由やメリットについてはDDDについての理解や説明となってしまうので詳細には本記事では語りませんが、ドメインの概念をより表現できるようになったり、値の不変性を保てることが採用されるメリットになるでしょう。
値オブジェクトを実装してみる
以下のようなUserというドメインモデルが持つIDを値オブジェクトとして表現することを考えてみます。
type User struct {
Id UserId
Name string
}
型エイリアスの使用を考えてみる
以下のような型エイリアスでUserIdという値オブジェクトを表現してみます。
type UserId int64
この値オブジェクトを作成してみると以下のようになります。
func main() {
ui := domain.UserId(1)
fmt.Println(ui) // 1
}
では、オブジェクトの値を変更してみましょう。
func main() {
ui := domain.UserId(1)
ui = 2 // <- これを追加
fmt.Println(ui) // 2
}
一度作成した値が途中で変わってしまいました。これは値オブジェクトの重要な特徴である不変性が備わっていません。
構造体の使用を考えてみる
値オブジェクトはクラスが存在する言語ではクラスで表現されます。Goではクラスはありませんが構造体が用意されているので構造体を使用することを考えてみます。
type UserId struct {
Value int64
}
func NewUserId(value int64) UserId {
return UserId{value}
}
func main() {
ui := domain.NewUserId(1)
fmt.Println(ui) // {1}
}
ではこの値の不変性は保たれているでしょうか?
func main() {
ui := domain.NewUserId(1)
ui.Value = 2
fmt.Println(ui) // {2}
}
UserId
のValue
の値が公開されてしまっているので値が書き換えられてしまいました。値が書き換えられないように非公開にしてみましょう。
type UserId struct {
value int64 // フィールドを非公開に変更
}
func main() {
ui := domain.NewUserId(1)
ui.value = 2 // フィールドが非公開のためコンパイルエラー
fmt.Println(ui)
}
上記のようにフィールドを非公開にしたことでこの値オブジェクトは不変性を持っていそうです。加えて、値の比較もできるため値オブジェクトとしての性質を持っていそうです。
func main() {
ui := domain.NewUserId(1)
ui2 := domain.NewUserId(1)
fmt.Println(ui == ui2) // true
}
ジェネリクスを使って汎用的にする
値オブジェクトは一つではなくドメインモデルを表現するために非常に多く作成されることになります。試しに、ユーザー名を表すUserName
という値オブジェクトを追加してみます。
package domain
type UserId struct {
value int64
}
func NewUserId(value int64) UserId {
return UserId{value}
}
// ---- これを追加 ----
type UserName struct {
value string
}
func NewUserName(value string) UserName {
return UserName{value}
}
// -------------------
type User struct {
Id UserId
Name UserName // ここも変更
}
これに加えてそれぞれのオブジェクトにEquals()
やString()
、ゲッター関数などを定義する必要があり、少し実装が面倒くさいとも感じます。そこで、表現する値がstring
なのかint
なのか型が違うだけでジェネリクスが使えそうなため、以下のような汎用的な実装をしてみます。
package domain
import (
"fmt"
"reflect"
)
type ValueObject[T any] interface {
Value() T
Equals(other ValueObject[T]) bool
String() string
}
type valueObject[T any] struct {
value T
}
func NewValueObject[T any](v T) ValueObject[T] {
return &valueObject[T]{value: v}
}
func (v *valueObject[T]) Value() T {
return v.value
}
func (v *valueObject[T]) Equals(other ValueObject[T]) bool {
return reflect.DeepEqual(v.Value(), other.Value())
}
func (v *valueObject[T]) String() string {
return fmt.Sprintf("%v", v.value)
}
上記の実装を少し解説するとvalueObject
という構造体に非公開フィールドで値を保持させています。このオブジェクトには値を取り出すValue()
と値を比較するEquals()
に加え、文字列表示するためのString()
を実装させます。これを定義したい値オブジェクトの構造体に埋め込むことで使用します。
type UserId struct {
ValueObject[int64]
}
func NewUserId(value int64) *UserId {
return &UserId{NewValueObject[int64](value)}
}
ui := NewUserId(1)
fmt.Println(id.Value(), id.String(), id.Equals(id)) // 1 1 true
では、値オブジェクトをフィールドに持つ値オブジェクトを考えてみます。以下のようなfamilyName
とlastName
を持つUserName
という値オブジェクトを実装してみます。
type FirstName struct {
ValueObject[string]
}
func NewFirstName(value string) *FirstName {
return &FirstName{NewValueObject[string](value)}
}
type LastName struct {
ValueObject[string]
}
func NewLastName(value string) *LastName {
return &LastName{NewValueObject[string](value)}
}
type UserName struct {
firstName *FirstName
lastName *LastName
}
func NewUserName(firstName *FirstName, lastName *LastName) *UserName {
return &UserName{firstName, lastName}
}
このような場合は、UserName構造体に対してそれぞれの関数を実装する必要があります。
func (un *UserName) FullName() string {
return fmt.Sprintf("%v %v", un.firstName, un.lastName)
}
func (un *UserName) String() string {
return un.FullName()
}
func (un *UserName) Equals(other *UserName) bool {
return un.firstName.Equals(other.firstName) && un.lastName.Equals(other.lastName)
}
func main() {
name := domain.NewUserName(
domain.NewFirstName("yamanaka"),
domain.NewLastName("junichi"),
)
fmt.Println(name.FullName(), name.String(), name.Equals(name)) // yamanaka junichi yamanaka junichi true
}
ジェネリクスによる値オブジェクトの表現は単一のフィールドであれば可能ですが、上記のようの複数フィールドを持つような値オブジェクトを表現するときにはそれぞれの関数を定義してあげる必要がありそうです。
まとめ
今回はGoで値オブジェクトを実装する方法について紹介しました。
- 型エイリアスを使用して値オブジェクトを表現しようとすると値の不変性が保てない。
- 構造体を使用することで値の不変性を保つことができる。
- ジェネリクスを使用することで汎用的な値オブジェクトを表現できる。
-
Value()
、String()
、Equals()
などを実装しておくことで値オブジェクトの性質を表現できる。
DDDを本格的に実践せずに値オブジェクトのような実装パターンを扱うのは軽量DDDというアンチパターンとされることが多いようですが、ドメイン層のロジックを組みやすくなるならば値オブジェクトのみ採用するのもありなんじゃないかなと筆者個人としては思いますがDDDについての基礎知識があったうえで使用するのがいいと感じます。
この記事がGoでDDDを実践しようとしている方や値オブジェクトを実装しようとしている方の参考になれば嬉しいです。
今回は以上です🐼
Discussion