🐝

ValueObjectのススメ

2023/12/22に公開

こちらはKyash Advent Calendar 2023の22日目の記事です。

Kyashでサーバサイドの開発をしているdevkono (Yoshiharu Kono)です。

この記事はドメイン駆動開発で良く利用されるValue Objectの特性について言語化し、最後に「Value Objectはいいぞ」とオススメするために書かれたものです。
今回は主にKyashでも広く利用されているGo言語を例にして記載します。


「Entityには振る舞いを持たせるのに、なぜEntityの中の項目自体には振る舞いを持たせないのか?」
と、考えたことはないでしょうか?

一般的によく見かけるドメインエンティティオブジェクト(Value Objectを利用しない)

Entityに振る舞いを持たせることは一般的に広く行われています。
以下にGo言語で実装された振る舞いを持たせたEntityを示します。

package sample

import (
	"time"
)

// ドメインモデルのインタフェース

type MyDomainEntity interface {
	ID() uint64
	Enum() int
	Name() string
	Description() string
	CreatedAt() time.Time
	UpdatedAt() time.Time
}

// ドメインモデルの実体

type myDomainEntity struct {
	id          uint64
	enum        int
	name        string
	description string
	createdAt   time.Time
	updatedAt   time.Time
}

// 説明のためにシンプルなconstによる定義にしています

const (
	// iotaは並行開発のmerge等の影響で定数の順序が変わった場合に、
	// 意図しない値の変更に気付きにくいので利用を避けています

	MyEnumUNDEFINED   = 0 // 未定義。不正な値
	MyEnumRegistered  = 1 // 登録済み
	MyEnumUnderReview = 2 // 審査中
	MyEnumApproved    = 3 // 承認済み
	MyEnumPending     = 4 // 保留中
	MyEnumExpired     = 5 // 失効
)

// モデルをイミュータブルにするための制御に利用するファクトリメソッド

func NewMyDomainEntity(
	enum int,
	name string,
	description string,
) MyDomainEntity {
	return &myDomainEntity{
		// idはDBに登録する際に採番される
		enum:        enum,
		name:        name,
		description: description,
		createdAt:   time.Now(),
		updatedAt:   time.Now(),
	}
}

// interfaceを満たすためのレシーバメソッド

func (e *myDomainEntity) ID() uint64 {
	return e.id
}

func (e *myDomainEntity) Enum() int {
	return e.enum
}

func (e *myDomainEntity) Name() string {
	return e.name
}

func (e *myDomainEntity) Description() string {
	return e.description
}

func (e *myDomainEntity) CreatedAt() time.Time {
	return e.createdAt
}

func (e *myDomainEntity) UpdatedAt() time.Time {
	return e.updatedAt
}

// Entityに振る舞いを持たせる

func (e *myDomainEntity) IsInActive() bool {
	// 無効
	return e.enum == MyEnumUNDEFINED ||
		e.enum == MyEnumExpired ||
		e.enum == MyEnumRegistered
}

func (e *myDomainEntity) IsValid() bool {
	return e.enum != MyEnumUNDEFINED &&
		len(e.name) > 0 &&
		len(e.description) > 0
}

上記の何が問題なのか?

  1. namedescriptioncreatedAtupdatedAtは同じ型であるため、クライアントコードで取り違えて設定してしまう余地がある。
// ついうっかり、nameとdescriptionを取り違えてしまったコード
myDomainEntity := NewMyDomainEntity(
	MyEnumRegistered, // enum : int
	"my description", // name : string
	"my name") // description : string

  1. enumMyDomainEntity以外の他のEntityで定義されている場合、IsInActiveのような小さなロジックが他のEntityでも必要になる。
// 小さなロジックが各Entityで定義されてしまう
func (e *myDomainEntity) IsInActive() bool {
	// 無効
	return e.enum == MyEnumUNDEFINED ||
		e.enum == MyEnumExpired ||
		e.enum == MyEnumRegistered
}

// ... 近くに定義されていれば気付いて統合できるかもだけれど、把握しきれないかも
func (e *otherDomainEntity) IsInActive() bool {
	// 無効
	return e.enum == MyEnumUNDEFINED ||
		e.enum == MyEnumExpired ||
		e.enum == MyEnumRegistered
}

  1. それを集約しようとするとグローバルなスコープを持つ薄いロジックが増えることになる。大抵はそのようなロジックの相応しい置き場所に迷うため、その存在に気付かずに別の人が同じような「グローバルスコープを持つ薄いロジック」をどこか別の場所に実装してしまうかもしれない。
