🐘

goでDDDに入門する ①ドメインオブジェクト編 

2025/01/20に公開

はじめに

プロダクトでDDDを使い始めたのですが、理解が浅かったので本を読んで学習した内容をまとめます。
今回はこちらの本を読んで勉強しました。

ドメインとは

ソフトウェア開発におけるドメインとは、プログラムが適用される対象領域を指します。
このドメインの概念を抽象化する作業をモデリングと呼び、モデリングで得られたものをドメインモデルといいます。ドメインモデルは、現実の事象や概念を必要な部分だけ抽出して抽象化したものです。

良いモデルとは、問題を解決できるモデルのことです。しかし、ドメインモデルを抽出するだけでは問題は解決できません。これをコードとして表現したものをドメインオブジェクトと呼びます。エンジニアは、ドメインモデルの全てをドメインオブジェクトにするわけではなく、実際に利用者の問題を解決できるものだけを実装します。

ドメインが変われば、ドメインモデルやドメインオブジェクトも連動して変化させる必要があります。この連動をスムーズに行うために、エンティティやリポジトリといった戦術的設計パターンがベストプラクティスとして存在します。

ドメインオブジェクトを表現するパターン

以下の3つがあります。それぞれ説明していきます。

  • 値オブジェクト
  • エンティティ
  • ドメインサービス

値オブジェクト

値オブジェクトによって業務の関心事を直接表現することができます。
例えばMoneyを値オブジェクトとして扱うと以下のように、パッとみて何を扱っているのかわかります。

int x = 100 // なんの数値かわからない
Money x = 100 // お金を扱ってる

値オブジェクトは以下の特性があります。

  • イミュータブル
  • 交換可能
  • 等価性で比較可能

イミュータブルだと値オブジェクトそのものは変化しません。イミュータブルにすることで知らないところで値が書き換わっていることを防ぐことができるため、開発者の心配事が減らせます。

type Money int

// 1000円持ってて、100円追加したいケース
var money = Money{1000}

// ミュータブルなケース
money.Add(100) // 値が変更できるためダメ

// イミュータブルなケース
var moneyAdded = Money{1100} // 新しくオブジェクトを生成する

書籍ではイミュータブルにする方法として以下が紹介されていました。

  • インスタンス変数はコンストラクタでオブジェクトの生成時に設定する
  • インスタンス変数を変更するメソッド(setterメソッド)を作らない
  • 別の値が必要であれば、別のインスタンス(オブジェクト)を作る

ref. 現場で役立つシステム設計の原則 P36

次にMoney型を以下のようにしてみます。

type Money struct {
	cost        int
	concurrency string
}

この時Money型のインスタンスが等しいことを比較するなら以下の形も考えられます。

main.go
var moneyA = Money{
    cost:        1000,
    concurrency: "yen",
}

var moneyB = Money{
    cost:        1000,
    concurrency: "yen",
}

moneyA.cost == moneyB.cost && moneyA.currency == moneyB.currency

けれど、値の比較は基本的に以下の形が一般的です

1 == 1

そのため値オブジェクトは値として扱いたいため、上記の表現を再現できるように実装します。

moneyA == moneyB

goだと==を使って構造体を比較することができます。ですが構造体の中にポインタ型が含まれる場合はアドレスが異なるためポインタの値が同じでもfalseになります。

type Number int

type HasPointerNumber struct {
	number *Number
}

var num1 = Number(1)
var num2 = Number(1)
var x = HasPointerNumber{
    number: &num1,
}
var y = HasPointerNumber{
    number: &num2,
}
fmt.Println(x == y) // false

この問題を解決するために、reflect.DeepEqualを使用します。reflect.DeepEqualドキュメントによるとポインタの比較は以下のように書かれています。

Pointer values are deeply equal if they are equal using Go's == operator or if they point to deeply equal values.

つまり

they are equal using Go's == operator

これは以下のように、ポインタのアドレスが全く同じ場合を指します。

var num1 = Number(1)
var num2 = &num1
var num3 = &num1

fmt.Println(num2 == num3) // true

they point to deeply equal values.

これは深い等価性のことを行っており、ポインタの値が同じであればTrueを返すということです。

var num1 = Number(1)
var num2 = Number(1)

// アドレスは異なるがポインタの実際の値は等しい
fmt.Println(reflect.DeepEqual(num1, num2)) // true 

そのためポインタを指定しても実際の値を見てくれるようになります。これを元に実装すると以下のようになります。

domain/money.go
type Money struct {
	cost        *int // ポインタ型に変更
	concurrency string
}

func NewMoney(cost *int, concurrency string) (*Money, error) {
	if *cost < 0 {
		return nil, fmt.Errorf("cost must be a positive number")
	}

	return &Money{
		cost:        cost,
		concurrency: concurrency,
	}, nil
}

// 比較メソッドを追加
func (m *Money) Equals(other *Money) bool {
	return reflect.DeepEqual(m, other)
}

以下のように使います

main.go
func main() {
	var cost = 1000
	moneyA, err := domain.NewMoney(&cost, "yen")
	if err != nil {
		panic(err)
	}
	moneyB, err := domain.NewMoney(&cost, "yen")
	if err != nil {
		panic(err)
	}
    
    fmt.Println(moneyA == moneyB) // false
    fmt.Println(moneyA.Equals(moneyB)) // true
}

これで等価比較可能になりました。

ですが、型は同じパッケージにあるとフィールドにアクセスできてしまうようです。これだとイミュータブルでは無くなってしまいます。

