ValueObjectのススメ
こちらは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
}
上記の何が問題なのか?
-
name
とdescription
、createdAt
とupdatedAt
は同じ型であるため、クライアントコードで取り違えて設定してしまう余地がある。
// ついうっかり、nameとdescriptionを取り違えてしまったコード
myDomainEntity := NewMyDomainEntity(
MyEnumRegistered, // enum : int
"my description", // name : string
"my name") // description : string
-
enum
がMyDomainEntity
以外の他の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
}
- それを集約しようとするとグローバルなスコープを持つ薄いロジックが増えることになる。大抵はそのようなロジックの相応しい置き場所に迷うため、その存在に気付かずに別の人が同じような「グローバルスコープを持つ薄いロジック」をどこか別の場所に実装してしまうかもしれない。
// 例:「みんな使うんで、レシーバとかじゃなくてグローバルなfuncにしておきました!」と言うのも残念
func IsInActive(e int) bool {
// 無効
return e.enum == MyEnumUNDEFINED ||
e.enum == MyEnumExpired ||
e.enum == MyEnumRegistered
}
- 「グローバルスコープを持つ薄いロジック」を集約せずに利用箇所毎に実装をすれば、修正時に影響調査と対応が漏れてしまうリスクがある。
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を使わない場合に発生しうる問題は解決されているのか?
- nameとdescription、createdAtとupdatedAtは同じ型であるため、クライアントコードで取り違えて設定してしまう余地がある。
name
はName
型、description
はDescription
型なので(※ 同様にcreatedAt
はCreatedAt
型、updatedAt
型はUpdatedAt
方)、コンパイラがチェックしてくれるようになりました。
enum
がMyDomainEntity
以外の他のEntityで定義されている場合、IsInActive
のような小さなロジックが他のEntityでも必要になる。
enum
の型であるMyEnum
を使っている箇所ではそのままIsInActive
をcallすることができます。
- それを集約しようとするとグローバルなスコープを持つ薄いロジックが増えることになる。大抵はそのようなロジックの相応しい置き場所に迷うため、その存在に気付かずに別の人が同じような「グローバルスコープを持つ薄いロジック」をどこか別の場所に実装してしまうかもしれない。
enum
の型であるMyEnum
型がIsInActive
のような関連する操作を知っていますので、関数の存在にも気付きやすく、置き場所に迷うこともありません。
- 「グローバルスコープを持つ薄いロジック」を集約せずに利用箇所毎に実装をすれば、修正時に影響調査と対応が漏れてしまうリスクがあるだけでなく、修正後のテスト範囲も拡大する。
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