🧱

Goでエンティティを実装(「ドメイン駆動設計入門」Chapter3)

2022/02/21に公開

概要

戦術的 DDD の実装パターンを Go で実装します。
今回は、「ドメイン駆動設計入門」Chapter3 のエンティティ(Entity)の実装をします。

実装したソースコードは以下です。
https://github.com/Msksgm/go-itddd-03-entity

参考にしたサンプルコードは以下です。
https://github.com/nrslib/itddd/tree/master/SampleCodes/Chapter3/_07

エンティティ

まず、エンティティについて説明します。
「ドメイン駆動設計入門」ではエンティティの性質を以下のように解説されていました。

  • 可変である
  • 同じ属性であっても区別される
  • 同一性により区別される

出典:ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本 Chapter3

可変であると記述されていますが、以下のことも記述されています。

但し、すべての属性を必ず可変にする必要はありません。エンティティはあくまでも、必要に応じて属性を可変にすることが許可されているに過ぎません。可変なオブジェクトは基本的には厄介な存在です。可能な限り不変にしておくことはよい習慣です。

出典:ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本 Chapter3

これを踏まえた上で、エンティティは、同一性により区別されるについて考えます。
エンティティは同一性を「識別子」によって表現し、同じ属性であっても「識別子」が異なれば区別され、属性が変化しても「識別子」が同等であれば同一とみなされます。
識別子が変更されると、本来同一でないエンティティと同一になる可能性があります。
つまり、識別子は不変な属性です。不変な場合、識別子に値オブジェクトを選定できるようになり、よりドメインに沿った表現ができます。

ユーザーに識別子のようなものを入力させる仕様にした場合、変更するユースケースも考えられます。しかし変更を許可すると、識別子にならないと考えます。
識別子を変更した際に、一意性が崩れて違うエンティティになるからです。
たとえば、zenn では記事を作成するときに slug を入力します。slug を変更すると、zenn では違う記事の扱いになります。zenn は「記事」をエンティティ、「slug」を識別子というように扱っていることになります。

実装

本記事では、Userをエンティティとしました。
また、識別子にはUserIdを定義し、UserIdは不変であるため値オブジェクトにしました。

domain_model
ドメインモデル図

ディレクトリ構成

ディレクトリ構成は以下のように配置しました。
domain/model配下にドメインごとのオブジェクトを配置します。
本筋から逸れるため、本記事ではエラーハンドリング(iterrors/)とテスト(user_test.gouserid_test.go)の説明を省略します。

ディレクトリ構成
> tree
.
├── README.md
├── domain
│   └── model
│       └── user
│           ├── user.go
│           ├── user_test.go
│           ├── userid.go
│           └── userid_test.go
├── go.mod
├── go.sum
├── iterrors
│   ├── iterrors.go
│   └── iterrors_test.go
└── main.go

4 directories, 10 files

user.go

user.goUserエンティティを実装しました。
Go には言語でサポートされているコンストラクタが存在しませんが、NewUserと関数内のuser := new(User)によってUserエンティティを生成し、その後に初期化します。
userIdsetUserId()というようなセッターを用意していないので、識別子userIdは不変な属性になります。
また、user.userId = userIdでは値オブジェクトUserIdnilでないことを保証しているものとして、nilチェックしません。
ChangeUserName()で、オブジェクトの振る舞いとセッターの両方を実装しています。
どのようにエンティティの条件を満たしているのか、確認していきます。

./domain/model/user/user.go
package user

import (
	"fmt"
	"reflect"

	"github.com/Msksgm/go-itddd-03-entity/iterrors"
)

type User struct {
	userId UserId
	name   string
}

func NewUser(userId UserId, name string) (*User, error) {
	user := new(User)

	user.userId = userId

	if err := user.ChangeUserName(name); err != nil {
		return nil, err
	}
	return user, nil
}

func (user *User) ChangeUserName(name string) (err error) {
	defer iterrors.Wrap(&err, "user.ChangeUserName(%q)", name)
	if name == "" {
		return fmt.Errorf("name is required")
	}
	if len(name) < 3 {
		return fmt.Errorf("name %v is less than three characters long", name)
	}
	user.name = name
	return nil
}

