Goでエンティティを実装(「ドメイン駆動設計入門」Chapter3)
概要
戦術的 DDD の実装パターンを Go で実装します。
今回は、「ドメイン駆動設計入門」Chapter3 のエンティティ(Entity)の実装をします。
実装したソースコードは以下です。
参考にしたサンプルコードは以下です。
エンティティ
まず、エンティティについて説明します。
「ドメイン駆動設計入門」ではエンティティの性質を以下のように解説されていました。
- 可変である
- 同じ属性であっても区別される
- 同一性により区別される
可変であると記述されていますが、以下のことも記述されています。
但し、すべての属性を必ず可変にする必要はありません。エンティティはあくまでも、必要に応じて属性を可変にすることが許可されているに過ぎません。可変なオブジェクトは基本的には厄介な存在です。可能な限り不変にしておくことはよい習慣です。
これを踏まえた上で、エンティティは、同一性により区別されるについて考えます。
エンティティは同一性を「識別子」によって表現し、同じ属性であっても「識別子」が異なれば区別され、属性が変化しても「識別子」が同等であれば同一とみなされます。
識別子が変更されると、本来同一でないエンティティと同一になる可能性があります。
つまり、識別子は不変な属性です。不変な場合、識別子に値オブジェクトを選定できるようになり、よりドメインに沿った表現ができます。
ユーザーに識別子のようなものを入力させる仕様にした場合、変更するユースケースも考えられます。しかし変更を許可すると、識別子にならないと考えます。
識別子を変更した際に、一意性が崩れて違うエンティティになるからです。
たとえば、zenn では記事を作成するときに slug を入力します。slug を変更すると、zenn では違う記事の扱いになります。zenn は「記事」をエンティティ、「slug」を識別子というように扱っていることになります。
実装
本記事では、User
をエンティティとしました。
また、識別子にはUserId
を定義し、UserId
は不変であるため値オブジェクトにしました。
ドメインモデル図
ディレクトリ構成
ディレクトリ構成は以下のように配置しました。
domain/model
配下にドメインごとのオブジェクトを配置します。
本筋から逸れるため、本記事ではエラーハンドリング(iterrors/
)とテスト(user_test.go
、userid_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.go
にUser
エンティティを実装しました。
Go には言語でサポートされているコンストラクタが存在しませんが、NewUser
と関数内のuser := new(User)
によってUser
エンティティを生成し、その後に初期化します。
userId
はsetUserId()
というようなセッターを用意していないので、識別子userId
は不変な属性になります。
また、user.userId = userId
では値オブジェクトUserId
がnil
でないことを保証しているものとして、nil
チェックしません。
ChangeUserName()
で、オブジェクトの振る舞いとセッターの両方を実装しています。
どのようにエンティティの条件を満たしているのか、確認していきます。
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
と同様にNewUserId
とuser := new(UserId)
で、コンストラクタと同様の働きをしています。
セッターを使用しないことで、生成条件の強制と値の保証をしました。
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
の実装は以下です。
コマンドライン引数によって、id
とname
を指定するようにしました。
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}
異常形
> go run main.go
2022/02/20 13:17:47 NewUserId(): userId is required
exit status 1
> go run main.go -id a
2022/02/20 13:18:04 user.ChangeUserName(""): name is required
exit status 1
> 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
を以下のように定義します。
先述した実装よりも、簡潔になり良さそうに見えます。
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
の条件、「""
(空文字列)を許容しない」を満たさなくなります。
// 以下のソースコードが通る
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.go
にuserId
のバリデーション追加する方法もありますが、それはUser
エンティティがuserId
のドメイン知識を持っていることになるので、実装してはいけません。
これが、原因 1 つ目です。
直接プリミティブ型を代入すると、型チェックをすり抜ける
見た方が早いので、ソースコードを載せます。
userid.go
のソースコードは 1 つ目と同じだと考えてください。
user.New()
の第 1 引数はUserId
型のはずなのに、直接プリミティブ型が入ってしまいます。
func main() {
flag.Parse()
newUser, err := user.NewUser("name", *name) // 直接 プリミティブ型("name")を指定しているがエラーが発生しない
if err != nil {
log.Fatal(err)
}
fmt.Println(newUser)
}
つまり、以下が実行時にエラーが発生せずに通ります。
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 点疑問点が残ったので、考察として残しておきます。
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
生成のときにuserId
をnil
チェックしていません。
以下を記述したかったのですが、コンパイラが通らなかったので、言語として保証されていることとして処理しました。
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.go
にuserId := user.UserId{}
は、実行が通ってしまいます。
回避するために、UserId
にバリデーションのメソッドを追加しようか迷いましたが、User
エンティティの責務が増えてしまうため、やめました。
また、defined type と同じ問題ですが、直接プリミティブ型を代入できないことから、以下の例を実装しました。
こちら、良い方法を知っている方はコメントでご指摘のほどお願いします。
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.go
にuserId := user.UserId{}
が実行できてしまう問題をバリデーションを追加によって解決しようとした場合です。
しかし、これには 2 点の問題がありました。
1 つめはUser
エンティティが、UserId.id != ""
であることを知っていることです。これはドメイン知識が漏洩しています。
2 つ目は go のプライベートな関数は同じ package 内で使用できてしまうことです。同じ package(package user
)であれば、setUserId
で上書きできてしまいます。不変な属性である識別子を更新できてしまいます。
以上により、不変な属性である識別子に自己カプセル化を使うのは不適切だと考えました。
こちらも良い方法を知っている方はコメントのほどお願いします。
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 のオーバーロード
サンプルコード(該当箇所)では以下のように実装されていました。
Equals
とGeHashCode
をオーバーロードしています。
ReferenceEqueals()
とGetHashCode()
を Go で表現する方法がわからず、先述した実装にしました。
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
が両方とも""
(空文字列)になり比較ができなくなります。
「実践ドメイン駆動設計」では、その際にすべての属性を比較するように解説されていました。値オブジェクトを比較するときと、同様のやり方です。
サンプルコードが遅延生成を対象にしていないので、今回は実装を見送りました。
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 つである、エンティティを実装しました。
サンプルコードを参考にし、エンティティの性質である以下を意識しながら実装しました。
- 可変である
- 同じ属性であっても区別される
- 同一性により区別される
ソースコードで表現しきれなかったこと、疑問に思ったことを考察に書いたので、良い方法を知っている方は、コメントでご指摘のほどお願いします。
余談
今回はコマンドライン引数で識別子を生成しましたが、識別子には「生成方法」「生成タイミング」「識別子の不変性」「バリデーション」の違いで実装がかわります。
それらについては「実践ドメイン駆動設計」(IDDD)で詳細に説明されています。
今後、それらの実装方法を記事にするかもしれません。
参考
Discussion