キャンプ場を検索・予約できるサービス(以下hinata spot)の開発を担当している名嘉眞です。
hinata spotではbackendにGoを採用しています。 また予約機能では試行錯誤ですがDDDを取り入れて開発をおこなっています。
今回はDDDやオブジェクト指向の文脈でよく紹介される値オブジェクトを、実際にGoでどのように取り入れているかをブログにしようと思います。
値オブジェクトとは
値オブジェクトとは何かについてはこのブログでは詳しく記載しませんが、以下のような性質をを持つオブジェクトです。
- 不変である
- オブジェクト自体を変更することはできない
- 交換が可能である
- オブジェクト自体を変更することはできないですが、同じ値オブジェクトどうしであれば交換(代入)が可能
- 等価性によって比較される
- 値オブジェクトがもつ値が同じかどうかでオブジェクトどうしが同じものかどうか判別できる
値オブジェクトに関してより詳しい内容についてや、値オブジェクトを取り入れると良い点についてはネットで検索すると多くの記事が見つかります。
ちなみにhinata spotで値オブジェクトを取り入れようと考えたきっかけは、「ドメイン駆動設計入門」という書籍の輪読会が行われエンジニアチーム内でDDDについて学んだことです。
Goで値オブジェクトを定義する
実際にhinata spotのコードでも定義している、メールアドレスの値オブジェクトを例にしてみます。
ちなみに、メールアドレスを値オブジェクトにする理由としては、以下のような点があります。
- メールアドレスは構成のルールがあります。そのためメールアドレスの生成にはバリデーションを設定したい。
- メールアドレスは一度インスタンス化したあとは変更できない値として扱いたい
コードは以下のようになっています。
// Email メールアドレスの値オブジェクト. type Email struct { value string } func (e Email) Value() string { return e.value } // NewEmail メールアドレスの値オブジェクトのコンストラクタ. func NewEmail(value string) (Email, error) { if "メールアドレスバリデーション処理" { return nil, errors.New("バリデーションエラーです") } return Email{value: value}, nil }
コードの内容を説明していきます。
Email型はprivateなstring型のフィールドをもつ構造体で定義しています。 また、Email型のvalueフィールドのgetterも定義しています。 なぜこの構成にしているかは後述します。
コンストラクタであるNewEmail関数は、引数にメールアドレスとなる文字列を受け取り、Email型とエラーを戻り値にしています。 コンストラクタ内でメールアドレスのバリデーションを実施しています。 バリデーションをコンストラクタ内で行うことで、以下のメリットがあると考えています。
- メールアドレスの生成にNewEmail関数と使うことで、不正なメールアドレスのオブジェクトが生成されにくい
- メールアドレスのルールが定義される箇所が、NewEmail関数のみになり、仮にルールが変更になってもNewEmail関数の変更のみで済む可能性が高い
戻り値のEmail型は特に理由がなければポインタ型にしないです。
Email型の構成について
なぜ、Email型が以下のような構成になっているか説明します。
// Email メールアドレスの値オブジェクト. type Email struct { value string }
Goには型エイリアスと呼ばれる機能があり、以下のように宣言することもできます。 これでもEmail型として扱うことができ、関数の引数や関数の戻り値でEmail型を使うことで可読性が上がるなどのメリットがあります。
type Email string // 以下のような使い方ができる func GetUserEmail(userID string) Email { }
ただ型エイリアスの場合、宣言もとのプリミティブ型の代入によって値を書き換えることができます。
package main import "fmt" type Email string func NewEmail(value string) Email { return Email(value) } func main() { email := NewEmail("test@test") fmt.Println(email) email = "hogehoge" fmt.Println(email) } // 出力結果 // test@test // hogehoge
チーム内でプリミティブ型での代入をしないようにすることが徹底できれば型エイリアスでも良いかなとは思います。
privateフィールドを定義した構造体の場合、プリミティブ型による代入はできなくなります。
package main import "fmt" // Email メールアドレスの値オブジェクト. type Email struct { value string } func NewEmail(value string) Email { return Email(value) } func main() { email := NewEmail("test@test") fmt.Println(email) email = "hogehoge" // エラー fmt.Println(email) }
しかし、メールアドレスの値を取得するgetterが必要になる場合があります。
このgetterは値オブジェクトが定義されているpackage以外で値オブジェクトの情報を参照したい場合に使われます。
Goはフィールドを読み取り専用にする仕組みはなく、privateなフィールドとして定義するとEmail型と同じパッケージでのみアクセス可能となるためです。
// Email メールアドレスの値オブジェクト. type Email struct { value string } func (e Email) Value() string { return e.value }
型エイリアスで値オブジェクトを表現するか、privateなフィールドをもつ構造体で値オブジェクトを表現するかはチーム方針にもよると思います。 hinata spotでは型エイリアスではなく、privateなフィールドをもつ構造体で定義しています。
値オブジェクトは不変にする
値オブジェクトは不変にしたいので基本的にはポインタ型で扱わなくて良いと考えています。 (実際のプロダクトのコードはポインタ型になっている部分は多いです。理由は特になくて最初の頃とりあえずポインタで良いかなと考えていました)
例えばPrice(金額)という値オブジェクトを定義するとします。 この金額オブジェクトに金額を加算するふるまいを定義してみます。加算処理を定義したAddメソッドは、レシーバをポインタにはしません。
package valueobject type Price struct { value uint } func NewPrice(price uint) Price { return Price{value: price} } func (p Price) Add(ap Price) Price { p.value += ap.value return p }
この金額オブジェクトを使った例が以下のようになります。
package main import ( "fmt" "play.ground/valueobject" ) func main() { basePrice := valueobject.NewPrice(1000) optionPrice := valueobject.NewPrice(2000) totalPrice := basePrice.Add(optionPrice) fmt.Println(basePrice) fmt.Println(optionPrice) fmt.Println(totalPrice) } // 出力結果 {1000} {2000} {3000}
上記のコードを読んでみます。
金額の値オブジェクトで基本金額(1000円)とオプション金額(2000円)を宣言しています。
そして基本金額にオプション金額を加算して合計金額を算出しています。
今回の例では単純な足し算なので伝わりづらいかもしれませんが、計算処理が値オブジェクトのふるまいとして定義されることで、計算処理の内容が値オブジェクトを利用する側のパッケージに漏れ出していません。
計算処理が漏れ出さないことで、コード全体に具体的な計算処理が定義されにくい状態を保つことができ、保守性が向上します。
また、値オブジェクトは不変なオブジェクトなので、合計金額を算出しても基本金額自体に変更はありません。
このため変数の値の状態を気にすることがなくなります。また値が変化しないのでより具体的な変数名を設定することができます。
まとめ
Goで値オブジェクトを定義し利用するまでを紹介してみました。
構造体にprivateなフィールドを定義する構成だと、対象のフィールドのgetterが必要になる場合があるなど、他の言語で値オブジェクトを定義する場合とは異なる点があると思います。
実際、getterがあることでドメインオブジェクトとは別のパッケージで値を参照することができ、結局ドメインオブジェクトとは別のレイヤでロジックを定義してしまっている部分もあります。 これは今後修正していく予定です。(例えばレイヤごとの構造体に変換する関数でのみ、getterを使うようにするなど)
またGoはDDDに向いていないという意見もあると思いますが、DDDの考え方は業務アプリケーションを設計する上でとても参考になっています。
Goで表現しづらい部分はありますが今後もより良いコードが書けるように試行錯誤していきたいと考えています。