func (user *User) Equals(other *User) bool {
	return reflect.DeepEqual(user.userId, other.userId)
}

可変である

構造体Userの属性nameは、可変であるため、ChangeUserNameで変更できるようになっています。
セッターの役割も兼ねています。振る舞いを意識した名前となるためChangeUserNameという名前になったと推察しています。
値の検証し、不正なデータではエラーを返すようにしています。

可変である
func (user *User) ChangeUserName(name string) (err error) {
	defer iterrors.Wrap(&err, "user.ChangeUserName(%q)", name)
	if name == "" {
		return fmt.Errorf("name is required")
	}
	if len(name) < 3 {
		return fmt.Errorf("name %v is less than three characters long", name)
	}
	user.name = name
	return nil
}

同じ属性にあっても区別される、同一性により区別される

Equalsは識別子(userId)の比較します。
値が同じだったときに同一と判断します。

識別子によって区別する
func (user *User) Equals(other *User) bool {
	return reflect.DeepEqual(user.userId, other.userId)
}

userId.go

userId.goでは値オブジェクトである、UserIdを作成しました。
値オブジェクトの解説は本記事ではしませんが、性質の 1 つである不変を意識した実装になっています。
Userと同様にNewUserIduser := new(UserId)で、コンストラクタと同様の働きをしています。
セッターを使用しないことで、生成条件の強制と値の保証をしました。

./domain/model/user/userid.go
package user

import (
	"fmt"

	"github.com/Msksgm/go-itddd-03-entity/iterrors"
)

type UserId struct {
	id string
}

func NewUserId(id string) (_ *UserId, err error) {
	defer iterrors.Wrap(&err, "NewUserId(%s)", id)
	userId := new(UserId)
	if id == "" {
		return nil, fmt.Errorf("userId is required")
	}
	userId.id = id
	return userId, nil
}

動作確認

main.go

main.go の実装は以下です。
コマンドライン引数によって、idnameを指定するようにしました。

main.go
package main

import (
	"flag"
	"fmt"
	"log"

	"github.com/Msksgm/go-itddd-03-entity/domain/model/user"
)

var (
	id   = flag.String("id", "", "id of user")
	name = flag.String("name", "", "name of user")
)

func main() {
	flag.Parse()
	userId, err := user.NewUserId(*id)
	if err != nil {
		log.Fatal(err)
	}

	newUser, err := user.NewUser(*userId, *name)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(newUser)
}

実行

正常形

正常終了するパターン
> go run main.go -id a -name name
&{{a} name}

異常形

idを入力しないパターン
> go run main.go
2022/02/20 13:17:47 NewUserId(): userId is required
exit status 1
nameを入力しないパターン
> go run main.go -id a
2022/02/20 13:18:04 user.ChangeUserName(""): name is required
exit status 1
nameが3文字未満のパターン
> go run main.go -id a -name na
2022/02/20 13:18:31 user.ChangeUserName("na"): name na is less than three characters long
exit status 1

考察

考察した結果、ソースコードに実装しなかった、できなかったことを記述してきます。
個人的な見解ですので、読み飛ばしていただいて大丈夫です。

defined type を使用しなかった

Go には defined type が用意されています。

defined type は簡単に型を定義できますが、少し使用した結果、値オブジェクトの性質に従えないことがわかりました。そのため、今回は採用を見送りました。
理由は以下です。

  • 生成条件を強制できない
  • 直接プリミティブ型を代入すると、型チェックをすり抜ける

順番に解説します。

生成条件を強制できない

たとえば、userIdを以下のように定義します。
先述した実装よりも、簡潔になり良さそうに見えます。

./domain/model/user/userid.go
type UserId string

func NewUserId(id string) (_ *UserId, err error) {
	defer iterrors.Wrap(&err, "userId.NewUserId(%s)", id)
	if id == "" {
		return nil, fmt.Errorf("id is required")
	}
	userId := UserId(id)
	return &userId, nil
}

しかし、この実装では以下でコンパイルエラーにならず、通ってしまいます。

生成方法の比較
// 間違った生成方法
userId := user.UserId("name")

// 正しい生成方法
userId, err := user.NewUserId("name")
if err != nil {
	log.Fatal(err)
}

何が問題なのかというと、条件にuserIdの条件、「""(空文字列)を許容しない」を満たさなくなります。

