🐀

個人的に戦術的DDDの実装パターンと相性が悪いと思ったGoの仕様と理由

2022/06/16に公開約10,400字2件のコメント

概要

以前、「ドメイン駆動設計入門」という本に掲載されている、戦術的 DDD の内容を C#から Go に置き換えることで戦術的 DDD を学んでいました。
色々、実装してみて戦術的 DDD のミニマムな実装(エンティティ、値オブジェクトなど)において「Go と DDD の相性が悪くて不向きなのでは」という考えが生まれたので、理由を記述していきます。
自分自身は凄腕 Gopher でもなければ、大規模サービスに DDD を導入した経験もないので、考察の浅い意見が含まれている可能性があります。
是非、コメントにてご指摘のほどお願いします。

理由

先に結論を書くと、 「DDD で守りたい原則を Go の言語仕様で守ってくれる部分が少なく人の目で守らなければならないことが多い」 に尽きると考えています。
具体的な理由を記述します。

理由 1 「言語が用意しているコンストラクタがない」

DDD では、値オブジェクトやエンティティを生成する際に、コンストラクタでプロパティのバリデーション(いわゆる完全コンストラクタ)を記述します。
生成した時点でオブジェクトがドメインルールを守ったオブジェクトであることが保証され、凝集度が高いコードになります。

public class UserName {
    private String name;

    public UserName(String name) {
        if (name.trim().length() == 0) {
            throw new IllegalArgumentException("名前を入力してください");
        }
        if (name.trim().length() > 10) {
            throw new IllegalArgumentException("名前は10文字以内で入力してください");
        }
        this.name = name;
    }
}

Go で同様のことを実装すると、コンストラクタを実装する必要があり、一般的には以下のような書き方になります。

type UserName struct {
	Name string
}

func NewUserName(name string) ( *UserName, error) {
    if utf8.RuneCountInString(strings.Trim(name, " ")) == 0 {
        return nil, fmt.Errorf("名前を入力してください")
    }
    if utf8.RuneCountInString(strings.Trim(name, " ")) > 10 {
        return nil, fmt.Errorf("名前は10文字以内で入力してください")
    }
    return UserName{Name: name}, nil
}

結果、Go でもコンストラクタを使えば、ドメインルールを守ったドメインオブジェクトを生成できます。

ドメインルールを持った構造体を生成できる
// 以下だったらOK
userName1, err := NewUserName("hoge")
if err != nil {
    // ドメインルールを守っていないと、エラーハンドリング
}

しかし、コンストラクタの実装自体は言語で強制されているわけではなく、あくまで開発者が一般的に実装しがちなコンストラクタです。
そのため、以下みたいに直接構造体を生成したり、他のコンストラクタを定義することで、ドメインルールが破綻したドメインオブジェクトを生成できてしまいます。

別ファイル
// ただし、不正な値を直接生成できる
userName2 := UserName{Name: "012345678910"}
// 他の名前をつけたコンストラクタを実装する人がいるかもしれない
userName3 := OtherNewUserName{Name: ""}

他のコンストラクタを作成すること自体は Go 特有の問題ではありませんが、言語が用意しているコンストラクタが存在しないことから指摘しています。
自分も、リポジトリからドメインオブジェクト生成用のコンストラクタはバリデーションがないことは割と普通だと考えています。
その場合、Java(or Kotlin)であれば、リポジトリからドメインオブジェクト生成用のコンストラクタをユースケース層で使わせないために、ArchiUnit でチェックできます。しかし、Go で同様のことができるか確認できていないです(これは自身の調査不足です)。

簡単にドメインルールを破ったドメインオブジェクトを生成できてしまうので、DDD の原則に沿わなくなってしまいます。
結局、人の目で確認して「コンストラクタを使っていない」と指摘するしかありません。

理由 2 「変数を不変にする手段がない」

理由 1 と関連しますが、コンストラクタのプロパティはほとんどの場合finalで値を固定にするかセッターを実装しません。
これは、ドメインオブジェクトのドメインルールを破られる可能性がなくすためのもので、開発者は安心して開発を進められます。

public class UserName {
    private String name;

    public UserName(String name) {
        if (name.trim().length() == 0) {
            throw new IllegalArgumentException("名前を入力してください");
        }
        if (name.trim().length() > 10) {
            throw new IllegalArgumentException("名前は10文字以内で入力してください");
        }
        this.name = name;
    }

    // ゲッターは設定しても良い
    public String Name() {
        return this.name
    }

    // セッターは実装しないか、privateにして自己カプセル化で実装する(自己カプセル化もまずやらない)
    // public void setName() {
    //     this.name = name
    // }
}


