はじめに

「このクラスは外部APIに依存していてテストが書けない」「データベース接続がないと単体テストが実行できない」という課題に直面したことはないでしょうか。これらの問題の根本原因は、多くの場合依存関係のハードコーディングにあります。

依存性注入(Dependency Injection、以下DI)は、こうした問題を解決し、テスタブルで柔軟な設計を実現するための設計パターンです。Martin Fowlerが2004年に発表した記事「Inversion of Control Containers and the Dependency Injection pattern」で体系化され、現在では多くのフレームワーク(Spring、Angular、NestJSなど)の基盤となっています。

本記事では、DIの基本概念から実践的なパターン、そしてTDD(テスト駆動開発)との関係性まで、体系的に解説します。この記事を読み終える頃には、テスタブルなコード設計の原則を理解し、実務で活用できるようになっているでしょう。

依存性注入とは

依存性注入とは、オブジェクトが必要とする依存オブジェクト(サービス)を、オブジェクト自身が生成するのではなく、外部から渡してもらう設計パターンです。

DIなしのコード(密結合)

まず、DIを使わない典型的なコードを見てみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// userService.js - DIなし(密結合)
class UserService {
  constructor() {
    // 依存関係がハードコーディングされている
    this.repository = new UserRepository()
    this.emailClient = new EmailClient()
    this.logger = new ConsoleLogger()
  }

  async createUser(userData) {
    const user = await this.repository.save(userData)
    await this.emailClient.sendWelcomeEmail(user.email)
    this.logger.info(`User created: ${user.id}`)
    return user
  }
}

このコードには以下の問題があります。

問題 影響
テスト困難 実際のDB接続やメール送信なしにテストできない
柔軟性の欠如 別のリポジトリ実装(インメモリなど)に差し替えられない
単一責任の違反 UserServiceが依存オブジェクトの生成責任も負っている
再利用性の低下 異なる環境(本番/テスト/開発)で構成を変更できない

DIありのコード(疎結合)

DIを適用したコードを見てみましょう。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// userService.js - DIあり(疎結合)
class UserService {
  constructor(repository, emailClient, logger) {
    // 依存関係は外部から注入される
    this.repository = repository
    this.emailClient = emailClient
    this.logger = logger
  }

  async createUser(userData) {
    const user = await this.repository.save(userData)
    await this.emailClient.sendWelcomeEmail(user.email)
    this.logger.info(`User created: ${user.id}`)
    return user
  }
}

依存関係を外部から注入することで、以下のメリットが得られます。

  • テスト容易: モックやスタブを注入してテスト可能
  • 柔軟性向上: 実装を差し替えるだけで振る舞いを変更可能
  • 責務の分離: オブジェクト生成と利用が分離される
  • 再利用性向上: 異なる環境で同じコードを再利用可能

DIの4つの登場人物

DIパターンを理解するには、4つの登場人物を把握することが重要です。

flowchart TB
    subgraph roles["DIの4つの登場人物"]
        Service["サービス<br/>(依存される側)"]
        Client["クライアント<br/>(依存する側)"]
        Interface["インターフェース<br/>(契約)"]
        Injector["インジェクター<br/>(組み立て役)"]
    end
    
    Injector -->|"依存関係を注入"| Client
    Injector -->|"サービスを生成"| Service
    Client -->|"インターフェースを通じて利用"| Interface
    Service -->|"インターフェースを実装"| Interface
    
    style Service fill:#e8f5e9,stroke:#2e7d32,color:#000000
    style Client fill:#e3f2fd,stroke:#1565c0,color:#000000
    style Interface fill:#fff3e0,stroke:#e65100,color:#000000
    style Injector fill:#f3e5f5,stroke:#7b1fa2,color:#000000
役割 説明
サービス 有用な機能を提供するオブジェクト UserRepository, EmailClient
クライアント サービスを利用するオブジェクト UserService
インターフェース サービスとクライアント間の契約 IUserRepository, IEmailClient
インジェクター 依存関係を組み立てる役割 DIコンテナ, main関数

5歳児にもわかるDI

DIの概念を、有名なたとえ話で説明します。

冷蔵庫から自分で飲み物を取ってくると、ドアを開けっぱなしにしたり、パパやママが飲んでほしくないものを取ったり、そもそもないものや賞味期限切れのものを探し続けたりするかもしれません。

