Spring FrameworkにおけるDIのベストプラクティス: コンストラクタインジェクションの優位性

Spring Frameworkでの依存性注入には、

  • フィールドインジェクション
  • コンストラクタインジェクション
  • セッターインジェクション

の3つの方法があります。これらの方法は、オブジェクトが他のオブジェクトに依存している場合に、その依存オブジェクトを自動的に挿入するために使用されます。この中で主に使われるのはフィールドインジェクションとコンストラクタインジェクションです。

Springのコミュニティでは、フィールドインジェクションよりもコンストラクタインジェクションの方が推奨されています。なぜコンストラクタインジェクションが推奨されているのかをフィールドインジェクションのデメリットとコンストラクタインジェクションのメリットの観点から見ていきましょう。

フィールドインジェクション

フィールドインジェクションは、クラスのフィールドに直接依存性を注入する方法です。これは@Autowiredアノテーションをフィールドに適用することで行われます。例えば、

@Component
public class MyService {
    @Autowired
    private DependencyClass dependency;
}

のように書きます。

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

コンストラクタインジェクションは、オブジェクトのコンストラクタを通じて依存性を注入する方法です。コンストラクタに@Autowiredアノテーションを付けることで、Springが自動的に依存オブジェクトを挿入します。例えば、

@Component
public class MyService {
    private final DependencyClass dependency;

    @Autowired
    public MyService(DependencyClass dependency) {
        this.dependency = dependency;
    }
}

のように書きます。

コンストラクタインジェクションが推奨される理由

Spring Framework でコンストラクタインジェクションが推奨される理由のうち、特に理由としてふさわしいと思ったものには以下が2つがあります。

依存コンポーネントの不変性を宣言できる

前述のコード例を見てもらうとわかりますが、フィールドインジェクションではオブジェクト生成時点でフィールドの値が初期化されないため、final宣言をつけることができません。一方、コンストラクタインジェクションではオブジェクト生成時点でフィールドの値が初期化されるため、final宣言をつけることができます。

これにより依存コンポーネントがイミュータブル(不変)であることを明示することができるようになります。

実際にイミュータブルであることで誤って再初期化してしまうことを防ぐことはほとんどないと思いますが、実際に変更してしまうかどうかは別として、変更するつもりがないという設計意図をコードに表現できることが重要だと考えています。

循環依存を回避できる

コンストラクタインジェクションを使用するとコンストラクタの呼び出し時点でインジェクションされてイミュータブルになります。このとき循環依存があると、アプリケーションの起動時にエラーが発生して循環依存を検知することができます。

一方、フィールドインジェクションの場合は実際に対象のフィールドが呼び出されるまで検知できないため、循環依存の発見が遅れたり、発見自体が困難になる場合があります。

単一責任の原則に従っていないことに気づきやすくなるのか

単一責任の原則に従っていないことに気づきやすくなるというメリットを挙げているのを見かけますが、個人的にはそれほど効果がないのではないかと思っています。

まったくないとは言いませんが、Lombokを使用していると、ほぼフィールドインジェクションのような使い心地でコンストラクタインジェクションを使えてしまうので、レビューで検出する方が確実だと思います。例えば、

@Component
@RequiredArgsConstructor
public class MyService {
    private final DependencyClass dependency;
}

のように@RequiredArgsConstructorアノテーションを使用することで、コンストラクタを省略することが可能です。これ自体はボイラープレートコードを書かなくて済むというメリットがありますが、コンストラクタ自体を書かないため、依存コンポーネントが多すぎることに気づきにくいと思います。

コンストラクタインジェクションに頼るのではなく、きちんとレビュー前のセルフチェックやレビューで依存関係の複雑さや単一責任の原則に従っているのかなどをチェックすることが重要だと考えています。

単体テストコードを書きやすくなるのか

元々はコンストラクタインジェクションの方が書きやすかったと思うのですが、現在ではほとんど変わらないと思いました。

フィールドインジェクションを使ったコンポーネントの例

フィールドインジェクションを使ったコンポーネントのテストを書く例として、次のようなサービスクラスに対するテストケースを考えてみましょう。

package com.example.demo;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class SampleService {

    @Autowired
    private DummyComponent dummyComponent;

    public String doSomething() {
        return dummyComponent.doSomething();
    }
}

このサービスクラスに対するテストは以下のようになります。

package com.example.demo;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class SampleServiceTest {

    @InjectMocks
    private SampleService sampleService;

    @Mock
    private DummyComponent dummyComponent;

    @Test
    public void testDoSomething() {
        when(dummyComponent.doSomething()).thenReturn("test");

        assertEquals("test", sampleService.doSomething());
    }
}