しかし、Go ではfinalといった変数を不変にする手段もなければ、メソッドを通さずに直接更新できます。
具体的にドメインルールを無視する過程をソースコードで確認します。以下は Go にセッターを実装していない実装です。

実装例
type UserName struct {
	Name string
}

func NewUserName(name string) ( *UserName, error) {
    if utf8.RuneCountInString(strings.Trim(name, " ")) == 0 {
        return nil, fmt.Errorf("名前を入力してください")
    }
    if utf8.RuneCountInString(strings.Trim(name, " ")) > 10 {
        return nil, fmt.Errorf("名前は10文字以内で入力してください")
    }
    return UserName{Name: name}, nil
}// ゲッターを設定する
(userName *UserName) Name() string {
     return userName.Name
}

// セッターを実装しなくても。。。。
// (userName *UserName) setName(name: string) error {
//     userName.name = name
//     return nil
// }

しかし、セッターがなくても Go は直接代入できるうえに、final がないので簡単に更新できます。

// コンストラクタでドメインルールを守ったオブジェクトを生成する
var userName, _ = NewUserName("hoge")
// しかし、セッターがなくても直接でき、finalもないので、簡単にドメインルールが崩壊する
userName.Name = "0123456789"

直接変更できるということは、属性を更新するメソッドを作成してバリデーションを強制できない上に、更新のロジックをドメイン外に記述できるため、凝集度が低いコードになります。
例えば、以下の Java のソースコードでは、プロパティが private のため変更の際にはChangeNameメソッドの使用を強制できるため、ドメインルールが守られます。

仮に変更するロジックを持たせてもバリデーションを記述できる
public class UserName {
    private String name;

    public UserName(String name) {
        if (name.trim().length() == 0) {
            throw new IllegalArgumentException("名前を入力してください");
        }
        if (name.trim().length() > 10) {
            throw new IllegalArgumentException("名前は10文字以内で入力してください");
        }
        this.name = name;
    }

    public void ChangeName(String name) {
        if (name.trim().length() == 0) {
            throw new IllegalArgumentException("名前を入力してください");
        }
        if (name.trim().length() > 10) {
            throw new IllegalArgumentException("名前は10文字以内で入力してください");
        }
        this.name = name;
    }

}

Go でも似たような実装が可能です。

type UserName struct {
	Name string
}

func NewUserName(name string) ( *UserName, error) {
    if utf8.RuneCountInString(strings.Trim(name, " ")) == 0 {
        return nil, fmt.Errorf("名前を入力してください")
    }
    if utf8.RuneCountInString(strings.Trim(name, " ")) > 10 {
        return nil, fmt.Errorf("名前は10文字以内で入力してください")
    }
    return UserName{Name: name}, nil
}// 変更用のメソッドにバリデーションを持たせても。。。
(userName *UserName) ChangeName(name: string) error {
    if utf8.RuneCountInString(strings.Trim(name, " ")) == 0 {
        return fmt.Errorf("名前を入力してください")
    }
    if utf8.RuneCountInString(strings.Trim(name, " ")) > 10 {
        return fmt.Errorf("名前は10文字以内で入力してください")
    }
    userName.name = name
    return nil
}

しかし、先述の通り直接代入してもエラーが発生しないため、ドメインルールが破られます。フィールドを公開(exported)しているのも理由ですが、同じ package だと更新できるため(後述)一旦無視します。
代入前に引数のバリデーションをその都度おこなえばドメインルールを守れますが、凝集度が下がりドメイン知識の漏洩するためドメインモデル貧血症なソースコードが出来上がります。

var userName, _ = NewUserName("hoge")
// メソッドを使えば、変更してもドメインルールは守られる
if err := userName.ChangeName("fuga"); err != nil {
    return err
}
// しかし、直接代入してもエラーはでない
userName.Name = "0123456789"
// 引数のバリデーションをすれば、ドメインルールを守れるが、凝集度が低下する
var newName  = "piyo"
if utf8.RuneCountInString(strings.Trim(name, " ")) == 0 {
    return fmt.Errorf("名前を入力してください")
}
if utf8.RuneCountInString(strings.Trim(name, " ")) > 10 {
    return fmt.Errorf("名前は10文字以内で入力してください")
}
userName.Name = newName

一連の具体例は様々な問題を引き起こします。例えば、全ての属性が不変であるはずの値オブジェクトは不変でなくなること、エンティティに必要な一意な識別子も可変なので同一性を用いた比較ができなくなることなどです。これは、DDD が目指す高凝集疎結合なソースコードではなくなります。
対策は開発者全員に「このドメインオブジェクトの属性に値を代入してはならない、このドメインオブジェクトの属性は代入しても良い」というドメインモデルの周知と教育の徹底になります。
開発者がドメインモデルを理解するのは DDD を実践するために必要ですが、教える側と教わる側の両者にずっと同じ人がいれるわけではありません。
そのような場合に、ソースコードで表現できないことやコンパイル時に問題を指摘されないと、人間の保守では限界があります。