./main.go
// 以下のソースコードが通る
func main() {
	flag.Parse()
	userId := user.UserId(*id) // idの条件を満たしているか確認しない

	newUser, err := user.NewUser(userId, *name)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(newUser)
}

つまり、以下のようになります。

実行結果
> go run main.go  -name name
&{ name}

値オブジェクトは生成した時点で属性が条件を満たしていることを保証しないといけません。
user.gouserIdのバリデーション追加する方法もありますが、それはUserエンティティがuserIdのドメイン知識を持っていることになるので、実装してはいけません。
これが、原因 1 つ目です。

直接プリミティブ型を代入すると、型チェックをすり抜ける

見た方が早いので、ソースコードを載せます。
userid.goのソースコードは 1 つ目と同じだと考えてください。
user.New()の第 1 引数はUserId型のはずなのに、直接プリミティブ型が入ってしまいます。

main.go
func main() {
	flag.Parse()

	newUser, err := user.NewUser("name", *name) // 直接 プリミティブ型("name")を指定しているがエラーが発生しない
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(newUser)
}

つまり、以下が実行時にエラーが発生せずに通ります。

main.go
func main() {
	flag.Parse()

	newUser, err := user.NewUser("", *name) // 「""」なのにUserエンティティを生成できてしまう。
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(newUser)
}

型チェックをすり抜けると、先ほどと同様に値オブジェクトの属性の性質を保証できていません。
これが原因 2 つ目です。

以上の、「生成条件を強制できない」「直接プリミティブ型を代入すると、型チェックをすり抜ける」、が defined type を値オブジェクトに採用しなかった理由です。
厳格な戦術的 DDD によってセキュアなコーディングをするのであれば、defined type は避けた方がよいと考察しました。

しかし、これらはコードレビューによって防ぐことができます。
具体的には以下の 2 つです。

  • 「生成条件を強制できない」は defined type から直接生成する
  • 「直接プリミティブ型を代入すると、型チェックをすり抜ける」はプリミティブ型ではなく変数を使うことでエラーが発生させる

また、defined type を使った方が、ソースコードが簡潔になり DB とのマッピングもしやすくなるというメリットもあります。
メリットを優先し、リスク受容できるのであれば、defined type を使っても問題ないと考えます。

コンストラクタについて

今回、コンストラクタは以下のように実装しました。
こちらについて、2 点疑問点が残ったので、考察として残しておきます。

domain/model/user/user.go
func NewUser(userId UserId, name string) (*User, error) {
	user := new(User)

	user.userId = userId

	if err := user.ChangeUserName(name); err != nil {
		return nil, err
	}
	return user, nil
}

userId の nil チェックについて

User生成のときにuserIdnilチェックしていません。
以下を記述したかったのですが、コンパイラが通らなかったので、言語として保証されていることとして処理しました。

domain/model/user/user.go
func NewUser(userId UserId, name string) (*User, error) {
	user := new(User)

	if userId == nil {
		return nil, fmt.Errorf("")
	}
	user.userId = userId

	if err := user.ChangeUserName(name); err != nil {
		return nil, err
	}
	return user, nil
}

しかし、以下のようにmain.gouserId := user.UserId{}は、実行が通ってしまいます。
回避するために、UserIdにバリデーションのメソッドを追加しようか迷いましたが、Userエンティティの責務が増えてしまうため、やめました。
また、defined type と同じ問題ですが、直接プリミティブ型を代入できないことから、以下の例を実装しました。
こちら、良い方法を知っている方はコメントでご指摘のほどお願いします。

main.go
func main() {
	flag.Parse()

	userId := user.UserId{} // 条件を満たしていない userId

	newUser, err := user.NewUser(userId, *name)
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(newUser)
}
> go run main.go -id a -name name
&{{} name}

自己カプセル化について

