Nguyen Le Phong

ソフトウェアアーキテクチャの基礎全 6 回中第 3 回

Dependency Injection と Inversion of Control——魔法なしで理解する

DIコンテナは、根底にあるシンプルなアイデアを見るまで魔法のように感じられます:コードが自分で依存を作るのをやめ、外から受け取るようにする、それだけです。初歩から始めるガイドで、実例とテストの恩恵をお届けします。

ある時点ですべての開発者は同じ壁にぶつかります。コードの一部をテストしたいのに、そのコードが内部で new Database() を呼んでいます。何をしても、本物のデータベース接続が起動します。メールプロバイダーを交換したいのに、メールSDKが三層も深くに埋まっています。同じロジックをHTTPリクエストとスケジュールジョブの両方で動かしたいのに、フレームワークがあまりにもがっちりとボルトで締まっていて、実質的に不可能です。

これらはすべて、一つの根本的な原因の症状です:自分自身の依存を作るコード。解決策には正式な名前があります――Dependency Injection――ですが、その根底にあるアイデアは拍子抜けするほどシンプルです。コードが自分で協調者を見つけるのをやめて、外から渡すようにします。それだけです。他のすべて――IoCコンテナ・DIフレームワーク・インジェクショントークン――はその一つのアイデアの上に構築された仕組みにすぎません。

このガイドでは初めからほぐしていきます:問題、原則、パターン、そしてテストにおける実践的な恩恵。TypeScript を全体で使用します。あらゆるWebまたはサーバーバックグラウンドを持つエンジニアにとって読みやすいからです。

ハードワイヤードな依存:コードが自分で new を呼ぶとき

注文を処理するサービスがあります。一見すると完全に合理的に見えます:

// order-service.ts — 「修正前」バージョン
class OrderService {
  async placeOrder(cart: Cart): Promise<Order> {
    const db     = new PostgresClient(process.env.DATABASE_URL!)  // ハードワイヤードなDB
    const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)        // ハードワイヤードなベンダー
    const mailer = new SendGridMailer(process.env.SENDGRID_KEY!)    // ハードワイヤードなベンダー

    const order = Order.fromCart(cart)
    await stripe.charges.create({ amount: order.total, source: cart.token })
    await db.query('INSERT INTO orders …', [order])
    await mailer.send({ to: cart.email, template: 'order-confirmed' })
    return order
  }
}

これを使おうとした瞬間、三つの問題が明確になります:

  • 単独でテスト不可能。本物のクレデンシャル・ライブのデータベース・ライブのStripeサンドボックスなしに placeOrder をユニットテストで実行できません。デフォルトで、すべてのテストが遅くて壊れやすいインテグレーションテストになります。
  • 特定の実装に硬くカップリングされている。ビジネスが Stripe から Adyen に移行するとき、このファイルを開いて手術をします。ステージングにダミーのメーラーが必要なとき、プラグインする明確な方法がありません。
  • 隠れた依存。クラスのシグネチャを読む人は placeOrder(cart) を見て、このメソッドが秘密裡に三つの外部サービスを必要とすることを知りません。依存はすべての行を読むまで見えません。
注目すべきパターン

メソッドやコンストラクター本体の内部new SomeExternalThing() を見るたびに――コンポジションルートではなく――それはハードワイヤードな依存です。クラスは自分の仕事をすることと、その仕事に必要なツールを見つけて構築することの両方に責任を負っています。これは二つの仕事であり、互いに反対方向に引っ張ります。

Inversion of Control:誰が作成を管理するか?

コードで問題を解決する前に、関連する原則を名前で呼ぶことが役立ちます。Inversion of Control(IoC)は、コードが自分自身の依存を取得することに責任を負うべきではないというアイデアです。その代わりに、外部のもの――フレームワーク・テストハーネス・コンポジションルート――が物を作って渡すことを担います。

いわゆるハリウッド原則を聞いたことがあるかもしれません:「こちらから電話しないで、向こうから電話するから。」古典的な命令型コードでは、ビジネスロジックが依存を直接呼び出します――データベースに手を伸ばして掴みます。IoCでは、依存があなたのところに来ます。その関係の制御が逆転します:いつどのように協調者を取得するかをあなたが決めるのではなく、外の世界が決めて届けます。

