🧱

Goで値オブジェクトを実装(「ドメイン駆動設計入門」Chapter2)

2022/02/28に公開約14,300字

概要

戦術的 DDD の実装パターンを Go で実装します。
今回は、「ドメイン駆動設計入門」Chapter2 の値オブジェクト(Value Object)の実装をおこないます。

実装したソースコードは以下です。

https://github.com/Msksgm/go-itddd-02-valueobject

参考にしたサンプルコードは以下です。

https://github.com/nrslib/itddd/tree/master/SampleCodes/Chapter2/_24

https://github.com/nrslib/itddd/tree/master/SampleCodes/Chapter2/_31

値オブジェクト

まず、値オブジェクトについて説明します。
「ドメイン駆動設計入門」では値オブジェクトの性質を以下のように解説されていました。

  • 不変である
  • 交換が可能である
  • 等価性によって比較される

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

不変であるは、生成したときに値オブジェクトの属性が確定され、セッターによって更新されない必要があります。そのため、値オブジェクトのセッターは private にして外部から隠蔽するか、コンストラクタに処理を記述する実装が多いです。
交換が可能であるは、属性を変更したいときには新しい値オブジェクトを生成するという意味です。変更するメソッドではなく、インスタンスを生成するメソッドを実装します。不変だから他に方法がないだけであって、実は特徴でもない気がします。「交換可能でなかったら、変数の再代入が禁止されているのか」といった疑問に答えるだけだと考えます。
等価性によって比較されるは、値オブジェクトの比較をおこなう際には全ての属性の比較をおこなう、という意味です。

エンティティと値オブジェクトは両方とも戦略的 DDD の実装パターンの中で最小単位のオブジェクトです。この 2 つはよく似ています。主に以下が違います。
ライフサイクルを持っても同一性で判断するためエンティティにする、という観点が重要です。再代入や永続化が面倒なことを理由にエンティティを選択してはいけません。

  • エンティティの属性は変更が許容されるが、値オブジェクトの属性は不変
  • エンティティは同一性(識別子のこと)で区別、値オブジェクトはすべての属性で区別

実装

本記事では、サンプルコードに倣い 2 つの値オブジェクトを実装しました。
1 つめはFullName、2 つめはMoneyです。
簡易的なドメインモデル図は以下になります。
FullNameMoneyの関連はまったくありません。

domain_model
ドメインモデル図

ディレクトリ構成

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

ディレクトリ構成
.
├── cmd
│   ├── fullname
│   │   └── fullname.go
│   └── money
│       └── money.go
├── domain
│   └── model
│       ├── fullname
│       │   ├── fullname.go
│       │   └── fullname_test.go
│       └── money
│           ├── money.go
│           └── money_test.go
├── go.mod
├── go.sum
└── iterrors
    ├── iterrors.go
    └── iterrors_test.go

8 directories, 10 files

fullname.go

値オブジェクトFullNameの実装は以下になります。
NewFullName()がコンストラクタの役割になっています。
サンプルコードに加えて、firstNamelastNameが変更されたときの更新メソッドを追加しました。
今回は説明のために追加しましたが、本来は変更する必要がない属性に更新用のメソッドを追加してはいけません。モデリング時に変更不要と決断したら再代入するメソッドも不要です。
どのように値オブジェクトの条件を満たしているのか確認します。

./domain/model/fullname/fullname.go
package fullname

import (
	"fmt"
	"reflect"
	"regexp"

	"github.com/Msksgm/itddd-go-02-valueobject/iterrors"
)

type FullName struct {
	firstName string
	lastName  string
}

func NewFullName(firstName string, lastName string) (_ *FullName, err error) {
	defer iterrors.Wrap(&err, "fullname.NewFullName(%s, %s)", firstName, lastName)
	fullName := new(FullName)

	// set firstName
	if firstName == "" {
		return nil, fmt.Errorf("firstName is required")
	}
	if !ValidateName(firstName) {
		return nil, fmt.Errorf("firstName has an invalid character. letter is only")
	}
	fullName.firstName = firstName

	// set lastName
	if lastName == "" {
		return nil, fmt.Errorf("lastName is required")
	}
	if !ValidateName(lastName) {
		return nil, fmt.Errorf("lastName has an invalid character. letter is only")
	}
	fullName.lastName = lastName

	return fullName, nil
}

func ValidateName(value string) bool {
	return regexp.MustCompile(`^[a-zA-Z]+$`).MatchString(value)
}

func (fullName *FullName) WithChangeFirstName(firstName string) (_ *FullName, err error) {
	changedFullName, err := NewFullName(firstName, fullName.lastName)
	if err != nil {
		return nil, err
	}
	return changedFullName, nil
}