本当にすべきことは「お昼ご飯と一緒に何か飲みたい」と言うこと。そうすれば、食卓についたときに必要なものが用意されています。

— John Munsch, 2009年10月28日

この例えでは、子供(クライアント)が冷蔵庫(サービス)を直接操作するのではなく、親(インジェクター)に必要なものを伝え、親が適切なものを用意してくれます。

依存性注入の3つのパターン

DIには主に3つの注入パターンがあります。それぞれの特徴と使い分けを解説します。

コンストラクタインジェクション

最も推奨されるパターンです。コンストラクタの引数を通じて依存関係を注入します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// Java - コンストラクタインジェクション
public class UserService {
    private final UserRepository repository;
    private final EmailClient emailClient;
    
    // コンストラクタで依存関係を受け取る
    public UserService(UserRepository repository, EmailClient emailClient) {
        if (repository == null || emailClient == null) {
            throw new IllegalArgumentException("Dependencies must not be null");
        }
        this.repository = repository;
        this.emailClient = emailClient;
    }
    
    public User createUser(UserData userData) {
        User user = repository.save(userData);
        emailClient.sendWelcomeEmail(user.getEmail());
        return user;
    }
}

メリット:

  • オブジェクト生成時に必ず依存関係が揃う(不変条件の保証)
  • finalフィールドにできる(イミュータブル設計)
  • 依存関係が明示的でわかりやすい

デメリット:

  • 依存関係が多いとコンストラクタが肥大化する
  • 循環依存があると使えない

セッターインジェクション

setterメソッドを通じて依存関係を注入するパターンです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Java - セッターインジェクション
public class UserService {
    private UserRepository repository;
    private EmailClient emailClient;
    
    // setterで依存関係を受け取る
    public void setRepository(UserRepository repository) {
        if (repository == null) {
            throw new IllegalArgumentException("Repository must not be null");
        }
        this.repository = repository;
    }
    
    public void setEmailClient(EmailClient emailClient) {
        if (emailClient == null) {
            throw new IllegalArgumentException("EmailClient must not be null");
        }
        this.emailClient = emailClient;
    }
    
    public User createUser(UserData userData) {
        User user = repository.save(userData);
        emailClient.sendWelcomeEmail(user.getEmail());
        return user;
    }
}

メリット:

  • オプショナルな依存関係に適している
  • 生成後に依存関係を変更可能

デメリット:

  • 未設定のままメソッドが呼ばれる可能性がある
  • オブジェクトが不完全な状態で存在しうる

メソッドインジェクション

特定のメソッド呼び出し時にのみ依存関係を渡すパターンです。

1
2
3
4
5
6
7
8
9
// Java - メソッドインジェクション
public class ReportGenerator {
    
    // メソッドの引数として依存関係を受け取る
    public Report generate(ReportData data, Formatter formatter) {
        String content = formatter.format(data);
        return new Report(content);
    }
}

メリット:

  • 一時的な依存関係に適している
  • 呼び出しごとに異なる実装を使える

デメリット:

  • 毎回依存関係を渡す必要がある
  • 呼び出し側の負担が増える

パターンの使い分け

flowchart TD
    Q1{"依存関係は<br/>必須か?"}
    Q2{"オブジェクトの<br/>ライフサイクル全体で<br/>使用するか?"}
    Q3{"呼び出しごとに<br/>異なる実装が<br/>必要か?"}
    
    Q1 -->|"はい"| Q2
    Q1 -->|"いいえ"| Setter["セッター<br/>インジェクション"]
    
    Q2 -->|"はい"| Constructor["コンストラクタ<br/>インジェクション"]
    Q2 -->|"いいえ"| Q3
    
    Q3 -->|"はい"| Method["メソッド<br/>インジェクション"]
    Q3 -->|"いいえ"| Constructor
    
    style Constructor fill:#e8f5e9,stroke:#2e7d32,color:#000000
    style Setter fill:#fff3e0,stroke:#e65100,color:#000000
    style Method fill:#e3f2fd,stroke:#1565c0,color:#000000
パターン 推奨シーン
コンストラクタ 必須の依存関係(推奨デフォルト)
セッター オプショナルな依存関係、循環依存の回避
メソッド 一時的な依存関係、呼び出しごとに変わる実装

DIとテスタビリティ

DIの最大のメリットはテスタビリティの向上です。依存関係を外部から注入できることで、テスト用のモックやスタブに簡単に差し替えられます。

