コンテンツにスキップ

10-4. Spring Bootで書籍APIの土台とCRUDを作る

  • REST の考え方で書籍 API の URL・HTTP メソッド・ステータスコードを設計する方法
  • Controller / Service / DTO を分ける理由
  • @Valid とバリデーションアノテーションで不正な入力を早めに止める方法
  • 例外ハンドリングで 400 / 404 / 201 / 204 を明確に返す方法
  • HTTP ステータス、レスポンスボディ、ログを使って API の問題を切り分ける方法

10-3 までで、フロントエンド側では一覧表示・追加・更新・削除・入力チェックまでそろった。 ここからは、そのデータをブラウザ内だけで持つのではなく、Spring Boot の API として扱える形 へ広げていく。

ただし、いきなりデータベースへ進む前に、まずは メモリ上のデータ で CRUD の流れを完成させる。 これにより、REST 設計・DTO・バリデーション・例外処理を DB なしで先に理解できる。


このセクションの終わりでは、次の API が動く状態を目指す。

操作HTTP メソッドURL代表的な成功コード
一覧取得GET/api/books200 OK
1件取得GET/api/books/{id}200 OK
新規作成POST/api/books201 Created
状態更新PATCH/api/books/{id}200 OK
削除DELETE/api/books/{id}204 No Content

アプリの流れは次のようになる。

HTTPリクエスト(JSON)
Controller
↓ DTO で受け取る
Service
↓ Map<Long, Book> で保持する
HTTPレスポンス(JSON)

エラー時には次のような JSON を返す。

{
"message": "入力値を確認してください",
"errors": {
"title": "タイトルは必須です",
"category": "カテゴリは必須です",
"status": "状態は必須です"
}
}

10-4 は Java ファイルが一気に増えるので、1エンドポイントを追加するごとに curl で動作を確かめながら進める ほうが理解しやすい。