// 例:「みんな使うんで、レシーバとかじゃなくてグローバルなfuncにしておきました!」と言うのも残念
func IsInActive(e int) bool {
	// 無効
	return e.enum == MyEnumUNDEFINED ||
		e.enum == MyEnumExpired ||
		e.enum == MyEnumRegistered
}

  1. 「グローバルスコープを持つ薄いロジック」を集約せずに利用箇所毎に実装をすれば、修正時に影響調査と対応が漏れてしまうリスクがある。

Value Objectを定義すると上記の問題が解決される

では、Value Objectを利用した場合のEntity定義を見てみましょう。
以下にGo言語で実装された振る舞いを持たせたValueObjectを利用した例を示します。

package valueobject

import (
	"time"
)

// ドメインモデルのインタフェース

type MyDomainEntity interface {
	ID() MyDomainEntityID
	Enum() MyEnum
	Name() Name
	Description() Description
	CreatedAt() CreatedAt
	UpdatedAt() UpdatedAt
}

// ドメインモデルの実体

type myDomainEntity struct {
	id          MyDomainEntityID
	enum        MyEnum
	name        Name
	description Description
	createdAt   CreatedAt
	updatedAt   UpdatedAt
}

// モデルをイミュータブルにするための制御に利用するファクトリメソッド

func NewMyDomainEntity(
	enum MyEnum,
	name Name,
	description Description,
) MyDomainEntity {
	return &myDomainEntity{
		// idはDBに登録する際に採番される
		enum:        enum,
		name:        name,
		description: description,
		createdAt:   CreatedAt(time.Now()),
		updatedAt:   UpdatedAt(time.Now()),
	}
}

// interfaceを満たすためのレシーバメソッド

func (e *myDomainEntity) ID() MyDomainEntityID {
	return e.id
}

func (e *myDomainEntity) Enum() MyEnum {
	return e.enum
}

func (e *myDomainEntity) Name() Name {
	return e.name
}

func (e *myDomainEntity) Description() Description {
	return e.description
}

func (e *myDomainEntity) CreatedAt() CreatedAt {
	return e.createdAt
}

func (e *myDomainEntity) UpdatedAt() UpdatedAt {
	return e.updatedAt
}

// Entityに振る舞いを持たせる

func (e *myDomainEntity) IsValid() bool {
	return !(e.enum.IsUndefined() &&
		e.name.IsBlank() &&
		e.description.IsBlank())
}
package valueobject

import "time"

// Value Objectに振る舞いを持たせる

type MyDomainEntityID uint64
type MyEnum int
type Name string
type Description string
type CreatedAt time.Time
type UpdatedAt time.Time

func (e *Name) IsBlank() bool {
	return len(*e) > 0
}

func (e *Description) IsBlank() bool {
	return len(*e) > 0
}

const (
	// iotaは並行開発のmerge等の影響で定数の順序が変わった場合に、
	// 意図しない値の変更に気付きにくいので利用を避けています

	MyEnumUNDEFINED   = MyEnum(0) // 未定義。不正な値
	MyEnumRegistered  = MyEnum(1) // 登録済み
	MyEnumUnderReview = MyEnum(2) // 審査中
	MyEnumApproved    = MyEnum(3) // 承認済み
	MyEnumPending     = MyEnum(4) // 保留中
	MyEnumExpired     = MyEnum(5) // 失効
)

func (e *MyEnum) IsUndefined() bool {
	return *e == MyEnumUNDEFINED
}

func (e *MyEnum) IsInActive() bool {
	// 無効
	return *e == MyEnumUNDEFINED ||
		*e == MyEnumExpired ||
		*e == MyEnumRegistered
}

先ほどのValue Objectを使わない場合に発生しうる問題は解決されているのか?

  1. nameとdescription、createdAtとupdatedAtは同じ型であるため、クライアントコードで取り違えて設定してしまう余地がある。

nameName型、descriptionDescription型なので(※ 同様にcreatedAtCreatedAt型、updatedAt型はUpdatedAt方)、コンパイラがチェックしてくれるようになりました。

  1. enumMyDomainEntity以外の他のEntityで定義されている場合、IsInActiveのような小さなロジックが他のEntityでも必要になる。

