0
0

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 ③3層構造でタスクのCRUDを作る

0
Last updated at Posted at 2026-10-19

note. (43).png

はじめに

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

02_get-999-404.png

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

01_mvnw-test-13-success.png

①②のテスト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"

03_post-blank-400.png

今度はきちんと 400 で弾かれ、errors に「タイトルは必須です」と理由が入っています。

手順3(Before) 手順5(After)
ステータス 201 Created 400 Bad Request
結果 タイトルが空のタスクが登録された 登録されず、理由が返る

手順6:再起動するとデータが消える

最後に、ひとつ観察をしておきます。

  1. アプリが起動している状態で create-task.json を POST し、一覧に表示されることを確認する
  2. Ctrl+C で止めて、.\mvnw.cmd spring-boot:run で再起動する
  3. もう一度一覧を取得する

再起動前の一覧には、登録した1件が入っています。

[{"id":1,"title":"牛乳を買う","description":"低脂肪のもの","completed":false}]

ところが、再起動後に一覧を取得すると……

curl.exe -i http://localhost:8080/api/tasks

04_restart-empty.png

空っぽです。データは 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 を本当に変えずに済むのか。今回の設計が報われるかどうか、確かめるのが楽しみです。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?