/note/tech

「10万円以上は部長承認」をどこに書くか。Goのドメインサービスと、使いすぎない線引き

要約:

■ 1. 記事のテーマとスコープ

  • 扱う問題:
    • GoとDDDの学習記録の連載第5回で、前回は多段承認を実装しながら集約の境界を引いた
    • 今回は「10万円以上の申請は部長の承認も必要」というルールをどこに書くかという問題に取り組む
    • このルールは申請にも承認ルートにも収まりが悪いが、業務のルールであることは間違いない
  • ドメインサービスの位置づけ:
    • DDDはこうした行き場のないふるまいのためにドメインサービスという概念を用意している
    • 便利な分だけ誤用もしやすい概念なので、その線引きを論じる
  • スコープの限定:
    • 承認ルートの決定ロジックの置き場所に絞る
    • 金額の条件は「10万円以上なら部長まで」という単純なものにし、ルール自体の複雑さは扱わない

■ 2. ドメインサービスの定義

  • ドメインサービスとは:
    • 業務のルールなのに特定のモデルの持ち物にできないふるまいを置く場所
  • モデル優先の原則:
    • DDDでは業務のルールをモデルの中に書くのが基本で、「申請中でなければ承認できない」は申請のメソッドに書く
    • 複数のモデルにまたがるものや、モデルの外にある情報が必要なものは、モデルに押し込むと不自然になる
    • そうしたルールのために独立した置き場所を用意する
  • アプリケーションサービスとの違い:
    • 名前が似ているアプリケーションサービス(連載でいうusecase)は処理の手順を並べる場所で、業務のルールは書かない
    • ドメインサービスは業務のルールそのものを書く場所
    • ドメイン層はデータの整合性の担保が責務、アプリケーション層はドメイン層の公開メソッドを組み合わせてユースケースを組み立てるのが責務

■ 3. 置き場所が見つからないルール

  • ルールが必要とする知識:
    • 申請の金額は申請が持っている
    • 部長承認が必要になる境界は組織のルールで、申請の外にある
    • その申請者にとっての課長・部長が誰かは組織図の知識で、申請の外にある
  • Applicationに持たせる案:
    • 申請というモデルが「10万円という境界」と「誰が課長か」という組織構造の知識を抱え込む形になる
    • 承認ルートは申請を作る時点で必要なので、申請が生成される前に判定が終わっていなければならず、申請のメソッドとしては順番が合わない
  • ApprovalRouteに持たせる案:
    • 承認ルートは「課長、部長」という決まった並びを表すだけのモデル
    • ここに判定ロジックを入れるのは、できあがった名簿に「この名簿の作り方」を書き込むようなもの
    • 名簿は結果であって、作り方の説明書ではない
  • usecaseに書く案:
    • 動作はするし、多くのコードがここに落ち着くと考えられる
    • 「10万円」という業務上の境界がusecaseにあると、バッチ処理や管理画面など別の入口から申請を作るときにルールが再現されない可能性がある
    • ビジネスルールはdomainに置くという連載の方針から外れる
  • 結論:
    • どのモデルにも属さないが間違いなくドメインの知識であり、この行き場のなさがドメインサービスの出番

■ 4. ドメインサービスは最後の手段

  • 濫用の危険:
    • モデルに収まらないものを何でも置けるため、「置き場所に迷ったらドメインサービス」を続けるとエンティティからロジックが吸い出されていく
    • 最後に残るのはデータを保持するだけのエンティティと、ふるまいを全部抱えたサービス群になる
    • これはドメインモデル貧血症と呼ばれる状態で、DDDで避けたい形の代表格
  • ドメインサービスにする条件:
    • 技術的な処理ではなくドメインの知識であること
    • 特定のエンティティや値オブジェクトの責務にすると不自然になること
    • 複数のモデルや、モデルの外の知識をまたぐこと
    • 今回のルールは3つとも満たし、1つでも欠けるならモデルのメソッドとして書けないか考え直す
  • 適用しない例:
    • 第4回で作った「順番を飛ばせない」は承認ステップの並びを見れば判定できるので、申請のメソッドで足りる
    • この線引きがドメインモデル貧血症への転落を防ぐ歯止めになる

■ 5. Goでのドメインサービス実装

  • 金額の値オブジェクト化:
    • Moneyは日本円の金額を表す値オブジェクトで、円未満の端数を扱わないため整数で保持する
    • NewMoneyは負の値を受け付けず、GreaterThanOrEqualという比較のふるまいを自分で持つ
    • 自己検証を持つ値オブジェクトの型どおりの実装
  • 組織図参照のinterface:
    • 「その申請者にとっての課長は誰か」を知っているのは申請ドメインの外なので、ApproverResolverというinterfaceで要求だけを書く
    • 実装はinfrastructure層に置き、domainは組織図の実体を知らない
    • 「interfaceは使う側に置く」という第1回の方針がリポジトリと同じ構図で効いている
  • DecideApprovalRouteの実装:
    • 申請者IDと金額とresolverを受け取り、課長を解決してルートを作る
    • 金額が閾値以上なら部長を解決してルートに追加する
    • structのメソッドではなくただの関数にしたのは、この判定が状態を持たず、入力が同じなら出力も同じで保持すべきインスタンスがないため
  • 関数で書ける理由:
    • JavaのDDDサンプルではドメインサービスはクラスとして書かれ、言語の制約でそうせざるを得ない面もある
    • Goは関数を第一級で扱えるので、状態を持たないドメインサービスは関数のまま置ける
    • 第1回で書いた「Goにクラスがないことは制約ではない」がここでも当てはまる
  • 閾値をMoney型にする理由:
    • intのまま比較すると裸の数値比較になり、100000が何なのかコードから読み取れない
    • 値オブジェクト同士の比較にすることで、意味のある比較として残る

