golangでのドメインモデルの初期化、更新パターン
DDDを実コードに取り入れようとする際に考えさせられるのが、モデル初期化をどのように記述するのかである。 モデル初期化を定義せずにDDDをコードにするとモデルの定義違反のコードが生成される可能性がある
// bad type Note struct { Title string Body string State NoteState CreatedAt time.Time UpdatedAt time.Time } func register(title, body string) error { // CreatedAt, UpdatedAtの定義がない。Stateが空文字になる note := &Note{ Title: title, Body: body, } // ... }
ドメインモデルを実装する際にはモデルの定義の他に、新規作成、更新がモデルの定義に沿った形で行われる必要がある
今回はgolangでのモデルの初期化、更新方法として以下の3通りを比較、検討する
- config構造体による初期化、更新
- Functional Option Patternによる初期化、更新
- Method Chainingによる初期化、更新
1. config構造体による初期化、更新
初期化
このパターンの方針としてはNew, Update時の引数を引数専用の構造体を用意し そこに更新したい値を詰め込む方式である
type Note struct { Title string Body string State NoteState CreatedAt time.Time UpdatedAt time.Time } type NoteCreateConfig struct { Title *string Body *string } type NoteState string const ( stateDraft NoteState = "draft" statePublished NoteState = "published" ) var ( defaultTitle = "defaultTitle" defaultState = stateDraft ) var nowFunc = func() time.Time { return time.Now() } func New(conf NoteCreateConfig) *Note { note := Note{ Title: defaultTitle, State: defaultState, CreatedAt: nowFunc(), UpdatedAt: nowFunc(), } if conf.Title != nil { note.Title = *conf.Title } if conf.Body != nil { note.Body = *conf.Body } return ¬e } func register() error { title := "hogehoge" note := New(NoteCreateConfig{ Title: &title, }) // ...save return nil }
本パターンのメリットはconfig設定時に設定しなかった値についてはデフォルト値を使うなど、
ドメインモデルの整合性をNew
メソッド内で担保できることである。
注意すべき点としてはconfig構造体のFieldをポインター型定義にする必要がある。 理由としてはgolangのゼロ値と未定義を区別する為だ。
更新
更新の場合はドメインモデルにUpdate
メソッドを定義する形になる
type NoteUpdateConfig struct { Title *string Body *string State *string } func (n *Note) Update(conf NoteUpdateConfig) error { if conf.Title != nil { n.Title = *conf.Title } if conf.Body != nil { n.Body = *conf.Body } if conf.State != nil { if err := n.updateState(*conf.State); err != nil { return err } } return nil } func (n *Note) updateState(s string) error { for _, state := range []NoteState{stateDraft, statePublished} { if s == string(state) { n.State = state } } return errors.New("invalid state") }
このようにすることで、以下ができるようになる * モデルの整合性検証を行う * ゼロ値とnilを区別することで更新すべきFieldを判定する
Functional Option Patternによる初期化、更新
Functional Option Pattern(FOP)とは、golangの構造体初期化時にオプションを与える方法である。 元ネタはこの辺であると言われている
初期化
先程のNoteモデルでFunctional Option Patternにすると以下である
type Option func(*Note) error func Title(s string) Option { return func(n *Note) error { n.Title = s return nil } } func Body(s string) Option { return func(n *Note) error { n.Body = s return nil } } func New(opts ...Option) (*Note, error) { note := Note{ Title: defaultTitle, State: defaultState, CreatedAt: nowFunc(), UpdatedAt: nowFunc(), } for _, opt := range opts { if err := opt(¬e); err != nil { return nil, err } } return ¬e, nil } func register() error { note, err := New( Title("hoge"), ) // ...save return nil }
ポイントとしてはNew
の引数が対象構造体を引数にとり、破壊的に更新するインターフェース型であるということだ
具体的には以下の部分
func Body(s string) Option { return func(n *Note) error { n.Body = s return nil } }
インターフェース型にすることにより以下のメリットがある * 引数を可変長引数型にまとめることができる * 引数ごとにバリデーションを定義することができる
更新
FOPで厳密に更新する場合はUpdateとNew時のインターフェースを分けることで、利用可能なOptionを分離する必要がある。 以下はOption引数をNew時と分離した例である。
func U_Title(s string) UpdateOption { return func(n *Note) error { n.Title = s return nil } } func U_Body(s string) UpdateOption { return func(n *Note) error { n.Body = s return nil } } func U_State(s string) UpdateOption { return func(n *Note) error { return n.updateState(s) } } func (n *Note) Update(opts ...UpdateOption) error { for _, opt := range opts { if err := opt(n); err != nil { return err } } return nil }
この形にすると、NewとUpdate両方でOption引数を定義するのでコード記述量が増える。 そのため妥協案としてNew時とUpdate時のOption引数を共通化して使うケースが普段使いでは多い。
これでもモデルの各Fieldのバリデーションは行われるので問題になるケースはあまりないと思う
// OptionをNewと共通化 func (n *Note) Update(opts ...Option) error { for _, opt := range opts { if err := opt(n); err != nil { return err } } return nil }
3. MethodChainingに初期化、更新
MethodChainingとはモデル生成用のBuilderを用意し、Methodにモデル生成用の値を詰めながら自分自身を返すことで任意の数のOption設定ができるようにするパターンである
初期化
type Builder struct { title *string body *string state *NoteState _err error } func NewBuilder() *Builder { return &Builder{} } func (b *Builder) Title(s string) *Builder { if len(s) > 255 { if b._err == nil { b._err = errors.New("title exceed maxlength") } else { b._err = fmt.Errorf("title exceed maxlength: %w", b._err) } return b } b.title = &s return b } func (b *Builder) Body(s string) *Builder { if len(s) > 1024 { if b._err == nil { b._err = errors.New("body exceed maxlength") } else { b._err = fmt.Errorf("body exceed maxlength: %w", b._err) } return b } b.body = &s return b } func register() error { builder := NewBuilder() note, err := builder. Title("hogehoge"). Body("fugafuga"). Build() // ... save return nil }
このパターンで実装する場合は、途中で発生するエラーをどのようにハンドリングするかが実装上で考えさせられる 今回はerrorをWrapする形で記述したが、記述をシンプルにするのであれば初回のエラーのみ格納する等でも 取り回し上差し支えないと思う。
更新
実装したい事柄はChainingに取り入れたOptionパラメーターの反映である。そのため以下の形での実装となる
func (b *Builder) Update(note *Note) error { if b.title != nil { note.Title = *b.title } if b.body != nil { note.Body = *b.body } if b.state != nil { note.State = *b.state } return b._err }
終わりに
今回は主にDDDのモデルに着眼したgoの構造体初期化、更新パターンを考えてみたが 普段利用する中では以下のような使い分けで考えている。
- コード記述量を減らしたい: config構造体
- 丁寧にバリデーションを書きたい: Functional Option Pattern, MethodChaining