🥞

ドメインサービスでrepositoryの実行は必要か?

に公開
5

前提

「これは違う」と否定したいわけではなく自分の中で納得したいから突き詰めて考える記事です!

ドメインサービスでrepositoryを呼んでるコード

弊社のコードベースにはドメインサービスでrepositoryを実行してるコードがたくさんあった。
前職の技術顧問から「ドメインサービスでrepositoryを呼んではいけない」と聞いていたので違和感を持ちました。ただ昔すぎて詳細に関しては忘却の彼方でした。

DDDで有名な人も呼んでる

DDDで有名な松岡さんの記事でもドメインサービスでrepositoryを呼んでいます。
https://little-hands.hatenablog.com/entry/2021/03/08/aggregation#実装方法2-ドメインサービスを使用する

この記事の中の「Taskが作成されたらActivityReportが作成される」ということはドメイン層の知識として重要なのに、その知識がドメイン層に書かれていない(=ドメイン層のコードを読んでも読み取れない)」という文章に関して確かにと思いました。

そもそもドメインサービスとは

私が聞いた限りドメインサービスとはこういうものだと解釈しています。

------------------------------------------
package domain

type BankAccount struct {
	//口座にある貯金額
	Amount int
}

//出金のメソッド
func (b BankAccount) Withdraw(amount int) error {
	//持ってる金額以上の金額を引き出そうとするとエラー
if b.Amount < amount {
		return errors.New("error")
	}
	b.Amount = b.Amount - amount
}


------------------------------------------
//シンプルな出金の実装
package usecase

func Usecase() error {

	a := repo.GetBankAccountByID(100)
	if err := a.Withdraw(1000); err != nil {
		return err
	}
	repo.UpdateBankAccount(a)
}

------------------------------------------
//2つの口座間の送金の実装
package usecase

func Usecase() error {
	a := repo.GetBankAccountByID(100)
	b := repo.GetBankAccountByID(200)

// 送金のロジック
// ①ビジネスロジックをusecaseにベタ書きしてるので良くない(のでモデルに実装しましょう)
	a.Amount = a.Amount - 1000
	b.Amount = b.Amount + 1000

// ②ドメインロジックとして実装し、それを利用してみる
// (ドメインサービスにする前の送金関数)引数aとb、どっちが送り元でどっちが送り先かわからない
// →こういう違和感のあるロジックが発生した場合に使うのがドメインサービス
//  特定のモデルに実装すると違和感のある時にしかたなーく使う実装パターン
	a.Transfer(b, 1000)

	repo.UpdateBankAccount(a)
	repo.UpdateBankAccount(b)
}



------------------------------------------
// a.Transfer(b, 1000)が違和感があるのでドメインサービスで実装したのがこちら
package domain

// ③このtransfer関数がドメインサービス(基本ファイルに切り出す)
func Transfer(to, from BankAccount, amount int) error {
	// ほんとはwithDrawメソッド(出金)を使うけど簡略化
	from.Amount = from.Amount - amount
	to.Amount = to.Amount + amount
}

特徴

  • ドメインサービスは実態としては関数
  • こういう違和感のある設計が発生しちゃうので、②のように、ドメインロジックとして送金ロジック実装したいけどどこに置けばいいんだ?という時に仕方なく使うのがドメインサービス(ドメインサービスに変えたのが③)
  • 特定のドメインモデルにメソッド持たせると、使いづらいっていう時に仕方なく使う実装パターンで実態としては関数になる。
  • ドメインサービスの置き場はドメインと同じ要領でディレクトリ掘って分けたり分けなかったりする

結局domain serviceでrepositoryを呼びたいか?

最初に共有した記事でdomain serviceを使いたい理由としては「Taskが作成されたらActivityReportが作成される」ということはドメイン層の知識として重要なのに、その知識がドメイン層に書かれていない(=ドメイン層のコードを読んでも読み取れない)」という理由だったことから、問題は「Task生成時にはReportが必要」というビジネス不変条件をドメイン層に明示したい、ということだと思って考えてみます。
まずチャッピーに「たとえば、taskモデルの情報を保存したらreportモデルの情報も保存したいというロジックを、repository介在させずに、domain serviceで保存させたかどうかみたいなロジックもって、usecaseでrepository interfaceをよぶみたいなことできない?」と聞いてみました。

そして出てきたコードが下記

domain/task.go
package domain

type Task struct {
	ID   string
	Name string
}

