はじめに
Clean Architectureやレイヤードアーキテクチャでは、どのようにレイヤーを定義するかついては言及されています。
そのような中usecase(レイヤードアーキテクチャではApplication層)をどのように実装するべきかについての議論は少ないです。
しかし私はリーダブルなアーキテクチャを実現するために、一番大切なことはusecaseを適切に実装することであると考えています。
そこでusecaseを実装する上で起こりがちな抽象度の問題を例に、リーダブルなアーキテクチャを考えいていきたいと思います。
サンプル
1:1のチャットアプリでUserとWorkerが存在して会話ができるアプリを例にあげます。
以下の図では青い背景はinfraの関数実行、緑色の背景はdomainの関数実行、赤い背景はusecaseの関数実行を示しています。
usecaseのCreateChat関数が以下のように存在しています。
しかしMessageの送信だけをしたいというユースケースがでたので関数を分離します。
しかしメンバーの参加だけをしたいというユースケースがでたので関数を分離します。
このような変更はよく起こると思いますが、問題はないでしょうか?
問題点
問題点1
問題点2
問題点3
他の関数をどこまで実行しているかわからないので、すべての実装を読まないとどのような処理を実施しているか理解することができません。
解決策
- 素直にべた書きをする
- Usecaseの中で時間軸を制御するレイヤーと機能を定義するレイヤーを分割する
1. 素直にべた書きをする
usecase内での関数化を諦めて素直にべた書きをします。
こうすることでusecaseはdomainとinfraを実行するレイヤーとして抽象度が揃います。
適切にドメインやインフラを実装できていれば、usecaseにはロジックは登場せずほとんどがドメインとインフラの関数呼び出しをするだけになるはずです。
べた書きでusecaseの実装が複雑になるのであれば、そもそもその責務分割が間違っている可能性が高いので、まずはそちらを直せないかを検討するべきです。
2. usecaseの中で時間軸を制御するレイヤーと機能を定義するレイヤーを分割する
usecaseの中で時間制御レイヤーと機能定義レイヤーを分割して、同じレイヤー間の実行を禁止します。
時間制御レイヤーから機能定義レイヤーを実行することだけが許されます。
これによりusecaseの抽象度が揃います。
複数のレイヤーの関数に時間軸の横断がなくなり、時間制御レイヤーを読むだけでどのような機能が時間軸上で実現されているかを把握することが可能となります。また機能を関数化も可能となりDRYになります。
つまりすべての実装を読まなければ理解できなかったユースケースを、1つの関数内の関数実行だけを読めば理解できるようになりました。
恐らく理解するのに読まなければいけないコード行数は50行以上から10行くらいに削減されたのではないでしょうか?
考察
domain、infraとusecaseの違い FunctionとFlow
doaminとPresentation(infra)を分割して、domainがPresentationに依存しないというPresentation Domain Separationはよく語られる話だと思います。
しかし今回はPDSだけでは捉えることができない点を、domainとinfraを機能を実現する同じ分類とし、usecaseを時間軸と副作用を制御する層として注目します。
ソフトウェア開発で大切なことの1つは副作用を制御することです。
バックエンド開発における副作用を大きく2種類存在しており、domainを操作することでのオンメモリ上での副作用とinfraを操作することでの永続化の副作用です。
domainとinfraは副作用を起こしますが、純粋な機能であり時間軸を持ちません。
usecaseでは、時間軸が存在しており、その時間軸の上でdomainとinfraを利用して副作用を制御する責務を持っています。
このときusecaseにdomainとinfraの機能が漏れ出してはいけません。そうするとusecaseに機能と副作用の制御の責務が混在することなります。
domainとinfraをfunction(機能)と捉え、usecaseをFlow(時間軸)としてFunction Flow Separationという認識がとても大切ではないかと考えています。
usecaseがusecaseを呼び出すことの問題点
usecaseがusecaseを呼び出すということは、関数を深く潜って実装を読んでいかないと副作用がどのように完了するかを理解することができません。
このようなコードは、メソッド化されているにもかかわらず適切なレイヤー化が行われていないため、とても読みにくいコードとなります。
副作用が一目瞭然であるためには、1つのusecaseの関数にすべての副作用が表現されていることが大切です。
しかしこれでは同じ処理を関数化できないという問題が発生します。そのような場合はusecaseの中に機能を実現するレイヤーを定義して、そのレイヤーをUsecaseの時間軸を制御するレイヤーから実行するようにします。
このようにすると機能を実現するレイヤーの実装を読まずとも、時間軸を制御するレイヤーさえ読めば機能的に何を実現しているかがわかります。
先ほどの悪い例ではJoinChatの中でメッセージを送信するという副作用を起こしていることは、JoinChatの実装を読まなければわかりませんが、良い例ではJoinChatとSendJoinMessageをUsecaseから呼び出しているためJoinChatの実装を読まずともメッセージを送信しているということがわかります。
また今回は言及していませんが、domain serviceでinfraを実行する場合も同様の問題が発生します。
私はこのようなdomain serviceはdomain serviceではなくusecaseであると考えています。domain serviceは永続化に関する処理はするべきではありません。
まとめ
usecaseを実装する際に陥りがちな問題について説明しました。
コードを読む際に1つのレイヤーの抽象度が揃っているということはとても大切なことです。 SLAP(Single Level of Abstraction Principle)という原則も存在しています。
しかしDRYを突き詰めるあまりにそれが破綻してしまうことがあります。
そのDRYのための関数化は本当に必要なのかを考え、まずはusecaseの実装を適切にドメインやインフラに委譲できないかを考えます。それでも難しい場合は、時間軸の制御が必要なレイヤーとそうでないレイヤーを明確に責務を分けることで解決します。
usecaseを実装する際には以下を考えて実装するとよいでしょう。
- usecaseにdomainとinfraの機能が漏れ出してはいけない
- 1つのusecaseの関数にすべての副作用が表現されている
- usecaseの抽象度が揃っている