2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Spring Boot 4】ゼロから作るタスク管理API ②DIとIoCコンテナを「壊して」理解する

2
Last updated at Posted at 2026-10-15

note. (41).png

はじめに

Spring Bootをゼロから学びながら、「JWT認証付きのタスク管理REST API」を作っていくシリーズの2本目です。

# テーマ
① 環境構築編:プロジェクト作成、Hello World
② 核となる概念編:DI/IoCコンテナ、アノテーション駆動(この記事)
③ 最初のREST API編:タスクの基本CRUD(メモリ上)
④ DB連携編:Spring Data JPA、H2→PostgreSQL
⑤ 設定・プロファイル編:application.yamlで環境分離
⑥ テスト編:JUnit + MockMvc
⑦ 認証・認可編:ユーザー登録・ログイン・JWT
⑧ 総仕上げ編:ページネーション・絞り込み、Docker化

前回は GET /hello で Hello, Spring Boot! を返すところまで作りました。そのとき、ひとつ気になったことがあります。

HelloController を一度も new していないのに、なぜ動くのか?

今回はこの疑問の答えである DI(依存性の注入) と IoCコンテナ を扱います。ただ、言葉の説明を読むだけではピンとこなかったので、「DIなし」と「DIあり」を自分の手で書き比べ、さらにわざと壊してエラーを観察する、という進め方にしました。

前回と同じく、Claude Code には手順書だけを作ってもらい、コードは自分の手で書いています。

この記事のゴール

  • 「DIなし」と「DIあり」の違いを、コードを書き比べて説明できる
  • DIにしたことでテストが失敗する理由と、その直し方が分かる
  • DIの仕組みが壊れたときのエラーメッセージを読める

環境

項目 バージョン
OS Windows 11
Spring Boot 4.1.1
Spring Framework 7.0.9(Spring Boot 4.1.1 に含まれるもの)
Java 21(Eclipse Temurin 21.0.5)
ビルドツール Maven(Maven Wrapper経由)
ターミナル PowerShell

プロジェクトは前回作った task-management-api をそのまま使います。新しい依存関係の追加はありません。

この記事で出てくる用語

先に用語をまとめておきます。最初から全部覚える必要はないので、本文で分からない言葉が出てきたら戻ってきてください。

用語 意味(この記事での使い方)
依存(Dependency) あるクラスが仕事をするために必要な別のクラス。HelloController は GreetingService に「依存」している
Service(サービス層) 業務ロジックを書く層。Controller(HTTPの入り口)から呼ばれる
IoC(Inversion of Control:制御の反転) オブジェクトを「作る・つなぐ・管理する」制御を、自分のコードではなくフレームワーク側に任せる考え方
DI(Dependency Injection:依存性の注入) IoC を実現する具体的な方法。依存を自分で new せず、外から渡してもらう
IoCコンテナ/ApplicationContext Spring の中で、Bean の生成・依存の受け渡し・管理を担う仕組み。ApplicationContext はそのインターフェース名
Bean IoCコンテナに登録・管理されているオブジェクト。@Service などを付けたクラスのインスタンス
コンポーネントスキャン 指定したパッケージ配下から、@Component 系のアノテーションが付いたクラスを探して Bean として登録する仕組み
ステレオタイプアノテーション @Component / @Controller(@RestController)/ @Service / @Repository の総称。「Bean として登録して」+役割の表明
自動設定(Auto-configuration) 依存ライブラリを見て、Spring Boot が設定を自動で用意する仕組み
コンストラクタインジェクション コンストラクタの引数で依存を受け取る DI の方式。ほかにフィールドインジェクション・セッターインジェクションがある
シングルトン(スコープ) Bean のデフォルトの生成単位。コンテナの中で1つだけ作られ、使い回される(通常のアプリではアプリ全体で1つ)
テストスライス(@WebMvcTest) アプリ全体ではなく、特定の層(ここでは Web 層)の Bean だけを登録して起動する軽量テスト
モック(Mock)/Mockito テスト用の偽物オブジェクト。返す値を自由に決められる。Mockito はそれを作る Java のライブラリ
@MockitoBean Mockito のモックを Bean としてコンテナに登録し、本物の代わりに注入させるアノテーション
POJO(Plain Old Java Object) 特定のフレームワークに依存しない、ただの Java オブジェクト
NoSuchBeanDefinitionException 必要な型の Bean がコンテナに見つからないときの例外