enumの型であるMyEnumを使っている箇所ではそのままIsInActiveをcallすることができます。

  1. それを集約しようとするとグローバルなスコープを持つ薄いロジックが増えることになる。大抵はそのようなロジックの相応しい置き場所に迷うため、その存在に気付かずに別の人が同じような「グローバルスコープを持つ薄いロジック」をどこか別の場所に実装してしまうかもしれない。

enumの型であるMyEnum型がIsInActiveのような関連する操作を知っていますので、関数の存在にも気付きやすく、置き場所に迷うこともありません。

  1. 「グローバルスコープを持つ薄いロジック」を集約せずに利用箇所毎に実装をすれば、修正時に影響調査と対応が漏れてしまうリスクがあるだけでなく、修正後のテスト範囲も拡大する。

IsInActiveの定義は1箇所にまとまっていますので、新しいMyEnumが追加された場合にも修正は1箇所で済みます。

デメリットはないのか?

私が最初にValue Objectを知った際に抱いた懸念について、いくつか説明します。

私が最初にValue Objectを使った際に抱いた懸念 その1

独自に定義したtypeだとgormを利用したデータアクセスの際に引数のbindで不都合がでるのでは?

これはドメインのデータモデルと、DBのデータモデルを分割していれば、gormなどのDBライブラリを利用する際も問題ありません。以下のようにEntityにDBのデータモデルとの相互変換の関数を定義して、repository層でcallすることで、プリミティブな型でgormなどのライブラリに渡すことができます。

まずDBのデータモデルを定義します。

package valueobject

import (
	"time"
)

// DBのデータモデルの定義

type MyDBEntity struct {
	id          uint64
	enum        int
	name        string
	description string
	createdAt   time.Time
	updatedAt   time.Time
}

次にEntityにドメインのデータモデルとDBのデータモデルの相互変換用の関数を定義します。

// ドメインモデルとデータモデルの変換

func DomainModelToDBModel(entity MyDomainEntity) MyDBEntity {
	return MyDBEntity{
		id:          uint64(entity.ID()),
		enum:        int(entity.Enum()),
		name:        string(entity.Name()),
		description: string(entity.Description()),
		createdAt:   time.Time(entity.CreatedAt()),
		updatedAt:   time.Time(entity.UpdatedAt()),
	}
}

func DBModelToDomainModel(entity MyDBEntity) MyDomainEntity {
	// ここでは全ての項目をmappingする必要があるため、パブリックなスコープになっているファクトリメソッドは使わない
	return &myDomainEntity{
		id:          MyDomainEntityID(entity.id),
		enum:        MyEnum(entity.enum),
		name:        Name(entity.name),
		description: Description(entity.description),
		createdAt:   CreatedAt(entity.createdAt),
		updatedAt:   UpdatedAt(entity.updatedAt),
	}
}

最後にrepositoryの部品で相互変換します。


// repositoryの実装詳細については本記事の説明範囲を超えるため、割愛します

func (r *MyRepository) Save(entity MyDomainEntity) error {
	myDBEntity := DomainModelToDBModel(entity)
	if err := r.db.Save(&myDBEntity).Error; err != nil {
		return err
	}
	return nil
}

func (r *MyRepository) Find(entity MyDomainEntity) (MyDomainEntity, error) {
	myDBEntity := DomainModelToDBModel(entity)
	if err := r.db.First(&myDBEntity).Error; err != nil {
		return nil, err
	}
	return DBModelToDomainModel(myDBEntity), nil
}

私が最初にValue Objectを使った際に抱いた懸念 その2

全ての項目をtypeにしてしまうとプリミティブな型を利用するのに比べて、メモリを食ったり処理効率が落ちたりするのでは?

現場で問題になったことはないのですが、計測してみたこともないので簡易な検証を行ってみました。
検証の内容は後述しますが、Value Objectを使うことでメモリを余計に食ったり、遅くなったりすることはないと思われます。

どのくらいメモリに差があるのか、検証してみました

現実のシステム稼働条件とはかけ離れていますが、メモリを多く使用するかどうかだけのシンプルな比較に限るため簡易な検証とさせていただきました。

比較結果

先に検証コードの実行結果を記載します。
大量のValue Objectの型は用意していないなど厳密なテスト条件ではありませんが、プリミティブ型とValue Objectで顕著な性能差は見られないようです。