main.go
var moneyA = Money{
    cost:        1000,
    concurrency: "yen",
}
moneyA.cost = 2000 // イミュータブルに反する

golangでは別パッケージにし、フィールドの頭文字を小文字にしてプライベートにすることで隠せます。
domainパッケージを作成し、その配下にmoney.goを作成し以下のようにしました。

domain/money.go
package domain

type Money struct {
	cost        int
	concurrency string
}

func NewMoney(cost int, concurrency string) Money {
	return Money{
		cost:        cost,
		concurrency: concurrency,
	}
}

使う側はこのようにします。

main.go
package main

import "github.com/stutkhd-0709/golang-ddd/domain"

func main() {
	var moneyA = domain.NewMoney(1000, "yen")
	var moneyB = domain.NewMoney(1000, "yen")

	moneyA.cost = 2000 // エラー

	println(moneyA == moneyB)
}

ジェネリクスを使って表現する方法もあるそうです。
https://zenn.dev/jy8752/articles/757f37b3a7c7cd

そして、値オブジェクトはドメインロジック(振る舞い)を値と一緒に扱います。
例えば、金額を表すドメインオブジェクトをMoneyとして扱った場合、金額は0より大きいはずです。また、金額同士を合算するときは通貨も同じである必要があります。

domain/money.go
type Money struct {
	cost        int
	concurrency string
}

func NewMoney(cost int, concurrency string) (*Money, error) {
	if cost < 0 {
		return nil, fmt.Errorf("cost must be a positive number")
	}

	return &Money{
		cost:        cost,
		concurrency: concurrency,
	}, nil
}

func (m Money) Add(addMoney Money) (*Money, error) {
	if m.concurrency != addMoney.concurrency {
		return nil, fmt.Errorf("concurrency must match")
	}
	
	addedMoney, err := NewMoney(m.cost + addMoney.cost, m.concurrency)
	if err != nil {
		return nil, err
	}
	return addedMoney, nil
}

このように振る舞いと値を同じ場所で固めておくことで、振る舞いに変更が出た際の修正箇所を1箇所に済ませることができます。またドメインごとにロジックが固まるので記述箇所が明確になるメリットもあるそうです。

エンティティ

エンティティと値オブジェクトの違いは同一性で識別されるかどうかです。

以下がエンティティの特性です。

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

賃貸の部屋を例にして考えてみます。賃貸の部屋は以下のような情報を持っています。

type Room struct {
	roomID int
	rent   Money // 賃料
	age    RoomAge // 築年数
}

このとき、rentやageが変わってもその部屋自体が別のものになったりはしません。部屋が別のものかどうかを区別できるのはroomID(identity)だけです。この一意なidentityによる区別を同一性による区別と呼んでいます。そのため、以下はidentity以外が同じ値でも区別することができます。

var roomA = NewRoom(1, 1000, 2)
var roomB = NewRoom(2, 1000, 2)

roomA.Equal(roomB) // false

また、identity以外が変わっても同一性は変化しないことから、それ以外の値は可変にすることが許容されています。

var roomA = NewRoom(1, 1000, 2)

// 🙆‍♀️ identity以外の属性は変更可能
roomA.setRent(2000)

// ❌ identityは変更不可
roomA.setRoomID(4)

値オブジェクトとエンティティの判別方法

書籍では以下のように記述されていました。

ライフサイクルが存在し、そこに連続性が存在するかというのは大きな判断基準になります
ref. ドメイン駆動設計入門 ボトムアップでわかる!ドメイン駆動設計の基本 P58

ライフサイクルの中で識別子以外の属性が変化すると考えられるならエンティティとして扱ったほうが良さそうです。エンティティであればセッターを使ってモデルの変化を表現することが可能です。

ドメインサービス

ドメインサービスは値オブジェクトやエンティティで表現すると不自然になってしまう振る舞いを表現します。
よくあるのが重複チェックと述べられていました。

type User struct {
  userID int
  name string
}

func (u *User) Exits(user *User) bool {
  // 重複チェック
}

これを使うと以下のようになりますが、作成したUserオブジェクト自身が自分が存在するのか確認するのには少し違和感があります。

var user = &User{
    UserID: 1,
    Name:   "John Doe",
}
isExist := user.Exist(user) // userが自分自身を重複してるか確認する

その際以下のようなドメインサービスを作成し、違和感を無くします。

service/userservice.go
package service

import "github.com/stutkhd-0709/golang-ddd/domain"

type userService struct {}

func NewUserService() *userService {
	return &userService{}
}

func (s *userService) Exist(u *domain.User) {
	// 重複確認
}

ドメインサービスを使うことで、違和感をなくせますが濫用は危険です。ドメインサービスは基本的にどんな振る舞いも記述することができますが、それではドメインへの振る舞いが少なくなる場合があります。ドメインの振る舞いが不足してる状態をドメインモデル貧血症と呼びます。
なので、基本的にドメインサービスは使用せず本当に不自然になった場合にだけ導入するのが良さそうです。

まとめ

今回はドメインオブジェクトについてまとめてみました。値と振る舞いをまとめ影響範囲を狭めるのは非常に有効だと感じました。
また、個人的にはアプリケーションサービスとドメインサービスの違いが今回明白になったのがよかったです。サービスと名前がついていても表現してることが全く異なってるため今後の実装でつまづくことはなさそうです。
次回はDDDでアプリケーションを表現する実装についてまとめていきたいです...!

Discussion