コンストラクションインジェクションを使ったコンポーネントの例

コンストラクタインジェクションを使ったコンポーネントの例も見てみましょう。同じサービスクラスをコンストラクタインジェクションで書き換えた場合、以下のようになります。

package com.example.demo;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class SampleService {

    private final DummyComponent dummyComponent;

    @Autowired
    public SampleService(DummyComponent dummyComponent) {
        this.dummyComponent = dummyComponent;
    }

    public String doSomething() {
        return dummyComponent.doSomething();
    }
}

このサービスクラスに対するテストは以下のようになります。

package com.example.demo;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class SampleServiceTest {

    @InjectMocks
    private SampleService sampleService;

    @Mock
    private DummyComponent dummyComponent;

    @Test
    public void testDoSomething() {
        when(dummyComponent.doSomething()).thenReturn("test");

        assertEquals("test", sampleService.doSomething());
    }
}

見ての通り同じテストケースクラスでテストできます。

現状ではテストのしやすさという点ではその差はほぼないと考えて差し支えないと思います。

まとめ

依存コンポーネントに対して不変性を明示でき、循環依存を回避できるようになる点を考慮すると、コンストラクタインジェクションを積極的に使用していくことは重要だと思います。

一方で、単一責任の原則に従っているかどうか、依存関係の多いファットなコンポーネントになっていないかどうかについては、ペアプログラミング時に議論したり、セルフチェックやレビューによってチェックすることが重要だと思います。

TypeScriptでJestを使ったテスト環境を構築する

TypeScriptでJestを使ったテスト環境を構築します。

パッケージのインストール

必要なパッケージをインストールします。

$ npm install -D jest ts-jest @types/jest ts-node

ts-nodeはTypeScriptの環境構築の過程ですでインストールしている場合があります。インストールしていない場合はts-nodeもインストールしてください。

設定ファイルを作成する

設定ファイルを作成します。本手順でインストールしたバージョンは29.3.1ですが、バージョンによって挙動や質問の内容が変更になっている場合があるのでご注意ください。

$ npx jest --init

The following questions will help Jest to create a suitable configuration for your project

✔ Would you like to use Jest when running "test" script in "package.json"? … yes
✔ Would you like to use Typescript for the configuration file? … yes
✔ Choose the test environment that will be used for testing › node
✔ Do you want Jest to add coverage reports? … yes
✔ Which provider should be used to instrument code for coverage? › v8
✔ Automatically clear mock calls, instances, contexts and results before every test? … yes

✏️  Modified /Users/t0k0sh1/Workspace/honeycomb/packages/cli/package.json

📝  Configuration file created at /Users/t0k0sh1/Workspace/honeycomb/packages/cli/jest.config.ts

ここでは、TypeScriptで設定ファイルを書く設定(2つ目の質問)にしています。ただ、実際に設定ファイルを出力してみると、JavaScriptで書かれた設定ファイルjest.config.jsも出力されました。これが正しい挙動かは不明ですが、複数の設定ファイルがあるとjestコマンドが実行できないため、以下のいずれかの対応を行なってください。

  • TypeScriptで設定ファイルを定義したい場合はjest.config.jsjest.config.js.mapを削除する
  • JavaScriptで設定ファイルを定義したい場合は2つ目の質問でnoを選択する

設定ファイルの出力後、jest.config.tsに1箇所修正を加えます。

export default {
  // A map from regular expressions to paths to transformers
- // transform: undefined,
+ transform: {
+   "^.+\\.(ts|tsx)$": "ts-jest",
+ },
}

この設定はテストコードを書いた時にimport/exportが使用できるようにするための設定です。

テストコードを書く

実際にテストコードを書いてみましょう。

まずはテスト対象のコードです。

export function greet(name: string): string {
  return `Hello ${name}`;
}

次にテストコードです。前述で設定を行なっているため、テストコード内でimportが使用可能です。

import { greet } from "./greeting";

test("Greeting is", () => {
  expect(greet("John")).toBe("Hello John");
});

テストを実行すると以下のようになります。

$ npm run test

> example@.1.0 test
> jest

 PASS  src/greeting.spec.ts
 PASS  dist/greeting.spec.js
-------------|---------|----------|---------|---------|-------------------
File         | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s 
-------------|---------|----------|---------|---------|-------------------
All files    |     100 |      100 |     100 |     100 |                   
 greeting.ts |     100 |      100 |     100 |     100 |                   
-------------|---------|----------|---------|---------|-------------------

Test Suites: 2 passed, 2 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        0.866 s, estimated 1 s
Ran all test suites.

モバイルバージョンを終了