1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

承認ルートは申請の中に置くべきか、外に出すべきか。Goで多段承認を作りながら集約の境界を引く

1
Posted at

はじめに

ピースミール・テクノロジー 進地 です。

GoとDDDの学習記録、連載の第4回です。前回はリポジトリをSQLite実装に差し替えました。

今回は多段承認を実装します。これまでの申請は承認者が1人でしたが、現実の稟議は「課長の次は部長」と段階を踏みます。そこで承認ルートという新しい概念が登場し、それをどこに置くかという問題が出てきます。DDDでいう集約、つまりモデルの区切り方の話です。

想定読者

  • 連載を読んでくれている人(第1回から第3回の設計を前提にしますが、要点は振り返ります)
  • 集約という言葉は知っているが、境界の引き方の基準が分からない人

この記事のスコープ

集約の境界を決める話と、多段承認の実装に絞ります。承認ルートを組織のマスタから取得する仕組みは扱いません。

本記事時点のコードは article-04 タグ、前回からの差分は article-03...article-04 の比較 で確認できます。

集約とは、一緒に守るものの囲い

実装に入る前に「集約」という言葉を確認します。

集約は、常に整合していなければならないモデルの囲いです。囲いの中身はひとまとまりとして扱われ、まとめて保存され、まとめて読み出されます。そして外から中身を直接いじることはできず、必ず代表者(集約ルート)を通します。

なぜ囲いを作るかというと、守りたいルールがあるからです。たとえば「同じ人が2回承認することはできない」というルールは、承認の記録がバラバラに存在していると守れません。誰かが承認を追加しようとした瞬間に、他の承認の状況が見える必要がある。つまり整合性を守りたい範囲が、囲いの範囲になります。

第3回までの申請は、この囲いが小さいままでした。

type Application struct {
	id          ApplicationID
	applicantID ApplicantID
	title       string
	status      Status
}

すべてが単純な値で、囲いの中に他のモデルがありません。今回、ここに承認の進捗が入ってきます。

承認ルートの「定義」と「進捗」は別物だった

多段承認を入れると、少なくとも次の3つが必要になります。

  • 誰がどの順番で承認するのか
  • いま何段目まで承認されたのか
  • 次に承認するのは誰か

最初、これらをまとめて「承認ルート」と呼んでいました。しかし設計を進めるうちに、性質の違う2つが混ざっていることに気づきました。

承認ルートの定義は、申請とは独立して存在します。「10万円以上の物品購入は課長と部長の承認が必要」というのは組織のルールであって、個別の申請の持ち物ではありません。人事異動で部長が変われば、ルートの定義も変わります。

承認の進捗は、申請と一体でなければ意味を持ちません。「2段目まで承認された」という情報は、どの申請の話なのかと切り離せない。そして「順番を飛ばせない」「同じ人が2回承認できない」というルールは、この進捗の整合性を守るためのものです。

そこで進捗は申請の中、ルートの定義は申請の外と切り分けることにしました。

写し取るか、参照し続けるか

図の点線、つまり「申請作成時に写し取る」の部分にも判断がありました。承認ルートの定義を申請に写し取ってしまうのか、それとも常にマスタを参照するのか。

参照し続ける設計だと、組織のマスタが変わったとき、進行中の申請の承認者まで変わってしまいます。課長の承認が済んだ後に組織改編があったら、その承認は誰の承認だったのか。申請の整合性が、申請の外側の変更に振り回される状態です。

写し取る設計なら、申請作成の時点で承認者が確定し、以後は申請の中だけで整合が完結します。集約の内側で不変条件を守るという考え方に沿うのは、こちらです。

私が経験してきた民間企業の稟議でも、申請後に組織が変わっても承認ルートはそのまま、という運用が普通でした。業界によって違いがあるとは思いますが、今回は写し取る方式を採用しました。

承認ステップを実装する

方針が決まったので、コードにします。まず承認ルートと承認ステップです。

// domain/application/approval.go
package application

// ApprovalRoute は承認者の並び。「課長 → 部長」のような多段承認を表す。
// 本来は組織のマスタから引いてくる想定だが、本連載では外から渡す形にしている
type ApprovalRoute []ApproverID

// validate は承認ルートとして成立しているかを検証する
func (r ApprovalRoute) validate(applicantID ApplicantID) error {
	if len(r) == 0 {
		return ErrEmptyRoute
	}
	seen := map[ApproverID]bool{}
	for _, approverID := range r {
		if string(approverID) == string(applicantID) {
			return ErrSelfApproval
		}
		if seen[approverID] {
			return ErrDuplicateRoute
		}
		seen[approverID] = true
	}
	return nil
}

