はじめに
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 を実行すると、問題なく通ります。
Service に切り出す理由
Controller は「HTTPの入り口」、Service は「業務ロジック」と、役割を分けるためです。今は1行しかありませんが、次回③のタスク管理では、Controller → Service → Repository(DBとのやり取り)とつながる形になります。
この書き方の何が問題か
動きはします。ただ、次のような問題があります。
-
使うクラスが固定されている:
HelloControllerが「GreetingServiceをこう作る」と自分で決めているので、別のものに差し替えるにはコードを書き換えるしかありません -
テストで偽物に差し替えられない:将来
GreetingServiceが DB を使うようになると、Controller のテストにも DB が必要になってしまいます -
生成のされ方を誰も管理していない:使う側が増えるたびに、あちこちに
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 を開くと、見た目は何も変わりません。
HelloController のどこにも new GreetingService() はありません。それなのに、ちゃんと GreetingService が渡されて動いています。
起動時に裏で起きていること
Spring Boot は起動時に、次のことをしてくれています。
-
@SpringBootApplicationが付いた起動クラスのパッケージ配下をコンポーネントスキャンする -
@RestControllerや@Serviceが付いたクラスを見つけて、Bean として登録する -
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 を実行しました。すると……
[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つもない |
外側の例外は「結果として何が起きたか」、**一番下の Caused by が「そもそもの原因」**です。長いスタックトレースに出会ったら、まず一番下の Caused by を探す。これは Spring に限らず使える読み方だと思いました。
Failures ではなく Errors になった理由
前回の記事では、Controller の戻り値をわざと変えてテストを失敗させました。そのときの結果は Failures: 1 でした。今回は Errors: 1 です。
| 前回(①) | 今回 | |
|---|---|---|
| 結果 | Failures: 1 |
Errors: 1 |
| 何が起きたか | 期待値と実際の値が違った | テストの準備(コンテナの起動)の段階で例外が出た |
| 意味 | アサーションの失敗 | テストの中身に入る前の失敗 |
Failures は「テストは実行できたが、結果が期待と違った」、Errors は「テストを実行する途中で予期しない例外が起きた」という区別です。どちらも失敗ですが、Errors のときはテストの書き方や環境の側を疑う、という目安になりそうです。
テストなのに APPLICATION FAILED TO START が出る理由
ログの途中には、こんなメッセージも出ていました。
***************************
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 を実行すると、今度は通りました。
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)は、前回から入っているテスト用の依存に含まれているので、追加は不要です。
0.089秒 vs 1.888秒
ログを見ていて面白かったのが、テストにかかった時間です。
| テスト | 時間 | Spring を起動するか |
|---|---|---|
GreetingServiceTest |
0.089 秒 | しない(new するだけ) |
HelloControllerTest |
1.888 秒 | する(Web層だけ) |
※ 自分の環境で1回実行したときの値です。
GreetingServiceTest の実行中は、Spring のロゴ(バナー)も一度も表示されませんでした。
③以降で Service に Repository などの依存が増えても、new TaskService(偽物のRepository) のように、コンストラクタに偽物を渡すだけでテストできます。手順2でコンストラクタインジェクションを選んだ理由が、ここで効いてきます。
手順6:@Service を外して起動してみる(実験)
最後に、DIの仕組みをわざと壊してみます。GreetingService の @Service をコメントアウトして、アプリを起動しました。
//@Service
public class GreetingService {
手順3のテストで見たものと同じ APPLICATION FAILED TO START が出ました。テストでも本番の起動でも、同じ仕組みが同じ原因を教えてくれています。
Tomcat が起動しかけて止まる
ログを順番に追うと、Tomcat initialized with port 8080 の後に Bean の組み立てで失敗し、Stopping service [Tomcat] で Tomcat が止められていました。
つまり、リクエストを1件も受け付ける前に、起動の時点で失敗しているわけです。もし new する書き方で同じような設定ミスがあると、該当の処理が実際に呼ばれるまで気づけないかもしれません。DIなら、依存が足りないことを起動時に教えてくれます。
@Service を元に戻して起動し直すと、最後まで起動しました。
@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 してテストする」が、そのまま活きてくるはずです。