func (fullName *FullName) WithChangeLastName(lastName string) (_ *FullName, err error) {
	changedFullName, err := NewFullName(fullName.firstName, lastName)
	if err != nil {
		return nil, err
	}
	return changedFullName, nil
}

func (fullName *FullName) Equals(otherFullName FullName) bool {
	return reflect.DeepEqual(fullName.firstName, otherFullName.firstName) && reflect.DeepEqual(fullName.lastName, otherFullName.lastName)
}

不変である

以下はFullNameのコンストラクタとしてNewFullName()を実装しました。
firstNamelastNameのセッターは作成せず、コンストラクタ内で代入します。
セッターが存在しないので、同一 package、外部 package 問わず、firstNamelastNameは不変です。

func NewFullName(firstName string, lastName string) (_ *FullName, err error) {
	defer iterrors.Wrap(&err, "fullname.NewFullName(%s, %s)", firstName, lastName)
	fullName := new(FullName)

	// set firstName
	if firstName == "" {
		return nil, fmt.Errorf("firstName is required")
	}
	if !ValidateName(firstName) {
		return nil, fmt.Errorf("firstName has an invalid character. letter is only")
	}
	fullName.firstName = firstName

	// set lastName
	if lastName == "" {
		return nil, fmt.Errorf("lastName is required")
	}
	if !ValidateName(lastName) {
		return nil, fmt.Errorf("lastName has an invalid character. letter is only")
	}
	fullName.lastName = lastName

	return fullName, nil
}

交換が可能である

以下は、firstNamelastNameを変更するメソッドです。値オブジェクトの特徴を説明するために記述しました。
fullName.firstName = changedFirstNameのように、直接インスタンスを変更するのではなく、NewFullName()で新しいインスタンスを生成します。
先述しましたが、説明のために記述したメソッドのため、モデリング時点で変更不要ならば、このメソッドも不要です。

func (fullName *FullName) WithChangeFirstName(firstName string) (_ *FullName, err error) {
	changedFullName, err := NewFullName(firstName, fullName.lastName)
	if err != nil {
		return nil, err
	}
	return changedFullName, nil
}

func (fullName *FullName) WithChangeLastName(lastName string) (_ *FullName, err error) {
	changedFullName, err := NewFullName(fullName.firstName, lastName)
	if err != nil {
		return nil, err
	}
	return changedFullName, nil
}

等価性によって比較される

Equals()は 2 つのFullNameを比較するメソッドです。
全ての属性(firstNamelastName)を比較し、全て等しいときのみtrueを返します。

func (fullName *FullName) Equals(otherFullName FullName) bool {
	return reflect.DeepEqual(fullName.firstName, otherFullName.firstName) && reflect.DeepEqual(fullName.lastName, otherFullName.lastName)
}

money.go

続いて、money.goの実装です。
Go には decimal 型が存在しないので、サードパッケージであるgithub.com/shopspring/decimalを使用しました。
money.goでは値オブジェクトの特徴である不変である交換可能であるが実装されていました(なぜか、等価性によって比較されるは実装されていませんでした)。
特徴を確認します。

./domain/model/money/money.go
package money

import (
	"fmt"

	"github.com/Msksgm/itddd-go-02-valueobject/iterrors"
	"github.com/shopspring/decimal"
)

type Money struct {
	amount   decimal.Decimal
	currency string
}

func NewMoney(amount decimal.Decimal, currency string) (_ *Money, err error) {
	money := new(Money)

	money.amount = amount
	money.currency = currency

	return money, nil
}

func (money *Money) Add(arg Money) (_ *Money, err error) {
	defer iterrors.Wrap(&err, "money.Add(%v)", arg)
	if money.currency != arg.currency {
		err = fmt.Errorf("currency is different between money:%s and arg:%s", money.currency, arg.currency)
		return nil, err
	}
	newMoney, err := NewMoney(money.amount.Add(arg.amount), money.currency)
	return newMoney, err
}

不変である

コンストラクタ代わりのNewMoney()です。
セッターが存在しないので、戻り値moneyの属性は不変になります。
バリデーションを記述していないのでわかりづらいですが、本来はcurrencyが有効な通過かどうかの検証もおこないます。
バリデーションを書かなかった理由は、サンプルコードに書いていないのと、decimal パッケージで decimal が保証されているからです。

func NewMoney(amount decimal.Decimal, currency string) (_ *Money, err error) {
	money := new(Money)

	money.amount = amount
	money.currency = currency

	return money, nil
}

交換が可能である

以下は money 同士の足し算をおこなうためのメソッドです。
currency が同じかどうかを確認して、足し算します。
newMoneyには、新しいインスタンスを作成することで、交換を実現します。