IoC は広い原則であり、特定の技術ではありません。フレームワークはさまざまな方法でそれを実装しています:

  • Dependency Injection(DI)――協調者をコンストラクター引数またはメソッドパラメーターとして渡す。最も一般的で最も明示的な形式。
  • Service Locator――コードが実行時に名前で依存を取得するグローバルレジストリ。(後でこれがアンチパターンである理由について触れます。)
  • Template Method――基底クラスがサブクラスがオーバーライドするフックメソッドを呼び出すので、基底クラスがフローを制御し、サブクラスが空白を埋めます。
  • Event / Observer――コンポーネントがイベントを発行し、フレームワークがそれをサブスクライバーにルートします。どちらの側も相手の存在を知りません。

これらの中で、Dependency Injection は最も透明でテストしやすいものです。またIoC自体と最もよく混同されるものでもあります。だから二つの用語をほぐす価値があります:IoCは原則であり、DIはその原則の一つの実装です。

Dependency Injection:依存を受け取る

DIの仕組みは、一度見れば恥ずかしいほどシンプルです。クラスの内部で協調者を構築するのではなく、パラメーターとして宣言し、呼び出し側に提供させます。

三つの種類がありますが、ほとんどの状況で一つの明確な勝者があります:

コンストラクターインジェクション(推奨)

依存はコンストラクターで宣言されます。これが標準的な選択です:すべての依存がクラスのシグネチャに見え、構築時に必要とされ、後で何かが再代入できないように readonly にできます。

// order-service.ts — コンストラクターインジェクションを使った「修正後」バージョン
interface PaymentGateway { charge(amount: Money, token: string): Promise<Receipt> }
interface OrderRepository { save(order: Order): Promise<void> }
interface Mailer          { send(to: string, template: string): Promise<void> }

class OrderService {
  constructor(
    private readonly payments: PaymentGateway,
    private readonly orders:   OrderRepository,
    private readonly mailer:   Mailer,
  ) {}

  async placeOrder(cart: Cart): Promise<Order> {
    const order = Order.fromCart(cart)                   // 純粋なビジネスルール
    await this.payments.charge(order.total, cart.token)
    await this.orders.save(order)
    await this.mailer.send(cart.email, 'order-confirmed')
    return order
  }
}

そのコンストラクターのシグネチャを声に出して読んでください:「OrderService を作るには、決済ゲートウェイ・注文リポジトリ・メーラーが必要です。」これは、メソッド本体を一行も読まずにプレーンなコードで書かれた見える契約です。

セッター / メソッドインジェクション(時々使用)

依存は構築後にメソッド経由で設定されます。オプションの協調者や、二段階でオブジェクトを構築するフレームワーク(例:一部のテストフレームワーク・一部のIoCコンテナ)に役立ちます。控えめに使いましょう――オブジェクトが部分的に設定された状態で存在することを許可し、これが微妙なバグの元になります。

// セッターインジェクション — 構築時の配線が不可能な場合のみ使用
class ReportGenerator {
  private formatter: ReportFormatter = new DefaultFormatter()

  setFormatter(f: ReportFormatter) { this.formatter = f }  // オプションの上書き

  generate(data: ReportData): string {
    return this.formatter.format(data)
  }
}
シンプルな経験則

デフォルトでコンストラクターインジェクションを使いましょう。クラスが常に必要とするすべての依存はコンストラクターに属します。依存が本当にオプションの場合、またはフレームワークが強制する場合のみセッターインジェクションを使いましょう。依存グラフについて考えることを避けるための逃げ道としてセッターインジェクションを使わないでください。

コンポジションルート:すべてを一つの場所でつなぐ

コンストラクターを通じて依存を注入すると、新しい質問が生まれます:実際に誰が new を呼んでピースをつなぎ合わせるのか?答えはコンポジションルートです:アプリケーションのエントリーポイントにある、すべての本物のオブジェクトが構築されてお互いに注入される単一の場所。

コードベース全体で new ConcreteImplementation() を自由に書いても良い唯一の場所だと考えてください。他のすべてはインターフェースの言葉で話します。コンポジションルートは「抽象システム」と「現実の世界」の間のシームです。

// composition-root.ts — 本物のものが配線される唯一の場所
import { PostgresOrderRepository } from './adapters/postgres-order-repository'
import { StripePaymentGateway }    from './adapters/stripe-payment-gateway'
import { SendGridMailer }          from './adapters/sendgrid-mailer'
import { OrderService }            from './domain/order-service'
import { OrderController }         from './http/order-controller'