理由 3 「オブジェクト指向における private が存在しない」

Go は公開かそうじゃないか(この記事では「非公開」と呼びます)で、スコープを定義します。詳細は以下の記事にまとめました。

https://zenn.dev/msksgm/articles/20220527-go-package-scope

一見、可変な値(構造体、フィールド)は公開して、不変にしたい値は非公開にすればうまくいきそうですが、そうなりません。
このスコープは最小単位がオブジェクト指向でいう、protected までになります。
つまり、同一 package に存在するメソッドは呼び出し放題ですし、プロパティも更新を止めることができません。
例えば、以下のような package 構成があるとします。

package構成
.
├── domain
│   └── user
│       ├── user.go
│       └── username.go
└── go.mod

2 directories, 3 files

先ほどの UserName のフィールドである name を非公開にします。
package 外部から隠蔽しました。

username.go
type UserName struct {
    // 小文字にすると、package外から参照・更新できなくなる
	name string
}

func NewUserName(name string) ( *UserName, error) {
    if utf8.RuneCountInString(strings.Trim(name, " ")) == 0 {
        return nil, fmt.Errorf("名前を入力してください")
    }
    if utf8.RuneCountInString(strings.Trim(name, " ")) > 10 {
        return nil, fmt.Errorf("名前は10文字以内で入力してください")
    }
    return UserName{Name: name}, nil
}

// セッターを実装しなくても。。。。
// (userName *UserName) setName(name: string) error {
//     userName.name = name
//     return nil
// }

しかし、同じ package にある User は、UserName を途中で更新できます。
実際に user.go でこのような実装は防ぐことができますが、他のファイルが増えていくにつれて、管理できなくなり実装してしまう可能性は十分にあります。

user.go
type User struct {
	aUserName UserName
}

func NewUserName(userName UserName) (*User, error) {
    // 不正な値に更新できる
    userName.name = ""
    return User{aUserName: userName}, nil
}

これは、構造体の属性を小文字にして非公開にしても、同一 package から更新できるので、ドメインオブジェクトのルールを簡単に破れることになります。
更新を禁止するフィールドは周知が必要になるので、同様に人の目視で守る必要があります。

一応、以下の構成にすれば、オブジェクト指向の private ぽい実装が可能です。
しかし、全てのドメインオブジェクトごとに package を作成する必要が生まれるため、管理が煩雑になります。そのためやめておいたほうがいいでしょう。

.
├── domain
│   ├── user
│   │   └── user.go
│   ├── userid
│   │   └── userid.go
│   └── username
│       └── username.go
└── go.mod

4 directories, 4 files

理由 4 「Go でよく使われる package 構成は、DDD のアーキテチャとマッチしていない」

完全に主観ですが、Go ではディレクトリ構成を深くするパターンはみたことない気がしますし、推奨される構成もシンプルかつフラットに作成する印象が強いです。
例えば、Go 公式のライブラリpkgsiteは多大なファイルを持ちますが、ディレクトリの階層は基本的に 2 か 3 ぐらいです。

https://github.com/golang/pkgsite

実際に DDD のアーキテクチャを導入すると、レイヤーやドメイン間でドメインオブジェクトの型を参照するために、全てのフィールドを公開する場合もあり、いたずらに複雑化させてしまいます。そのためフラットに書いた方が良かったりします。
そして、フラットに書くと、理由 3 にて説明した同一 package 内でドメインルールを破るオブジェクトの更新が可能になります。
そうなると、あまり DDD らしい書き方ではない次第です。

以下のソースコードは「実践ドメイン駆動設計」のサンプルコードです。
ディレクトリ構成が深いのは、Java の package 管理はもともと層が深いというのもありますが、DDD ではドメインごとに package 構成を区切っていくので、ある程度深くなっていきます。
しかし、Go とはオブジェクトやフィールドの公開範囲の概念が異なるため、DDD の原則を守りながらアーキテクチャの責務が成立します。

https://github.com/VaughnVernon/IDDD_Samples

とりあえず package を区切って、構造体とフィールドの公開と非公開を適切にすれば、ある程度は保証されます。
しかし、Go でこのような細かい単位で package を増やしたり、クリーンアーキテクチャを採用して無駄に複雑になるのは、あまり Go らしくなさそうという印象もあるため、理由に書かきました。

まとめ