手順1:まず「DIなし」で書いてみる

いきなりDIの形で書くと、何が嬉しいのかが分かりません。そこで最初は、あえて自分で new する書き方にしました。

挨拶文を作る処理を、Controller から GreetingService というクラスに切り出します。

src/main/java/com/example/taskmanagementapi/hello/GreetingService.java(新規作成)

package com.example.taskmanagementapi.hello;

public class GreetingService {

    public String greet() {
        return "Hello, Spring Boot!";
    }
}

src/main/java/com/example/taskmanagementapi/hello/HelloController.java(編集)

package com.example.taskmanagementapi.hello;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloController {

    // DIなし:自分で new している
    private final GreetingService greetingService = new GreetingService();

    @GetMapping("/hello")
    public String hello() {
        return greetingService.greet();
    }
}

mvnw test を実行すると、問題なく通ります。

01_step1-mvnw-test-success-summary.png

Service に切り出す理由

Controller は「HTTPの入り口」、Service は「業務ロジック」と、役割を分けるためです。今は1行しかありませんが、次回③のタスク管理では、Controller → Service → Repository(DBとのやり取り)とつながる形になります。

この書き方の何が問題か

動きはします。ただ、次のような問題があります。

  1. 使うクラスが固定されている:HelloController が「GreetingService をこう作る」と自分で決めているので、別のものに差し替えるにはコードを書き換えるしかありません
  2. テストで偽物に差し替えられない:将来 GreetingService が DB を使うようになると、Controller のテストにも DB が必要になってしまいます
  3. 生成のされ方を誰も管理していない:使う側が増えるたびに、あちこちに new が散らばります

この「自分で作る」を「外から渡してもらう」に逆転させるのが、次の手順です。

手順2:DIに書き換える

変更は2か所だけです。

GreetingService.java:クラスに @Service を付けます。

package com.example.taskmanagementapi.hello;

import org.springframework.stereotype.Service;

@Service
public class GreetingService {

    public String greet() {
        return "Hello, Spring Boot!";
    }
}

HelloController.java:new をやめて、コンストラクタで受け取ります。

package com.example.taskmanagementapi.hello;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloController {

    private final GreetingService greetingService;

    // コンストラクタが1つだけなので @Autowired は省略できる
    public HelloController(GreetingService greetingService) {
        this.greetingService = greetingService;
    }

    @GetMapping("/hello")
    public String hello() {
        return greetingService.greet();
    }
}

起動してブラウザで http://localhost:8080/hello を開くと、見た目は何も変わりません。

03_step2-hello-browser.png

HelloController のどこにも new GreetingService() はありません。それなのに、ちゃんと GreetingService が渡されて動いています。

起動時に裏で起きていること

Spring Boot は起動時に、次のことをしてくれています。

  1. @SpringBootApplication が付いた起動クラスのパッケージ配下をコンポーネントスキャンする
  2. @RestController や @Service が付いたクラスを見つけて、Bean として登録する
  3. HelloController を作るとき、コンストラクタの引数に GreetingService が必要だと分かる → 登録済みの Bean から型で探して渡す

このように、オブジェクトを「作る・つなぐ・管理する」役目を自分のコードから Spring に渡すことを IoC(制御の反転) と呼びます。そして、必要なものを外から渡してもらう具体的な方法が DI(依存性の注入) です。Spring で Bean を管理している入れ物が IoCコンテナ(ApplicationContext)です。

