DIすると何がいいんだっけ
はじめに
こんにちは、majimaccho です。
読者の皆さんは最近、DI(Dependency Injection:依存の注入)してますでしょうか。
DI は素晴らしい仕組みである一方で全く DI しない Ruby on Rails のようなフレームワークが支配的な時代もありました。
それでも DI は今でも有用な考え方として残っている中で、DI にどう向き合っていけばいいのでしょうか。自分なりに考えをまとめるために調べてみたので、同じような疑問を持っている方に参考になれば幸いです。
TL;DR
- 単純にコード量が増加することに加え、DI の仕組み自体が複雑さを内包しているので開発生産性が低くなることがあります。そのため、DI は言語によっては局所的かつ限定的に利用する方が良い場合があります。
- いくつかの工夫によって DI が持つメリットを享受しつつ、不要な複雑さを排除して、シンプルかつ堅牢なコードを実現することができます。
なぜ依存を注入するのか
なぜ依存を注入するのか DI の原理・原則とパターンでは DI を導入する目的について以下のように説明しています。
ソフトウェア開発において依存注入は導入すること自体が目的なのではなく、目的を達成するための手段でしかありません。依存注入を導入するためにインターフェースを介する設計にすることでオブジェクト間の関係は疎結合になり、その結果、保守容易性が向上します。
つまり、DI は疎結合にするための方法の 1 つということになります。
疎結合であることの具体的なメリットとして、同書の中では拡張可能性、保守性、テスト容易性などが挙げられています。
特に Java を前提として述べられており、今日でも Java では有効な方法であると思われます。
また、同書では DI をするべき対象の依存を揮発性依存と呼んでいます。揮発性依存は以下の条件を 1 つでも満たすものとして定義されています。(簡略化したものですので、正確な内容を知りたい方はぜひ書籍を参照してください。)
- 対象の依存を導入してアプリケーションを適切に稼働させるには、実行環境に関する設定や調整が必要となる(データベース、メッセージキュー、ファイルシステムなど)
- 対象の依存となる具象クラスがまだ用意されてない、もしくは開発中である
- 対象の依存が開発に関わる全ての環境に用意されていない
- 依存の対象に非決定的な振る舞いが含まれている
一般的に、このうち DI する対象の依存のうち多くは、「DI をする対象は 1 つの対象の依存を導入してアプリケーションを適切に稼働させるには、実行環境に関する設定や調整が必要となる」の条件を満たしたものになると思います。同書では、こうした依存を直接使うことのデメリットとして、テストができなくなってしまうと主張されています。
DI のデメリット
DI を利用して疎結合なソフトウェアを作ることにはメリットがある一方で、トレードオフとなるデメリットもあります。
大きく以下の 3 つに大別されると思います。
- DI 自体の仕組みの複雑さ
- DI コンテナの複雑さ
- DI と多く用いられる Repository パターンなどのマッピングの多さ
「DI 自体の仕組みの複雑さ」、「DI コンテナの複雑さ」については、言語やフレームワークに依るところも大きいのでこちらではあまり言及しませんが、どの言語、フレームワークでも一定発生するものです。
マッピングの多さはデータの詰め替えが多くなってしまい、コードベースの多くを詰め替えのためのコードに割かなければならなくなることが問題になります。
kawasima さんの「データ詰め替え戦略」の中で、Clean Architecture で Full Mapping している場合と Ruby on Rails の比較がされており、比較して見ると詰め替えのコード量の差を想像しやすいと思います。画像は同記事からの引用です。
Clean Architecture で Full Mapping している場合
全く Mapping を行わない Ruby on Rails
1 つのユースケースのみを実装するのであれば、マッピングを行わない設計の方が実装が早く終わることは明らかです。
一方で、全て密結合させてしまうことは保守性の低下を招きやすいこともまた確かです。(Ruby on Rails のコミュニティではこうした特徴を受けて、フレームワーク特有の設計プラクティスが蓄積されているようです。)
Ruby on Rails の作者はDependency injection is not a virtueというブログポストの中で、テストのためだけであれば、Ruby のような柔軟な言語では DI は不要であると主張しています。
現代では、レイヤードアーキテクチャの設計方法として、ActiveRecord やクリーンアーキテクチャで紹介されているようなマッピング手法などから選択することができ、それぞれのメリット・デメリットを評価して適切に選択する必要があるのではないでしょうか。
書籍ドメイン駆動設計をはじめようでは、コアドメインであることの是否かとデータの複雑さによって、実装方法を選択するという方法を紹介しています。こちらも参考にしてみてください。
訳者の増田さんのスライド:
データベースの DI
書籍単体テストの考え方・使い方で結合テストでは、管理下にある依存、つまり、テスト対象のアプリケーションからしかアクセスされない依存について、以下のように述べられています。
外部から観察できないプロセス外依存とのコミュニケーションは実装の詳細になる。つまり、そのコミュニケーションプロセスに対してリファクタリングを行う場合、同じデータ構造を持つことも、同じ手順を踏むことも維持する必要がなくなる。そのため、このような依存はモックを持って検証すべきではない。
このような、管理下にあるプロセス外依存の代表的な例として、アプリケーションコードと同じ場所でスキーマの管理をされている データベース が挙げられています。
つまり、テストという観点では管理下にある データベース アクセスへの依存を注入する必要はないということになります。
関数型における DI
現代では関数型言語を利用していなくても、多くの設計プラクティスを関数型言語から学ぶことができます。
書籍 関数型ドメインモデリングとSix approaches to dependency injectionというブログの中で I/O を処理の開始と終了の両端に追いやることでドメインロジックを純粋に保つことができるという考え方が紹介されています。
これらについては少し前に X でも話題になっていました。引用元のスレッドも参考にしてみてください。
また、類似する考え方として、上記で紹介した書籍単体テストの考え方・使い方でも関数型アーキテクチャにすることによって単体テストを行いやすくすることを勧めています。
こうすることにより、副作用のないドメインロジックに関してはテストが容易な状態になり、プロセス外依存に依存しないコードになります。
ユースケース、コントローラーと呼ばれる、プロセス外依存と通信を行うレイヤーでは管理された依存である データベース についてはモックせずそのままテストすれば良いので、テストの観点では データベース の DI は不要ということになります。
ただし、管理された依存ではない、外部 API などを扱うときなどはモックする必要があり、その方法の一つとして DI を行うことは検討の余地があります。
型による分離
テストだけではなく、レイヤー間の依存関係を確保するために、DI が有効なケースもあるかと思います。抽象度の高いレイヤーが抽象度の低いレイヤーに依存してはいけないというルールを守るためです。たとえば、オニオンアーキテクチャにおけるアプリケーションレイヤーがインフラストラクチャーレイヤーに依存することは好ましくないといった場合です。
この場合、言語によっては依存対象の型のみを抽象度の高いレイヤーにおいて、実装を抽象度の低いレイヤーに配置することで、依存関係を整理することができます。
たとえば TypeScript では以下のように定義することができます。
まず、抽象度の高いレイヤーに型のみを定義します。
// model/repository.ts
export type HogeRepository {
findHogeById: (id: HogeId) => Hoge
addHoge(hoge: Hoge) => Promise<void>
}
そして、抽象度の低いレイヤーに実装を定義します。
// infrastructure/datasource.ts
import { HogeRepository } from "@model/repository"
export const HogeDataSource: HogeRepository {
async findHogeById: (id: HogeId) => {
return db.hoge.find({where: { id }})
}
async addHoge(hoge: Hoge) => {
await db.hoge.insert({ ...hoge})
}
}
こうすることで、レイヤーごとの適切な依存関係を保つことができ、DI を行わなくとも、疎結合なコードを実現することができます。
ただし、この時に注意が必要なのは、DI した場合と異なり、このHogeDataSource
を呼び出した側は infrastructure に依存する形になることです。レイヤーごとの依存関係に合わせて、適切な箇所で呼び出されるように注意が必要です。
必要な DI
必要以上に DI を行うことはデメリットの方が上回りますが、必要な DI も存在します。
それは単体テストの考え方 / 使い方の用語で言うところの 「管理されていないプロセス外依存」 です。つまり、別組織によって開発されている API であったり、Auth0 や Amazon Cognito のような IdaaS などがそれにあたります。これらは、自動テストの際に、公開されたインターフェースをモックしてテストをする必要があるため、何らかの形で DI を行う必要があります。
また、Web API の 1 リクエストに閉じたロガーやデータベースへのコネクションなどを共通で利用したい時にも DI は必要になります。この場合は DI コンテナに限らず、Web フレームワークや言語自体にそう言った仕組みがあることがありますが、DI を行っていることにはかわりません。
終わりに
DI はソフトウェアを疎結合に保つ上で重要な役割を担ってきた一方で、現代の言語の中で DI することでデメリットが上回る使い方もあります。
扱う言語と用途によって適切に DI を使い分けていく必要がある時代になっているのと感じています。
Discussion
新しい言語習得で何か適当にAPIサーバー作ろうとして、DI に慣れちゃってるからその言語の DI ライブラリの使い方を学ぼうとするんだけど面倒だから結局 main から手動で全部ぶち込む方式にして、最終的に書くのが面倒になって寝る