type ActivityReport struct {
	TaskID string
}

// 非公開コンストラクタ
func newTask(id, name string) *Task {
	return &Task{
		ID:   id,
		Name: name,
	}
}


func newReport(id string) *ActivityReport{
    return &ActivityReport{
		TaskID: task.ID,
	}
}
// 公開ファクトリ
func NewTaskAndReport(id, name string) (*Task, *ActivityReport) {
    // taskを作りたいときはここ経由じゃないと作れないようにする
	task := newTask(id, name)
	report := newReport(id)
	return task, report
}
usecase
task, report := domain.NewTaskAndReport(id, name)

u.taskRepo.Save(task)
u.reportRepo.Save(report)

松岡さんの記事の「これを防ぐために一つ工夫をしてみます。(この工夫は、DDD一般的な技法ではなく筆者の個人的な案なので、その前提でお読みください)」以降の実装の考え方と似てると思います。

そしてTaskとReportに関して本当にモデルとして分けるべきか?とも思ってきます

type Task struct {
    ID     string
    Name   string
    Report ActivityReport
}

でもいいかもしれないし、

下記のようにTaskのメソッドとして実装する手もあるかもですが、個人的には

domain/task.go
func (t *Task) CreateInitialReport() *ActivityReport {
    return &ActivityReport{
        TaskID: t.ID,
    }
}
usecase.go
task := domain.NewTask(...)
report := task.CreateInitialReport()

非公開関数で定義したnew関数をどこかで実行させておく方が、「非公開にしてるからどこでもかんでも実行できるわけではない」ことがわかるし、それが実行されてるところは、「このタイミングでReportモデルを作って欲しいんだな」ということがわかるので好みではあります。

結局今考えることができる範囲で、domain serviceでリポジトリを実行したい場面がないので、この結論にいたってるのですが、もっと納得いくdomain serviceでリポジトリを実行したい理由があったら知りたいです。

追記

  • 書いた直後からそもそもrepositoryのinterfaceってdomainレイヤにおくし、domain serviceとrepositoryは同じレイヤだから別に依存しても困らないんじゃね、と考えてます。
  • しかし10章のしなやかな設計の 副作用のない関数に保つという設計思想(しなやかな設計)を追求すると、自然と「Domain Service内でRepositoryは呼ばない方が美しい」という結論に行き着きそうです。
  • 同じ集約である場合は下記のコードが適切そうです。
domain.go
package domain

// ActivityReportはTask集約の内部オブジェクト
type ActivityReport struct {
}

// Taskは集約のルート
type Task struct {
	ID     string
	Name   string
	Report *ActivityReport // TaskがReportを所有する
}

// 公開ファクトリ(集約全体をひとまとまりとして生成する)
func NewTask(id, name string) *Task {
	// TaskとReportを必ずセットで生成し、1つのルートエンティティとして返す
	return &Task{
		ID:   id,
		Name: name,
		Report: &ActivityReport{},
	}
}
usecase.go
package usecase

import (
	"context"
	"yourproject/domain"
)

// トランザクションを管理するためのインターフェース(インフラ層で実装する)
type TransactionManager interface {
	DoInTx(ctx context.Context, f func(ctx context.Context) error) error
}

// アプリケーション層(ユースケース)
type TaskAppService struct {
	txManager TransactionManager
	taskRepo  domain.TaskRepository
}

func NewTaskAppService(txManager TransactionManager, repo domain.TaskRepository) *TaskAppService {
	return &TaskAppService{
		txManager: txManager,
		taskRepo:  repo,
	}
}

// ユースケースの実行
func (app *TaskAppService) CreateTask(ctx context.Context, id, name string) error {
	// アプリケーション層でトランザクション境界を張る(作業の調整)
	return app.txManager.DoInTx(ctx, func(txCtx context.Context) error {
		
		// 1. ドメイン層のファクトリを呼び出し、集約(TaskとReport)をひとまとまりとして生成
		task := domain.NewTask(id, name)

		// 2. リポジトリを呼び出して集約を保存
		// ※ リポジトリ内部では INSERT は行うが、Commit は行わない
		if err := app.taskRepo.Save(txCtx, task); err != nil {
			return err // エラーが返れば DoInTx 内で自動的に Rollback される
		}

		return nil // 成功すれば DoInTx 内で Commit される
	})
}
  • taskとrecordが別の集約の場合、
