【Spring Boot】Spring BootでシンプルなREST APIを作ってみる【実践】タスク管理APIを一気通貫で実装

Java

こんにちは、かつコーチです。

これまでの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);
}

コントローラ側も@GetMappingPageableを引数として追加するだけで、?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, 上級者向け, 実践プロジェクト

タイトルとURLをコピーしました