func (money *Money) Add(arg Money) (_ *Money, err error) {
	defer iterrors.Wrap(&err, "money.Add(%v)", arg)
	if money.currency != arg.currency {
		err = fmt.Errorf("currency is different between money:%s and arg:%s", money.currency, arg.currency)
		return nil, err
	}
	newMoney, err := NewMoney(money.amount.Add(arg.amount), money.currency)
	return newMoney, err
}

考察

Go で値オブジェクトを実装するにあたって、課題と考察を記述していきます。
個人的な考察になるので、読み飛ばしてもらっても構わないです。

セッターについて

値オブジェクトは、属性を不変にするため、セッター(に該当する処理)を隠蔽し、外部のファイルから参照できないようにする必要があります。
Java、C#、Kotlin ではアクセス修飾子があるため、private なセッターを他のファイルから隠蔽できます。
しかし、Go ではアクセス修飾子がなく、大文字小文字で package 単位の公開非公開をします。
そのため、セッター(に該当する処理)を記述するには工夫が必要です。
そこで以下の 3 つ方法を考えました。順番に解説します。

  1. セッターに該当する関数を記述しない
  2. ドメインオブジェクトごとに package を区切る
  3. validation を関数にして、コンストラクタの外部に記述する

1 セッターに該当する関数を記述しない

セッターに該当する関数を記述しないやり方は本記事で採用した方法です。コンストラクタに全ての属性の初期化をおこないます。
メリットはシンプルに実装できることです。
デメリットは属性が多いとコンストラクタの行数が膨大になることです。膨大になると、最初の実装時だけでなくリファクタリング時にドメイン知識を実装し忘れてしまう可能性があります。
シンプルな値オブジェクトで属性が少ないときに有効です。
迷ったらこれでもいいと思います。

func NewFullName(firstName string, lastName string) (_ *FullName, err error) {
	defer iterrors.Wrap(&err, "fullname.NewFullName(%s, %s)", firstName, lastName)
	fullName := new(FullName)

	// set firstName
	if firstName == "" {
		return nil, fmt.Errorf("firstName is required")
	}
	if !ValidateName(firstName) {
		return nil, fmt.Errorf("firstName has an invalid character. letter is only")
	}
	fullName.firstName = firstName

	// set lastName
	if lastName == "" {
		return nil, fmt.Errorf("lastName is required")
	}
	if !ValidateName(lastName) {
		return nil, fmt.Errorf("lastName has an invalid character. letter is only")
	}
	fullName.lastName = lastName

	return fullName, nil
}

2 ドメインオブジェクトごとに package を区切る

ドメインオブジェクトごとに package を区切ることで、セッターが外部の package から参照できないようにします。
例えば、以下のようなソースコードがあるとします。
NewFullNameからセッターに該当する関数を分離させました。
setFirstNamesetLastNameは先頭が小文字なので packge にのみ公開され一見良さそうに見えますが、何が問題なのでしょうか。

func NewFullName(firstName string, lastName string) (_ *FullName, err error) {
	defer iterrors.Wrap(&err, "fullname.NewFullName(%s, %s)", firstName, lastName)
	fullName := new(FullName)

	// set firstName
	err = fullName.setFirstName(firstName)
	if err != nil {
		return nil, err
	}

	// set lastName
	err = fullName.setLastName(lastName)
	if err != nil {
		return nil, err
	}

	return fullName, nil
}

func (fullName *FullName) setFirstName(firstName string) (err error) {
	if firstName == "" {
		return fmt.Errorf("firstName is required")
	}
	if !ValidateName(firstName) {
		return fmt.Errorf("firstName has an invalid character. letter is only")
	}
	fullName.firstName = firstName
	return nil
}

func (fullName *FullName) setLastName(lastName string) (err error) {
	if lastName == "" {
		return fmt.Errorf("lastName is required")
	}
	if !ValidateName(lastName) {
		return fmt.Errorf("lastName has an invalid character. letter is only")
	}
	fullName.lastName = lastName
	return nil
}

このとき、ディレクトリ構成は以下にしなければなりません。
package ごとに区切る必要があります。

ディレクトリの構成例
.
├── domain
   └── model
       ├── fullname
       │   ├── fullname.go
       │   └── fullname_test.go
       ├── other1
       │   ├── other1.go
       │   └── other1_test.go
       └── other2
           ├── other2.go
           └── other2_test.go

以下の構成にしてはいけません。同一 package ならsetFirstNamesetLastNameother1.goother2.goから呼び出せてしまうからです。
不変性が保たれず、値オブジェクトの特徴がそこなわれてしまいます。

ディレクトリの構成例
.
├── domain
   └── model
       ├── fullname
           ├── fullname.go
           └── fullname_test.go
           ├── other1.go
           └── other1_test.go
           ├── other2.go
           └── other2_test.go

