はじめに
Spring Bootをゼロから学びながら、「JWT認証付きのタスク管理REST API」を作っていくシリーズの3本目です。
| # | テーマ |
|---|---|
| ① | 環境構築編:プロジェクト作成、Hello World |
| ② | 核となる概念編:DI/IoCコンテナ、アノテーション駆動 |
| ③ | 最初のREST API編:タスクの基本CRUD(メモリ上)(この記事) |
| ④ | DB連携編:Spring Data JPA、H2→PostgreSQL |
| ⑤ | 設定・プロファイル編:application.yamlで環境分離 |
| ⑥ | テスト編:JUnit + MockMvc |
| ⑦ | 認証・認可編:ユーザー登録・ログイン・JWT |
| ⑧ | 総仕上げ編:ページネーション・絞り込み、Docker化 |
前回は、HelloController が GreetingService を new せずに受け取る DI(依存性の注入) を、書き比べたり壊したりしながら確かめました。Controller → Service の2層がつながった状態です。
今回はそこに Repository(データの出し入れ係) を加えた3層構造で、いよいよこのシリーズの本体である タスクの CRUD(作成・取得・更新・削除)API を作ります。
データの保存先は、まだ DB ではなくメモリ上の Map です。DB は次回④で導入します。ただし今回は、「④で保存先を DB に差し替えても、Service と Controller はほとんど変えずに済む」形を最初から作っておく、という裏テーマがあります。
また、いきなり完成形を作るのではなく、途中でわざと問題を残した状態で動かしてみてから直す、という順番で進めます。「なぜこのコードが必要なのか」が、Before / After で見えるようにするためです。
この記事のゴール
- Controller / Service / Repository の3層で、REST API の CRUD を作れる
- Entity(
Task)をそのまま返さず、DTO を介す理由を説明できる - 「見つからない」を 404 に、「入力が不正」を 400 に変換する仕組み(
@RestControllerAdvice+ProblemDetail、Bean Validation)が分かる
環境
| 項目 | バージョン |
|---|---|
| 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 をそのまま使います。今回は途中(手順5)で、入力チェック用の依存関係を2つ追加します。
今回作るAPI
| メソッド | URL | 処理 | 成功時のステータス |
|---|---|---|---|
| GET | /api/tasks |
タスク一覧を取得 | 200 OK |
| GET | /api/tasks/{id} |
タスクを1件取得 | 200 OK(存在しなければ 404) |
| POST | /api/tasks |
タスクを登録 | 201 Created(+Location ヘッダー) |
| PUT | /api/tasks/{id} |
タスクを更新(全項目を置き換え) | 200 OK(存在しなければ 404) |
| DELETE | /api/tasks/{id} |
タスクを削除 | 204 No Content(存在しなければ 404) |
入力が不正なとき(タイトルが空など)は 400 Bad Request を返します。
タスクの項目は id / title / description / completed の4つだけにしました。期限や優先度も欲しくなりますが、今回は CRUD の流れを覚えるのが目的なので最小限にしています。
最終的なファイル構成は次のとおりです(hello パッケージは①②のものです)。
src/main/java/com/example/taskmanagementapi/
├── TaskManagementApiApplication.java
├── common/
│ └── GlobalExceptionHandler.java ← 例外を HTTP のエラーに変換する
├── hello/ ← ①②のもの
└── task/
├── Task.java ← タスクそのもの
├── TaskRepository.java ← 保存・取得の約束(インターフェース)
├── InMemoryTaskRepository.java ← その実装(メモリ上の Map)
├── TaskService.java ← 業務ルール
├── TaskController.java ← HTTP の入り口
├── TaskCreateRequest.java ← 登録用 DTO
├── TaskUpdateRequest.java ← 更新用 DTO
├── TaskResponse.java ← レスポンス用 DTO
└── TaskNotFoundException.java ← 「見つからない」を表す例外
②と同じく、パッケージは層単位(controller/ service/…)ではなく機能単位(task/ に全部入れる)にしています。アプリ全体で共通の例外ハンドラだけは common に置きました。
この記事で出てくる用語
今回は用語が多めです。最初から全部覚える必要はないので、本文で分からない言葉が出てきたら戻ってきてください。
| 用語 | 意味(この記事での使い方) |
|---|---|
| REST API | URL で「何を(リソース)」、HTTP メソッドで「どうするか」を表す Web API の設計スタイル。今回は「タスク」がリソース |
| リソース | API で扱う対象のこと。/api/tasks は「タスクの集まり」、/api/tasks/1 は「ID が 1 のタスク」を表す |
| CRUD | Create(作成)・Read(取得)・Update(更新)・Delete(削除)の頭文字。データ操作の基本の4つ |
| HTTP メソッド | リクエストの種類。CRUD とおおむね POST=作成、GET=取得、PUT=更新、DELETE=削除と対応する |
| HTTP ステータスコード | 処理結果を表す3桁の数字。200 番台=成功、400 番台=クライアント側の誤り、500 番台=サーバー側の誤り |
| Repository(リポジトリ層) | データの保存・取得だけを担当する層。Service は「どこに保存されているか」を知らずに Repository を呼ぶ |
ドメインオブジェクト(Task) |
アプリの中で扱う「タスク」そのものを表すクラス。④ではこれが DB のテーブルと対応する エンティティ になる |
| インメモリ | データを DB ではなくアプリのメモリ上(今回は Map)に置くこと。アプリを止めると消える |
| スレッドセーフ | 複数のリクエストが同時に処理されても、データが壊れない性質。今回は ConcurrentHashMap と AtomicLong で実現する |
Optional |
「値があるかもしれないし、ないかもしれない」を表す Java の型。null を直接返す代わりに使う |
| DTO(Data Transfer Object) | 層の間や API の入出力でデータを運ぶためだけのクラス。今回はリクエスト用とレスポンス用を作る |
record |
Java 16 で正式に入った、値を運ぶためのクラスを短く書ける構文。フィールド・コンストラクタ・値を取り出すメソッド・equals などが自動で作られる |
| JSON/Jackson | JSON は API でよく使うデータ形式。Jackson は Java のオブジェクトと JSON を相互に変換するライブラリで、Spring Boot が自動で使う(Spring Boot 4 では Jackson 3) |
@RequestMapping / @GetMapping など |
「どの URL・どの HTTP メソッドのリクエストを、どのメソッドで処理するか」を対応づけるアノテーション |
@PathVariable |
URL の一部(/api/tasks/{id} の {id})をメソッドの引数として受け取るアノテーション |
@RequestBody |
リクエストの本文(JSON)を Java のオブジェクトに変換して受け取るアノテーション |
ResponseEntity |
ステータスコード・ヘッダー・本文をまとめて指定してレスポンスを返すためのクラス |
Location ヘッダー |
「作成したリソースはこの URL にあります」と伝えるレスポンスヘッダー。201 Created とセットで返すのが REST の慣習 |
@RestControllerAdvice / @ExceptionHandler |
複数の Controller で発生した例外を1か所で受け止め、レスポンスに変換する仕組み |
ProblemDetail(RFC 9457) |
エラー内容を JSON で返すための標準形式(type / title / status / detail / instance)。Spring Framework 6 以降で標準サポート |
Bean Validation(@Valid / @NotBlank / @Size など) |
アノテーションで入力値のルールを宣言し、自動でチェックする仕組み。実装は Hibernate Validator |
MethodArgumentNotValidException |
@Valid を付けた @RequestBody のチェックに失敗したときに発生する例外 |
手順1:Task と Repository を作る
まずは一番下の層、データの出し入れ係から作ります。この時点ではまだ HTTP からは呼べません。
src/main/java/com/example/taskmanagementapi/task/Task.java
package com.example.taskmanagementapi.task;
public class Task {
private Long id;
private String title;
private String description;
private boolean completed;
public Task(String title, String description) {
this.title = title;
this.description = description;
this.completed = false;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public boolean isCompleted() {
return completed;
}
public void setCompleted(boolean completed) {
this.completed = completed;
}
}
id は保存するときに Repository が割り振るので、コンストラクタでは受け取りません。また、「新しく作ったタスクは必ず未完了から始まる」というルールを、コンストラクタで completed = false にすることで表しています。
Task は後で出てくる DTO と違って record ではなく、普通のクラスにしています。理由は2つあります。1つは、record は値を後から変えられないので、更新処理と相性が悪いこと。もう1つは、④で JPA のエンティティにするとき、Jakarta Persistence の仕様で record はエンティティとして使えないことです。Lombok も②と同じ理由で使わず、getter / setter を手で書いています。
src/main/java/com/example/taskmanagementapi/task/TaskRepository.java
package com.example.taskmanagementapi.task;
import java.util.List;
import java.util.Optional;
// メソッド名は Spring Data JPA(④で導入)の JpaRepository に合わせている
public interface TaskRepository {
List<Task> findAll();
Optional<Task> findById(Long id);
Task save(Task task);
boolean existsById(Long id);
void deleteById(Long id);
}
src/main/java/com/example/taskmanagementapi/task/InMemoryTaskRepository.java
package com.example.taskmanagementapi.task;
import org.springframework.stereotype.Repository;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
@Repository
public class InMemoryTaskRepository implements TaskRepository {
// Bean はシングルトンで全リクエストから共有されるため、スレッドセーフな Map とカウンタを使う
private final Map<Long, Task> store = new ConcurrentHashMap<>();
private final AtomicLong sequence = new AtomicLong();
@Override
public List<Task> findAll() {
return store.values().stream()
.sorted(Comparator.comparing(Task::getId))
.toList();
}
@Override
public Optional<Task> findById(Long id) {
return Optional.ofNullable(store.get(id));
}
@Override
public Task save(Task task) {
if (task.getId() == null) {
task.setId(sequence.incrementAndGet());
}
store.put(task.getId(), task);
return task;
}
@Override
public boolean existsById(Long id) {
return store.containsKey(id);
}
@Override
public void deleteById(Long id) {
store.remove(id);
}
}
findById の戻り値を Optional<Task> にしているのは、「見つからないかもしれない」ことを型で表すためです。null を返すと、呼び出し側が null チェックを忘れて NullPointerException になりがちです。
@Repository は②で出てきたステレオタイプアノテーションの1つで、これを付けたクラスは Bean として登録されます。
Repository をインターフェースにしておく理由
②では GreetingService をインターフェースにしませんでした。「実装が1つしかないなら分けなくてよい(YAGNI)」という判断です。
今回分けたのは、④で中身を DB 版に差し替えることが決まっているからです。Service は TaskRepository(インターフェース)だけを知っているので、④で InMemoryTaskRepository を消して DB 版に置き換えても、Service のコードは変えずに済むはずです。
さらに、メソッド名を findAll / findById / save / existsById / deleteById にしているのには理由があります。これらは④で使う Spring Data JPA の JpaRepository が持っているメソッドと同じ名前で、形もほぼ同じにしてあります。うまくいけば、④では「このインターフェースを extends JpaRepository<Task, Long> に書き換えるだけ」で DB 版になる想定です。本当にそうなるかは④で確かめます。
「いつインターフェースに分けるか」は、②と③で判断が分かれた、ちょうど良い比較になりました。
ConcurrentHashMap と AtomicLong を使う理由
②で「Bean はシングルトン。アプリ全体で1つのインスタンスを使い回す」と学びました。つまり InMemoryTaskRepository の store と sequence は、全ユーザーのリクエストから同時に触られることになります。
普通の HashMap や long の変数で id++ していると、同時に2件登録されたときに同じ ID が振られる、といった事故が起こりえます。そこで、同時アクセスに強い(スレッドセーフな)ConcurrentHashMap と AtomicLong を使っています。
もう1点、findAll で ID 順に並べ替えているのは、ConcurrentHashMap が取り出す順番を保証しないためです。一覧の順番が毎回変わる API は使いにくいですよね。
テスト
Repository のテストは、②の GreetingServiceTest と同じく Spring を使わない素の JUnit です。
src/test/java/com/example/taskmanagementapi/task/InMemoryTaskRepositoryTest.java
package com.example.taskmanagementapi.task;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class InMemoryTaskRepositoryTest {
private InMemoryTaskRepository repository;
@BeforeEach
void setUp() {
repository = new InMemoryTaskRepository();
}
@Test
void saveAssignsSequentialIds() {
Task first = repository.save(new Task("牛乳を買う", null));
Task second = repository.save(new Task("記事を書く", "③の下書き"));
assertThat(first.getId()).isEqualTo(1L);
assertThat(second.getId()).isEqualTo(2L);
assertThat(repository.findAll()).extracting(Task::getTitle)
.containsExactly("牛乳を買う", "記事を書く");
}
@Test
void findByIdReturnsEmptyWhenNotFound() {
assertThat(repository.findById(999L)).isEmpty();
}
@Test
void deleteByIdRemovesTask() {
Task saved = repository.save(new Task("牛乳を買う", null));
repository.deleteById(saved.getId());
assertThat(repository.existsById(saved.getId())).isFalse();
}
}
@BeforeEach で毎回新しい Repository を作っているので、テスト同士でデータが混ざりません。
.\mvnw.cmd test
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] BUILD SUCCESS
①②のテスト3件に、今回の3件が加わって6件です。
手順2:DTO と Service を作る
次は真ん中の層、業務ルールを判断する Service です。その前に、Service が受け取ったり返したりするデータの入れ物(DTO)を作ります。
src/main/java/com/example/taskmanagementapi/task/TaskCreateRequest.java(登録用)
package com.example.taskmanagementapi.task;
public record TaskCreateRequest(
String title,
String description
) {
}
src/main/java/com/example/taskmanagementapi/task/TaskUpdateRequest.java(更新用)
package com.example.taskmanagementapi.task;
public record TaskUpdateRequest(
String title,
String description,
Boolean completed
) {
}
src/main/java/com/example/taskmanagementapi/task/TaskResponse.java(レスポンス用)
package com.example.taskmanagementapi.task;
public record TaskResponse(
Long id,
String title,
String description,
boolean completed
) {
public static TaskResponse from(Task task) {
return new TaskResponse(
task.getId(),
task.getTitle(),
task.getDescription(),
task.isCompleted()
);
}
}
src/main/java/com/example/taskmanagementapi/task/TaskNotFoundException.java
package com.example.taskmanagementapi.task;
public class TaskNotFoundException extends RuntimeException {
public TaskNotFoundException(Long id) {
super("タスクが見つかりません(id=" + id + ")");
}
}
src/main/java/com/example/taskmanagementapi/task/TaskService.java
package com.example.taskmanagementapi.task;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class TaskService {
private final TaskRepository taskRepository;
public TaskService(TaskRepository taskRepository) {
this.taskRepository = taskRepository;
}
public List<TaskResponse> findAll() {
return taskRepository.findAll().stream()
.map(TaskResponse::from)
.toList();
}
public TaskResponse findById(Long id) {
return TaskResponse.from(getTask(id));
}
public TaskResponse create(TaskCreateRequest request) {
Task task = new Task(request.title(), request.description());
return TaskResponse.from(taskRepository.save(task));
}
public TaskResponse update(Long id, TaskUpdateRequest request) {
Task task = getTask(id);
task.setTitle(request.title());
task.setDescription(request.description());
task.setCompleted(request.completed());
return TaskResponse.from(taskRepository.save(task));
}
public void delete(Long id) {
if (!taskRepository.existsById(id)) {
throw new TaskNotFoundException(id);
}
taskRepository.deleteById(id);
}
private Task getTask(Long id) {
return taskRepository.findById(id)
.orElseThrow(() -> new TaskNotFoundException(id));
}
}
Service は今のところ Repository を呼ぶだけに見えますが、「新規作成時は未完了」「存在しないタスクは更新・削除できない」といったルールを判断する場所です。⑦では「自分のタスクだけ操作できる」というルールもここに入る予定です。
TaskNotFoundException は「指定された ID のタスクがない」ことを表す自作の例外です。これを HTTP の 404 に変換するのは Service の仕事ではありません(Service は HTTP のことを知らないようにしておきたい)。変換は手順4で別の場所に書きます。
update の最後で save を呼んでいるのは、実はインメモリ版では不要です。findById で取り出した Task を直接書き換えれば、Map の中身も変わるからです。それでも「変更したら save する」形にしておくと、④の DB 版でも同じ書き方のまま使えます。
Entity をそのままレスポンスに使わない理由
TaskService は Task ではなく TaskResponse を返しています。なぜわざわざ別のクラスに詰め替えるのでしょうか。
-
API の見た目と、内部のデータの形を切り離すためです。④で
Taskに DB 用の項目が増えたり、⑦でユーザーとの関連が増えたりしても、API が返す形はTaskResponseで固定できます -
受け取ってはいけない項目を、そもそも受け取らない形にできるからです。
TaskCreateRequestにはid(サーバーが振る)もcompleted(最初は必ず未完了)もありません。クライアントが"id": 100を送ってきても、入れる場所がないので無視されます - 用途ごとに入力チェックのルールを書き分けられるからです(手順5で効いてきます)
DTO を登録用・更新用・レスポンス用の3つに分けたのもこのためです。1つのクラスで兼用すると、「登録時は completed を受け取らない」「更新時は必須」のような違いを表せません。
更新用の completed が boolean ではなく Boolean(ラッパー型)なのにも理由があります。boolean だと、クライアントが completed を送ってこなかったときに false になり、完了済みのタスクがうっかり未完了に戻ってしまいます。Boolean なら「送られてこなかった」を null として見分けられます(null のチェックは手順5で追加します)。
Task から TaskResponse への変換は、Controller ではなく Service で行うことにしました。Service が Task を返すと、④以降で DB のエンティティが Controller まで出てきてしまうからです。「Service の外にはエンティティを出さない」と決めておきます。
DTO を record で書く
record を使うと、TaskResponse(Long id, ...) と書くだけで、フィールド・コンストラクタ・id() のような値を取り出すメソッド・equals などが自動で作られます。値を運ぶだけのクラスなら、getter を並べるよりずっと短く書けます。Jackson も record を JSON に変換できます。
TaskResponse.from(task) のように、変換処理を DTO 側に置いておくと、Service のあちこちに変換コードを書かずに済みます。
テスト
②で予告した「Service を new して、モックの Repository を渡すテスト」をここで実際にやります。
src/test/java/com/example/taskmanagementapi/task/TaskServiceTest.java
package com.example.taskmanagementapi.task;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class TaskServiceTest {
private TaskRepository taskRepository;
private TaskService taskService;
@BeforeEach
void setUp() {
// Spring を使わず、モックの Repository をコンストラクタで渡す
taskRepository = mock(TaskRepository.class);
taskService = new TaskService(taskRepository);
}
@Test
void createSavesNewTaskAsNotCompleted() {
when(taskRepository.save(any(Task.class))).thenAnswer(invocation -> {
Task task = invocation.getArgument(0);
task.setId(1L);
return task;
});
TaskResponse response = taskService.create(new TaskCreateRequest("牛乳を買う", "低脂肪"));
assertThat(response).isEqualTo(new TaskResponse(1L, "牛乳を買う", "低脂肪", false));
}
@Test
void findByIdThrowsWhenNotFound() {
when(taskRepository.findById(999L)).thenReturn(Optional.empty());
assertThatThrownBy(() -> taskService.findById(999L))
.isInstanceOf(TaskNotFoundException.class);
}
@Test
void deleteDoesNotCallRepositoryWhenNotFound() {
when(taskRepository.existsById(999L)).thenReturn(false);
assertThatThrownBy(() -> taskService.delete(999L))
.isInstanceOf(TaskNotFoundException.class);
verify(taskRepository, never()).deleteById(any());
}
}
-
mock(TaskRepository.class)で偽物の Repository を作り、コンストラクタで渡しています。Repository がインターフェースなので、モックも作りやすいです -
thenAnswerで、「saveに渡されたTaskに ID を振って返す」という本物の Repository のふりをさせています -
isEqualTo(new TaskResponse(...))で中身を丸ごと比較できるのは、recordがequalsを自動で作ってくれているおかげです -
verify(taskRepository, never()).deleteById(any())は、「存在しないときは削除処理を呼ばない」ことを確かめています。戻り値がない処理は、「呼ばれたかどうか」で確かめます
[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] BUILD SUCCESS
手順3:Controller を作る(GET と POST)
いよいよ HTTP の入り口です。まずは「登録」と「取得」だけを作って、実際に叩いてみます。
src/main/java/com/example/taskmanagementapi/task/TaskController.java
package com.example.taskmanagementapi.task;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.net.URI;
import java.util.List;
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskService taskService;
public TaskController(TaskService taskService) {
this.taskService = taskService;
}
@GetMapping
public List<TaskResponse> findAll() {
return taskService.findAll();
}
@GetMapping("/{id}")
public TaskResponse findById(@PathVariable Long id) {
return taskService.findById(id);
}
@PostMapping
public ResponseEntity<TaskResponse> create(@RequestBody TaskCreateRequest request) {
TaskResponse created = taskService.create(request);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(created.id())
.toUri();
return ResponseEntity.created(location).body(created);
}
}
-
@RequestMapping("/api/tasks"):クラスに付けると、中のメソッドすべての URL の先頭に/api/tasksが付きます。@GetMapping("/{id}")は/api/tasks/{id}になります -
@PathVariable Long id:URL の{id}の部分をLongに変換して受け取ります -
@RequestBody:リクエスト本文の JSON をTaskCreateRequestに変換して受け取ります。逆に、メソッドの戻り値(TaskResponseやList<TaskResponse>)は JSON に変換して返されます。どちらも変換しているのは Jackson です
URL を /api/tasks(複数形の名詞)にしたのは、REST では URL で「何を」、HTTP メソッドで「どうするか」を表すのが基本だからです。/getTasks のように URL に動詞を入れるのは避けます。/api を付けたのは、①の /hello や、将来の画面用の URL と区別しやすくするためです。
そして、Controller が薄いことにも注目してください。Controller がやっているのは「HTTP の入出力の変換」と「Service の呼び出し」だけです。業務ルールは Service、保存は Repository と、②で学んだ責務の分け方が3層に広がりました。
POST だけ ResponseEntity を使う理由
何も指定しないと、レスポンスのステータスは 200 OK になります。でも REST では、「新しく作った」ことを 201 Created で表し、作ったリソースの URL を Location ヘッダーで返すのが慣習です。
ステータスとヘッダーを自分で指定したいので、POST だけ ResponseEntity.created(location) を使っています。ServletUriComponentsBuilder.fromCurrentRequest() は、今のリクエストの URL(http://localhost:8080/api/tasks)を元に /1 を付け足した URL を組み立ててくれます。ホスト名やポートを直接書かずに済みます。
テスト
Controller のテストは、②と同じく @WebMvcTest + @MockitoBean です。
src/test/java/com/example/taskmanagementapi/task/TaskControllerTest.java
package com.example.taskmanagementapi.task;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(TaskController.class)
class TaskControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private TaskService taskService;
@Test
void findAllReturnsTasks() throws Exception {
when(taskService.findAll()).thenReturn(List.of(
new TaskResponse(1L, "牛乳を買う", null, false)));
mockMvc.perform(get("/api/tasks"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(1))
.andExpect(jsonPath("$[0].title").value("牛乳を買う"));
}
@Test
void createReturns201WithLocation() throws Exception {
when(taskService.create(any(TaskCreateRequest.class)))
.thenReturn(new TaskResponse(1L, "牛乳を買う", null, false));
mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"title": "牛乳を買う"}
"""))
.andExpect(status().isCreated())
.andExpect(header().string("Location", "http://localhost/api/tasks/1"))
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.completed").value(false));
}
}
-
jsonPath("$[0].title"):返ってきた JSON の中の値を指定して確かめる書き方です。$が JSON 全体、[0]が配列の0番目、.titleがその中の項目です - テストの
Locationはhttp://localhost/api/tasks/1(ポートなし)になります。MockMvc は実際のサーバーを起動せず、仮のリクエストで動くためです - テストの JSON は、
"""で囲むテキストブロック(Java 15〜)で書いています。\"のエスケープなしで JSON をそのまま書けます
ネットの記事では、ObjectMapper で DTO を JSON 文字列に変換してテストに渡す例をよく見かけます。ただ、Spring Boot 4 では Jackson 3 になってパッケージ名が com.fasterxml.jackson から tools.jackson に変わっており(@JsonProperty などのアノテーションは com.fasterxml.jackson.annotation のまま)、古い記事の import をそのまま使うとコンパイルできないことがあります。JSON を文字列で書いておけば、この違いの影響を受けません。
[INFO] Tests run: 11, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] BUILD SUCCESS
実際に叩いてみる
アプリを起動します。
.\mvnw.cmd spring-boot:run
別のターミナルから curl で叩きます。ここでひとつ、Windows ならではの注意点があります。
Windows の curl で日本語の JSON を送ると 400 になる
最初は、次のように JSON をコマンドに直接書くつもりでした。
# うまくいかない例
curl.exe -X POST http://localhost:8080/api/tasks -H "Content-Type: application/json" -d '{"title": "牛乳を買う"}'
ところが Windows では、コマンドラインに書いた日本語が Shift_JIS で送られてしまい、サーバー側で JSON parse error: Invalid UTF-8 start byte 0x8b となって 400 Bad Request が返ってきます。さらに PowerShell では " のエスケープの扱いがバージョンによって変わり、JSON が途中で切れてしまうこともあります(Windows PowerShell 5.1 では " が消えてしまいます。PowerShell 7.3 以降では改善されています)。
そこで、JSON を UTF-8 のファイルに書いておき、--data-binary "@ファイル名" で送ることにしました。こうすれば文字コードもエスケープも気にしなくて済みます。
task-management-api 直下に requests フォルダを作り、次の3つのファイルを UTF-8 で保存します。
requests/create-task.json
{"title": "牛乳を買う", "description": "低脂肪のもの"}
requests/create-task-2.json
{"title": "Qiitaの記事を書く"}
requests/create-task-blank.json
{"title": ""}
ターミナルで日本語の表示が文字化けする場合は、先に chcp 65001 を実行してください。表示だけの問題で、データ自体は正しく保存されています。
まずは2件登録します。
curl.exe -i -X POST http://localhost:8080/api/tasks -H "Content-Type: application/json" --data-binary "@requests/create-task.json"
HTTP/1.1 201
Location: http://localhost:8080/api/tasks/1
Content-Type: application/json
Transfer-Encoding: chunked
Date: Fri, 09 Oct 2026 06:10:26 GMT
{"id":1,"title":"牛乳を買う","description":"低脂肪のもの","completed":false}
201 と、Location: http://localhost:8080/api/tasks/1 が返ってきました。id はサーバー側で振られ、completed は送っていないのに false になっています。
create-task-2.json も同じように POST してから、一覧を取得します。
curl.exe -i http://localhost:8080/api/tasks
HTTP/1.1 200
Content-Type: application/json
Content-Length: 169
Date: Fri, 09 Oct 2026 06:10:26 GMT
[{"id":1,"title":"牛乳を買う","description":"低脂肪のもの","completed":false},{"id":2,"title":"Qiitaの記事を書く","description":null,"completed":false}]
description を送らなかった2件目は null になっています。/api/tasks/1 で1件だけ取得することもできました。
あえて残した2つの問題
ここで、わざと「おかしな」リクエストを2つ送ってみます。
実験1:存在しない ID を取得する
curl.exe -i http://localhost:8080/api/tasks/999
HTTP/1.1 500
Content-Type: application/json
Transfer-Encoding: chunked
Date: Fri, 09 Oct 2026 06:10:27 GMT
Connection: close
{"timestamp":"2026-10-09T06:10:27.049Z","status":500,"error":"Internal Server Error","path":"/api/tasks/999"}
500 Internal Server Error になりました。アプリを起動しているターミナルには、次のようなログが出ています。
ERROR 21100 --- [task-management-api] [io-8080-exec-10] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: com.example.taskmanagementapi.task.TaskNotFoundException: タスクが見つかりません(id=999)] with root cause
com.example.taskmanagementapi.task.TaskNotFoundException: タスクが見つかりません(id=999)
at com.example.taskmanagementapi.task.TaskService.lambda$getTask$0(TaskService.java:48) ~[classes/:na]
at java.base/java.util.Optional.orElseThrow(Optional.java:403) ~[na:na]
at com.example.taskmanagementapi.task.TaskService.getTask(TaskService.java:48) ~[classes/:na]
at com.example.taskmanagementapi.task.TaskService.findById(TaskService.java:23) ~[classes/:na]
at com.example.taskmanagementapi.task.TaskController.findById(TaskController.java:32) ~[classes/:na]
...
Service で投げた TaskNotFoundException が誰にも受け止められず、そのまま外に出てしまっています。Spring Boot はこれを「サーバーの不具合」として扱い、500 を返します。
でも本来これは、「ID の指定が間違っている(=そんなタスクはない)」というクライアント側の問題なので、404 Not Found を返すべきです。500 では、クライアントから見て「サーバーが壊れた」のか「ID が間違っている」のか区別がつきません。
実験2:タイトルが空のタスクを登録する
curl.exe -i -X POST http://localhost:8080/api/tasks -H "Content-Type: application/json" --data-binary "@requests/create-task-blank.json"
HTTP/1.1 201
Location: http://localhost:8080/api/tasks/3
Content-Type: application/json
Transfer-Encoding: chunked
Date: Fri, 09 Oct 2026 06:10:27 GMT
{"id":3,"title":"","description":null,"completed":false}
201 Created で、タイトルが空のタスクが登録できてしまいました。 入力チェックがないので、何でも通ってしまいます。本来は 400 Bad Request で弾くべきです。
この2つを、手順4と手順5で直していきます。
手順4:PUT・DELETE と「見つからない」を 404 にする
アプリを Ctrl+C で止めてから編集します。
TaskController.java に import を追加し、create メソッドの下に update と delete を追加します。
// 追加する import
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
// create メソッドの下に追加
@PutMapping("/{id}")
public TaskResponse update(@PathVariable Long id, @RequestBody TaskUpdateRequest request) {
return taskService.update(id, request);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable Long id) {
taskService.delete(id);
}
削除は返すものがないので、戻り値を void にし、@ResponseStatus(HttpStatus.NO_CONTENT) でステータスを 204 No Content(成功したけど本文はない)にしています。POST のように ResponseEntity を使ってもよいのですが、ステータスを変えるだけならアノテーションのほうが短く書けます。
更新は、送った項目だけを変える PATCH ではなく、全項目を置き換える PUT にしました。PATCH は「送られなかった項目」と「null を送った項目」を区別する必要があり、最初に作るには少し複雑です。項目が4つしかないので、今回は PUT で十分です。
@RestControllerAdvice で例外を一か所で受け止める
次に、500 になっていた TaskNotFoundException を 404 に変換します。common パッケージを新しく作り、次のクラスを置きます。
src/main/java/com/example/taskmanagementapi/common/GlobalExceptionHandler.java
package com.example.taskmanagementapi.common;
import com.example.taskmanagementapi.task.TaskNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(TaskNotFoundException.class)
public ProblemDetail handleTaskNotFound(TaskNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Task Not Found");
return problem;
}
}
@RestControllerAdvice は、全 Controller 共通の「例外の受け止め係」です。Controller や Service で投げられた例外が Controller の外に出てきたところで、@ExceptionHandler で指定した型の例外をつかまえ、メソッドの戻り値をレスポンスとして返します。
これで、
-
Service は HTTP のことを知らないまま(
TaskNotFoundExceptionを投げるだけ) - Controller も
try-catchを書かないまま
404 を返せるようになりました。Controller で Optional を見て ResponseEntity.notFound() を返す書き方もありますが、それだと Controller ごとに同じ分岐を書くことになります。例外処理は1か所にまとめておくほうが、後から直しやすくなります。
ちなみに、@RestControllerAdvice を付けたクラスが Bean として登録されるのは、②のステレオタイプアノテーションと同じ仕組みです(@RestControllerAdvice をたどると @ControllerAdvice → @Component と付いていて、@Component の仲間です)。
ProblemDetail(RFC 9457)
エラーの返し方には、ProblemDetail を使いました。これは、HTTP API のエラーを JSON で返すための標準形式(RFC 9457)を表す Spring のクラスです。
forStatusAndDetail でステータスと詳細メッセージを指定して返すだけで、status / title / detail / instance(どの URL で起きたか)が入った JSON になります。さらに、Spring が ProblemDetail を JSON に変換するときに Content-Type を application/problem+json にしてくれます。自作のエラー用クラスを作る例もよく見かけますが、標準があるならそれに乗ることにしました。
テストも追加します。TaskControllerTest.java の createReturns201WithLocation の下に追加します(import の追加は不要です)。
@Test
void findByIdReturns404WhenNotFound() throws Exception {
when(taskService.findById(999L)).thenThrow(new TaskNotFoundException(999L));
mockMvc.perform(get("/api/tasks/999"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.title").value("Task Not Found"))
.andExpect(jsonPath("$.detail").value("タスクが見つかりません(id=999)"));
}
@WebMvcTest は @RestControllerAdvice の Bean も読み込むので、例外ハンドラ込みで Controller の振る舞いを確かめられます。Service のモックに thenThrow で例外を投げさせ、それが 404 に変換されることを確認しています。
[INFO] Tests run: 12, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] BUILD SUCCESS
動かしてみる
更新用の JSON を用意します。
requests/update-task.json
{"title": "牛乳を買う", "description": "低脂肪のもの", "completed": true}
アプリを起動し直すと、データはメモリ上にあったので消えています。先に create-task.json を POST して1件登録し直してから、PUT で完了にします。
curl.exe -i -X PUT http://localhost:8080/api/tasks/1 -H "Content-Type: application/json" --data-binary "@requests/update-task.json"
HTTP/1.1 200
Content-Type: application/json
Content-Length: 86
Date: Fri, 09 Oct 2026 06:11:05 GMT
{"id":1,"title":"牛乳を買う","description":"低脂肪のもの","completed":true}
completed が true になりました。続いて、手順3で 500 になった存在しない ID をもう一度取得してみます。
curl.exe -i http://localhost:8080/api/tasks/999
500 だったものが 404 になり、Content-Type も application/problem+json になりました。手順3の結果と並べると違いがよく分かります。
| 手順3(Before) | 手順4(After) | |
|---|---|---|
| ステータス | 500 Internal Server Error | 404 Not Found |
| 意味 | サーバー側の不具合 | 指定されたものがない(クライアント側の指定ミス) |
| 本文 | Spring Boot 標準のエラー形式 |
ProblemDetail(何が見つからないかが分かる) |
最後に DELETE を2回続けて叩いてみます。
curl.exe -i -X DELETE http://localhost:8080/api/tasks/1
curl.exe -i -X DELETE http://localhost:8080/api/tasks/1
HTTP/1.1 204
Date: Fri, 09 Oct 2026 06:11:05 GMT
HTTP/1.1 404
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Fri, 09 Oct 2026 06:11:05 GMT
{"detail":"タスクが見つかりません(id=1)","instance":"/api/tasks/1","status":404,"title":"Task Not Found"}
1回目は 204(本文なし)、2回目は1回目で消えているので 404 です。Service の delete で、先に existsById を確認しているのはこのためです。
手順5:入力チェック(Bean Validation)
最後に、空のタイトルが登録できてしまう問題を直します。アプリを Ctrl+C で止めてから編集します。
依存関係を追加する
pom.xml の <dependencies> に2つ追加します。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation-test</artifactId>
<scope>test</scope>
</dependency>
spring-boot-starter-validation は、Bean Validation(Jakarta Validation)の仕様と、その実装である Hibernate Validator を入れるスターターです。Spring Boot 2.3 以降、Web のスターターには含まれなくなったため、別途追加する必要があります。
2つ目の -validation-test は、Spring Boot 4 の Spring Initializr で Validation を選ぶと一緒に追加されるテスト用のスターターです。①で -webmvc と -webmvc-test がペアになっていたのと同じ考え方ですね。
IDE を使っている場合は、pom.xml を変更したあとに Maven の再読み込みをしないと、jakarta.validation の import が赤くなることがあります。mvnw での実行には影響しません。
DTO にルールを書く
TaskCreateRequest.java
package com.example.taskmanagementapi.task;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record TaskCreateRequest(
@NotBlank(message = "タイトルは必須です")
@Size(max = 100, message = "タイトルは100文字以内で入力してください")
String title,
@Size(max = 1000, message = "説明は1000文字以内で入力してください")
String description
) {
}
TaskUpdateRequest.java
package com.example.taskmanagementapi.task;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
public record TaskUpdateRequest(
@NotBlank(message = "タイトルは必須です")
@Size(max = 100, message = "タイトルは100文字以内で入力してください")
String title,
@Size(max = 1000, message = "説明は1000文字以内で入力してください")
String description,
@NotNull(message = "完了フラグは必須です")
Boolean completed
) {
}
-
@NotBlank:null・空文字・空白だけ、のどれも NG(文字列向け) -
@NotNull:nullだけ NG。手順2でcompletedをBooleanにしておいたのは、ここで「送られてこなかった」を弾くためです -
@Size(max = 100):文字数の上限。descriptionは任意項目なので、@NotBlankは付けず@Sizeだけにしています
message を指定しないと、Hibernate Validator 標準のメッセージが使われます。その言語は基本的にリクエストの Accept-Language ヘッダーで決まり、ヘッダーがない場合(curl や MockMvc の既定)はサーバーの既定ロケールになるため、環境によって日本語になったり英語になったりします。テストでメッセージの中身を確かめたいので、今回は自分で固定しました。
DTO を登録用と更新用に分けておいたおかげで、「更新時だけ completed が必須」というルールをそれぞれに書き分けられています。
Controller に @Valid を付ける
TaskController.java に import を追加し、create と update の @RequestBody の前に @Valid を付けます。
// 追加する import
import jakarta.validation.Valid;
public ResponseEntity<TaskResponse> create(@Valid @RequestBody TaskCreateRequest request) {
public TaskResponse update(@PathVariable Long id, @Valid @RequestBody TaskUpdateRequest request) {
@Valid を付け忘れても、エラーにならず素通りする
ここが今回いちばん気をつけたいポイントです。
DTO に @NotBlank を書いただけでは、何も起きません。Controller の引数に @Valid を付けて初めて、Spring がメソッドを呼ぶ前にチェックし、ルール違反があれば MethodArgumentNotValidException を投げます。
そして @Valid を付け忘れても、コンパイルエラーにも起動エラーにもならず、チェックが静かに素通りします。手順3の「空タイトルで 201」と同じ状態に戻るだけなので、気づきにくいのです。後で出てくるテストで「空タイトルなら 400」を確かめているのは、この付け忘れを防ぐためでもあります。
入力チェック失敗を ProblemDetail で返す
MethodArgumentNotValidException も GlobalExceptionHandler で受け止め、どの項目がダメだったかを返すようにします。ファイル全体は次のとおりです。
package com.example.taskmanagementapi.common;
import com.example.taskmanagementapi.task.TaskNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.LinkedHashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(TaskNotFoundException.class)
public ProblemDetail handleTaskNotFound(TaskNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Task Not Found");
return problem;
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> errors = new LinkedHashMap<>();
ex.getBindingResult().getFieldErrors()
.forEach(error -> errors.putIfAbsent(error.getField(), error.getDefaultMessage()));
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "入力値に誤りがあります");
problem.setTitle("Validation Failed");
problem.setProperty("errors", errors);
return problem;
}
}
ProblemDetail の標準の項目には、「どの項目がダメだったか」を入れる場所がありません。そこで setProperty で独自の errors 項目を追加し、{"title": "タイトルは必須です"} の形で返しています。画面側はこれを見て、入力欄の横にメッセージを出せます。
put ではなく putIfAbsent にしているのは、1つの項目が複数のルールに同時に違反したとき(例:@NotBlank と @Size の両方)に、最初の1つだけを返すためです。
テスト
TaskControllerTest.java に import を2つ追加し、テストを追加します。
// 追加する import
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@Test
void createReturns400WhenTitleIsBlank() throws Exception {
mockMvc.perform(post("/api/tasks")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"title": ""}
"""))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors.title").value("タイトルは必須です"));
verify(taskService, never()).create(any());
}
verify(taskService, never()).create(any()) で、入力チェックに失敗したら Service までたどり着かないことを確かめています。チェックは Controller の入り口で行われる、ということです。
全テスト13件が通った
.\mvnw.cmd test
①②のテスト3件に、今回の Repository 3件・Service 3件・Controller 4件が加わって、13件すべて通りました。
Before / After
アプリを起動して、手順3で 201 になってしまった空タイトルの登録をもう一度送ってみます。
curl.exe -i -X POST http://localhost:8080/api/tasks -H "Content-Type: application/json" --data-binary "@requests/create-task-blank.json"
今度はきちんと 400 で弾かれ、errors に「タイトルは必須です」と理由が入っています。
| 手順3(Before) | 手順5(After) | |
|---|---|---|
| ステータス | 201 Created | 400 Bad Request |
| 結果 | タイトルが空のタスクが登録された | 登録されず、理由が返る |
手順6:再起動するとデータが消える
最後に、ひとつ観察をしておきます。
- アプリが起動している状態で
create-task.jsonを POST し、一覧に表示されることを確認する - Ctrl+C で止めて、
.\mvnw.cmd spring-boot:runで再起動する - もう一度一覧を取得する
再起動前の一覧には、登録した1件が入っています。
[{"id":1,"title":"牛乳を買う","description":"低脂肪のもの","completed":false}]
ところが、再起動後に一覧を取得すると……
curl.exe -i http://localhost:8080/api/tasks
空っぽです。データは InMemoryTaskRepository の Map に入っていただけなので、アプリ(JVM)が止まるとメモリごと消えてしまいます。
これを解決するのが、次回④の DB 連携です。④では InMemoryTaskRepository を消して、TaskRepository を Spring Data JPA 版に差し替えます。そのとき TaskService と TaskController は(ほぼ)変えずに済むはずです。それこそが、今回わざわざ3層に分け、Repository をインターフェースにしておいた理由です。
主要な用語のメリット・デメリット
「便利だから使う」で終わらせず、何と引き換えにしているのかを整理しておきます。
Controller / Service / Repository の3層構造
| メリット | デメリット |
|---|---|
| 「HTTP の受け答え」「業務ルール」「データの出し入れ」と責務が分かれ、どこを直せばよいかが明確になる | 今回のような単純な CRUD では、Service がほぼ Repository を呼ぶだけになり、ファイルが増えただけに見える |
| 層ごとに差し替え・テストができる。④で保存先を DB に変えても Service と Controller はそのまま使える想定 | 1つの機能を追うのに、読むファイルが3つ以上になる |
DTO(TaskCreateRequest / TaskUpdateRequest / TaskResponse)
| メリット | デメリット |
|---|---|
API の入出力の形と、内部のデータの形を切り離せる。Task に項目が増えても、API に出す項目は自分で選べる |
Task → TaskResponse のような変換コードが必要になる |
クライアントに勝手に変えてほしくない項目(id など)を、そもそもリクエストで受け取らない形にできる |
似たようなクラスが増える(今回は3つ) |
| 入力チェックのルールを、用途ごとに書き分けられる(手順5) | 項目を追加するとき、Task と DTO の両方を直す必要がある |
Repository をインターフェース+実装に分けること
| メリット | デメリット |
|---|---|
| Service はインターフェースだけを知っているので、④で中身を DB 版に差し替えても Service を変えずに済む | ファイルが2つになる(②の GreetingService では YAGNI として分けなかった) |
| テストでインターフェースのモックを作りやすい(手順2) | 実装が1つしかない間は、インターフェースを読む → 実装を探す、と1手間増える |
インメモリ保存(ConcurrentHashMap + AtomicLong)
| メリット | デメリット |
|---|---|
| DB の準備なしで、すぐに API の動きを試せる | アプリを止めるとデータがすべて消える(手順6) |
| 仕組みが単純で、Repository が何をしているかが目で見て分かる | 複数のサーバーでデータを共有できない。検索・並べ替えも自分で書く必要がある |
ConcurrentHashMap / AtomicLong を使えば、同時アクセスでもデータが壊れない |
普通の HashMap や long を使うと、同時アクセスで壊れる可能性がある(②の「シングルトン Bean」の注意点そのもの) |
@RestControllerAdvice による例外の一元管理
| メリット | デメリット |
|---|---|
Controller ごとに try-catch を書かずに済み、Controller は正常系の処理だけに集中できる(手順4) |
例外がどこで処理されているかが Controller のコードからは見えない(②の DI と同じ「見えない」系の難しさ) |
| エラーレスポンスの形式をアプリ全体でそろえられる | 対応していない例外(壊れた JSON など)は Spring Boot 標準の形式のまま返り、形式がそろわない部分が残る(後述の「残課題」) |
ProblemDetail(RFC 9457)
| メリット | デメリット |
|---|---|
| 標準化された形式なので、クライアント側も「エラーはこの形」と決め打ちで扱える | 標準の項目だけでは足りないことが多く、今回の errors のように独自の項目を追加することになる |
| Spring が標準でサポートしており、クラスを自作しなくてよい | 古い記事では自作のエラー用クラスを使う例が多く、情報が混在している |
Bean Validation(@Valid + @NotBlank など)
| メリット | デメリット |
|---|---|
| ルールをアノテーションで宣言するだけで、チェック処理を書かなくてよい | 「タイトルの重複禁止」のように、DB を見ないと判断できないルールは書けない(Service 側で書く) |
| ルールが DTO のフィールドの横に並ぶので、仕様が読み取りやすい |
@Valid を付け忘れると、エラーにもならず静かにチェックが素通りする |
正直なところ、今回の題材は小さいので、デメリットのほうが目立つ部分もあります。3層構造と Repository インターフェースのメリットが本当に効いてくるかは、④で DB に切り替える場面で確かめます。
つまずいたポイントまとめ
| つまずき | 原因 | 対処 |
|---|---|---|
curl で日本語の JSON を送ると Invalid UTF-8 start byte で 400 になる |
Windows のコマンドラインに書いた日本語が Shift_JIS で送られる | JSON を UTF-8 のファイルに書き、--data-binary "@ファイル名" で送る |
| ターミナルで日本語の表示が文字化けする | ターミナルの文字コード |
chcp 65001 を実行する(表示だけの問題) |
| 存在しない ID を指定すると 500 になる | Service が投げた例外を誰も受け止めていない |
@RestControllerAdvice で受け止め、ProblemDetail で 404 を返す |
| 空のタイトルでも登録できてしまう | 入力チェックがない | Bean Validation のアノテーションを DTO に書き、Controller の引数に @Valid を付ける |
ネットの記事の ObjectMapper の import が使えないことがある |
Spring Boot 4 は Jackson 3 で、パッケージ名が変わっている | テストの JSON はテキストブロックで文字列として書く |
残課題
今回の範囲では扱わず、後の回に回したものです。
-
壊れた JSON や
/api/tasks/abcは、Spring Boot 標準のエラー形式で返る:{"title":のような壊れた JSON や、ID に数値以外を指定したときも 400 にはなります。ただし本文は{"timestamp":...,"status":400,"error":"Bad Request","path":...}という Spring Boot 標準の形式で、ProblemDetailの形にはそろっていません。全部のエラーをProblemDetailにそろえる方法は、⑤設定・プロファイル編か⑥テスト編で調べます - テスト実行時に Mockito の WARNING が出る:②から持ち越している件です。⑥テスト編で扱います
まとめ
今回は、Controller / Service / Repository の3層で、タスクの CRUD API を作りました。
- Repository は「保存・取得」、Service は「ルールの判断」、Controller は「HTTP の入出力」と、責務を分けて作った
-
Taskをそのまま返さず、登録用・更新用・レスポンス用の DTO をrecordで作った - POST は 201 Created +
Location、DELETE は 204 No Content と、操作に合ったステータスを返した - 存在しない ID で 500 になっていたのを、
@RestControllerAdvice+ProblemDetailで 404 にした - 空のタイトルで登録できていたのを、Bean Validation で 400 にした
- 再起動するとデータが消える。これは④で解決する
今回いちばん実感したのは、「わざと問題を残して動かしてから直す」と、そのコードが必要な理由がはっきりすることでした。最初から @RestControllerAdvice や @Valid 入りの完成形を書いていたら、「500 で返ってくると何が困るのか」「@Valid を付け忘れるとどうなるのか」を、ここまで具体的にはイメージできなかったと思います。
次回予告
次回は④DB連携編です。Spring Data JPA を導入し、データの保存先をメモリから DB(まずは H2、次に PostgreSQL)に切り替えます。
今回 TaskRepository のメソッド名を JpaRepository に合わせておいたので、「インターフェースを書き換えるだけ」で本当に DB 版になるのか、そして TaskService と TaskController を本当に変えずに済むのか。今回の設計が報われるかどうか、確かめるのが楽しみです。