DIなしのコードをテストする困難さ

DIを使わないコードのテストには、多くの問題があります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 問題のあるコード - DIなし
class OrderService {
  constructor() {
    this.paymentGateway = new StripePaymentGateway() // 実際のStripeに接続
    this.inventory = new DatabaseInventory()         // 実際のDBに接続
    this.notifier = new SlackNotifier()              // 実際のSlackに送信
  }

  async placeOrder(order) {
    const stock = await this.inventory.checkStock(order.productId)
    if (stock < order.quantity) {
      throw new Error('在庫不足')
    }
    
    const payment = await this.paymentGateway.charge(order.customerId, order.amount)
    await this.inventory.decreaseStock(order.productId, order.quantity)
    await this.notifier.sendOrderConfirmation(order)
    
    return { orderId: payment.transactionId }
  }
}

// このコードをテストするには...
// - 実際のStripe APIキーが必要
// - テスト用のデータベースが必要
// - テストのたびにSlackに通知が飛ぶ
// - テストが遅く、不安定になる

DIを使ったテスタブルなコード

DIを適用すると、テストが劇的に簡単になります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// テスタブルなコード - DIあり
class OrderService {
  constructor(paymentGateway, inventory, notifier) {
    this.paymentGateway = paymentGateway
    this.inventory = inventory
    this.notifier = notifier
  }

  async placeOrder(order) {
    const stock = await this.inventory.checkStock(order.productId)
    if (stock < order.quantity) {
      throw new Error('在庫不足')
    }
    
    const payment = await this.paymentGateway.charge(order.customerId, order.amount)
    await this.inventory.decreaseStock(order.productId, order.quantity)
    await this.notifier.sendOrderConfirmation(order)
    
    return { orderId: payment.transactionId }
  }
}

テストコードは以下のように書けます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// orderService.test.js
describe('OrderService', () => {
  let orderService
  let mockPaymentGateway
  let mockInventory
  let mockNotifier

  beforeEach(() => {
    // スタブを作成
    mockPaymentGateway = {
      charge: jest.fn().mockResolvedValue({ transactionId: 'txn_123' })
    }
    mockInventory = {
      checkStock: jest.fn().mockResolvedValue(10),
      decreaseStock: jest.fn().mockResolvedValue(undefined)
    }
    mockNotifier = {
      sendOrderConfirmation: jest.fn().mockResolvedValue(undefined)
    }

    // スタブを注入
    orderService = new OrderService(mockPaymentGateway, mockInventory, mockNotifier)
  })

  it('在庫があれば注文を処理できる', async () => {
    const order = { productId: 'prod_1', quantity: 2, customerId: 'cust_1', amount: 1000 }

    const result = await orderService.placeOrder(order)

    expect(result.orderId).toBe('txn_123')
    expect(mockPaymentGateway.charge).toHaveBeenCalledWith('cust_1', 1000)
    expect(mockInventory.decreaseStock).toHaveBeenCalledWith('prod_1', 2)
    expect(mockNotifier.sendOrderConfirmation).toHaveBeenCalledWith(order)
  })

  it('在庫不足の場合はエラーをスローする', async () => {
    mockInventory.checkStock.mockResolvedValue(0) // 在庫なし

    const order = { productId: 'prod_1', quantity: 2, customerId: 'cust_1', amount: 1000 }

    await expect(orderService.placeOrder(order)).rejects.toThrow('在庫不足')
    expect(mockPaymentGateway.charge).not.toHaveBeenCalled() // 決済は実行されない
  })
})

Javaでのテスト例(JUnit 5 + Mockito)