各実装段階の末尾に「ここで確認する」を置く。毎回、次の順で進めよう。

  1. 指定されたファイルを追加・修正する
  2. 保存する
  3. 1本目のターミナルでサーバーを再起動する(Ctrl+C → ./gradlew bootRun
  4. 2本目のターミナルで curl -i を実行する
  5. HTTP ステータスとレスポンスボディを確認する

見た目が変わらないステップもある。 その場合は「まだ HTTP の入口まで届いていない」「今はファイル配置だけ整えた段階だ」と説明できればよい。


書籍管理 API を作るとき、「追加」「一覧」「削除」などの操作は決まっている。 そこで URL と HTTP メソッドに一貫したルールを持たせるのが REST の考え方である。

/api/books + GET → 一覧
/api/books/1 + GET → 1件取得
/api/books + POST → 新規作成
/api/books/1 + PATCH → 状態更新
/api/books/1 + DELETE → 削除

こうしておく利点は次のとおりである。

  • URL だけ見て「何を扱う API か」が分かる
  • HTTP メソッドだけ見て「読み取りか更新か」が分かる
  • フロントエンドやテストコードからも予測しやすい

REST は「おしゃれな流行語」ではなく、チームで API を読みやすくする共通ルール だと考えると実務に結び付きやすい。


2. Controller / Service / DTO を分ける

Section titled “2. Controller / Service / DTO を分ける”

まず全体の責務を整理する。

Controller
├─ URL、HTTPメソッド、ステータスコードを扱う
└─ Request/Response DTO を受け渡しする
Service
├─ 書籍を追加する
├─ 既存書籍の状態を更新する
└─ 見つからない場合に例外を投げる
DTO
├─ APIで受け取る入力の形
└─ APIから返す出力の形

これを1クラスに詰め込むと、HTTP の話と業務ルールの話が混ざって読みにくくなる。 分けておくと、「今どこを直すべきか」が明確になる。

Spring Boot プロジェクトでは、src/main/java の下に package 名と同じ階層でフォルダを作る。 このセクションで出てくる package com.example.bookmanager... は、VS Code 上では次の配置になる。

bookmanager/
├── build.gradle
├── gradlew
├── gradlew.bat
├── settings.gradle
└── src/
└── main/
├── java/
│ └── com/
│ └── example/
│ └── bookmanager/
│ ├── controller/
│ │ └── BookController.java
│ ├── dto/
│ │ ├── BookResponse.java
│ │ ├── CreateBookRequest.java
│ │ └── UpdateBookRequest.java
│ ├── exception/
│ │ ├── BookNotFoundException.java
│ │ └── GlobalExceptionHandler.java
│ ├── model/
│ │ └── Book.java
│ └── service/
│ └── BookService.java
└── resources/
└── application.properties

build.gradlegradlew はプロジェクト直下、Java のソースコードは src/main/java/...、設定ファイルは src/main/resources/ に置く。 コード例の先頭にある package 行は、そのファイルが入るフォルダの住所 だと考えると迷いにくい。

役割置き場所この章で作るファイル
DTOsrc/main/java/com/example/bookmanager/dto/CreateBookRequest.javaUpdateBookRequest.javaBookResponse.java
Modelsrc/main/java/com/example/bookmanager/model/Book.java
Servicesrc/main/java/com/example/bookmanager/service/BookService.java
Controllersrc/main/java/com/example/bookmanager/controller/BookController.java
Exceptionsrc/main/java/com/example/bookmanager/exception/BookNotFoundException.javaGlobalExceptionHandler.java
設定src/main/resources/application.properties(10-6 で追加)

2-2. VS Code でファイルを作る手順

Section titled “2-2. VS Code でファイルを作る手順”
  1. 10-0 で book-app/bookmanager/ を VS Code で開く
  2. エクスプローラーで src/main/java/com/example/bookmanager/ を開く
  3. dtomodelservicecontrollerexception フォルダを作る
  4. それぞれのフォルダの中に .java ファイルを作る
  5. 先頭の package 文とフォルダの位置が一致しているか確認する

たとえば package com.example.bookmanager.dto; と書くなら、ファイルは src/main/java/com/example/bookmanager/dto/ の下に置く。 フォルダがずれると import エラーやコンパイルエラーになりやすいので、「package 名 = フォルダの並び」 を必ず見比べる。

  • VS Code の Explorer で dtomodelservicecontrollerexception フォルダが src/main/java/com/example/bookmanager/ の下に並んでいることを確認する
  • これから作るファイルの置き場を、自分の言葉で説明できるか確かめる

API では、内部で使う Book オブジェクトと、外へ見せる JSON の形を分けるほうが安全である。 ここでは DTO を record でシンプルに書く。

10-0 で https://start.spring.io/ から Gradle - Groovy を選び、Spring Web / Validation / Spring Data JPA / H2 Database を入れて生成していれば、build.gradle には次の依存関係が入っている。

確認する場所: Spring Initializr で生成した build.gradledependencies ブロックに以下が含まれていることを確認する(自動生成済み)。

build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-h2console'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
runtimeOnly 'com.h2database:h2'
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
testImplementation 'org.springframework.boot:spring-boot-starter-validation-test'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

次に、リクエスト DTO を作る。

CreateBookRequest.java
package com.example.bookmanager.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
public record CreateBookRequest(
@NotBlank(message = "タイトルは必須です")
String title,
@NotBlank(message = "著者名は必須です")
String author,
@NotBlank(message = "カテゴリは必須です")
String category,
@NotNull(message = "価格は必須です")
@Min(value = 0, message = "価格は0以上で入力してください")
Integer price,
@NotBlank(message = "状態は必須です")
String status,
@Size(max = 120, message = "メモは120文字以内で入力してください")
String memo
) {
}
UpdateBookRequest.java
package com.example.bookmanager.dto;
import jakarta.validation.constraints.NotBlank;
public record UpdateBookRequest(
@NotBlank(message = "状態は必須です")
String status
) {
}

今回は 10-3 のフロントエンド導線に合わせ、更新 API は書籍の状態だけを切り替える形にする。

レスポンス DTO も用意する。

BookResponse.java
package com.example.bookmanager.dto;
import com.example.bookmanager.model.Book;
public record BookResponse(
Long id,
String title,
String author,
String category,
Integer price,
String status,
String memo
) {
public static BookResponse from(Book book) {
return new BookResponse(
book.getId(),
book.getTitle(),
book.getAuthor(),
book.getCategory(),
book.getPrice(),
book.getStatus(),
book.getMemo()
);
}
}

内部モデルをそのまま返すと、後でフィールドが増えたときに API まで意図せず変わる危険がある。 DTO は 外部との約束 を固定する役割を持つ。

  • CreateBookRequest.javaUpdateBookRequest.javaBookResponse.java の 3 ファイルが src/main/java/com/example/bookmanager/dto/ の下にあることを確認する
  • 各ファイルの先頭の package com.example.bookmanager.dto; と実際のフォルダ位置が一致しているか見直す
  • VS Code の Problems に import や package のエラーが出ていないことを確認する

4. 内部で使う Book モデルを作る

Section titled “4. 内部で使う Book モデルを作る”

今回は DB をまだ使わないので、サービスの内部で扱う Book クラスを用意し、Map<Long, Book> で管理する。

Book.java
package com.example.bookmanager.model;
public class Book {
private Long id;
private String title;
private String author;
private String category;
private Integer price;
private String status;
private String memo;
public Book(Long id, String title, String author, String category, Integer price, String status, String memo) {
this.id = id;
this.title = title;
this.author = author;
this.category = category;
this.price = price;
this.status = status;
this.memo = memo;
}
public Long getId() {
return id;
}
public String getTitle() {
return title;
}
public String getAuthor() {
return author;
}
public String getCategory() {
return category;
}
public Integer getPrice() {
return price;
}
public String getStatus() {
return status;
}
public String getMemo() {
return memo;
}
public void updateStatus(String status) {
this.status = status;
}
}

Book はアプリ内部の状態を表し、DTO は HTTP の入出力を表す。 同じ「書籍」を扱っていても、役割が違うので分けて考える。

  • src/main/java/com/example/bookmanager/model/Book.java が作成されていることを確認する
  • getter と updateStatus() がそろっているか見直す
  • まだ API は起動しなくてよい。今は Service が扱う内部モデルを用意した段階 である

5. 一覧取得を実装して最初に動かす(GET /api/books)

Section titled “5. 一覧取得を実装して最初に動かす(GET /api/books)”

Service と Controller の最小構成を作り、最初の curl で動作を確かめる。

まず「存在しない ID を問い合わせたとき」の例外を先に定義しておく。 後の Service でこの例外を使う。

BookNotFoundException.java
package com.example.bookmanager.exception;
public class BookNotFoundException extends RuntimeException {
public BookNotFoundException(Long id) {
super("id=" + id + " の書籍は見つかりません");
}
}

この例外を HTTP レスポンスへ変換する GlobalExceptionHandler はセクション 10 で追加する。

一覧取得と、存在しない ID への対応で使う getBookOrThrow を先に用意する。

BookService.java
package com.example.bookmanager.service;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Service;
import com.example.bookmanager.dto.BookResponse;
import com.example.bookmanager.dto.CreateBookRequest;
import com.example.bookmanager.dto.UpdateBookRequest;
import com.example.bookmanager.exception.BookNotFoundException;
import com.example.bookmanager.model.Book;
@Service
public class BookService {
private final Map<Long, Book> books = new LinkedHashMap<>();
private final AtomicLong sequence = new AtomicLong(2L);
public BookService() {
books.put(1L, new Book(1L, "リーダブルコード", "Dustin Boswell", "バックエンド", 3200, "読書中", "第3章まで読了"));
books.put(2L, new Book(2L, "ドメイン駆動設計入門", "Eric Evans", "設計", 5400, "未読", "週末に読み始める"));
}
public List<BookResponse> findAll() {
return books.values().stream()
.map(BookResponse::from)
.toList();
}
private Book getBookOrThrow(Long id) {
Book book = books.get(id);
if (book == null) {
throw new BookNotFoundException(id);
}
return book;
}
}

LinkedHashMap は追加順を保つので、一覧の表示順が安定しやすい。 sequence は後のステップで採番に使う。getBookOrThrow は ID で本を取り出し、見つからなければ例外を投げる共通処理である。

BookController.java
package com.example.bookmanager.controller;
import java.util.List;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.example.bookmanager.dto.BookResponse;
import com.example.bookmanager.service.BookService;
@RestController
@CrossOrigin(origins = "*")
@RequestMapping("/api/books")
public class BookController {
private final BookService bookService;
public BookController(BookService bookService) {
this.bookService = bookService;
}
@GetMapping
public List<BookResponse> listBooks() {
return bookService.findAll();
}
}

@CrossOrigin(origins = "*") は 10-5 でブラウザから fetch できるようにするための設定である。 実務では許可するオリジンを絞るが、ここでは API 連携の流れを理解すること を優先する。

  1. 1本目のターミナルで ./gradlew bootRun を実行する
  2. Tomcat started on port 8080Started ...Application が出るまで待つ
  3. 2本目のターミナルで次を実行する
Terminal window
curl -i http://localhost:8080/api/books

期待する結果:

HTTP/1.1 200
[
{"id":1,"title":"リーダブルコード","author":"Dustin Boswell","category":"バックエンド","price":3200,"status":"読書中","memo":"第3章まで読了"},
{"id":2,"title":"ドメイン駆動設計入門","author":"Eric Evans","category":"設計","price":5400,"status":"未読","memo":"週末に読み始める"}
]

ここで初めて Controller と Service が HTTP でつながった ことが見える。

HTTP/1.1 200 ではなく 404 が返ったときは、次の順に確認する。

1. サーバーのログに Started BookManagerApplication が出ているか

1本目のターミナルを確認する。Tomcat started on port 8080 より後に Started BookManagerApplication のような行が出ていれば起動済みである。出ていない場合は、その直前に ERROR がないかを確認する。

2. package 宣言とフォルダ構成が一致しているか

BookController.java の先頭行が package com.example.bookmanager.controller; になっているか、ファイルが src/main/java/com/example/bookmanager/controller/ の下にあるかを確認する。package 名とフォルダがずれていると Spring が Controller を見つけられず 404 になる。

正しい位置
src/main/java/com/example/bookmanager/controller/BookController.java
先頭: package com.example.bookmanager.controller;
ずれている例
src/main/java/com/example/BookController.java
先頭: package com.example.bookmanager.controller; ← フォルダと合っていない

上記を確認・修正したらサーバーを再起動して curl を再実行する。


6. 1件取得を追加する(GET /api/books/{id})

Section titled “6. 1件取得を追加する(GET /api/books/{id})”

追加する場所: findAll() の後、getBookOrThrow() の前に追加する。

BookService.java
public List<BookResponse> findAll() {
return books.values().stream()
.map(BookResponse::from)
.toList();
}
public BookResponse findById(Long id) {
return BookResponse.from(getBookOrThrow(id));
}
private Book getBookOrThrow(Long id) {

6-2. Controller に getBook を追加する

Section titled “6-2. Controller に getBook を追加する”

追加する import: ファイル先頭の import ブロックに追加する。

BookController.java
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.PathVariable;

追加するメソッド: listBooks() の後、クラスの閉じカッコ前に追加する。

BookController.java
@GetMapping
public List<BookResponse> listBooks() {
return bookService.findAll();
}
@GetMapping("/{id}")
public BookResponse getBook(@PathVariable Long id) {
return bookService.findById(id);
}
}

サーバーを止めて再起動(Ctrl+C → ./gradlew bootRun)してから確認する。

Terminal window
curl -i http://localhost:8080/api/books/1

期待する結果:

HTTP/1.1 200
{"id":1,"title":"リーダブルコード","author":"Dustin Boswell","category":"バックエンド","price":3200,"status":"読書中","memo":"第3章まで読了"}

ID 1 の本だけが返ることを確認する。
パラメータを2に変えて、ID 2 の本も返ることを確かめよう。


7. 新規作成を追加する(POST /api/books)

Section titled “7. 新規作成を追加する(POST /api/books)”

追加する場所: findById() の後に追加する。

BookService.java
public BookResponse findById(Long id) {
return BookResponse.from(getBookOrThrow(id));
}
public BookResponse create(CreateBookRequest request) {
Long id = sequence.incrementAndGet();
Book book = new Book(id, request.title(), request.author(), request.category(), request.price(), request.status(), request.memo());
books.put(id, book);
return BookResponse.from(book);
}
private Book getBookOrThrow(Long id) {

7-2. Controller に createBook を追加する

Section titled “7-2. Controller に createBook を追加する”

追加する import:

BookController.java
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import jakarta.validation.Valid;

また CreateBookRequest も import に追加する。

BookController.java
import com.example.bookmanager.dto.CreateBookRequest;

追加するメソッド: getBook() の後に追加する。

BookController.java
@GetMapping("/{id}")
public BookResponse getBook(@PathVariable Long id) {
return bookService.findById(id);
}
@PostMapping
public ResponseEntity<BookResponse> createBook(@Valid @RequestBody CreateBookRequest request) {
BookResponse response = bookService.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
}

@Valid で DTO のバリデーションが有効になる。201 Created は「新規作成に成功した」ことを表すステータスである。

サーバーを再起動してから確認する。

Terminal window
curl -i -X POST http://localhost:8080/api/books \
-H "Content-Type: application/json" \
-d '{"title":"実践Java設計","author":"研修 花子","category":"バックエンド","price":2800,"status":"未読","memo":"演習用サンプル"}'

期待する結果:

HTTP/1.1 201
{"id":3,"title":"実践Java設計","author":"研修 花子","category":"バックエンド","price":2800,"status":"未読","memo":"演習用サンプル"}

レスポンスに id が含まれ、201 が返ることを確認する。続けて一覧取得を実行し、登録した本が含まれていることも確かめよう。

Terminal window
curl -i http://localhost:8080/api/books

8. 状態更新を追加する(PATCH /api/books/{id})

Section titled “8. 状態更新を追加する(PATCH /api/books/{id})”

追加する場所: create() の後に追加する。

BookService.java
public BookResponse create(CreateBookRequest request) {
Long id = sequence.incrementAndGet();
Book book = new Book(id, request.title(), request.author(), request.category(), request.price(), request.status(), request.memo());
books.put(id, book);
return BookResponse.from(book);
}
public BookResponse update(Long id, UpdateBookRequest request) {
Book book = getBookOrThrow(id);
book.updateStatus(request.status());
return BookResponse.from(book);
}
private Book getBookOrThrow(Long id) {

8-2. Controller に updateBookStatus を追加する

Section titled “8-2. Controller に updateBookStatus を追加する”

追加する import:

BookController.java
import org.springframework.web.bind.annotation.PatchMapping;
import com.example.bookmanager.dto.UpdateBookRequest;

追加するメソッド: createBook() の後に追加する。

BookController.java
@PostMapping
public ResponseEntity<BookResponse> createBook(@Valid @RequestBody CreateBookRequest request) {
BookResponse response = bookService.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@PatchMapping("/{id}")
public BookResponse updateBookStatus(@PathVariable Long id, @Valid @RequestBody UpdateBookRequest request) {
return bookService.update(id, request);
}
}

サーバーを再起動してから確認する。

Terminal window
curl -i -X PATCH http://localhost:8080/api/books/1 \
-H "Content-Type: application/json" \
-d '{"status":"読了"}'

期待する結果:

HTTP/1.1 200
{"id":1,"title":"リーダブルコード","author":"Dustin Boswell","category":"バックエンド","price":3200,"status":"読了","memo":"第3章まで読了"}

status読了 へ変わっていることを確認する。


9. 削除を追加する(DELETE /api/books/{id})

Section titled “9. 削除を追加する(DELETE /api/books/{id})”

追加する場所: update() の後、getBookOrThrow() の前に追加する。

BookService.java
public BookResponse update(Long id, UpdateBookRequest request) {
Book book = getBookOrThrow(id);
book.updateStatus(request.status());
return BookResponse.from(book);
}
public void delete(Long id) {
Book removed = books.remove(id);
if (removed == null) {
throw new BookNotFoundException(id);
}
}
private Book getBookOrThrow(Long id) {

9-2. Controller に deleteBook を追加する

Section titled “9-2. Controller に deleteBook を追加する”

追加する import:

BookController.java
import org.springframework.web.bind.annotation.DeleteMapping;

追加するメソッド: updateBookStatus() の後に追加する。

BookController.java
@PatchMapping("/{id}")
public BookResponse updateBookStatus(@PathVariable Long id, @Valid @RequestBody UpdateBookRequest request) {
return bookService.update(id, request);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteBook(@PathVariable Long id) {
bookService.delete(id);
return ResponseEntity.noContent().build();
}
}

204 No Content は「削除に成功し、返す本文はない」ことを表すステータスである。

サーバーを再起動してから確認する。

Terminal window
curl -i -X DELETE http://localhost:8080/api/books/2

期待する結果:

HTTP/1.1 204

本文が返らないことを確認する。続けて一覧取得を行い、id=2 の本が含まれていないことも確かめよう。

Terminal window
curl -i http://localhost:8080/api/books

10. 例外を HTTP レスポンスへ変換する

Section titled “10. 例外を HTTP レスポンスへ変換する”

ここまでの実装では、存在しない ID や不正入力を渡すと 500 が返ってしまう。 GlobalExceptionHandler を追加し、例外を意味のある HTTP ステータスへ変換する。

GlobalExceptionHandler.java
package com.example.bookmanager.exception;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BookNotFoundException.class)
public ResponseEntity<Map<String, String>> handleBookNotFound(BookNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("message", ex.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> errors = new LinkedHashMap<>();
ex.getBindingResult().getFieldErrors()
.forEach(error -> errors.put(error.getField(), error.getDefaultMessage()));
Map<String, Object> body = new LinkedHashMap<>();
body.put("message", "入力値を確認してください");
body.put("errors", errors);
return ResponseEntity.badRequest().body(body);
}
}

これにより、

  • 存在しない ID → 404 Not Found
  • 入力値不正 → 400 Bad Request

と意味のある応答が返せるようになる。

サーバーを再起動してから、2本ずつ試す。

存在しない ID の確認:

Terminal window
curl -i http://localhost:8080/api/books/999
HTTP/1.1 404
{"message":"id=999 の書籍は見つかりません"}

バリデーションエラーの確認:

Terminal window
curl -i -X POST http://localhost:8080/api/books \
-H "Content-Type: application/json" \
-d '{"title":"","author":"","category":"","price":-1,"status":""}'
HTTP/1.1 400
{
"message": "入力値を確認してください",
"errors": {
"title": "タイトルは必須です",
"author": "著者名は必須です",
"category": "カテゴリは必須です",
"price": "価格は0以上で入力してください",
"status": "状態は必須です"
}
}

message だけでなく、どの項目が悪いかを errors で返せていることを確認する。


11. なぜ今 DTO とバリデーションを入れるのか

Section titled “11. なぜ今 DTO とバリデーションを入れるのか”

「まだ DB もないのに DTO や @Valid は早すぎないか」と感じるかもしれない。 しかし実際は逆で、早い段階で入れておくほうが後の変更に強い。

入力の形を固定する
Controller と Service の責務が分かれる
DB を入れても API の契約を保ちやすい

10-6 で JPA に切り替えるときも、Controller の DTO 契約が保たれていれば、変更範囲を主に Service と Repository 側へ閉じ込めやすい。


失敗起きやすい原因どう確認するか
@Valid を付けたのに無効spring-boot-starter-validation を追加していないbuild.gradle を確認する
400 ではなく 500 になるGlobalExceptionHandler がない、または別の例外が飛んでいるログと @RestControllerAdvice を確認する
POST で値が入らない@RequestBody を付け忘れたController の引数を確認する
404 になるパス変数や URL がずれている/api/books/{id} と実際のリクエストを見比べる
DELETE 後に本文が返る204 No Content の意味を誤解しているResponseEntity.noContent() を使う
DTO と内部モデルが混ざるController から Book を直接返しているBookResponse を返す形に戻す

REST API の不具合は、次の順で切り分けると落ち着いて追いやすい。

200台 → 成功
400台 → リクエスト側の問題
500台 → サーバー側の問題

これだけで「入力を見直すべきか」「コードを直すべきか」の方向が見える。

2. レスポンス本文のメッセージを読む

Section titled “2. レスポンス本文のメッセージを読む”

バリデーションエラーなら、どのフィールドが問題かを本文で返す。 画面やテストが失敗したときも、この本文があると原因を特定しやすい。

3. アプリケーションログを見る

Section titled “3. アプリケーションログを見る”

500 が出たら、サーバー側のスタックトレースを確認する。 最初に出てくる例外名と、自分のクラス名が出てくる行を追う。

ブラウザ経由だと UI の問題と混ざる。 curl -i なら、純粋に HTTP の入出力だけを確認できる。

操作入力期待する結果
新規作成正しい JSON201 と作成データ
新規作成タイトル空400 とエラー詳細
1件取得存在しない ID404

こうして期待値を明文化すると、テストも書きやすくなる。 これは 10-7 のテストと切り分けにも直結する考え方である。


キーワード説明
RESTURL と HTTP メソッドに一貫した意味を持たせる API 設計
DTOAPI の入力・出力の形を表す専用の型
ControllerHTTP とアプリ処理をつなぐ入口
Service業務ルールや CRUD の中心処理
@ValidDTO の入力チェックを有効にするアノテーション
201 Created新規作成成功を表すステータス
204 No Content削除成功で本文がないことを表すステータス
400 Bad Request入力不正
404 Not Found指定した資源が見つからない
@RestControllerAdvice例外を HTTP レスポンスへ変換する仕組み

まずは 演習問題 に取り組み、REST の設計・DTO・バリデーション・ステータスコードの対応を整理しよう。

その後は 10-5. fetchでフロントエンドとAPIを接続する へ進み、今作った API をブラウザの画面から呼べるようにしよう。