前回の「HelloController を new していないのに動く」も、同じ仕組みでした。HelloController も @RestController によって Bean として登録され、Spring が作ってくれていたわけです。

@SpringBootApplication の正体

起動クラスに付いている @SpringBootApplication は、主に次の3つをまとめたものです。

アノテーション 役割
@SpringBootConfiguration このクラスが設定クラスであることを宣言する
@EnableAutoConfiguration 依存ライブラリに応じて自動設定を行う(前回、Tomcat が勝手に起動したのはこれ)
@ComponentScan このクラスのパッケージ配下をスキャンする(前回、Controller を起動クラスの配下に置いた理由)

また、@Service や @RestController はステレオタイプアノテーションと呼ばれ、基本形は @Component です。どれも「Bean として登録して」という意味は同じで、名前によって役割を表しています(@Repository だけは、DBの例外を Spring の例外に変換する機能も持っています。④で扱う予定です)。

なぜコンストラクタインジェクションなのか

依存を受け取る方法は、コンストラクタ以外にもあります。

方式 書き方 今回採用しなかった理由
フィールドインジェクション フィールドに @Autowired を付ける 短く書けるが、final にできない。Spring なしで new してテストしにくい
セッターインジェクション セッターメソッドで受け取る なくても動く「任意の依存」向け。今回は必須の依存なので不要
コンストラクタインジェクション コンストラクタの引数で受け取る 採用。final にでき、何に依存しているかがコンストラクタを見れば分かる

Spring の公式ドキュメントでも、必須の依存はコンストラクタで受け取ることが推奨されています。また、コンストラクタが1つだけなら @Autowired は省略できます(Spring 4.3 以降)。

もうひとつ大事なのが、Bean はデフォルトでシングルトンだという点です。GreetingService はコンテナの中で1つだけ作られ、すべてのリクエストで使い回されます。そのため、Service のフィールドに「リクエストごとに変わる値」を持たせてはいけません。③以降で気をつけたいポイントです。

手順3:テストが失敗した(今回いちばんの学び)

アプリを止めてから、テストは何も変えずに mvnw test を実行しました。すると……

07_step3-test-summary-build-failure.png

[ERROR] Tests run: 2, Failures: 0, Errors: 1, Skipped: 0
[INFO] BUILD FAILURE

手順1では通っていた HelloControllerTest が失敗しました。アプリ本体は問題なく動いているのに、です。

原因は、前回書いたテストに付いている @WebMvcTest(HelloController.class) にあります。@WebMvcTest は、アプリ全体ではなく Web層の Bean だけを登録して起動する軽量なテスト(テストスライス)です。@Service はその対象外なので、テスト用のコンテナには GreetingService が存在しません。

手順1では、Controller が自分で new していたので問題ありませんでした。依存を外から受け取るようにした結果、「誰かが渡さないと動かない」状態になったわけです。テストの失敗は、DIに移行できた証拠でもありました。

なお、アプリ全体を起動するテスト(contextLoads)は成功したままです。全体を起動すれば @Service もスキャンされるからです。同じコードでも、テストの起動範囲によって結果が変わることが分かります。

例外は3段。一番下の Caused by から読む

ログはとても長く、スクリーンショットで21枚分ありました。最初は面食らいましたが、見るべきところは限られています。スタックトレースから例外の部分だけを抜き出すと、次の3段になっていました。

java.lang.IllegalStateException: Failed to load ApplicationContext for [WebMergedContextConfiguration@... testClass = ...HelloControllerTest, ...]
  ...
Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'helloController' ...: Unsatisfied dependency expressed through constructor parameter 0: No qualifying bean of type 'com.example.taskmanagementapi.hello.GreetingService' available: ...
  ...
Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'com.example.taskmanagementapi.hello.GreetingService' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}
段 例外 意味
外側 IllegalStateException: Failed to load ApplicationContext テスト用のコンテナを起動できなかった
中 UnsatisfiedDependencyException helloController を作ろうとしたが、コンストラクタの0番目の引数を用意できなかった
根本原因 NoSuchBeanDefinitionException GreetingService 型の Bean がコンテナに1つもない

