こんにちは、かつコーチです。
これまでのSpring Boot編では、DI・JPA・Security・テストと、個別のテーマを1本ずつ掘り下げてきました。
今回はその集大成として、タスク管理REST APIを最初から最後まで一気通貫で実装します。
Entity定義からCRUDエンドポイント、バリデーション、例外ハンドリング、そして動作確認まで、実務でREST APIを作るときの一連の流れをそのまま体験できる構成にしました。
この記事で扱うコードはすべて動く完全な形で載せているので、手元で動かしながら読み進めてください。
タスク管理APIの全体設計
作るものと使う技術要素
今回作るのは、以下のエンドポイントを持つシンプルなタスク管理APIです。
| メソッド | パス | 用途 |
|---|---|---|
| GET | /api/tasks | タスク一覧取得 |
| GET | /api/tasks/{id} | タスク1件取得 |
| POST | /api/tasks | タスク作成 |
| PUT | /api/tasks/{id} | タスク更新 |
| DELETE | /api/tasks/{id} | タスク削除 |
使う技術要素は、これまでの記事で個別に解説してきたものの組み合わせです。
- Spring Data JPA(Entity・Repository)
- Bean Validation(入力チェック)
- DTOとEntityの使い分け
- @ControllerAdviceによる例外ハンドリング
- Lombokによるコード簡略化
一つひとつは既出のテーマですが、実際のプロジェクトではこれらを組み合わせて1本のAPIに仕上げる工程こそが重要です。
個別記事だけを読んで「わかったつもり」になっていた部分が、通しで実装すると意外とつまずくというのはよくあることです。
プロジェクトのディレクトリ構成
レイヤードアーキテクチャの記事で紹介した構成に沿って、以下のようにパッケージを分けます。
src/main/java/com/example/taskapi
├── TaskApiApplication.java
├── controller
│ └── TaskController.java
├── service
│ └── TaskService.java
├── repository
│ └── TaskRepository.java
├── entity
│ └── Task.java
├── dto
│ ├── TaskRequest.java
│ └── TaskResponse.java
└── exception
├── TaskNotFoundException.java
└── GlobalExceptionHandler.java
コントローラ・サービス・リポジトリを役割ごとに分離することで、後から機能を追加するときも影響範囲を把握しやすくなります。
実装手順:Entityからエンドポイントまで
手順1:Entityを定義する
まずはタスクを表すTaskエンティティを定義します。
package com.example.taskapi.entity;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.time.LocalDateTime;
@Entity
@Table(name = "tasks")
@Getter
@Setter
@NoArgsConstructor
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String title;
@Column(length = 500)
private String description;
@Column(nullable = false)
private boolean completed = false;
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
void onCreate() {
this.createdAt = LocalDateTime.now();
}
}
@PrePersistで作成日時を自動セットしているのは、実務でもよく使うテクニックです。
createdAtをリクエストパラメータとして受け取ってしまうと、クライアント側で任意の日時を送信できてしまうため、サーバー側で強制的に設定しています。
手順2:Repositoryを定義する
Spring Data JPAの記法どおり、インターフェースを定義するだけでCRUDメソッドが揃います。
package com.example.taskapi.repository;
import com.example.taskapi.entity.Task;
import org.springframework.data.jpa.repository.JpaRepository;
public interface TaskRepository extends JpaRepository<Task, Long> {
}
今回は単純なCRUDのみのため追加のクエリメソッドは不要ですが、将来的に「未完了のタスクだけ取得する」といった要件が出た場合は、以下のようにクエリメソッドを1行追加するだけで対応できます。
List<Task> findByCompletedFalse();
手順3:DTOでリクエスト・レスポンスを分離する
Entityをそのままレスポンスとして返すのではなく、DTOを経由させます。
理由はDTOとEntityの使い分けの記事で解説したとおり、Entityの内部構造をAPIの外部仕様に直結させないためです。
package com.example.taskapi.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class TaskRequest {
@NotBlank(message = "タイトルは必須です")
@Size(max = 100, message = "タイトルは100文字以内で入力してください")
private String title;
@Size(max = 500, message = "説明は500文字以内で入力してください")
private String description;
}
package com.example.taskapi.dto;
import com.example.taskapi.entity.Task;
import lombok.Getter;
import java.time.LocalDateTime;
@Getter
public class TaskResponse {
private final Long id;
private final String title;
private final String description;
private final boolean completed;
private final LocalDateTime createdAt;
public TaskResponse(Task task) {
this.id = task.getId();
this.title = task.getTitle();
this.description = task.getDescription();
this.completed = task.isCompleted();
this.createdAt = task.getCreatedAt();
}
}
TaskRequestにはBean Validationのアノテーションを付与し、リクエスト段階で不正な値をはじいています。
手順4:Serviceでビジネスロジックをまとめる
package com.example.taskapi.service;
import com.example.taskapi.dto.TaskRequest;
import com.example.taskapi.dto.TaskResponse;
import com.example.taskapi.entity.Task;
import com.example.taskapi.exception.TaskNotFoundException;
import com.example.taskapi.repository.TaskRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class TaskService {
private final TaskRepository taskRepository;
public List<TaskResponse> findAll() {
return taskRepository.findAll().stream()
.map(TaskResponse::new)
.toList();
}
public TaskResponse findById(Long id) {
Task task = taskRepository.findById(id)
.orElseThrow(() -> new TaskNotFoundException(id));
return new TaskResponse(task);
}
@Transactional
public TaskResponse create(TaskRequest request) {
Task task = new Task();
task.setTitle(request.getTitle());
task.setDescription(request.getDescription());
return new TaskResponse(taskRepository.save(task));
}
@Transactional
public TaskResponse update(Long id, TaskRequest request) {
Task task = taskRepository.findById(id)
.orElseThrow(() -> new TaskNotFoundException(id));
task.setTitle(request.getTitle());
task.setDescription(request.getDescription());
return new TaskResponse(task);
}
@Transactional
public void delete(Long id) {
if (!taskRepository.existsById(id)) {
throw new TaskNotFoundException(id);
}
taskRepository.deleteById(id);
}
}
クラス全体に@Transactional(readOnly = true)を付け、更新系メソッドにだけ個別で@Transactionalを上書きしているのがポイントです。
読み取り専用のメソッドが多いAPIでは、この書き方にすることで各メソッドへの付け忘れを防げます。
手順5:Controllerでエンドポイントを公開する
package com.example.taskapi.controller;
import com.example.taskapi.dto.TaskRequest;
import com.example.taskapi.dto.TaskResponse;
import com.example.taskapi.service.TaskService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/tasks")
@RequiredArgsConstructor
public class TaskController {
private final TaskService taskService;
@GetMapping
public List<TaskResponse> findAll() {
return taskService.findAll();
}
@GetMapping("/{id}")
public TaskResponse findById(@PathVariable Long id) {
return taskService.findById(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public TaskResponse create(@Valid @RequestBody TaskRequest request) {
return taskService.create(request);
}
@PutMapping("/{id}")
public TaskResponse update(@PathVariable Long id, @Valid @RequestBody TaskRequest request) {
return taskService.update(id, request);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
taskService.delete(id);
return ResponseEntity.noContent().build();
}
}
@Validを@RequestBodyの前に付けることで、TaskRequest側のバリデーションアノテーションが自動的に評価されます。
手順6:例外ハンドリングを一元化する
@ControllerAdviceの記事で紹介したとおり、例外処理をコントローラから切り離します。
package com.example.taskapi.exception;
public class TaskNotFoundException extends RuntimeException {
public TaskNotFoundException(Long id) {
super("タスクが見つかりません。id=" + id);
}
}
package com.example.taskapi.exception;
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;
import java.util.LinkedHashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(TaskNotFoundException.class)
public ResponseEntity<Map<String, String>> handleNotFound(TaskNotFoundException ex) {
Map<String, String> body = new LinkedHashMap<>();
body.put("error", ex.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> body = new LinkedHashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
body.put(error.getField(), error.getDefaultMessage()));
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
}
}
存在しないIDへのアクセスは404、バリデーションエラーは400として、クライアントが判別しやすい形でレスポンスを返します。
よくあるつまずきポイント・エラー対処
❌Before:EntityをそのままControllerで返してしまう
実装を急ぐと、DTOを作らずにEntityを直接返したくなる場面があります。
// ❌Before:Entityをそのままレスポンスに使う
@GetMapping("/{id}")
public Task findById(@PathVariable Long id) {
return taskRepository.findById(id).orElseThrow();
}
筆者が初めてSpring BootでAPIを作ったときも、最初はこの書き方をしていました。
ところが後から「タスクに担当者(User)のリレーションを追加した」タイミングで、レスポンスJSONにUserエンティティの全カラムがそのまま出力されてしまい、パスワードハッシュまで返してしまうという事故を経験しています。
// ✅After:DTOを経由させてレスポンスの形を固定する
@GetMapping("/{id}")
public TaskResponse findById(@PathVariable Long id) {
return taskService.findById(id);
}
DTOを間に挟んでおけば、Entityに新しいフィールドやリレーションを追加しても、意図的にDTOへ追加しない限りレスポンスには出てきません。
小さなAPIだからとDTOを省略すると、プロジェクトが育ったときに必ずこのつまずきに直面します。
❌Before:例外をコントローラ内でtry-catchする
// ❌Before:コントローラごとにtry-catchを書く
@GetMapping("/{id}")
public ResponseEntity<?> findById(@PathVariable Long id) {
try {
return ResponseEntity.ok(taskService.findById(id));
} catch (TaskNotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.getMessage());
}
}
エンドポイントが増えるたびに同じtry-catchを書き続けることになり、コントローラの見通しが悪くなります。
// ✅After:@RestControllerAdviceで一元管理する
@GetMapping("/{id}")
public TaskResponse findById(@PathVariable Long id) {
return taskService.findById(id);
}
例外処理をGlobalExceptionHandlerに集約することで、コントローラは「何をするか」だけに集中できます。
動作確認時のエラー:Content-Typeの指定漏れ
実際にcurlでPOSTを試すと、以下のようなエラーに遭遇することがあります。
curl -X POST http://localhost:8080/api/tasks -d '{"title":"買い物"}'
{"timestamp":"2026-08-31T09:00:00.000+00:00","status":415,"error":"Unsupported Media Type",...}
これはContent-Type: application/jsonを指定していないために起きるエラーです。
# ✅正しいリクエスト
curl -X POST http://localhost:8080/api/tasks \
-H "Content-Type: application/json" \
-d '{"title":"買い物","description":"牛乳を買う"}'
Content-Typeを明示するだけで解決しますが、初めてSpring Boot以外のクライアント(curlやPostman)からAPIを叩くときに、忘れやすいポイントです。
応用・一歩先の使い方
ページネーションへの拡張
タスクの件数が増えてきた場合、findAll()をそのまま使い続けると全件取得になってしまいます。
Spring Data JPAのPageableを使えば、既存のRepositoryをほぼ変更せずにページネーションへ拡張できます。
public Page<TaskResponse> findAll(Pageable pageable) {
return taskRepository.findAll(pageable).map(TaskResponse::new);
}
コントローラ側も@GetMappingにPageableを引数として追加するだけで、?page=0&size=20&sort=createdAt,descのようなクエリパラメータをそのまま受け付けられるようになります。
JWT認証との組み合わせ
JWT認証の記事で扱った仕組みと組み合わせれば、タスクをユーザーごとに紐づけて「自分のタスクだけ取得・更新できるAPI」に発展させられます。
TaskエンティティにuserIdを追加し、Service層でSecurityContextHolderから取得したユーザーIDと突き合わせるだけで、認可のロジックを追加できます。
今回のシンプルな構成が土台になっているからこそ、機能追加のたびに大きな作り直しが発生しにくいという点も、レイヤードアーキテクチャで設計しておくメリットです。
まとめ
この記事のポイント
- Entity・DTO・Repository・Service・Controller・例外ハンドリングを1本のAPIとして通しで実装した
- DTOを経由させることで、Entityの内部構造の変化からAPIレスポンスを守れる
- 例外処理は
@RestControllerAdviceに一元化し、コントローラをシンプルに保つ Content-Typeの指定漏れは、外部クライアントからAPIを叩く際の定番のつまずきポイント- ページネーションやJWT認証は、今回の構成を土台にそのまま拡張できる
次に読むべき記事
- レイヤードアーキテクチャの考え方
- @ControllerAdviceで例外ハンドリングを一元化する
- JWT認証を実装する
- Spring BootとDjango、初心者に向いているのはどちらか比較検証
ここまでお読みいただき、ありがとうございました。
タグ: Spring Boot, 上級者向け, 実践プロジェクト