Javaでも同様のパターンでテストを書けます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// OrderServiceTest.java
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    private PaymentGateway paymentGateway;
    
    @Mock
    private Inventory inventory;
    
    @Mock
    private Notifier notifier;
    
    @InjectMocks
    private OrderService orderService;

    @Test
    @DisplayName("在庫があれば注文を処理できる")
    void placeOrder_WithSufficientStock_ProcessesOrder() {
        // Arrange
        Order order = new Order("prod_1", 2, "cust_1", 1000);
        when(inventory.checkStock("prod_1")).thenReturn(10);
        when(paymentGateway.charge("cust_1", 1000))
            .thenReturn(new PaymentResult("txn_123"));
        
        // Act
        OrderResult result = orderService.placeOrder(order);
        
        // Assert
        assertThat(result.getOrderId()).isEqualTo("txn_123");
        verify(inventory).decreaseStock("prod_1", 2);
        verify(notifier).sendOrderConfirmation(order);
    }

    @Test
    @DisplayName("在庫不足の場合はInsufficientStockExceptionをスローする")
    void placeOrder_WithInsufficientStock_ThrowsException() {
        // Arrange
        Order order = new Order("prod_1", 2, "cust_1", 1000);
        when(inventory.checkStock("prod_1")).thenReturn(0);
        
        // Act & Assert
        assertThatThrownBy(() -> orderService.placeOrder(order))
            .isInstanceOf(InsufficientStockException.class)
            .hasMessage("在庫不足");
        
        verify(paymentGateway, never()).charge(anyString(), anyInt());
    }
}

テストダブルとDIの組み合わせ

DIを活用することで、様々なテストダブルを適切に使い分けられます。

flowchart LR
    subgraph production["本番環境"]
        RealRepo["PostgreSQL<br/>Repository"]
        RealEmail["SendGrid<br/>EmailClient"]
        RealLogger["CloudWatch<br/>Logger"]
    end
    
    subgraph test["テスト環境"]
        FakeRepo["InMemory<br/>Repository"]
        StubEmail["Stub<br/>EmailClient"]
        SpyLogger["Spy<br/>Logger"]
    end
    
    DI["DIコンテナ"]
    Service["UserService"]
    
    DI -->|"本番設定"| production
    DI -->|"テスト設定"| test
    production --> Service
    test --> Service
    
    style DI fill:#f3e5f5,stroke:#7b1fa2,color:#000000
    style Service fill:#e3f2fd,stroke:#1565c0,color:#000000

テストダブルの種類と使い分け

種類 目的 DIでの活用例
ダミー 引数を埋めるだけ 使用しないロガーを渡す
スタブ 固定値を返す 常に成功するPaymentGateway
スパイ 呼び出しを記録 通知が送信されたか検証
モック 期待する呼び出しを検証 特定の引数で呼ばれたか確認
フェイク 簡易版の実装 インメモリDB

詳細なテストダブルの使い方については、モック・スタブの使い方完全ガイドを参照してください。

DIとTDDのワークフロー

DIはTDD(テスト駆動開発)と非常に相性が良いパターンです。TDDのRed-Green-Refactorサイクルにおいて、DIがどのように活用されるかを見ていきましょう。

TDD × DIのワークフロー

flowchart TB
    subgraph red["Red: 失敗するテストを書く"]
        R1["1. インターフェースを定義"]
        R2["2. モック/スタブを作成"]
        R3["3. テストを書く(失敗)"]
        R1 --> R2 --> R3
    end
    
    subgraph green["Green: テストを通す"]
        G1["4. クラスをDI対応で実装"]
        G2["5. コンストラクタで依存を受け取る"]
        G3["6. 最小限のコードで通す"]
        G1 --> G2 --> G3
    end
    
    subgraph refactor["Refactor: 改善する"]
        RF1["7. 重複を排除"]
        RF2["8. 命名を改善"]
        RF3["9. 設計を洗練"]
        RF1 --> RF2 --> RF3
    end
    
    red --> green --> refactor --> red
    
    style red fill:#ffebee,stroke:#c62828,color:#000000
    style green fill:#e8f5e9,stroke:#2e7d32,color:#000000
    style refactor fill:#e3f2fd,stroke:#1565c0,color:#000000

実践例: TDDでユーザー登録機能を実装

TDDとDIを組み合わせて、ユーザー登録機能を実装してみましょう。

Step 1: Red - 失敗するテストを書く

まず、インターフェースを定義し、テストを書きます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// interfaces.js - インターフェースの定義(型の契約)
/**
 * @typedef {Object} IUserRepository
 * @property {function(Object): Promise<Object>} save
 * @property {function(string): Promise<Object|null>} findByEmail
 */

/**
 * @typedef {Object} IPasswordHasher
 * @property {function(string): Promise<string>} hash
 */