06_step3-caused-by-no-such-bean.png

外側の例外は「結果として何が起きたか」、**一番下の Caused by が「そもそもの原因」**です。長いスタックトレースに出会ったら、まず一番下の Caused by を探す。これは Spring に限らず使える読み方だと思いました。

Failures ではなく Errors になった理由

前回の記事では、Controller の戻り値をわざと変えてテストを失敗させました。そのときの結果は Failures: 1 でした。今回は Errors: 1 です。

前回(①) 今回
結果 Failures: 1 Errors: 1
何が起きたか 期待値と実際の値が違った テストの準備(コンテナの起動)の段階で例外が出た
意味 アサーションの失敗 テストの中身に入る前の失敗

Failures は「テストは実行できたが、結果が期待と違った」、Errors は「テストを実行する途中で予期しない例外が起きた」という区別です。どちらも失敗ですが、Errors のときはテストの書き方や環境の側を疑う、という目安になりそうです。

テストなのに APPLICATION FAILED TO START が出る理由

ログの途中には、こんなメッセージも出ていました。

04_step3-application-failed-to-start.png

***************************
APPLICATION FAILED TO START
***************************

Description:

Parameter 0 of constructor in com.example.taskmanagementapi.hello.HelloController required a bean of type 'com.example.taskmanagementapi.hello.GreetingService' that could not be found.

Action:

Consider defining a bean of type 'com.example.taskmanagementapi.hello.GreetingService' in your configuration.

テストを実行しただけなのに「アプリケーションの起動に失敗」と出るのは、@WebMvcTest も内部では(範囲を絞った)Spring アプリケーションを起動しているからです。

このメッセージは、Spring Boot が先ほどの例外を人間向けに読みやすく変換してくれたものです。「HelloController のコンストラクタの0番目の引数に必要な GreetingService の Bean が見つからない」と、原因がそのまま書かれています。スタックトレースを読むのがつらいときは、まずこのメッセージを探すと早そうです。

手順4:@MockitoBean でモックを差し込む

テストを直します。テスト用のコンテナに GreetingService がないなら、偽物(モック)を用意して渡せばよい、という考え方です。

src/test/java/com/example/taskmanagementapi/hello/HelloControllerTest.java(編集)

package com.example.taskmanagementapi.hello;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;

import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@WebMvcTest(HelloController.class)
class HelloControllerTest {

    @Autowired
    private MockMvc mockMvc;

    // コンテナに「偽物の GreetingService」を Bean として登録する
    @MockitoBean
    private GreetingService greetingService;

    @Test
    void helloReturnsGreetingFromService() throws Exception {
        when(greetingService.greet()).thenReturn("Hello from mock!");

        mockMvc.perform(get("/hello"))
                .andExpect(status().isOk())
                .andExpect(content().string("Hello from mock!"));
    }
}
  • @MockitoBean:Mockito で作った偽物を、コンテナに Bean として登録します。HelloController のコンストラクタには、この偽物が渡されます
  • when(greetingService.greet()).thenReturn("Hello from mock!"):偽物の greet() が呼ばれたら Hello from mock! を返すように設定します

mvnw test を実行すると、今度は通りました。

08_step4-hellocontrollertest-pass.png

09_step4-test-summary-build-success.png

Spring Boot 4 では @MockBean が使えない

ネットで「Spring Boot テスト モック」と調べると、@MockBean を使った例がたくさん出てきます。ところが、@MockBean は Spring Boot 4.0 で削除されています。代わりに、Spring Framework 本体(6.2以降)にある @MockitoBean を使います。

古い書き方 Spring Boot 4 での書き方
アノテーション @MockBean @MockitoBean
パッケージ org.springframework.boot.test.mock.mockito org.springframework.test.context.bean.override.mockito