以上が、 ドメインオブジェクトごとに package を区切るパターンです。
メリットはコンストラクタが見やすくなることです。可読性が上がり、属性ごとに満たすべき条件がわかりやすくなります。
デメリット 2 つあります。1 つめは同じファイル内の他の関数からセッターを呼び出せることです。他の関数から呼び出せるなら、結局ヒューマンエラーによって可変になってしまう可能性があります。PR レビューで検出できる問題であり、途中で直接変数を代入するのと変わらないですが、可能なこと自体に違和感があります。Java、C#ならメンバー変数にfinalを宣言すれば防止できますが、Go には存在しないので防ぐことができません。2 つめは、ディレクトリの数が大量になることです。ドメインオブジェクトごとに作るので、見づらくなり、packge やディレクトリ構成の意味が薄れます。

オブジェクト指向言語らしい書き方ができますがデメリットが多く、推奨できるやり方じゃないかもしれません。

3 validation を関数にして、コンストラクタの外部に記述する

最後に紹介するのが、セッターに該当する関数を記述しないドメインオブジェクトごとに package を区切るを足し合わせたパターンです。
実装は以下になります。

firstNamelastNameのバリデーションを他の関数で用意し、値の代入はセッター関数を作成せずに、コンストラクタ内でおこないます。
こうすることでセッターを書かずに、値オブジェクトの属性を保証できます。
メリットは 3 つあります、1 つめはコンストラクタがシンプルになることです。バリデーションを分離させているので、属性の条件漏れを発見しやすくなります。2 つめはセッター(公開非公開問わず)を記述しないで済むことです。同一 package または同一ファイルで更新される可能性を下げることができます。3 つめはドメインオブジェクトごとに package を区切ると比較してディレクトリの数を増やさなくて済むことです。
デメリットは書き方に若干違和感があることです。バリデーション関数で属性を渡しているので、そのまま代入する実装が多いと思います。しかし、属性を変更できる関数を増やし、セッターになってしまうので、やってはいけません。

func NewFullName(firstName string, lastName string) (_ *FullName, err error) {
	defer iterrors.Wrap(&err, "fullname.NewFullName(%s, %s)", firstName, lastName)
	fullName := new(FullName)

	// set firstName
	err = validateFirstName(firstName)
	if err != nil {
		return nil, err
	}
	fullName.firstName = firstName

	// set lastName
	err = validateLastName(lastName)
	if err != nil {
		return nil, err
	}
	fullName.lastName = lastName

	return fullName, nil
}

func validateFirstName(firstName string) error {
	if firstName == "" {
		return fmt.Errorf("firstName is required")
	}
	if !ValidateName(firstName) {
		return fmt.Errorf("firstName has an invalid character. letter is only")
	}
	return nil
}

func validateLastName(lastName string) error {
	if lastName == "" {
		return fmt.Errorf("lastName is required")
	}
	if !ValidateName(lastName) {
		return fmt.Errorf("lastName has an invalid character. letter is only")
	}
	return nil
}

String()について

今回の実装では、値オブジェクトの本質的ではないのと、サンプルコードにも実装されていなかったため、String()を実装しませんでした。
以下のような実装になります。

domain/model/fullname/fullname.go
func (fullName *FullName) String() string {
	return fmt.Sprintf("FullName [firstName=%v, lastName=%v]", fullName.firstName, fullName.lastName)
}
./domain/model/money/money.go
func (money *Money) String() string {
	return fmt.Sprintf("Money[amoun=%v, currency=%v]", money.amount, money.currency)
}

外部から値オブジェクトのインスタンスをfmt.Printlnなどをするときに、見やすくなります。
これだけではわかりにくいですが、例えばパスワードなどの外部から絶対に秘匿にしたい情報などは、String()でも表示させないようにすることで安全性が向上します。
デバッグ時に便利なのと、「実践ドメイン駆動設計」では、全ての値オブジェクトにString()を記述していたので、好みに合わせて記述するといいと思います。

String()なし
> go run cmd/fullname/fullname.go
nameA: &{firstName lastName} is equal to nameB: &{firstName lastName}%
> go run cmd/money/money.go
&{{0x14000134400 0} JPY}
String()あり
> go run cmd/fullname/fullname.go
nameA: FullName [firstName=firstName, lastName=lastName] is equal to nameB: FullName [firstName=firstName, lastName=lastName]%
> go run cmd/money/money.go
Money[amoun=4000, currency=JPY]

まとめ

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

  • 不変である
  • 交換が可能である
  • 等価性によって比較される

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

Go の仕様に従うと、不変であるを担保するためにセッターの書き方を考慮する必要があると考えました。
何か良い方法があったら、コメントでご指摘のほどお願いします。

参考

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

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

https://qiita.com/MinoDriven/items/5e69d9bd028aa350e2c4

Discussion

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