■ 6. usecaseは組み立てるだけ

  • usecaseの中身:
    • 金額を値オブジェクトにし、ドメインサービスでルートを決め、申請を組み立てて保存する4つだけ
    • ifによる判定は1つもなく、10万円という数字もどこにも出てこない
  • 薄いusecaseの意義:
    • 第1回で書いたとおり、usecaseが薄いかどうかはレイヤ分離のバロメーター
    • ドメインサービスを使うと、判定ロジックがdomainに残ったままusecaseを薄く保てる
    • usecaseに書けば動くものをあえてdomainに置く理由がここにある

■ 7. テストの単純さ

  • テスト容易性:
    • ドメインサービスは状態を持たない関数なのでテストが単純
    • 組織図はinterfaceなので、テスト用の実装を10行ほど書けば済み、DBもモックライブラリも要らない
  • 境界値のテーブル駆動テスト:
    • 10万円未満は課長のみ、ちょうど10万円は部長まで、10万円超は部長まで、0円でも課長の承認は要るという4ケースを検証する
    • 「ちょうど10万円のときどうなるか」は業務ルールとして必ず確認が要る点
    • テストケースの名前がそのまま仕様の記述になっている
    • ドメインの知識がdomainパッケージにあると、テストもdomainパッケージで完結する

■ 8. デモの動作結果

  • 同じ申請者が5,000円と150,000円の申請を作ると、金額の違いだけで承認ルートが課長のみと課長から部長までに変わる
  • 10万円未満の申請は課長の承認だけでapprovedになる
  • 第4回で作った「全段が承認されたら承認済み」という仕組みが、段数1のルートでもそのまま動く
  • 組織図にない申請者を指定すると、承認者が見つからないエラーになる

■ 9. 既存コードの変更の少なさ

  • 変更規模:
    • 新しい業務ルールが増えて申請に金額という属性が加わったのに、既存ファイルの変更は6ファイルだった
    • 大半はデモ用のmain.goで、それを除くと実質40行ほど
    • 集約ルートであるApplicationの変更は、フィールドへのamount追加、コンストラクタと復元関数の引数追加、ゲッター追加の3点のみ
    • 承認のロジック、状態遷移、集約の整合性を守るコードには1行も触れていない
  • 新しい知識が新しい置き場所に入った:
    • ルート決定という知識はroute_policy.goという新しいファイルに丸ごと収まった
    • 既存のモデルに押し込んでいたら、ApplicationかApprovalRouteのどちらかが金額と組織の知識で膨らんでいた
  • 承認ルートを外から渡す設計だった:
    • NewApplicationは第4回の時点でルートを引数に取る設計だった
    • 当時はマスタの実装を避けるための都合だったが、結果としてルートの決め方が変わっても申請側が影響を受けない構造になっていた
  • 金額を値オブジェクトにした:
    • Moneyという1つの型にまとまっているので、Applicationに入ったのはフィールド1行で済んだ
    • 通貨や単位を別々のフィールドで持たせていたら、変更はもっと散らばっていた
  • 数字で見た利点:
    • DDDの利点は説明が抽象的になりがちだが、変更行数という数字で見ると具体的
    • 新しい業務ルールを追加しても既存のドメインモデルがほぼ無傷だったことが、5回積み上げてきた設計の答え合わせになった

■ 10. 迷ったこと

  • 閾値をコードに書く是非:
    • buchoThresholdは変数としてコードに埋め込まれているが、実際の組織では規程で決まっており改定もされる
    • 設定ファイルやDBから読むべきかもしれないが、外部から与える設計にすると「規程の値を管理するのは誰か」という別のモデルが必要になる
    • 連載の主題から外れるため踏み込まず、業務ルールの数値をどこまでコードに書いてよいかは整理できていない
  • スキーマ変更によるDB破損:
    • 申請に金額の列を追加したところ、第4回までのデモで作られたringi.dbが残る環境で動かなくなった
    • テーブルが既に存在するためCREATE TABLE IF NOT EXISTSは何もせず、列だけが足りない状態になる
    • マイグレーションは扱わないと決めていたので、リポジトリの初期化でテーブルを作り直す形にした
    • デモとしての割り切りだが、DBを持つアプリで必ず向き合うコストが5回目にして表面に出た
  • ドメインサービスの許容範囲:
    • 立てた3条件が妥当かはまだ分からない
    • 実務では「サービスにすべきかエンティティのメソッドか」で議論が割れる場面が多いと想像している
    • 基準を明文化しておかないと、気づいたときにはドメインモデル貧血症になっていそうな点が怖い

■ 11. まとめと今後

  • どのモデルにも属さないドメインの知識はドメインサービスとして独立させるが、「置き場所に迷ったら」で使うとドメインモデル貧血症に転落する
  • ドメインサービスにする条件を決めておく:
    • ドメインの知識であること
    • 特定のモデルの責務にすると不自然なこと
    • 複数の知識をまたぐこと
  • Goでは状態を持たないドメインサービスは関数でよく、クラスにする必要はない
  • 知識を正しい場所に置くと変更は局所に収まり、新しい業務ルールの追加で既存のドメインモデルはほぼ変わらなかった
  • 5回でエンティティ、値オブジェクト、リポジトリ、集約、ドメインサービスという戦術的DDDの主要な部品が揃い、連載は一区切りとする
  • 次はここで作ったユビキタス言語をAIに渡す話を、オントロジーやMCPを絡めた新しい連載として始める予定