以下のソースコードでは、NewUserId内で直接userId := idをするのではなく、プライベートなセッターsetIdによって、自己カプセル化をします。
上記で問題になった、main.gouserId := user.UserId{}が実行できてしまう問題をバリデーションを追加によって解決しようとした場合です。
しかし、これには 2 点の問題がありました。
1 つめはUserエンティティが、UserId.id != ""であることを知っていることです。これはドメイン知識が漏洩しています。
2 つ目は go のプライベートな関数は同じ package 内で使用できてしまうことです。同じ package(package user)であれば、setUserIdで上書きできてしまいます。不変な属性である識別子を更新できてしまいます。
以上により、不変な属性である識別子に自己カプセル化を使うのは不適切だと考えました。
こちらも良い方法を知っている方はコメントのほどお願いします。

domain/model/user/user.go
package user

import (
	"fmt"
	"reflect"

	"github.com/Msksgm/go-itddd-03-entity/iterrors"
)

type User struct {
	userId UserId
	name   string
}

func NewUser(userId UserId, name string) (*User, error) {
	user := new(User)

	if err := user.setUserId(userId); err != nil {
		return nil, err
	}

	if err := user.ChangeUserName(name); err != nil {
		return nil, err
	}
	return user, nil
}

func (user *User) setUserId(userId UserId) (err error) {
	defer iterrors.Wrap(&err, "user.setUserId(%q)", userId)
	if userId.id == "" {
		return fmt.Errorf("userId is required")
	}
	user.userId = userId
	return nil
}

func (user *User) ChangeUserName(name string) (err error) {
	defer iterrors.Wrap(&err, "user.ChangeUserName(%q)", name)
	if name == "" {
		return fmt.Errorf("name is required")
	}
	if len(name) < 3 {
		return fmt.Errorf("name %v is less than three characters long", name)
	}
	user.name = name
	return nil
}

func (user *User) Equals(other *User) bool {
	return reflect.DeepEqual(user.userId, other.userId)
}

Equals について

Equals と GetHashCode のオーバーロード

サンプルコード(該当箇所)では以下のように実装されていました。
EqualsGeHashCodeをオーバーロードしています。
ReferenceEqueals()GetHashCode()を Go で表現する方法がわからず、先述した実装にしました。

itddd/SampleCodes/Chapter3/_07/User.cs
        public bool Equals(User other)
        {
            if (ReferenceEquals(null, other)) return false;
            if (ReferenceEquals(this, other)) return true;
            return Equals(id, other.id); // 比較は id 同士で行われる
        }

        public override bool Equals(object obj)
        {
            if (ReferenceEquals(null, obj)) return false;
            if (ReferenceEquals(this, obj)) return true;
            if (obj.GetType() != this.GetType()) return false;
            return Equals((User)obj);
        }

        // 言語によりGetHashCodeの実装が不要な場合もある
        public override int GetHashCode()
        {
            return (id != null ? id.GetHashCode() : 0);
        }

識別子が遅延生成される場合

Equalsは以下の実装も考えました。
以下の実装は、識別子を遅延生成(オブジェクト生成時ではなく、永続化するときに取得する)の場合は、userId""(空文字、string の初期値)である可能性があります。
そのとき、比較元(user)と比較対象(other)のuserIdが両方とも""(空文字列)になり比較ができなくなります。
「実践ドメイン駆動設計」では、その際にすべての属性を比較するように解説されていました。値オブジェクトを比較するときと、同様のやり方です。
サンプルコードが遅延生成を対象にしていないので、今回は実装を見送りました。

domain/model/user/user.go
func (user *User) Equals(other *User) bool {
	if reflect.DeepEqual(user.userId, other.userId) && reflect.DeepEqual(user.userId, other.userId) {
		return true
	}
	return reflect.DeepEqual(user.userId, other.userId)
}

まとめ

Go で DDD の戦術的パターンの 1 つである、エンティティを実装しました。
サンプルコードを参考にし、エンティティの性質である以下を意識しながら実装しました。

  • 可変である
  • 同じ属性であっても区別される
  • 同一性により区別される

出典:ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本 Chapter3

ソースコードで表現しきれなかったこと、疑問に思ったことを考察に書いたので、良い方法を知っている方は、コメントでご指摘のほどお願いします。

余談

今回はコマンドライン引数で識別子を生成しましたが、識別子には「生成方法」「生成タイミング」「識別子の不変性」「バリデーション」の違いで実装がかわります。
それらについては「実践ドメイン駆動設計」(IDDD)で詳細に説明されています。
今後、それらの実装方法を記事にするかもしれません。

参考

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

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

Discussion