export function buildApp() {
  // 1. 最初に末端の依存を構築する(依存を持たないもの)
  const db      = new PostgresOrderRepository(process.env.DATABASE_URL!)
  const stripe  = new StripePaymentGateway(process.env.STRIPE_SECRET_KEY!)
  const mailer  = new SendGridMailer(process.env.SENDGRID_KEY!)

  // 2. サービスを構築し、依存を注入する
  const orderSvc = new OrderService(stripe, db, mailer)

  // 3. デリバリーレイヤーを構築し、サービスを注入する
  const orderCtrl = new OrderController(orderSvc)

  return orderCtrl
}

コンポジションルートがほぼレシピのように読めることに気づいてください:「これらの材料を作り、サービスに組み合わせ、サービスをコントローラーに入れます。」コードベース全体ですべてがどうつながっているかを理解するために行く場所が一つあります。これは新しいエンジニアをオンボーディングするとき、または材料を交換する必要があるときに非常に貴重な財産です。

テストでは、テスト固有のコンポジションルートを書きます――本物のアダプターの代わりにフェイクを配線する小さな同等物:

// テストファイルで — 同じサービス、フェイクの協調者
const fakePayments = new InMemoryPaymentGateway()
const fakeOrders   = new InMemoryOrderRepository()
const fakeMailer   = new NoOpMailer()

const svc = new OrderService(fakePayments, fakeOrders, fakeMailer)
// ミリ秒で実行;ネットワークなし、データベースなし

手動DIとDIコンテナ――何が変わり、いつそれが重要か

上記のすべては手動DI、またの名をPure DIです。配線コードを自分で書きます。多くのプロジェクト――特に小さなもの――では、これが正しい選択です:プレーンなコードで、魔法がなく、すぐにデバッグ可能で、新しい開発者が一つのファイルを読むだけで配線全体を理解できます。

アプリケーションが成長するにつれ、手動アプローチは摩擦を生じさせます。DIコンテナ(IoCコンテナと呼ばれることもある)は依存の登録と解決を自動化し、その上にライフタイム管理を追加します。

制御逆転の図:修正前(クラスが自分の依存に手を伸ばす)と修正後(コンポジションルートが依存を内向きに注入する)の比較。 BEFORE — class reaches out OrderService calls new() internally PostgresClient Stripe SDK SendGrid SDK deps created inside the class — untestable AFTER — composition root injects in Composition Root calls new() — only here OrderService accepts interfaces only Postgres Stripe deps flow IN — easy to swap, easy to test
左側では OrderService外向きに手を伸ばして自分の依存を構築しています――各矢印はクラス本体に焼き付けられたカップリングです。右側では、コンポジションルートが具体的なオブジェクトを構築して内向きに注入しています。矢印が逆転します。サービスはインターフェースについてだけ知っています;具体的な型は端の詳細です。
質問手動 / Pure DIDI コンテナ(NestJS・InversifyJS・tsyringe…)
型はどうやって登録する? コンポジションルートで明示的に配線コードを書く。 デコレーター・トークン・またはメタデータ――コンテナがスキャンして自動登録。
インスタンスはどうやって解決する? 正しい順序で自分で new を呼ぶ。 型をコンテナに要求すると、完全なグラフを再帰的に構築する。
ライフタイム / スコープ管理 自分で決める:モジュールごと・リクエストごと・またはトランジェント――参照を保持する。 宣言的:@Singleton()@RequestScoped() など。コンテナが廃棄を処理。
デバッグ性 すべてがプレーンなコード――どこでもブレークポイントを設置し、コールスタックを追える。 解決はコンテナ内部で起きる;「なぜこのインスタンスを得たのか?」はコンテナの内部を理解する必要がある。
ボイラープレート 大きなグラフでは高い――50クラスのアプリを手動で配線するのは冗長。 低い――コンテナが依存ツリーを歩いてすべてを自動的に構築する。
ビルド時の安全性 TypeScript がコンパイル時に不足しているコンストラクター引数を検出する。 コンテナによる;トークンベースのシステムはバインディングが不足している場合、実行時に失敗することがある。
最適なケース 小〜中規模アプリ・DI初心者チーム・焦点を絞ったスコープのマイクロサービス。 多くのサービスを持つ大規模アプリ・設定より規約を望むチーム・DIが中心の NestJS のようなフレームワーク。