usecase.go
task, report := domain.NewTaskAndReport("1", "タスク名")
taskRepo.Save(task)
reportRepo.Save(report) // もしここでDBエラーが起きたら、Taskだけが残ってしまう

evans本では、「複数の集約にまたがるルールはどれも、常に最新の状態にあるということが期待できない」と記載があり、それは別々の集約を同時に更新することは、ロックの競合やトランザクションの複雑化を招くためだと思います。

Discussion

ソイヤクンソイヤクン

データ登録するための入力境界の型をDomainに定義する、ということで、ドメイン層においてどんなデータを登録すべきか、を読み取れるようにするという手法に私は行き着きました

acomaguacomagu

この記事には「なぜドメインサービスからリポジトリを呼びたくないのか」が書かれていない。

DDD(特にDDD本)ではそもそもそのような詳細については意見していないはず。弊社ではドメイン層からリポジトリを普通に呼ぶし、外部サービスも普通に呼ぶが、「テスト可能性のためにドメイン層は純粋にしている」という思想についてはある程度理解できる。(うちはテストはE2Eに寄せているのであんまりそこのメリットはない)

ここまでをまとめると、「ドメインサービスでリポジトリを呼ぶべきか否か」という問いはDDDとはあまり関係がない。

そのうえで「なぜドメイン層でリポジトリを呼ぶか」は、つまり「『Taskが作成されたらActivityReportが作成される』ということはドメイン層の知識として重要」これに尽きる。言い換えると「ドメインロジックをユースケース層に流出させたくない」。「ドメイン層からリポジトリを呼ばない」と「ドメインロジックをユースケース層に流出させない」という2つのルールは、しばしば相反する。それを解決するために小一時間悩んで変に発明的なコードにする意味があるのか?というところが個人的に疑問。また、単にコードを冗長にしたくないというのもある。

horie-thorie-t

まず、ドメインオブジェクトの設計が間違えていますね。正しくは以下のように入金のメソッドもモデル化しないといけないです。(goは全然分からないので間違えた書き方かもしれませんが、雰囲気で分かると思います。)

type BankAccount struct {
	//口座にある貯金額
	Amount int
}

//出金のメソッド
func (b BankAccount) Withdraw(amount int) error {
	//持ってる金額以上の金額を引き出そうとするとエラー
	if b.Amount < amount {
		return errors.New("error")
	}
	b.Amount = b.Amount - amount
}

// 入金のメソッド
func (b BankAccount) Deposit(amount int) error {
	b.Amount = b.Amount + amount
}

で、送金はどうするかという話になりますが、エバンス本によると

(第5章のサービスより)
資金をある口座から別の口座に振り替える機能はドメインサービスである

と書いてあります。なので送金はドメインサービスになり、例えば以下のような実装になるかと思います。(エバンス本にはコード例はなかったですが)

func Transfer(to, from BankAccount, amount int) error {
	from.Withdraw(amount)
	to.Deposit(amount)
}

で、状態の変化したBankAcountをリポジトリを呼び出して永続化する処理ですが、そこはエバンス本には明記されていません。Hibernateのような変更追跡機能を持つ永続化メカニズムを使う場合を想定していたのかもしれません。

ただし、図 4.1ではトランザクションの管理は、アプリケーションレイヤでやっていますね。

リポジトリに関しては、

(6章のリポジトリより)
一般的に言えることだが、使っているフレームワークとは争わないこと。

とあるので、フレームワークの想定を知れべてみるのが良いでしょう。

一番大切なのは

私が聞いた限りドメインサービスとはこういうものだと解釈しています。

ではなく、疑問に感じたら原典にあたるという習慣ではないでしょうか。

horie-thorie-t

補足です。

「Taskが作成されたらActivityReportが作成される」ということはドメイン層の知識として重要なのに、その知識がドメイン層に書かれていない(=ドメイン層のコードを読んでも読み取れない)」

の「Taskが作成されたら~」のようなイベントとその対応については「7章 言語を使用する: 応用例」の荷役イベントの説明を読むのをお勧めします。

135yshr135yshr

「呼ぶか否か」の二択で議論してしまうのは少し勿体ないと思いました。「I/Oなしで解決できないドメインルールが存在するか」という観点で話せると、判断基準が言語化しやすくなりそうです。
Task→Reportの例はファクトリーで解決できましたが、ユーザー名の重複チェックのようにDBを見ないと判定できないルールはI/Oなしでは解決できないため、そのケースではドメインサービスからリポジトリを呼ぶことが正当化されそうです。