Goで巨大なモノリスシステムを堅牢に作りたい
はじめに
こんにちは。株式会社バニッシュ・スタンダードでサーバーサイドエンジニアをやっているhidechaeです。
弊社のサービスはもともとRuby on Railsで作られていましたが、3年ほど前からGoへのリプレースを始めて、今では6~7割程度はリプレース完了しています。
個人的には、Goで作られているシステムは、マイクロサービスやモジュラーモノリスのように、小さくモジュール化して作られることが多いようなイメージがありますが、様々な理由から(ここは端折りますが…)弊社ではまずは単純なモノリスとしてリプレースすることにしました。
今回は、(戦略的)DDD + Clean Architectureで実装していく中で、どうやって堅牢な書き方ができるのか考えたことを書いていこうと思います。事前に書いておきますが、これらは型で縛るためにはどのように書けば良いのかを考えた内容で、半分くらいは過剰だなと思って採用していません。
モデルのドメイン制約
DDDにおいて、不正なモデルを作ることができないようにすることが大事です。
1.通常のモデル定義
まず、以下のようなモデルがあるとします。
package model
type User struct {
Name string
}
どこからでも自由にName
を変更することができるため、Name
に制約が存在する場合、不正なデータを作成できてしまいます。
2.制約を表現する
以下のようにフィールドを非公開にして、Getter, Setterからのアクセスのみに強制することでバリデーションを行い、不正なデータを生成できなくなります。
package model
type User struct {
// フィールドをprivateにする
name string
}
func NewUser(name string) (*User, error) {
if err := /* バリデーション処理 */; err != nil {
return nil, err
}
return &User {
name: name,
}, nil
}
// Getter経由で取得する
func (u *User) Name() string {
return u.name
}
// Setterでバリデーションを行う
func (u *User) SetName(name string) error {
if err := /* バリデーション処理 */; err != nil {
return err
}
u.name = name
return nil
}
3.コンストラクタを強制する
制約を表現することはできましたが、Goではゼロ値でインスタンスを作成することができます。
初期値状態として期待するものがある場合、ゼロ値で生成されてほしくはありません。
以下のように、インタフェースだけ公開してstructは非公開とすることで、他パッケージからゼロ値のインスタンスを生成することを制限することができます。
package model
// インタフェースだけ公開する
type User interface {
Name() string
SetName(string) error
}
// 実装はprivateにする
type user struct {
name string
}
var _ User = &user{}
// コンストラクタは公開する
func NewUser(name string) (User, error) {
if err := /* バリデーション処理 */; err != nil {
return nil, err
}
return &user {
name: name,
}, nil
}
func (u *user) Name() string {
return u.name
}
func (u *user) SetName(name string) error {
if err := /* バリデーション処理 */; err != nil {
return err
}
u.name = name
return nil
}
実際にはvar u User
のようなnilなインスタンスは生成できてしまうし、逆に分かりづらくなるケースもあるので、ここまでやらなくて良いかなと思っています。
4.パッケージを絞る
他パッケージからゼロ値なインスタンスを生成することは制限できましたが、Goはパッケージスコープなので同じパッケージ内からは作成することができます。
これを制限するには、単一モデルしか属さないパッケージを作るしか無いので、ほとんどの場合過剰だと思います。
サービスのドメイン制約
複数の集約(モデル)をまたいだ制約がある場合、モデルで担保できないのでサービス層で担保します。
1.サービスでドメイン制約を表現する
User
に加えて、User
を管理するGroup
を考えます。
User
をGroup
に追加できるかどうかがUser
の状態に依存する時、Group
の保存前にUser
の状態をチェックする必要があります。
このとき、サービスの実装は以下のようになります。
package model
// GroupはUserIDのリストを持つ
type Group struct {
userIDs []int
}
package repository
type UserRepository interface {
Get(id int) (*User, error)
}
type GroupRepository interface {
Get(id int) (*Group, error)
Save(group Group) error
}
package service
type GroupService struct {
userRepository repository.UserRepository
groupRepository repository.GroupRepository
}
func (s *GroupService) AddUserToGroup(group Group, userID int) error {
user, err := s.userRepository.Get(userID)
if err != nil {
return err
}
// Groupに追加するかどうか、Userの状態をチェック
// 問題なければGroupにユーザーを追加し、保存する
if err := s.groupRepository.Save(group); err != nil {
return err
}
return nil
}
しかし、GroupRepository
は公開されており、Save
処理はサービス意外からも呼び出せてしまいます。
2.保存をサービスを経由することを強制する
サービスでしか呼べないようにするには、以下のように公開できるものとできないものでスコープを変えてあげることで実現することはできます。
package repository
type UserRepository interface {
Get(id int) (*User, error)
}
// サービス意外からも呼ばれて良いものはrepositoryパッケージに定義して公開する
type GroupRepository interface {
Get(id int) (*Group, error)
}
package service
// サービスでしか呼びたくないメソッドは、サービスパッケージ内に定義してprivateにする
type groupRepository interface {
Save(group Group) error
}
type GroupService struct {
userRepository repository.UserRepository
groupRepository groupRepository
}
func (s *GroupService) AddUserToGroup(group Group, userID int) error {
user, err := s.userRepository.Get(userID)
if err != nil {
return err
}
// Groupに追加しても良いか、Userの状態をチェック
// 問題なければGroupにユーザーを追加し、保存する
if err := s.groupRepository.Save(group); err != nil {
return err
}
return nil
}
ただし、DIがやたら面倒くさくなるし、ここまでやる必要は無いかなと思います。
まとめ
冒頭にも記述しましたが、採用しているものは少なくて、実際にサービス上で書いている書き方は、モデルの方は2, サービスの方は1です。
僕自身がもともと固い言語の出身なので、Goだとどれくらい縛ることができるのかを考えてみましたが、Goらしい書き方とは乖離しているなぁと思ってます。(何をもってGoらしいと言うかはさておき…)
型ではなく静的解析によるチェックも多言語に比べてやりやすいですし、どういう書き方、運用が最適なのか引き続き考えて行けると良いなと思っています。
Discussion