コンテナが見返りを得るしきい値は様々ですが、大まかな目安として:コンポジションルート自体がメンテナンスの負担になり始めた感じ――常にスクロールしていて、新しいバインディングを追加し忘れたり、オブジェクトのライフタイムと格闘したりしている――それがコンテナに手を伸ばすシグナルです。それまでは、プレーンなバージョンの方が理解しやすくデバッグしやすいです。

テストの恩恵:フェイクを注入して高速テストを得る

DI を学ぶ価値がある理由は、アーキテクチャの優雅さよりも、テストスイートにもたらすものです。すべての依存がコンストラクターを通じて届くとき、テストを書くことはフェイクを使った小さなコンポジションルートを書くことを意味します。データベースなし・ネットワークなし・ベンダーサンドボックスなし。テストはミリ秒で実行され、ステージング Stripe アカウントのレートリミットが理由で失敗することはありません。

// order-service.test.ts
class InMemoryPaymentGateway implements PaymentGateway {
  receipts: Receipt[] = []
  async charge(amount: Money, token: string): Promise<Receipt> {
    const r = { id: 'fake-receipt', amount }
    this.receipts.push(r)
    return r
  }
}

class InMemoryOrderRepository implements OrderRepository {
  store: Order[] = []
  async save(order: Order) { this.store.push(order) }
}

class SpyMailer implements Mailer {
  sent: string[] = []
  async send(to: string) { this.sent.push(to) }
}

// テスト自体は純粋で、高速で、決定論的:
it('注文を確認して顧客に通知する', async () => {
  const payments = new InMemoryPaymentGateway()
  const repo     = new InMemoryOrderRepository()
  const mailer   = new SpyMailer()

  const svc   = new OrderService(payments, repo, mailer)
  const order = await svc.placeOrder(testCart)

  expect(payments.receipts).toHaveLength(1)
  expect(repo.store).toContainEqual(order)
  expect(mailer.sent).toContain(testCart.email)
})

これは Ports & Adapters の中心にある洞察と同じです――インメモリのフェイクはテスト時のアダプターにすぎず、本物の Stripe または Postgres の実装は本番アダプターです。DIはスワップを可能にするメカニズムです;Ports & Adapters はバウンダリーを意図的で明示的にするアーキテクチャパターンです。

ここで SOLID についても触れる価値があります。「D」は依存性逆転の原則を意味します:高レベルモジュールは低レベルモジュールに依存すべきではなく、両方が抽象に依存すべきです。上記のインターフェース(PaymentGatewayOrderRepositoryMailer)がその抽象です。OrderService が高レベルモジュールです。StripePaymentGatewayPostgresOrderRepository が低レベルモジュールです。どちらも相手のことを直接知りません――インターフェースで出会い、コンポジションルートがそれらを組み合わせます。

アンチパターン:DIが間違った方向に行く方法

アイデアはシンプルですが、それを損なうよく踏まれた方法がいくつかあります。

Service Locator

Service Locator は、コードが実行時に依存を取得するために呼び出すグローバルレジストリです:

// アンチパターン:Service Locator — DIのように見えるがそうではない
class OrderService {
  async placeOrder(cart: Cart) {
    const payments = ServiceLocator.get<PaymentGateway>('PaymentGateway')
    const repo     = ServiceLocator.get<OrderRepository>('OrderRepository')
    // … 続く
  }
}

これはインラインの new Stripe() よりクリーンに感じられますが、同じ問題を隠しています。依存はシグネチャに見えません。テストは実行前にグローバルレジストリを設定しなければなりません。クラスはいつでも何でも要求できます――DIを価値あるものにする明示性を失っています。

過剰なインジェクション(10引数コンストラクター)

コンストラクターが七つ・八つ・または十のパラメーターを蓄積するとき、それは通常、DIが間違ったツールではなく、クラスがやりすぎているサインです。修正はクラスをより小さく焦点を絞った協調者に分解することで、それぞれが短い依存リストを持ちます。コンストラクターの長さは有用な健全性の指標です。

隠れたグローバルコンテナ

サービス内から直接DIコンテナにアクセスすること――コンポジションルートでのみではなく――は、別の名前のService Locatorパターンです。ルールは厳格です:コンポジションルートだけがコンテナと話します。サービスは注入されたインターフェースとだけ話します。