@MockBean は Spring Boot 3.4 で非推奨になり、4.0 で削除されたという流れです(Spring Boot 4.0 Migration Guide)。前回の spring-boot-starter-web → -webmvc や @WebMvcTest のパッケージ変更に続いて、またしても「ネットの情報が古い」パターンでした。

Hello from mock! が返ってきた意味

ポイントは、わざと本物とは違う文字列を返させていることです。

レスポンスが本物の Hello, Spring Boot! ではなく Hello from mock! になった、ということは、HelloController は渡されたものをそのまま使っているだけで、それが本物か偽物かを知らないということです。HelloController のコードは1行も変えずに、中身だけを差し替えられました。これこそが、手順1で「できない」と書いたDIによる差し替えです。

また、テストの関心も分けられました。Controller のテストは「Service から受け取った結果を HTTP レスポンスとして返せるか」だけを確かめ、挨拶文の中身が正しいかどうかは、次の Service のテストで確かめます。

手順5:Spring なしで Service をテストする

GreetingService そのもののテストを書きます。

src/test/java/com/example/taskmanagementapi/hello/GreetingServiceTest.java(新規作成)

package com.example.taskmanagementapi.hello;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class GreetingServiceTest {

    @Test
    void greetReturnsHelloMessage() {
        GreetingService service = new GreetingService();

        assertThat(service.greet()).isEqualTo("Hello, Spring Boot!");
    }
}

Spring のアノテーションが1つもない、ただの JUnit のテストです。@Service が付いていても、中身はただの Java クラス(POJO)なので、普通に new してテストできます。assertThat(AssertJ)は、前回から入っているテスト用の依存に含まれているので、追加は不要です。

12_step5-test-summary-build-success.png

0.089秒 vs 1.888秒

ログを見ていて面白かったのが、テストにかかった時間です。

11_step5-test-time-comparison.png

テスト 時間 Spring を起動するか
GreetingServiceTest 0.089 秒 しない(new するだけ)
HelloControllerTest 1.888 秒 する(Web層だけ)

※ 自分の環境で1回実行したときの値です。

GreetingServiceTest の実行中は、Spring のロゴ(バナー)も一度も表示されませんでした。

10_step5-greetingservicetest-no-spring.png

③以降で Service に Repository などの依存が増えても、new TaskService(偽物のRepository) のように、コンストラクタに偽物を渡すだけでテストできます。手順2でコンストラクタインジェクションを選んだ理由が、ここで効いてきます。

手順6:@Service を外して起動してみる(実験)

最後に、DIの仕組みをわざと壊してみます。GreetingService の @Service をコメントアウトして、アプリを起動しました。