/**
 * @typedef {Object} IEmailValidator
 * @property {function(string): boolean} isValid
 */
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// userRegistrationService.test.js
describe('UserRegistrationService', () => {
  let service
  let mockRepository
  let mockPasswordHasher
  let mockEmailValidator

  beforeEach(() => {
    // スタブを準備
    mockRepository = {
      save: jest.fn(),
      findByEmail: jest.fn()
    }
    mockPasswordHasher = {
      hash: jest.fn()
    }
    mockEmailValidator = {
      isValid: jest.fn()
    }

    service = new UserRegistrationService(
      mockRepository,
      mockPasswordHasher,
      mockEmailValidator
    )
  })

  describe('register', () => {
    it('有効なデータで新規ユーザーを登録できる', async () => {
      // Arrange
      mockEmailValidator.isValid.mockReturnValue(true)
      mockRepository.findByEmail.mockResolvedValue(null) // 既存ユーザーなし
      mockPasswordHasher.hash.mockResolvedValue('hashed_password')
      mockRepository.save.mockResolvedValue({ id: 'user_1', email: 'test@example.com' })

      // Act
      const result = await service.register({
        email: 'test@example.com',
        password: 'SecurePass123!'
      })

      // Assert
      expect(result.id).toBe('user_1')
      expect(mockPasswordHasher.hash).toHaveBeenCalledWith('SecurePass123!')
      expect(mockRepository.save).toHaveBeenCalledWith({
        email: 'test@example.com',
        passwordHash: 'hashed_password'
      })
    })

    it('無効なメールアドレスの場合はエラーをスローする', async () => {
      mockEmailValidator.isValid.mockReturnValue(false)

      await expect(service.register({
        email: 'invalid-email',
        password: 'SecurePass123!'
      })).rejects.toThrow('Invalid email address')
    })

    it('既に登録済みのメールアドレスの場合はエラーをスローする', async () => {
      mockEmailValidator.isValid.mockReturnValue(true)
      mockRepository.findByEmail.mockResolvedValue({ id: 'existing_user' })

      await expect(service.register({
        email: 'existing@example.com',
        password: 'SecurePass123!'
      })).rejects.toThrow('Email already registered')
    })
  })
})

この時点ではテストは失敗します(UserRegistrationServiceが存在しないため)。

Step 2: Green - テストを通す最小限のコード

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// userRegistrationService.js
class UserRegistrationService {
  constructor(repository, passwordHasher, emailValidator) {
    this.repository = repository
    this.passwordHasher = passwordHasher
    this.emailValidator = emailValidator
  }

  async register({ email, password }) {
    // メールアドレスの検証
    if (!this.emailValidator.isValid(email)) {
      throw new Error('Invalid email address')
    }

    // 既存ユーザーのチェック
    const existingUser = await this.repository.findByEmail(email)
    if (existingUser) {
      throw new Error('Email already registered')
    }

    // パスワードのハッシュ化と保存
    const passwordHash = await this.passwordHasher.hash(password)
    const user = await this.repository.save({ email, passwordHash })

    return user
  }
}

module.exports = { UserRegistrationService }

テストが通りました。

Step 3: Refactor - 改善

コードを見直し、必要に応じて改善します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// userRegistrationService.js - リファクタリング後
class UserRegistrationService {
  constructor(repository, passwordHasher, emailValidator) {
    this.repository = repository
    this.passwordHasher = passwordHasher
    this.emailValidator = emailValidator
  }

  async register({ email, password }) {
    await this.#validateEmail(email)
    await this.#ensureEmailNotRegistered(email)
    
    const passwordHash = await this.passwordHasher.hash(password)
    return this.repository.save({ email, passwordHash })
  }

  async #validateEmail(email) {
    if (!this.emailValidator.isValid(email)) {
      throw new Error('Invalid email address')
    }
  }

  async #ensureEmailNotRegistered(email) {
    const existingUser = await this.repository.findByEmail(email)
    if (existingUser) {
      throw new Error('Email already registered')
    }
  }
}

module.exports = { UserRegistrationService }

本番環境での組み立て

テストではモックを注入しますが、本番環境では実際の実装を注入する必要があります。

手動での組み立て(Composition Root)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// main.js - Composition Root(組み立て場所)
const { UserRegistrationService } = require('./userRegistrationService')
const { PostgresUserRepository } = require('./infrastructure/postgresUserRepository')
const { BcryptPasswordHasher } = require('./infrastructure/bcryptPasswordHasher')
const { RegexEmailValidator } = require('./infrastructure/regexEmailValidator')

// 本番用の依存関係を生成
const repository = new PostgresUserRepository(process.env.DATABASE_URL)
const passwordHasher = new BcryptPasswordHasher(12) // salt rounds
const emailValidator = new RegexEmailValidator()

