"The Clean Architecture" から学ぶ "抽象に依存する"

2022/06/15に公開

当たり前ではあるが、継続的にプロダクトを開発し続けるためには、継続的にプロダクトを開発し続けられるソフトウェアを維持する必要がある。
なので、継続的にプロダクトを開発し続けられるソフトウェアにするには、どうすればよいのかを理解しておく必要がある。

また、”スピードを優先する”的な発言が多々あると思うが、意図的に優先するためには、優先しない場合を理解し実践できる必要があると思われる。(意図的に負債をためている)
仮に、”優先しない場合を理解し実践できない”状態で”スピードを優先する”を行うと、”とりあえず動くが継続的に開発し続けられない”状態になるスピードも暗黙的に早めていると思われる。(意図せず負債をためている)

で、”とりあえず動くが継続的に開発し続けられない”状態になってしまったと仮定して、なんとか”継続的に開発し続けられる”状態に持っていきたい場合を想定する。
その状態に持っていくためには、"継続的にプロダクトを開発し続けられるソフトウェアにするにはどうすればよいのか"を理解する必要があるので、”Clean Architecture”を土台に情報を整理しておこうと思う。

The Clean Architecture

https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

よく、Clean Architecture本に書いてある、具体的な実装の話が出てくるが、そういったものはどうでも良いと思われる。
ここで言っているのは、N層にレイヤーを分割し内側のレイヤーにのみ依存する、なのでは。

で、なぜそれらを行うべきかと言うと、関心事を分離してメンテナンスしやすいテストしやすい、ソフトウェアにするためだと思う。

なので、例として図が上げられているが、具体的に何層するべきかは言及していない。
必然的に登場人物も固定されないことになる。

大切なのは、目的と手段を間違えないことである。
Clean Architectureの文脈においては、目的・手段は以下である。

  • 目的:関心事を分離してメンテナンスしやすいテストしやすい、ソフトウェアにする
  • 手段:N層にレイヤーを分割し内側のレイヤーにのみ依存する

そして、メンテナンスしやすい・テストしやすいソフトウェアこそが、継続的にプロダクトを開発し続けられるソフトウェアなのだと思われる。

抽象に依存する

N層にレイヤーを分割し内側のレイヤーにのみ依存するのを具体的にどう実装すればよいのか、に対する1つの答えが抽象に依存するであると。

抽象に依存し、依存注入(Dependency Injection)すれば、依存方向が内側のレイヤーに向けられる、ということ。
SOLID原則で言うところの、依存性逆転の法則(Dependency Inversion Principle)、である。

こちらも、あくまで選択肢の1つなので、必ずしもこの方法である必要はないはず。
場合によっては、ORMのModelをEntityとしても扱うが、関心事が分離されるようメソッドの呼び出しルールを決める、という手段を選んでも良いはず。(目的は変えないが、手段は変える)

実際に書いてみる

以下のコードを実行すると、new GreetUseCase(consoleLog).hello()new GreetUseCase(stdoutLog).hello()のどちらもHELLOと出力される。

また、GreetUseCaseから見ると、抽象であるLogに依存しているので、ConsoleLogStdoutLogどちらが渡されるかは分からない形になっている。

interface Log {
  info(message: string): void;
}

class ConsoleLog implements Log {
  info(message: string): void { console.log(message); }
}

class StdoutLog implements Log {
  info(message: string): void { process.stdout.write(message + '\n'); }
}

class GreetUseCase {
  log: Log;
  constructor(log: Log) { this.log = log; }
  hello(): void { this.log.info('HELLO'); }
}

function main() {
  const consoleLog = new ConsoleLog();
  const stdoutLog = new StdoutLog();

  new GreetUseCase(consoleLog).hello();
  new GreetUseCase(stdoutLog).hello();
}

main();

短いコードではあるが、図にしてみると次のような関係になっている。
N層にレイヤーを分割し内側のレイヤーにのみ依存する状態ができている。

このような関係性にすることで、GreetUseCase側はLog側の具体的な実装に関係なくロジックを組み立てられ、テストしやすい状態にもなっている。
また、Log側もGreetUseCase側の具体的な実装に関係なく実装できるし、DIする対象を変えれば実装を差し替えることも簡単にできる。
(たぶん、これはClean Architectureである、と言っても間違ってないはず?)

で、何層にしても基本は同じで、抽象に依存内側のレイヤーにのみ依存する状態を保てば良い。
後は必要に応じて、入出力で抽象を分離したり、N層下まで依存を許容するなど、状況に応じて対応すれば良い。

システム全体でも同じ事が言える

もう少し話を広げてみる。
抽象に依存するというのは、ソフトウェア単体のアーキテクチャだけでなく、システム全体のアーキテクチャにも適用できる考えだと思う。

例えば、プロダクトが成長してきて複数のチームで分担して開発が必要になった状況を仮定する。
その場合に、まずはモノリスであるソフトウェアを複数のモジュールに分割すると思う。(間違ってもいきなりマイクロサービスにするとかは危険な感じがする)
で、その時のソフトウェアの状態を図にしてみると次のような感じになると思う。

次の段階として、各モジュールをマイクロサービスとして切り出すことになると思う。
で、切り出した後のシステム全体の状態を図にしてみると次のような感じになると思う。

マイクロサービスとして切り出してはいるが、抽象に依存するという関係性は変わっていない。
サービス間で見ると抽象がAPIになっていて、サービスX内で見ると抽象がInterfaceになっている。

つまり、抽象=API、に依存しておけば、お互いにサービス内の実装方法は気にしなくて良くなる。
APIを壊さないようにすれば、複数チームで独立して実装を進められる。

まとめ

抽象に依存することでメンテナンスしやすい・テストしやすいソフトウェアにできる。
そういったソフトウェアこと、継続的にプロダクトを開発し続けられるソフトウェアなのだと思われる。

と信じたい。

あと、Webフロントエンドでよく使われる、Redux・Vuexとかのアーキテクチャも、結局の所抽象に依存するをそれぞれのライブラリに適した形にしているだけだと思われる。

Discussion