// ApprovalStep は承認ルートの1段分。何段目を誰が承認するか、承認済みかを持つ。
// Application 集約の内側でのみ生成・変更される
type ApprovalStep struct {
	order      int
	approverID ApproverID
	approved   bool
}

ここで、第1回から言い続けている「自己承認の禁止」の置き場所が変わりました。これまでは承認するときにチェックしていましたが、今は承認ルートを作る時点で弾いています。申請者を含むルートはそもそも存在できないので、承認時にチェックする必要がなくなりました。ルールが手前に移動した形です。

申請側は、ルートを受け取ってステップの並びを組み立てます。

// domain/application/application.go(抜粋)
func NewApplication(id ApplicationID, applicantID ApplicantID, title string, route ApprovalRoute) (*Application, error) {
	if err := route.validate(applicantID); err != nil {
		return nil, err
	}

	steps := make([]ApprovalStep, 0, len(route))
	for i, approverID := range route {
		steps = append(steps, ApprovalStep{
			order:      i + 1,
			approverID: approverID,
			approved:   false,
		})
	}

	return &Application{
		id:          id,
		applicantID: applicantID,
		title:       title,
		status:      StatusDraft,
		steps:       steps,
	}, nil
}

NewApplication がエラーを返すようになりました。不正な承認ルートを持つ申請はそもそも生成できないので、以降のコードは「ステップは必ず正しい」という前提で書けます。

状態と進捗を二重管理にしない

多段承認を入れるとき、一番悩んだのがここです。

第2回で、申請には下書き・申請中・承認済み・差戻しという状態があると決めました。そこに「2段目まで承認済み」という進捗が加わります。この2つは似ているので、うっかりすると状態のほうに「1段目承認済み」「2段目承認済み」と増やしたくなります。しかし段数が変わるたびに状態が増える設計では、第2回で作った遷移表が破綻します。

そこで、役割を分けました。状態は申請そのものの立場を表し、進捗はステップ側が持つ。承認しても、最終段が終わるまで状態は「申請中」のまま動きません。

// Approve は現在の段の承認者による承認を記録する。
// 最終段まで終わったときだけ、申請そのものが「承認済み」になる
func (a *Application) Approve(approverID ApproverID) error {
	step, err := a.currentStep(approverID)
	if err != nil {
		return err
	}
	if a.status != StatusSubmitted {
		return fmt.Errorf("%w: %s → %s", ErrInvalidTransition, a.status, StatusApproved)
	}

	step.approved = true

	if a.allApproved() {
		return a.transitionTo(StatusApproved)
	}
	return nil // まだ後続の段が残っているので、状態は「申請中」のまま
}

// currentStep は次に承認されるべき段を返す。
// approverID がその段の承認者でなければエラーにする
func (a *Application) currentStep(approverID ApproverID) (*ApprovalStep, error) {
	for i := range a.steps {
		if a.steps[i].approved {
			continue
		}
		if a.steps[i].approverID != approverID {
			return nil, fmt.Errorf("%w: %d段目の承認者は %s です",
				ErrNotYourTurn, a.steps[i].order, a.steps[i].approverID)
		}
		return &a.steps[i], nil
	}
	return nil, fmt.Errorf("%w: すべての段が承認済みです", ErrNotYourTurn)
}

currentStep が「順番を飛ばせない」を実現しています。先頭から見て最初の未承認の段が現在の段で、そこの承認者でなければ承認できません。部長が先に承認しようとすると、1段目が未承認なのでエラーになります。

第2回で作った遷移表は、今回まったく変更していません。状態の種類が増えていないからです。進捗という別の軸を追加しても、状態遷移のルールはそのまま使えます。

差戻したら、それまでの承認はどうなるのか

実装していて答えに詰まったのがこれです。差戻しの時点で承認済みの段があったとき、その承認はどうなるべきか。

最初は「再提出のときにリセットする」と書いていました。動作としては成立します。しかし差戻し中の申請を表示すると、1段目が承認済みのまま残る。すでに承認プロセスは中断されているのに、承認済みの表示が残っているのは、状態としてつじつまが合いません。リセットのタイミングを業務の意味ではなく実装の都合で決めていました。

そこで、差戻した時点でリセットする形に変えました。