// 依存関係を注入してサービスを生成
const userRegistrationService = new UserRegistrationService(
  repository,
  passwordHasher,
  emailValidator
)

// アプリケーションで使用
module.exports = { userRegistrationService }

DIコンテナの活用(Spring Framework)

大規模なアプリケーションでは、DIコンテナを使うと管理が容易になります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Spring Framework での設定
@Configuration
public class AppConfig {

    @Bean
    public UserRepository userRepository(DataSource dataSource) {
        return new JdbcUserRepository(dataSource);
    }

    @Bean
    public PasswordHasher passwordHasher() {
        return new BcryptPasswordHasher(12);
    }

    @Bean
    public EmailValidator emailValidator() {
        return new RegexEmailValidator();
    }

    @Bean
    public UserRegistrationService userRegistrationService(
            UserRepository userRepository,
            PasswordHasher passwordHasher,
            EmailValidator emailValidator) {
        return new UserRegistrationService(userRepository, passwordHasher, emailValidator);
    }
}

または、アノテーションベースで自動注入することもできます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// アノテーションベースの自動注入
@Service
public class UserRegistrationService {

    private final UserRepository userRepository;
    private final PasswordHasher passwordHasher;
    private final EmailValidator emailValidator;

    @Autowired // コンストラクタが1つの場合は省略可能
    public UserRegistrationService(
            UserRepository userRepository,
            PasswordHasher passwordHasher,
            EmailValidator emailValidator) {
        this.userRepository = userRepository;
        this.passwordHasher = passwordHasher;
        this.emailValidator = emailValidator;
    }
}

DIのアンチパターン

DIを正しく活用するために、避けるべきアンチパターンを紹介します。

Service Locatorパターンとの混同

Service Locatorは、DIの代替パターンですが、依存関係が暗黙的になるため、テスタビリティが低下します。

1
2
3
4
5
6
7
8
// アンチパターン: Service Locator
class UserService {
  constructor() {
    // 依存関係が隠れている
    this.repository = ServiceLocator.get('UserRepository')
    this.emailClient = ServiceLocator.get('EmailClient')
  }
}
1
2
3
4
5
6
7
8
// 推奨: コンストラクタインジェクション
class UserService {
  constructor(repository, emailClient) {
    // 依存関係が明示的
    this.repository = repository
    this.emailClient = emailClient
  }
}

過剰な依存関係

コンストラクタの引数が多すぎる場合は、クラスの責務が大きすぎる可能性があります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// アンチパターン: 過剰な依存関係(God Class)
public class OrderProcessor {
    public OrderProcessor(
        OrderRepository orderRepo,
        CustomerRepository customerRepo,
        ProductRepository productRepo,
        InventoryService inventory,
        PaymentGateway payment,
        ShippingService shipping,
        NotificationService notification,
        DiscountCalculator discount,
        TaxCalculator tax,
        AuditLogger audit) {
        // 10個の依存関係は多すぎる
    }
}

解決策は、責務を分割してファサードを作ることです。

1
2
3
4
5
6
7
8
9
// 改善: 責務を分割
public class OrderProcessor {
    public OrderProcessor(
        OrderValidationService validation,
        PaymentProcessingService payment,
        FulfillmentService fulfillment) {
        // 3つの高レベルなサービスに集約
    }
}

まとめ

本記事では、依存性注入(DI)の基本概念から実践パターン、TDDとの組み合わせ方法まで解説しました。

DIの重要ポイント

ポイント 説明
基本原則 依存関係は外部から注入し、オブジェクト自身が生成しない
推奨パターン コンストラクタインジェクションをデフォルトに
テスタビリティ モック・スタブを注入してテスト容易性を向上
TDDとの相性 Red-Green-Refactorサイクルと自然に統合

DIを適切に活用することで、以下のメリットが得られます。

  • テスタブルなコード: 外部依存を分離し、高速で安定したテストを実現
  • 柔軟な設計: 実装の差し替えが容易で、変更に強い
  • 明示的な依存関係: コードを読むだけで依存関係が把握できる
  • 責務の分離: オブジェクトの生成と利用が明確に分離される

TDDを実践する上で、DIは欠かせない設計パターンです。まずは小さなクラスからDIを適用し、テストを書く習慣を身につけていきましょう。

参考リンク