本記事は、 DDD-Community-Jp Advent Calendar 2020の4日目です。
はじめに
自分が入出力とロジックは分けたいなと思っておりますが、
なんで分けたほうがいいのか? というのがあまり言語化ができてなかったので、書いてみたいなと思いました。
入出力とロジックを分けることによるメリット
- 入出力とロジックを分けて、業務ロジックだけに集中することができる
- そのロジックが表すことに名前付けたり
- そもそも入出力と業務ロジックが一緒だと、変更するところが2箇所になってしまう
- たぶん、単一責任の原則1
入出力と業務ロジックの関心事は別
身長と体重を入力したら、BMIを出力するプログラムを考えてみます。
これの結果を出力するとしたら、こんな感じになりそうです。
def main(lines):
height, weight = map(int, lines.split())
# 体重 [kg] / 身長 [m] / 身長 [m]
bmi = int(weight / (height / 100) / (height / 100))
print(bmi)
if __name__ == '__main__':
lines = '150 60'
main(lines)
このmain関数の中には、2つの責務があります。
- BMIの計算
- 計算結果をターミナルに出力する
2つ責務があることによって、どういうことが起こりうるかと言いますと、
- 業務ロジックの関心事から出てくる変更要因
- BMIの計算式が間違っていたから変更する
- 入出力の関心事から出てくる変更要因
- 計算結果をデータベースに保存したい
- 計算結果をCSVにしたい
- 計算結果をJSON形式にしたい
といったように、2つ責務があることによって、様々な理由から、このmain関数に対して変更が求められてきます。
そして、大体は入出力にまつわる変更(出力先を変えたい、表現方法を変えたい)の方が、個人的な感覚では多いように思っています。
入出力と業務ロジックを分ける
入出力と業務ロジックを分けてみます。
class Body_Calculator:
def calc_bmi(self, height: int, weight: int) -> int:
# 体重 [kg] / 身長 [m] / 身長 [m]
return int(weight / (height / 100) / (height / 100))
def main(lines):
height, weight = map(int, lines.split())
bmi = Body_Calculator().calc_bmi(height, weight)
print(bmi)
if __name__ == '__main__':
lines = '150 60'
main(lines)
BMI計算をするのは、Body_Calculator().calc_bmi()
のメソッドの中で行われることになりました。
これによって、先程の変更理由を例に上げると
- 業務ロジックの関心事から出てくる変更要因
- BMIの計算式が間違っていたから変更する ->
calc_bmi()
を変更する
- BMIの計算式が間違っていたから変更する ->
- 入出力の関心事から出てくる変更要因
- 計算結果をデータベースに保存したい ->
main()
を変更する - 計算結果をCSVにしたい ->
main()
を変更する - 計算結果をJSON形式にしたい ->
main()
を変更する
- 計算結果をデータベースに保存したい ->
変更する箇所が限定的になってきました。
計算ロジックのインターフェースを見直す
BMI計算のロジックを見てみます。
def calc_bmi(self, height: int, weight: int) -> int:
# 体重 [kg] / 身長 [m] / 身長 [m]
return int(weight / (height / 100) / (height / 100))
これは、一見良さそうに見えます。
ですが、中身の計算を見ていると、暗黙的に身長はcmであることを要求しています。
型ヒントでintを要求しているので、1.6[m]などを渡さないと思いますが(いまのコードだと、map(int...)のところでエラーは出ます)引数名を変えてみます。
def calc_bmi(self, height_cm: int, weight_kg: int) -> int:
# 体重 [kg] / 身長 [m] / 身長 [m]
return int(weight_kg / (height_cm / 100) / (height_cm / 100))
値オブジェクトを導入する
引数名を変更するだけでは心許ないので、値オブジェクトを導入してみます。
入力は文字列で来るので、文字列→intに変換するメソッドを用意します。
@dataclass
class Height:
value: int
@staticmethod
def of(data: str) -> Height:
try:
return Height(int(data))
except ValueError as e:
raise ValueError('身長はcmで入力してください')
@dataclass
class Weight:
value: int
@staticmethod
def of(data: str) -> Weight:
try:
return Weight(int(data))
except ValueError as e:
raise e
calc_bmi()
の引数も値オブジェクトの型にします。
class Body_Calculator:
def calc_bmi(self, height_cm: Height, weight_kg: Weight) -> int:
# 体重 [kg] / 身長 [m] / 身長 [m]
return int(weight_kg.value / (height_cm.value / 100) / (height_cm.value / 100))
呼び出し側も変えていきます。
def main(lines):
h, w = lines.split()
bmi = Body_Calculator().calc_bmi(Height.of(h), Weight.of(w))
print(bmi)
これによって、 calc_bmi
は HeightとWeightを要求しているのを明示しています。
業務ロジックを入出力から引き剥がしておくことで、その業務ロジックの表現に集中ができる
現場で役立つシステム設計の原則の第5章に「ドメインモデルを育てる」という節があります。
その中で、
サービスクラスに安易に業務ロジックを書くより、ぎこちない名前でもドメインモデルにクラスを追加するほうが、業務ロジックの整理として優れています -- P.156
と書かれています。
これに近い感覚を持っていて、
たとえ、print文とかでも、まずは入出力と業務ロジックという2つの関心事を分離し、
そのロジックに対して何かぎこちなくても良いので名前とインターフェースを与えておくと、そこから設計をカイゼンしていくにはどうしたら良いのか、の考える取っ掛かりになるのではないかと思っております。
ちょっとしたポイント
何かしら、いい名前が思いつかなかった場合は、すっごくあり得ない名前をつけて、違和感を際立てる作戦をたまにやります。
- 普通に英語でコードを書いているなら、日本語でクラス名、メソッド名をつけてしまう
- ほげほげほげ、みたいな名前にしてしまう
- 絶対、目につく、ヤバい名前にしておくと、そこの名前を考えなきゃっていう意識が向きやすい
- あえて、ぎこちなさを極端に演出する方法
まとめ
入出力と業務ロジックの関心は別
- アプリケーションのサービス連携対応や、◯◯形式に出力できるようになりましたなどは、アプリケーションの機能ではあるが、業務ロジックの関心事ではない。
- 入出力のロジックは、煩雑になりやすいので、それが業務ロジックと混在していると、「どれが業務ロジックだったっけ…?」と混乱してしまう
- データベース操作
- JSON形式の変換など
- 入出力にまつわる変更は、あくまで自分の実感値だけど、業務ロジックの変更より多い
- だから、入出力は入出力で一つの関心事としてまとめておき、すぐ追加したり差し替えられたりするようにしておくと、メンテナンス性があがると思っている
-
クリーンアーキテクチャにある単一責任原則の章では、「モジュールは、たった1つのユーザやアクターに対して責務を負うべき」と書いてあります。なので、「BMI計算とその結果を出力する」というのは、1つのアクターだから良いのではないか? という意見もありそうです。でも、出力は表現方法に応じて(CSVやJSONなど)バリエーションがあり、それを求めるアクターが違うので、「計算とその結果を表示する責務は別」とも考えられそうです。 ↩