追跡できない魔法

一部のフレームワークはデコレーターとリフレクトメタデータを通じて依存を配線しますが、何かが壊れたときに本当に追いかけにくい方法でそれを行います。コンテナのドキュメントを読む時間が自分のビジネスロジックを読む時間より多ければ、それよりシンプルなアプローチを使うサインです――あるいは少なくとも主要なバインディングを明示的に書き出したよくドキュメント化されたコンポジションルートを追加すること。

DIの適切なサイズ:ソロプロジェクトからエンタープライズまで

ほとんどのアーキテクチャのアイデアと同様に、DIは問題に合わせてスケールします。目標は常に同じ――テストしやすく変更しやすいコード――ですが、そこに到達するための適切な仕組みは、チームとコードベースの成長に合わせて変わります。

会社のステージ推奨アプローチ典型的な形
ソロ / 初期スタートアップ 単一のコンポジションルートファイルを使った手動DI。 一つの buildApp() 関数;おそらく10〜20行。書くのが速く、読みやすい。
小規模チーム(5〜20人のエンジニア) まだ手動DI。おそらくドメインモジュールに分割し、各モジュールが独自の配線関数を持つ。 buildOrderModule()buildBillingModule()――それぞれ自己完結し、起動時に一度組み合わせる。
成長中のスケールアップ(20〜100人のエンジニア) 軽量なコンテナが見返りを得始める。tsyringeInversifyJS・またはフレームワーク組み込みのコンテナを検討。 クラスのデコレーター、モジュールごとのコンテナ設定;Webアプリのリクエストスコープサービス。
エンタープライズ / 大規模プラットフォーム フルDIフレームワークがほぼ必須。NestJS・Spring Boot・または ASP.NET Core はこのアイデアをスケールの中心に据えて構築されている。 設定より規約、ライフサイクルフック、モジュールのインポート/エクスポート、マルチテナントスコーピング。

フィンテックスタートアップは三つのサービスを配線する30行のコンポジションルートから始めるかもしれません。二年後、同じコードベースに60のサービスがあります:手動配線がそれ自体メンテナンスの作業になり、チームはグラフを自動的に処理するために NestJS か InversifyJS を使うようになります。基盤となるパターン――コンストラクターインジェクション・インターフェース・明確なコンポジションルート――が同じままであるため、スイッチは簡単です。コンテナは機械的なアシスタントであり、異なるアイデアではありません。

実践的なアドバイス

手動DIとコンポジションルートから始めましょう。配線ファイルが負担になったらいつでもコンテナを導入でき、移行は機械的です:明示的な new 呼び出しを登録に置き換えるだけです。ドメイン・サービス・テストの何も変える必要はありません――コンポジションルートだけが変わります。

まとめ

  • 根本的な原因は、クラスが自分の依存に対して new を呼ぶことです。修正は外から渡すことです。
  • Inversion of Control は原則(依存を提供することに責任を持つのは他の誰か)。Dependency Injection はそれを実装する最も明示的でテストしやすい方法です。
  • コンストラクターインジェクションがデフォルトの選択です。依存はシグネチャに見え、構築時に必要とされ、テストで簡単に交換できます。
  • コンポジションルートは、コードベースで本物のオブジェクトが配線される唯一の場所です。他のすべてはインターフェースを通じて話します。
  • 手動DIは小さなコードベースにとってよりシンプルでデバッグしやすいです。DIコンテナはコンポジションルート自体がメンテナンスの負担になったときに見返りを得ます。
  • テストの恩恵は即座です:テストでフェイクを注入し、ネットワークとデータベースをスキップし、何千ものケースを秒単位で実行します。
  • これはまた SOLIDの 依存性逆転の原則(「D」)の実践でもあります:高レベルモジュールと低レベルモジュールは両方が抽象に依存し、互いに直接依存しません。
  • Service Locator(隠れたグローバルレジストリ)・過剰なインジェクション(10引数コンストラクター)・コンポジションルートの外でのコンテナアクセスを避けましょう。

依存がどのように流れるかをしっかり理解したうえで、次の自然な質問はファイル自体をどう整理するかです。このシリーズの次のパートでまさにそれを見ていきます:コードベースの構成:フィーチャーフォルダー対レイヤー――その答えが、コードの形と同じくらいチームの形に依存する理由です。