// Reject は現在の段の承認者による差戻し(申請中 → 差戻し)。
// 差戻しは承認プロセスの中断なので、それまでの承認はこの時点で無効になる
func (a *Application) Reject(approverID ApproverID) error {
	if _, err := a.currentStep(approverID); err != nil {
		return err
	}
	if err := a.transitionTo(StatusRejected); err != nil {
		return err
	}
	a.resetSteps()
	return nil
}

// Resubmit は差戻された申請を再提出する(差戻し → 申請中)。
// 承認の進捗は差戻しの時点でリセット済みなので、ここでは状態を戻すだけでよい
func (a *Application) Resubmit() error {
	return a.transitionTo(StatusSubmitted)
}

差戻しは承認プロセスの中断であり、やり直すなら1段目から。この意味づけが決まったことで、Resubmit は状態を戻すだけの単純なメソッドになりました。

なお、これとは別の運用もあり得ます。それは後半の迷ったことで書きます。

集約の外から中身を触らせない

集約は、外から中身を直接いじれないことが要件でした。Goでこれをどう担保するか。

ステップの一覧を読みたい場面はあります(画面に進捗を表示するなど)。しかし単純にスライスを返すと、受け取った側が要素を書き換えられてしまいます。Goのスライスは内部の配列を共有するからです。

// Steps は承認の進捗を読み取り専用で返す。
// スライスの複製を返すことで、外から要素を書き換えられないようにする
func (a *Application) Steps() []ApprovalStep {
	copied := make([]ApprovalStep, len(a.steps))
	copy(copied, a.steps)
	return copied
}

複製を返しているので、app.Steps()[0] を書き換えても集約の中身は変わりません。テストでも確認しています。

func TestSteps_戻り値を書き換えても内部は変わらない(t *testing.T) {
	app := newSubmitted(t)

	steps := app.Steps()
	steps[0].approved = true

	if app.Steps()[0].Approved() {
		t.Error("外から集約の内部を書き換えられてしまいました")
	}
}

ApprovalStep のフィールドも小文字なので、パッケージの外からは読むことしかできません。承認という行為は、必ず集約ルートの Approve() を通ります。

集約は丸ごと保存する

永続化にも集約の考え方を応用しています。ステップは2つ目のテーブルに保存されますが、ステップ専用のリポジトリは作りません。集約はひとまとまりで扱われるので、リポジトリは集約ルート単位で1つです。

// 集約は常に丸ごと保存されるので、2つのテーブルへの書き込みは1つのトランザクションにまとめる
func (r *ApplicationSQLiteRepository) Save(app *application.Application) error {
	tx, err := r.db.Begin()
	if err != nil {
		return err
	}
	defer tx.Rollback() // Commit 済みなら何も起きない

	if _, err := tx.Exec(`
		INSERT INTO applications (id, applicant_id, title, status)
		VALUES (?, ?, ?, ?)
		ON CONFLICT(id) DO UPDATE SET
			applicant_id = excluded.applicant_id,
			title        = excluded.title,
			status       = excluded.status`,
		string(app.ID()), string(app.ApplicantID()), app.Title(), string(app.Status()),
	); err != nil {
		return err
	}

	// ステップは差分更新せず、いったん消してから入れ直す。
	// 集約の内側の構造をリポジトリが知りすぎないための割り切り
	if _, err := tx.Exec(
		`DELETE FROM approval_steps WHERE application_id = ?`, string(app.ID()),
	); err != nil {
		return err
	}
	for _, step := range app.Steps() {
		if _, err := tx.Exec(`
			INSERT INTO approval_steps (application_id, step_order, approver_id, approved)
			VALUES (?, ?, ?, ?)`,
			string(app.ID()), step.Order(), string(step.ApproverID()), step.Approved(),
		); err != nil {
			return err
		}
	}

	return tx.Commit()
}

第3回で「トランザクションは扱わない」と書きましたが、集約が複数テーブルにまたがった時点で必要になりました。申請だけ保存されてステップが古いまま、という状態が生まれてはいけないからです。集約の「まとめて整合する」という性質が、そのままトランザクションの範囲になりました。

ステップを差分更新ではなく全削除して入れ直しているのは割り切りです。差分更新にすると、リポジトリが「どのステップが変わったか」を知る必要が出てきます。集約の内部構造をinfrastructureが詳しく知るほど、集約の内側を変えたときの影響範囲が広がります。件数が少ないうちは、丸ごと書き換えたほうがシンプルと判断しました。

動かしてみる

デモを実行すると、承認が段階的に進む様子が見えます。記号は が承認済み、 が未承認、[ ] が次に承認する段です。