戦術的 DDD の実装パターンと Go の相性が悪いと感じた理由を 4 つ紹介しました。
ミニマムな実装パターンにおける感想なので、もっと視野を広げると(例えば、match 式で網羅性を担保できないなど)さらに理由が増えていくと考えています。
最初にも書いた通り、要約すると人の目視で守ることが多すぎると感じたため相性が悪いと考えています。
DDD の原則を守るために、静的型づけ言語を当たり前のように使われていると考えています。しかし、Go は静的言語ですが、オブジェクト指向言語ではないため、うまく DDD の原則を落とし込めていないと思いました。
その気になれば動的型付け言語でも、ある程度のリスクを人の目視で守れば DDD の原則を守れます(実装コストが高くなるという問題点がありますが)。同様に「値オブジェクトが不変にできないのは許容する」といったリスク受容し、Go を採用するメリットを優先すれば実践できます。
これらの理由で Go で DDD ができないとは考えられませんが、DDD で Go を採用する際には DDD を Go の性質に合わせる必要があります
これは、ドメインが技術に引っ張られることなので、やはり相性が悪い印象です。
そのため、DDD の原則に厳密に沿った実装を目指すのであれば、Java、Kotlin、C#といったオブジェクト指向言語の方が相性良さそうだと考えています。
しかし、書籍「Domain Modeling Made Functional」では、関数型言語(F#)を用いてを実践しています。そのため、Go で DDD を実践したバイブルが存在していないだけの可能性があります。
同様に、自分の考察が悪いだけの可能性がありますので、是非コメントにて指摘のほどお願いします。

参考

https://www.shoeisha.co.jp/book/detail/9784798131610

https://little-hands.booth.pm/items/1835632

https://little-hands.booth.pm/items/3363104

https://gihyo.jp/book/2017/978-4-7741-9087-7

Discussion

記事のタイトルに興味を惹かれ、読ませていただきました。
着眼点、とても勉強になります。貴重な記事をありがとうございます。
私はGo言語はまだ詳しくないのですが、こういったシーンでinterfaceを使うのではないかと、理解しています。

コードを書いてみたのですが、interfaceを使えば守ってくれることにならないでしょうか。

// sample/main.go
package main

import (
	"fmt"
	"sample/account"
)

func main() {
	// u := account.UserName{} // シンタックスエラー
	// u := account.userNameImpl{username: "hoge"} // シンタックスエラー
	u1, err := account.NewUserName("")
	if err != nil {
		fmt.Println(err)
	} else {
		fmt.Println(u1.Get())
	}
	u2, err := account.NewUserName("01234567890")
	if err != nil {
		fmt.Println(err)
	} else {
		fmt.Println(u2.Get())
	}
	u3, err := account.NewUserName("hoge")
	if err != nil {
		fmt.Println(err)
	} else {
		fmt.Println(u3.Get())
	}
}

// sample/account/username.go

package account

import (
	"fmt"
	"strings"
	"unicode/utf8"
)

type UserName interface {
	Get() string
}

type userNameImpl struct {
	UserName
	username string
}

func (i *userNameImpl) Get() string {
	return i.username
}

func validateUsername(s string) error {
	s = strings.Trim(s, " ")
	c := utf8.RuneCountInString(s)
	switch {
	case c == 0:
		return fmt.Errorf("名前を入力してください")
	case c > 10:
		return fmt.Errorf("名前は10文字以内で入力してください")
	}
	return nil
}

func NewUserName(s string) (UserName, error) {
	err := validateUsername(s)
	if err != nil {
		return nil, err
	}
	return &userNameImpl{username: s}, nil
}

@hiroaki_ohkawa さん

コメントありがとうございます!!!
なるほど、interface を駆使すれば、このように可能な限り不変にできるんですね。非常に参考になります。手元でも確認したかぎり、確かに不変にできそうです。

提案していただいた実装方法で、DDDの実装を深堀できていないため、正しい意見なのかわかりませんが気になった点が1点あります。
値オブジェクトやエンティティのように値に型を持たせたい場合に、Goらしい書き方なのかどうか気になりました。値を一つ要素にまとめるときには構造体を使う印象です。普段、interface で継承するときは依存を分離してDIしたり、自作error をwrap するときなどなので、値に型を持たせるパターンを拝見したことがなく気になりました。

ただ、Kotlin とかでもinterfaceを用いて、値オブジェクトを表現(Kotlin はinterfaceに値を持てます)したことがあるので、Go にも適用するとこうなるのかもしれません。
今まで、Go でDDDを実践してみた系の記事で見つけられなかったパターンなので、非常に参考になりました!ありがとうございます!

ログインするとコメントできます