はじめに
ピースミール・テクノロジー 進地 です。
GoとDDDの学習記録、連載の第5回です。前回は多段承認を実装しながら集約の境界を引きました。
今回は、前回避けて通った問題に取り組みます。「10万円以上の申請は部長の承認も必要」というルールを、どこに書くかという問題です。
このルールは、申請にも承認ルートにも収まりが悪い。しかし業務のルールであることは間違いない。DDDではこうした行き場のないふるまいのためにドメインサービスという概念を用意していますが、これは便利な分だけ誤用もしやすい概念です。今回はその線引きの話をします。
想定読者
- 連載を読んでくれている人(前回までの設計を前提にしますが、要点は振り返ります)
- ドメインサービスという言葉は知っているが、いつ使うべきか判断できない人
この記事のスコープ
承認ルートの決定ロジックの置き場所に絞ります。金額の条件は「10万円以上なら部長まで」という単純なものにして、ルール自体の複雑さは扱いません。
本記事時点のコードは article-05 タグ、前回からの差分は article-04...article-05 の比較 で確認できます。
ドメインサービスとは何か
先に言葉の説明をしておきます。
ドメインサービスは、業務のルールなのに特定のモデルの持ち物にできないふるまいを置く場所です。
DDDでは業務のルールをモデルの中に書くのが基本です。「申請中でなければ承認できない」は申請の話なので申請のメソッドに書く。第1回からずっとこの方針でやってきました。ところが業務には、どのモデルの持ち物とも言いがたいルールが出てきます。複数のモデルにまたがっていたり、モデルの外にある情報が必要だったりするものです。そういうものをモデルに無理やり押し込むと不自然になるので、独立した置き場所を用意する。それがドメインサービスです。
ポイントは、あくまでドメイン(業務)の知識を置く場所だという点です。名前が似ているのでアプリケーションサービス(この連載でいうusecase)と混同しやすいのですが、役割は違います。usecaseは処理の手順を並べる場所で、業務のルールは書きません。ドメインサービスは業務のルールそのものを書く場所です。
ドメイン層とアプリケーション層の責務の違いについては、こちらの記事の整理が分かりやすいです。ドメイン層はデータの整合性を担保するのが責務、アプリケーション層はドメイン層が公開するメソッドを組み合わせてユースケースを組み立てるのが責務、という説明になっています。
置き場所が見つからないルール
まず、今回のルールが必要とする知識を並べてみます。
- 申請の金額(申請が持っている)
- 部長承認が必要になる境界(組織のルール)
- その申請者にとっての課長・部長は誰か(組織図)
3つのうち、申請が持っているのは最初の1つだけです。残りは申請の外にあります。この時点で、置き場所が難しいことが見えてきます。
候補を順に検討しました。
Application に持たせる場合。 申請が「10万円という境界」と「誰が課長か」を知ることになります。申請というモデルが組織構造の知識を抱え込む形です。それに、承認ルートは申請を作る時点で必要なので、申請が生成される前に判定が終わっていなければなりません。申請のメソッドとして書くには順番が合いません。
ApprovalRoute に持たせる場合。 承認ルートは「課長、部長」という決まった並びを表すだけのモデルです。ここに判定ロジックを入れるのは、できあがった名簿に「この名簿の作り方」を書き込むようなものです。名簿は結果であって、作り方の説明書ではありません。
usecase に書く場合。 これは動きます。実際、多くのコードがここに落ち着くと思います。ただし第1回から避けてきた形でもあります。「10万円」という業務上の境界がusecaseにあると、別の入口(バッチ処理、管理画面)から申請を作るときにルールが再現されない可能性がある。ビジネスルールはdomainに置く、という連載の方針から外れます。
どのモデルにも属さないが、間違いなくドメインの知識である。この行き場のなさが、ドメインサービスの出番です。
ドメインサービスは最後の手段
ドメインサービスは便利です。モデルに収まらないものを何でも置ける。だからこそ危険でもあります。
「置き場所に迷ったらドメインサービス」を続けると、エンティティからロジックが吸い出されていきます。最後に残るのはデータを保持するだけのエンティティと、ふるまいを全部抱えたサービス群です。これはドメインモデル貧血症(Anemic Domain Model)と呼ばれる状態で、DDDで避けたい形の代表格です。第1回で「ビジネスルールはdomainのメソッドの中に書く」と強調したのは、まさにこれを避けるためでした。
そこで、ドメインサービスにする条件を自分なりに整理しました。
- ドメインの知識である(技術的な処理ではない)
- 特定のエンティティや値オブジェクトの責務にすると不自然になる
- 複数のモデルや、モデルの外の知識をまたぐ
今回のルールは3つとも満たします。逆に、1つでも欠けるならモデルのメソッドとして書けないか考え直したほうがいい、という基準にしました。
たとえば第4回で作った「順番を飛ばせない」は、承認ステップの並びを見れば判定できるので、申請のメソッドで足ります。ドメインサービスにする必要はありません。この線引きが、ドメインモデル貧血症への転落を防ぐ歯止めになります。
Goのドメインサービスは関数でよい
方針が決まったので実装します。まず、金額を値オブジェクトにします。
// domain/application/money.go
// Money は日本円の金額を表す値オブジェクト。
// 円未満の端数は扱わないので、整数で保持する
type Money struct {
yen int
}
// NewMoney は金額を生成する。負の値は受け付けない
func NewMoney(yen int) (Money, error) {
if yen < 0 {
return Money{}, fmt.Errorf("%w: %d", ErrNegativeAmount, yen)
}
return Money{yen: yen}, nil
}
// GreaterThanOrEqual は m が other 以上かを返す
func (m Money) GreaterThanOrEqual(other Money) bool {
return m.yen >= other.yen
}
第2回でやった値オブジェクトの型どおりです。自己検証を持ち、比較のふるまいを自分で持ちます。
次に、組織図を参照する手段です。「その申請者にとっての課長は誰か」を知っているのは申請ドメインの外なので、interfaceで要求だけを書きます。
// ApproverResolver は「その申請者にとっての課長は誰か」を答える。
// 組織図の知識は申請ドメインの外にあるので、interface として要求だけを書く
type ApproverResolver interface {
Resolve(applicantID ApplicantID, position Position) (ApproverID, error)
}
第1回でやった「interfaceは使う側に置く」がここでも効いています。実装はinfrastructure層に置き、domainは組織図の実体を知りません。リポジトリのときとまったく同じ構図です。
そしてドメインサービス本体です。
// domain/application/route_policy.go
// buchoThreshold はこの金額以上の申請に部長承認が必要になる境界
var buchoThreshold = Money{yen: 100000}
// DecideApprovalRoute は申請金額から承認ルートを決める。
//
// この判定は申請にも承認ルートにも属さない。金額(申請の情報)と
// 承認の閾値(組織のルール)と組織図(誰が課長か)という、
// 3つの知識をまたぐためである。どのモデルにも属さないが
// ドメインの知識であるものは、こうして独立した関数として置く。
func DecideApprovalRoute(applicantID ApplicantID, amount Money, resolver ApproverResolver) (ApprovalRoute, error) {
kacho, err := resolver.Resolve(applicantID, PositionKacho)
if err != nil {
return nil, err
}
route := ApprovalRoute{kacho}
// 10万円以上は部長の承認も必要
if amount.GreaterThanOrEqual(buchoThreshold) {
bucho, err := resolver.Resolve(applicantID, PositionBucho)
if err != nil {
return nil, err
}
route = append(route, bucho)
}
return route, nil
}
structのメソッドではなく、ただの関数にしました。この判定は状態を持たないからです。入力が同じなら出力も同じで、インスタンスとして保持すべきものが何もありません。
JavaのDDDサンプルでは、ドメインサービスはクラスとして書かれます。言語の制約でそうせざるを得ない面もあります。Goは関数を第一級で扱えるので、状態を持たないドメインサービスは関数のまま置けます。第1回で「Goにクラスがないことは制約ではない」と書きましたが、ここでも同じことが起きました。
なお buchoThreshold を Money 型にしているのは意図的です。intのまま比較すると amount.yen >= 100000 のような裸の数値比較になり、この100000が何なのかがコードから読み取れません。値オブジェクト同士の比較にすることで、意味のある比較として残ります。
usecaseは組み立てるだけ
ドメインサービスができたので、申請を作るユースケースを書きます。
// usecase/create_application.go
// Execute は申請を作成して保存する。
// 承認ルートの決定はドメインサービスに任せ、ここでは組み立てるだけ
func (u *CreateApplication) Execute(
id application.ApplicationID,
applicantID application.ApplicantID,
title string,
yen int,
) error {
amount, err := application.NewMoney(yen)
if err != nil {
return err
}
route, err := application.DecideApprovalRoute(applicantID, amount, u.resolver)
if err != nil {
return err
}
app, err := application.NewApplication(id, applicantID, title, amount, route)
if err != nil {
return err
}
return u.repo.Save(app)
}
金額を値オブジェクトにして、ドメインサービスでルートを決めて、申請を組み立てて、保存する。この4つだけです。if による判定は1つもありません。10万円という数字もどこにも出てきません。
第1回で「usecaseが薄いかどうかはレイヤ分離のバロメーター」と書きましたが、ドメインサービスを使うと、判定ロジックがdomainに残ったままusecaseを薄く保てます。usecaseに書けば動くものを、あえてdomainに置く理由がここにあります。
テストにDBもモックライブラリも要らない
ドメインサービスは状態を持たない関数なので、テストが単純です。組織図はinterfaceなので、テスト用の実装を10行ほど書けば済みます。
// テスト用の組織図。ドメインサービスのテストにDBは要らない
type fakeResolver struct {
positions map[Position]ApproverID
}
func (f fakeResolver) Resolve(_ ApplicantID, position Position) (ApproverID, error) {
approverID, ok := f.positions[position]
if !ok {
return "", ErrApproverNotFound
}
return approverID, nil
}
これで境界値をテーブル駆動で検証できます。
func TestDecideApprovalRoute_金額で承認ルートが変わる(t *testing.T) {
tests := []struct {
name string
yen int
want ApprovalRoute
}{
{"10万円未満は課長のみ", 99999, ApprovalRoute{"kacho"}},
{"ちょうど10万円は部長まで", 100000, ApprovalRoute{"kacho", "bucho"}},
{"10万円超は部長まで", 100001, ApprovalRoute{"kacho", "bucho"}},
{"0円でも課長の承認は要る", 0, ApprovalRoute{"kacho"}},
}
// ...
}
「ちょうど10万円のときどうなるか」は業務ルールとして必ず確認が要る点です。テストケースの名前がそのまま仕様の記述になっています。第2回の遷移表のときと同じで、ドメインの知識がdomainパッケージにあると、テストもdomainパッケージで完結します。
動かしてみる
デモでは、同じ申請者が金額違いの申請を2件作ります。
マウス購入 5,000円 draft ○kacho
開発端末の購入 150,000円 draft ○kacho → ○bucho
マウス購入 5,000円 approved ●kacho
組織図にない申請者: 承認者が見つかりません: 申請者 unknown の組織情報がありません
1行目と2行目で、金額の違いだけで承認ルートが変わっています。3行目では、10万円未満の申請が課長の承認だけで approved になりました。第4回で作った「全段が承認されたら承認済み」という仕組みが、段数1のルートでもそのまま動いています。
既存コードは40行しか変わらなかった
今回、コードを書き終えて差分を見たとき、変更の少なさに驚きました。
新しい業務ルールが増えて、申請に金額という属性が加わったのに、既存ファイルの変更は6ファイルです。しかもその大半はデモ用の main.go で、それを除くと実質40行ほどでした。
集約ルートである Application の変更に至っては、これだけです。
- フィールドに
amount Moneyを1行追加 - コンストラクタと復元関数の引数に
amountを追加 - ゲッターを1行追加
承認のロジックにも、状態遷移にも、集約の整合性を守るコードにも、1行も触れていません。第2回で作った遷移表も、第4回で作った承認ステップの制御も、そのままです。
なぜこうなったのか、理由は3つあると考えています。
新しい知識が、新しい置き場所に入ったから。 ルート決定という知識は route_policy.go という新しいファイルに丸ごと収まりました。既存のモデルに押し込んでいたら、Application か ApprovalRoute のどちらかが金額と組織の知識で膨らんでいたはずです。
前回、承認ルートを外から渡す形にしていたから。 NewApplication は第4回の時点でルートを引数に取る設計でした。当時は「マスタの実装を避けるための都合」でしたが、結果としてルートの決め方が変わっても申請側が影響を受けない構造になっていました。
金額を値オブジェクトにしたから。 Money という1つの型にまとまっているので、Application に入ったのはフィールド1行です。もし通貨や単位を別々のフィールドで持たせていたら、変更はもっと散らばっていました。
DDDの利点は説明が抽象的になりがちですが、変更行数という数字で見ると具体的です。「新しい業務ルールを追加したのに、既存のドメインモデルはほぼ無傷だった」というのが、5回積み上げてきた設計の答え合わせになりました。
やってみて迷ったこと
閾値をコードに直接書いていいのか
buchoThreshold は変数としてコードに埋め込まれています。実際の組織では、この金額は規程で決まっていて、改定もされるはずです。設定ファイルやDBから読むべきかもしれません。ただ、外部から与える設計にすると、今度は「規程の値を管理するのは誰か」という別のモデルが必要になります。今回は連載の主題から外れるので踏み込みませんでしたが、業務ルールの数値をどこまでコードに書いてよいかは、まだ整理できていません。
スキーマ変更で既存のDBが動かなくなった
申請に金額の列を追加したところ、第4回までのデモで作られた ringi.db が残っている環境で動かなくなりました。テーブルは既に存在するので CREATE TABLE IF NOT EXISTS は何もせず、列だけが足りない状態になるためです。連載では「マイグレーションは扱わない」と決めていたので、今回はリポジトリの初期化でテーブルを作り直す形にしました。デモとしては割り切りですが、DBを持つアプリでは必ず向き合うことになるコストが、5回目にして表面に出てきました。
ドメインサービスをどこまで許容するか
今回はドメインサービスにする条件を3つ立てましたが、これが妥当かはまだ分かりません。実務では「これはサービスにすべきか、エンティティのメソッドか」で議論が割れる場面が多いのではないかと想像しています。基準を明文化しておかないと、気づいたときにはドメインモデル貧血症になっていそうで、そこが怖いところです。
まとめ
- どのモデルにも属さないドメインの知識は、ドメインサービスとして独立させる。ただし「置き場所に迷ったら」で使うとドメインモデル貧血症に転落する
- ドメインサービスにする条件を決めておく。ドメインの知識であること、特定のモデルの責務にすると不自然なこと、複数の知識をまたぐこと
- Goでは状態を持たないドメインサービスは関数でよい。クラスにする必要はない
- 新しい業務ルールの追加で、既存のドメインモデルはほぼ変わらなかった。知識を正しい場所に置くと、変更は局所に収まる
ここまでの5回で、エンティティ、値オブジェクト、リポジトリ、集約、ドメインサービスという戦術的DDDの主要な部品が一通り揃いました。連載としては一区切りです。
次に書きたいのは、ここで作ったユビキタス言語をAIに渡す話です。「申請」「承認」「差戻し」という語彙を人間同士で共有するために整理してきましたが、その相手はAIでもよいはずです。オントロジーやMCPを絡めた新しい連載として、あらためて始める予定です。
サンプルコードの全体はリポジトリで公開しています。本記事時点のコードは article-05 タグを参照してください。
本記事のサンプルコードは説明用に簡略化しています。エラーハンドリングや並行性の考慮は最小限です。