本稿では、プログラムの詳細設計における「DI(Dependency Injection:依存性の注入)」の本質的な設計手法について解説する。DIを設計段階から正しく意識することは、拡張性に優れ、単体テストの容易なシステムを構築する上で不可欠である。
しかし、すべての構造体をインターフェース化するような「過剰設計(Over-engineering)」は、コードの可読性や追跡性を著しく低下させる。本稿では、設計時における4つの原則、DI適用の境界線、そしてGo言語独自の特性である「暗黙的インターフェース(ダックタイピング)」を活かしたアプローチについて紐解く。
1. 詳細設計においてDIを意識するための4つの設計原則
詳細設計の段階でDIの受容性を組み込むことにより、実装フェーズに入る前に堅牢なアーキテクチャを確定させることができる。設計時に遵守すべき原則は以下の4点である。
① 「動詞(アクション)」に着目したインターフェースの抽出
設計書の作成時、データベースや外部の通知サービスといった「名詞(具体的なシステムやコンポーネント)」をベースに思考すると、密結合な設計になりやすい。設計時には、これらを「動詞(抽象的な振る舞い)」へと変換して捉える必要がある。
- 非推奨(名詞的思考): MySQL に保存し、MailGun で通知する
- 推奨(動詞的思考): DataSaver(保存機能)を実行し、Notifier(通知機能)を実行する
振る舞いに着目して抽象化を行うことで、具体的なミドルウェアや外部サービスに依存しない「インターフェース」が設計の初期段階で論理的に導き出される。
② 「外部環境(不確定要素)」との境界線のインターフェース化
システム内部において、どのコンポーネントをインターフェース化すべきかの基準は、「自組織のコードで制御不能な外部要素」であるかどうかにある。これらはすべてDIの対象として隔離すべき領域である。
具体的には、以下の4要素が該当する。
- データストレージ / 外部API / ファイルシステム(ネットワークやI/Oの状態に依存する)
- システム時間(time.Now() 等)(実行するタイミングによって値が変動する)
- 乱数(ランダム値生成)(実行ごとに結果の再現性がない)
- 環境変数および外部設定ファイル
これらをビジネスロジック(コアとなる計算や判定処理)の内部に直接記述すると、「特定の時刻における挙動の検証」や「外部API障害時の異常系処理の検証」といった単体テストが不可能となる。外部と接する処理は、すべて「外部から注入される契約(インターフェース)」として設計するのが原則である。
③ 構造体生成関数(コンストラクタ)の同時設計
詳細設計(クラス図やシーケンス図)を記述する際、構造体を定義すると同時に、それを初期化するための生成関数(コンストラクタ)のシグネチャを必ず定義する。
// 詳細設計の段階で、依存関係を引数として明示する
func NewUserService(repo UserRepository, mailer Mailer) *UserService
この生成関数の引数を参照することで、そのオブジェクトが動作するために何を必要としているか(依存関係)が視覚的に明確になる。もしこの引数が極端に多くなる場合は、該当する構造体が複数の責務を抱え込みすぎている(単一責任の原則への違反)という設計上のアラートとして機能する。
④ 独立した単体テスト(Unit Test)の実行シミュレーション
詳細設計が完了した段階で、「この機能の単体テストを、完全にネットワークから遮断されたローカル環境で実行可能か」を思考実験する。
「AWSのストレージサービスに直接依存しているため、モックに差し替えなければ検証できない」といった課題が発見された場合、該当部分を ImageUploader のようなインターフェースとして切り出し、生成関数の引数から注入する設計へと修正を行う。テストの容易性を考慮することは、そのまま質の高いDI設計に直結する。
2. DI適用の境界線と過剰設計の回避
DIは強力な設計パターンであるが、トレードオフも存在する。最大のデメリットは、コードの「静的追跡性」の低下である。統合開発環境(IDE)等においてコードを追いかける際、「定義へのジャンプ」を実行すると具体的な実装ではなくインターフェースの定義に遷移するため、処理の全容を直感的に把握しにくくなる。
したがって、DIを「適用すべき対象」と「適用すべきでない対象」の境界線を明確に引くことが重要である。
❌ DIを適用すべきでない(具象のままでよい)対象
-
状態を保持するのみのデータ構造(ドメインモデルやDTO)
User、Product、Order といった、純粋にデータを格納する構造体をインターフェース化する必要はない。これらは交換可能な「振る舞い」を持たない具体的なデータそのものであるためである。 -
内部で完結する純粋関数(副作用のない計算ロジック)
外部との通信を行わず、同一の入力に対して常に同一の出力を返すロジックは、インターフェースを挟まず直接呼び出して差し支えない。
例:消費税の計算、文字列のフォーマット整形、入力値のバリデーションチェックなど。 これらは具象のままでも容易に単体テストが可能であり、抽象化のメリットが乏しい。 -
言語の標準ライブラリおよび不変の共通処理
文字列操作を行う標準パッケージ(Goにおける strings など)や、標準的なログ出力処理をわざわざインターフェースでラップしてDIにするのは、冗長な設計である。
⭕ DIを適用すべき対象
- 実装が2パターン以上切り替わる可能性のあるコンポーネント(例:本番用データベースとテスト用モック)
- 実行ごとに結果が変動する不確定要素(例:外部APIクライアント、時間依存処理、乱数生成器)
実務における段階的アプローチ
設計・開発における現実的な最適解は、最初から完全なDI構造を目指さないことである。まずは具象構造体同士を直接結合させたシンプルな構造で最短実装を行う。その後、単体テストを記述するフェーズ、あるいは「2つ目の異なる実装」が必要になった段階で、該当箇所をインターフェースとして切り出し、DI構造へとリファクタリングを行う手法が、開発効率の観点から推奨される。
3. Go言語における「暗黙的インターフェース」の優位性
前述した「必要に応じて後からDI化する」というアプローチを強力に支えるのが、Go言語のコンパイル時におけるインターフェース満足度判定アルゴリズム(ダックタイピング)である。
静的宣言を求める言語(Java等)との比較
明示的なインターフェース実装を要求する言語(Javaなど)では、コンポーネント(クラス)を作成する時点で、どのインターフェースに従うかを明示的に宣言しなければならない。
// Javaにおける明示的な実装宣言の例
class VacuumCleaner implements Cleaner {
// クラス定義時に「Cleaner」に従う宣言が必要
}
この方式では、後から既存のクラスを別の新しいインターフェースに適合させたい場合、該当するクラスのソースコード自体を修正し、宣言を追加する必要が生じる。
Go言語における「構造的型付け(Structural Typing)」
対してGo言語は、明示的な宣言(implements などのキーワード)を一切必要としない。構造体が特定のインターフェースで定義されているメソッドをすべて実装していれば、それだけでそのインターフェースを満たしているとシステム側で自動的に判定される。
// インターフェースの存在を意識せずに定義された構造体
type MySuperGadget struct {}
func (m MySuperGadget) Clean() string { return "Cleaning" }
func (m MySuperGadget) GetBattery() int { return 100 }
func (m MySuperGadget) PowerOff() {}
このコード自体には特定のインターフェースへの依存は記述されていない。しかし、別のパッケージや後続の設計によって、全く同一のシグネチャを持つ Cleaner インターフェースが定義された場合、この MySuperGadget はコードを1文字も修正することなく Cleaner 型の変数として割り当てることが可能となる。
package main
// Cleaner は、MySuperGadgetが持つメソッドと「全く同一のシグネチャ」で定義されたインターフェース
type Cleaner interface {
Clean() string
GetBattery() int
PowerOff()
}
サードパーティ製ライブラリのDIにおける利点
この特性が最も効果を発揮するのは、外部のサードパーティ製ライブラリをDIに組み込む場合である。外部ライブラリの製作者は、利用側が定義した独自のインターフェースを知る由もない。
他の言語であれば、外部ライブラリのクラスを自組織のインターフェースに適合させるために、アダプター(ラッパー)となるクラスを仲介させる設計が必要となる。しかしGo言語であれば、外部ライブラリの構造体が要求するメソッドを満たしてさえいれば、一切の仲介コードなしにそのまま自組織のコンポーネントへ注入(DI)することができる。
最後に
Go言語におけるインターフェースの本質は、「実装側に契約を強制するもの」ではなく、「利用側が要求する仕様を定義するもの」である。
詳細設計の段階において、この特性を正しく理解していれば、最初から過剰なインターフェースの大量生産に陥る必要がなくなる。変化の激しい外部境界線のみを適切に見極めて抽象化し、内部ロジックはシンプルに保つ。この柔軟性こそが、Go言語の設計において高い生産性と保守性を両立させるための鍵である。