実行 試行回数 Alloc Total Alloc Heap Alloc Heap Sys 経過時間
プリミティブ型のフィールド 100万回 152 MB 206 MB 152 MB 155 MB 8s
Value Object 100万回 152 MB 206 MB 152 MB 155 MB 8s
プリミティブ型のフィールド 1,000万回 1505 MB 2009 MB 1505 MB 1511 MB 1m26s
Value Object 1,000万回 1505 MB 2009 MB 1505 MB 1511 MB 1m24s
プリミティブ型のフィールド 1億回 14045 MB 21399 MB 14045 MB 15351 MB 14m27s
Value Object 1億回 14045 MB 21399 MB 14045 MB 15363 MB 14m31s

検証方法

entityのオブジェクトを配列に大量に確保し、オブジェクトの開放前にメモリの使用量を標準出力に出力しました。
参考までに実行時間も計測しています。

実行マシン

  • マシン: MacBook Pro 14インチ 2023
  • チップ: Apple M2 Pro
  • メモリ: 32GB
  • macOS: Sonoma 14.x.x

検証コード

Value Objectを利用しない方の検証コード

func main() {
	calc(100000000)
}

const (
	MemMB = 1024 * 1024
)

var mem runtime.MemStats

func calc(count int) {
	start := time.Now()
	entities := []entity.MyDomainEntity{}
	for i := 0; i < count; i++ {
		entities = append(entities, entity.NewMyDomainEntity(
			i,
			fmt.Sprintf("%d", i),
			fmt.Sprintf("%d", i)),
		)
		runtime.ReadMemStats(&mem)
	}
	end := time.Now()
	elapsed := end.Sub(start)
	fmt.Printf("alloc:%d MB, total:%d MB, heapAlloc:%d MB, heap:%d MB, len(entities):%d\n", mem.Alloc/MemMB, mem.TotalAlloc/MemMB, mem.HeapAlloc/MemMB, mem.HeapSys/MemMB, len(entities))
	fmt.Printf("elapsed:%s", elapsed.String())
}


Value Objectを利用する方の検証コード

func main() {
	calc(100000000)
}

const (
	MemMB = 1024 * 1024
)

var mem runtime.MemStats

func calc(count int) {
	start := time.Now()
	entities := []entity.MyDomainEntity{}
	for i := 0; i < count; i++ {
		entities = append(entities, entity.NewMyDomainEntity(
			vo.MyEnumApproved,
			vo.Name(fmt.Sprintf("%d", i)),
			vo.Description(fmt.Sprintf("%d", i)),
		))
		runtime.ReadMemStats(&mem)
	}
	end := time.Now()
	elapsed := end.Sub(start)
	fmt.Printf("alloc:%d MB, total:%d MB, heapAlloc:%d MB, heap:%d MB, len(entities):%d\n", mem.Alloc/MemMB, mem.TotalAlloc/MemMB, mem.HeapAlloc/MemMB, mem.HeapSys/MemMB, len(entities))
	fmt.Printf("elapsed:%s", elapsed.String())
}

実行ログ

Value Objectを利用しない場合の実行ログ

# 100万回ループ
alloc:152 MB, total:206 MB, heapAlloc:152 MB, heap:155 MB, len(entities):1000000
elapsed:8.504596709s%                                                                                                                        

# 1,000万回ループ
alloc:1505 MB, total:2009 MB, heapAlloc:1505 MB, heap:1511 MB, len(entities):10000000
elapsed:1m26.151571709s%      

# 1億回ループ
alloc:14045 MB, total:21399 MB, heapAlloc:14045 MB, heap:15351 MB, len(entities):100000000
elapsed:14m27.047036375s%     

Value Objectを使用する場合の実行ログ

# 100万回ループ
alloc:152 MB, total:206 MB, heapAlloc:152 MB, heap:155 MB, len(entities):1000000
elapsed:8.433988334s%                                                                                                                        

# 1,000万回ループ
alloc:1505 MB, total:2009 MB, heapAlloc:1505 MB, heap:1511 MB, len(entities):10000000
elapsed:1m24.770796375s%       

# 1億回ループ
alloc:14045 MB, total:21399 MB, heapAlloc:14045 MB, heap:15363 MB, len(entities):100000000
elapsed:14m31.995732417s%

さいごに

Value Objectはいいぞ!

Go言語では型は簡単に定義可能で、ありがたいことに軽量でもあるようです。
Value Objectを使って安全でストレスフリーな開発ライフを送りましょう。

Discussion