//@Service
public class GreetingService {

13_step6-application-failed-to-start.png

手順3のテストで見たものと同じ APPLICATION FAILED TO START が出ました。テストでも本番の起動でも、同じ仕組みが同じ原因を教えてくれています。

14_step6-build-failure-exit-code.png

Tomcat が起動しかけて止まる

ログを順番に追うと、Tomcat initialized with port 8080 の後に Bean の組み立てで失敗し、Stopping service [Tomcat] で Tomcat が止められていました。

つまり、リクエストを1件も受け付ける前に、起動の時点で失敗しているわけです。もし new する書き方で同じような設定ミスがあると、該当の処理が実際に呼ばれるまで気づけないかもしれません。DIなら、依存が足りないことを起動時に教えてくれます。

@Service を元に戻して起動し直すと、最後まで起動しました。

15_step6-restored-started.png

@Service が1つあるかないかで、「Tomcat を止める」か「最後まで起動する」かが分かれました。アノテーション1つの重みを実感した実験でした。

ちなみに、逆に同じ型の Bean が2つあると、「どちらを渡せばいいか分からない」というエラー(NoUniqueBeanDefinitionException)になるそうです。その解決方法(@Primary や @Qualifier)は、必要になった回で試してみます。

DIのメリット・デメリット

ひととおり体験したところで、メリットとデメリットを整理します。「便利だから使う」で終わらせず、何と引き換えにしているのかも押さえておきたいと思います。

DI(依存性の注入)/IoCコンテナ

メリット デメリット
使う側が、具体的なクラスの作り方を知らなくてよい(疎結合) オブジェクトがどこで作られ、どこから渡されたのかがコード上に現れない。初見では「なぜ動くのか」が分かりにくい
テストで本物を偽物に差し替えられる(手順4) エラーの原因を追うには、「コンテナが Bean を探して渡す」という仕組みの理解が必要(手順3・6)
依存の不足が起動時に分かる(手順6) 小さなプログラムでは、new で済む話に仕組みを持ち込む形になり、大げさに感じることもある

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

メリット デメリット
フィールドを final にでき、あとから書き換えられない フィールドに @Autowired を付ける書き方よりコード量が増える
何に依存しているかが、コンストラクタの引数を見れば分かる 依存が増えると引数が長くなる(ただし「クラスが責務を持ちすぎている」というサインにもなる)
Spring なしで new してテストできる(手順5) クラス同士が互いに依存し合う(循環参照)と、起動時にエラーになる(ただし循環参照自体が設計上の問題なので、早く気づけるとも言える)

テストスライス(@WebMvcTest)

メリット デメリット
Web層の Bean だけを起動するので、アプリ全体を起動するテストより速い Service などの依存は、自分でモックとして用意する必要がある(手順3・4)
DB などの外部の事情に左右されず、Controller の振る舞いだけを確かめられる 層をまたいだ連携が実際につながるかは確かめられない

モック(Mockito/@MockitoBean)

メリット デメリット
返す値を自由に決められるので、確かめたいことだけに集中できる モックが返す値は自分で決めたものなので、本物が正しく動くかは保証されない(だから手順5で Service を別にテストした)
DB や外部 API がなくてもテストできる モックの設定が多くなると、テストが読みにくくなり、実装を変えるたびにテストの修正も増える

正直なところ、今回の GreetingService は中身が1行なので、デメリットのほうが目立つ場面もありました。依存が増えるほどメリットが効いてくるはずなので、③以降で実感できるか確かめていきます。

つまずいたポイントまとめ

つまずき 原因 対処
DIに書き換えたらテストが失敗した @WebMvcTest は @Service を登録しない @MockitoBean でモックを登録する
@MockBean が見つからない Spring Boot 4.0 で削除された @MockitoBean(org.springframework.test.context.bean.override.mockito)を使う
エラーログが長すぎて読めない 例外が3段に重なっている 一番下の Caused by、または APPLICATION FAILED TO START を探す
Failures ではなく Errors だった アサーションではなく、テストの準備段階で例外が出た 「テストの中身」より先に「テストの起動」を疑う

まとめ

今回は、DIとIoCコンテナを「書き比べて」「壊して」確かめました。

  • new をやめてコンストラクタで受け取るようにするだけで、Spring が GreetingService を作って渡してくれた
  • DIにしたことでテストが失敗し、@MockitoBean で偽物を渡すと直った
  • 偽物に差し替えても HelloController は気づかない。これがDIによる差し替え
  • DIで書いたクラスは、Spring なしで new してテストできる(しかも速い)
  • @Service を1つ外すだけで、アプリは起動時に止まる

一番の収穫は、エラーログの読み方でした。前回の記事を書いた時点では、21枚分のログを見たら固まっていたと思います。「一番下の Caused by から読む」「APPLICATION FAILED TO START を探す」という2つを覚えただけで、長いログもそれほど怖くなくなりました。

次回予告

次回は③最初のREST API編です。いよいよタスク管理APIの本体に入り、Controller / Service / Repository の3層でタスクの基本的な CRUD(作成・取得・更新・削除)を作ります。DB はまだ使わず、データはメモリ上に保存します。

今回の GreetingService で練習した「コンストラクタで受け取る」「Service を new してテストする」が、そのまま活きてくるはずです。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?