現場で役立つシステム設計の原則 を読んで、
その内容をチームに伝えるためにまとめたメモです
前回のまとめ: ドメインモデルとは?
https://qiita.com/jimpei/items/93671b4b09407f9d4111
アプリケーション層の実装
アプリケーション層・・・つまりサービスの実装のこと
いきなり例題
残高からお金を引き出すシステムを考える
要件
- 引き出したい金額を入力
- 残高が足りていれば、残高から引き出し
- 更新後の残高を画面に表示
まずはイメージしてみる
- 引き出したい金額を入力
- →DBから残高を取得
- 残高が足りていれば、残高から引き出し
- 残高と入力値を比べる
- 足りなければエラー
- 足りていれば、残高から入力値を引いた金額でDBをUPDATE
- 更新後の残高を画面に表示
- DBから残高を取得
- 画面に残高を返す
注意点(おさらい)
残高の保存場所がDBと仮定しているが、残高は別のAPIから取得するかもしれない。
アプリケーション層の実装は残高をDBから取得しているのか、APIから取得しているのかには依存しないようにする。
実装ポイント
- 意味のある最小単位、かつ、単独でテスト可能な単位にメソッドを分離する
- 参照と更新は1メソッドで行わない
実装例(アンチパターン)
@Service
class BankAccountService {
@Autowired
BankAccountRepository repository;
// withdraw = 残高引き出し
Amount withdroaw(Amount amount) {
repository.withdraw(amount); // DBから残高引き出し(更新処理)
return repository.balance(); // DBから現在残高取得(参照)して返す
}
}
悪いところ
- withdrawの中で更新と参照を行っているのでダメ
- もっと細かい処理とか、似たような処理が生まれちゃう(変更するときにがんができやすい
実装例(正解)
@Service
// 残高を参照するサービス
class BankAccountService {
@Autowired
BankAccountRepository repository;
// 残高を参照するメソッド
Amount balance() {
return reopsitory.balance(); // DBから残高を取得するだけ
}
// 残高から引き出すことができるのか確認するメソッド
boolean canWithdraw(Amount amount) {
Amount balance = balance(); // ↑の残高取得メソッドを呼ぶ
return balance.has(amount); // 残高の比較は、Amountの関心事(getして比較しないこと)
}
}
@Service
// 残高を更新するサービス
class BankAccountUpdateService {
@Autowired
BankAccountRepository repository;
// 残高を更新するメソッド
void withdraw(Amout amount) { // 更新成功時は何も返さない(ここらへんのエラーハンドリングなどは別途説明
repository.withdraw(amount); // DB(残高)をamountで更新する
}
}
疑問
これはパーツであり、サービスになっていないのでは、、?
意味のある最小単位、かつ、単独でテスト可能な単位にメソッドを分離する
→ここからこのパーツを使って組み立てる必要がある!
どこで組み立てるべきか
選択肢は2つあります。
- プレゼンテーション層(コントローラで組み立て)
- アプリケーション層に新たな組み合わせ用のクラスを作る
→ アプリケーション層に新たな組み合わせ用のクラスを作る
こっちが正解!
どちらも例で説明します。
プレゼンテーション層での実装例(良くない例)
@Controller
class BankAccountController {
@Autowired
BankAccountService bankAccountService;
@Autowired
BankAccountUpdateService bankAccountUpdateService;
// ここがエントリポイントになる
String withdraw(Amount amount, Model model) {
if (!bankAccountService.canWithdraw(amount)) { // 入力した金額が引き出せるのか確認
return "残高不足画面"; // returnした文字列のテンプレート(html)が呼び出される
}
bankAccountUpdateService.withdraw(amount); // 残高を引き出す
Amount balance = bankAccountService.balance(); // 引き出し後の残高を取得
model.addAttribute("balance", balance); // 画面に渡すためにセット
return "引き出し完了画面";
}
}
悪いところ
- 業務の判断ルールが追加になったり、複雑になったときに、業務の関心事の変更なのに、プレゼンテーションのクラスを改修する必要がある
- →ドメインの変更であるべき
- プレゼンテーション層に業務ロジックが書かれ始めると、どこに何が書いてあるかわからないので、調査が大変になる
- 重複コードの誕生
アプリケーション層に組み合わせ用のサービスクラスを作る(良い例)
@Service
class BankAccountScenario { // シナリオクラス
@Autowired
BankAccountService bankAccountService;
@Autowired
BankAccountUpdateService bankAccountUpdateService;
// 「残高を引き出す」というシナリオ
Almount withdraw(Amount amount, Model model) {
if (!bankAccountService.canWithdraw(amount)) { // 残高が足りているか確認
throw new IllegalStateException("残高不足");
}
bankAccountUpdateService.withdraw(amount); // 残高を引き出す
return bankAccountService.balance(); // 引き出し後の残高を返す
}
}
プレゼンテーション層(Controller)は、このシナリオを呼ぶだけ。
方針
アプリケーション層を下記の2つに分けてしまう
サービスクラス群:基本サービスを提供する(パーツ)
シナリオクラス群:基本サービスの組み合わせ
こうすることで見通しがよくなる
シナリオクラスの効果
- コードの整理(見通しがよくなる
- アプリケーション機能の説明になる
- シナリオテストの単位になる
小さい単位に分解しているだけだと、どこに何が書いてあるかわからなくなる。
→分解してればいいわけではない
それを、業務視点で必要とする機能単位をシナリオとしてまとめる。
これが業務手順書であり、業務の具体例であり、シナリオテストの単位となる(ドキュメント削減にもつながる
シナリオテストの自動化がしやすくなる。
まとめ
- 実装は意味のある最小単位、かつ、単独でテスト可能な単位にメソッドを分離する
- アプリケーション層の実装は、サービスクラス(パーツ)群と、シナリオクラス(組み立て)群にわけると見通しがよくなる
- プレゼンテーション層に業務ロジックの改修の影響がないようにする
さいごに
現場で役立つシステム設計の原則 は、ボトムアップにソースレベルからドメインモデルについて理解できるので、DDDとは?とトップレベルから理解するよりエンジニアには易しいかもしれません。
※なので、DDD的にはそうじゃない、という表現があるかもしれません。