提出           submitted  [○kacho] → ○bucho
部長が先に承認: 現在の承認者ではありません: 1段目の承認者は kacho です
課長が承認     submitted  ●kacho → [○bucho]
部長が差戻し   rejected   ○kacho → ○bucho
再提出         submitted  [○kacho] → ○bucho
全段承認       approved   ●kacho → ●bucho

1行目と3行目を見比べると、[ ] が課長から部長に移っているのに、状態は submitted のままです。1段目の承認では申請そのものは承認済みにならない、という設計がそのまま出ています。最後の行で全段が済んで初めて approved になります。

4行目の差戻しでは、課長の承認が に戻っています。差戻した時点でリセットする、という判断がここに現れています。

テストが書き換わった

今回、既存のテストをいくつか書き換えることになりました。第3回まであった「別人なら承認できる」というテストは、多段承認では意味をなしません。承認できるかどうかは「別人かどうか」ではなく「現在の段の承認者かどうか」で決まるようになったからです。

// 第3回まで
func TestApproveApplication_別人なら承認できる(t *testing.T)

// 第4回
func TestApproveApplication_順番どおりでない承認は拒否される(t *testing.T)
func TestApproveApplication_全段の承認で承認済みになる(t *testing.T)

自己承認を防ぐテストも、承認時のテストから承認ルート生成時のテストに移りました。ルールの置き場所が変わったことで、テストの置き場所も変わったのです。

差戻しの仕様を詰めたときにも、テストが1本から2本に分かれました。「差戻し時にリセットされる」ことと「再提出後は1段目からやり直す」ことは、実装としては連動していますが業務ルールとしては別だからです。

func TestReject_承認の進捗がリセットされる(t *testing.T)
func TestResubmit_1段目からやり直しになる(t *testing.T)

集約の内側を作り変えると外部との契約も変わり、テストも影響を受ける形となりました。

やってみて迷ったこと

差戻しは1段だけ戻る運用もある

今回は「差戻したら1段目からやり直し」にしましたが、「差戻した段だけやり直す」運用も考えられます。部長が差し戻したなら、課長の承認は有効なままで、修正後は部長の承認から再開する。そのほうが効率的ですし、実務ではこちらも見かけます。

採用しなかったのは、別の問題が出てくるからです。差戻しの後、申請者は内容を修正します。すると課長が承認したときの内容と、修正後の内容が違う可能性がある。課長の承認は修正後の内容に対しても有効なのか。軽微な修正なら再承認は不要、といった線引きが必要になり、それ自体が業務ルールの設計になります。今回は集約の境界に集中したかったので、そこには踏み込みませんでした。

承認の履歴が残らない

今のステップは承認済みかどうかのフラグしか持っていません。誰がいつ承認したかは記録されず、差戻しでリセットされると痕跡も消えます。実際の稟議システムでは監査の観点から履歴が必須でしょう。履歴を持たせるなら、それは申請集約の中なのか、別の集約なのか。ここも集約の境界を引き直す話になりそうで、まだ整理できていません。

承認ルートのマスタをどう扱うか

今回、承認ルートは外から渡す形にして、マスタの実装を避けました。実際には「10万円以上なら部長まで」のような条件でルートが決まります。この判定は誰の責任なのか。申請集約の中に置くと金額と組織の知識が申請に入り込みますし、外に出すとルート決定のロジックが宙に浮きます。ドメインサービスという概念が必要になる場面かもしれません。

まとめ

  • 集約は、常に整合していなければならない範囲の囲い。守りたいルールが囲いの範囲を決める
  • 承認ルートは「定義」と「進捗」で性質が違う。定義は申請の外、進捗は申請の中に置いた
  • 状態と進捗は別の軸として持つ。段数が増えても状態は増えないので、第2回の遷移表はそのまま生き残った
  • 集約が複数テーブルにまたがると、保存はトランザクションで丸ごと行うことになる。リポジトリは集約ルート単位で1つにする

次回は、今回避けた承認ルートの決定ロジックを扱う予定です。「10万円以上なら部長まで」のような判定は、申請の中にも承認ルートの中にも収まりが悪い。どのモデルにも属さないふるまいをどこに置くかについて考えたいと思います。

サンプルコードの全体はリポジトリで公開しています。本記事時点のコードは article-04 タグを参照してください。


本記事のサンプルコードは説明用に簡略化しています。エラーハンドリングや並行性の考慮は最小限です。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?