Spring Bootは、JavaでWebアプリやREST APIを作るためのフレームワークです。@RestController を付けたクラスにメソッドを書くだけで、JSONを返すAPIが動きます。

ただ、「動くAPI」と「実務で使えるAPI」の間には、入力チェック、エラーの返し方、テストという大きな差があります。入門記事の多くはCRUDの基本で終わりますが、この記事では、注文APIを例に、入力チェックと共通のエラー処理(RFC 9457形式)、MockMvcでのテストまで、実務で必要な形を一通り解説します。バージョンは、現在の主流であるSpring Boot 4系(Java 17以上)を前提にしています。

この記事で作るAPI

メソッド URL 内容 成功時
GET /api/orders 注文の一覧 200 OK
GET /api/orders/{id} 注文を1件取得 200 OK(ないときは404)
POST /api/orders 注文を作成 201 Created + Locationヘッダー
DELETE /api/orders/{id} 注文を削除 204 No Content

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Spring BootでREST APIを作る|入力チェック・エラー処理・テストまで」

プロジェクトの作り方

Spring Initializr(start.spring.io)で、次の依存関係を選んでプロジェクトを作ります。

依存関係 役割
Spring Web MVC(spring-boot-starter-webmvc) REST APIを作る
Validation(spring-boot-starter-validation) 入力チェック
Spring Web MVC Test(spring-boot-starter-webmvc-test) MockMvcでのテスト
<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc-test</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>

Spring Boot 4では、Webの依存関係の名前が spring-boot-starter-web から spring-boot-starter-webmvc に変わりました。入力チェックも、spring-boot-starter-validation を明示的に追加する必要があります。

クラスの役割分担

com.example.order
├── OrderController.java       # HTTPの受け口(URLとメソッドの対応)
├── OrderService.java          # 業務のロジック
├── OrderRepository.java       # データの保存・取得
├── Order.java                 # 注文のデータ
├── CreateOrderRequest.java    # 作成リクエストの形と入力チェック
├── OrderNotFoundException.java
└── ApiExceptionHandler.java   # 共通のエラー処理
層 役割 書かないこと
Controller リクエストを受け取り、Serviceを呼び、レスポンスを返す 業務のロジック
Service 業務のルール(在庫の確認、状態の変更など) HTTPの知識(ステータスコードなど)
Repository データベースとのやり取り 業務のルール

Controllerを薄く保つのがポイントです。業務のロジックがServiceにまとまっていると、テストや再利用がしやすくなります。

データの定義:recordを使う

public record Order(long id, String item, int quantity, String status) {}
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record CreateOrderRequest(
        @NotBlank(message = "商品名は必須です")
        @Size(max = 100, message = "商品名は100文字以内で入力してください")
        String item,

        @Min(value = 1, message = "数量は1以上で指定してください")
        @Max(value = 99, message = "数量は99以下で指定してください")
        int quantity
) {}

record を使うと、フィールド・コンストラクター・getterなどが自動で作られ、リクエストやレスポンスの定義が短く書けます。入力チェックのルールは、アノテーションで項目ごとに付けます。

アノテーション チェック内容
@NotBlank null・空文字・空白だけを禁止(文字列)
@NotNull nullを禁止
@Size(max = 100) 文字数・要素数の範囲
@Min / @Max 数値の範囲
@Email メールアドレスの形式
@Pattern(regexp = ...) 正規表現に一致するか

Repository と Service

この記事では、APIの作り方に集中するため、メモリ上に保存する簡単なRepositoryを使います。データベースを使う場合は、Spring Data JPAなどに置き換えます。

import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Repository;

@Repository
public class OrderRepository {
    private final Map<Long, Order> store = new ConcurrentHashMap<>();
    private final AtomicLong seq = new AtomicLong();

    public Order save(String item, int quantity) {
        long id = seq.incrementAndGet();
        Order order = new Order(id, item, quantity, "pending");
        store.put(id, order);
        return order;
    }
    public Optional<Order> findById(long id) { return Optional.ofNullable(store.get(id)); }
    public List<Order> findAll() { return List.copyOf(store.values()); }
    public boolean delete(long id) { return store.remove(id) != null; }
}
public class OrderNotFoundException extends RuntimeException {
    public OrderNotFoundException(long id) {
        super("注文が見つかりません: id=" + id);
    }
}
import java.util.List;
import org.springframework.stereotype.Service;

@Service
public class OrderService {
    private final OrderRepository repository;

    public OrderService(OrderRepository repository) {   // コンストラクターインジェクション
        this.repository = repository;
    }

    public Order create(CreateOrderRequest request) {
        return repository.save(request.item(), request.quantity());
    }
    public Order get(long id) {
        return repository.findById(id).orElseThrow(() -> new OrderNotFoundException(id));
    }
    public List<Order> list() { return repository.findAll(); }
    public void delete(long id) {
        if (!repository.delete(id)) throw new OrderNotFoundException(id);
    }
}

依存するクラスは、コンストラクターで受け取る(コンストラクターインジェクション)のが推奨の書き方です。フィールドに @Autowired を付ける書き方より、テストで差し替えやすく、必須の依存関係が明確になります。

Controller

import jakarta.validation.Valid;
import java.net.URI;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

@RestController
@RequestMapping("/api/orders")
public class OrderController {
    private final OrderService service;

    public OrderController(OrderService service) {
        this.service = service;
    }

    @GetMapping
    public List<Order> list() {
        return service.list();
    }

    @GetMapping("/{id}")
    public Order get(@PathVariable long id) {
        return service.get(id);
    }

    @PostMapping
    public ResponseEntity<Order> create(@Valid @RequestBody CreateOrderRequest request) {
        Order created = service.create(request);
        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
                .path("/{id}").buildAndExpand(created.id()).toUri();
        return ResponseEntity.created(location).body(created);   // 201 + Location
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable long id) {
        service.delete(id);
        return ResponseEntity.noContent().build();               // 204
    }
}
ポイント 内容
@RestController 戻り値をJSONに変換して返す
@Valid @RequestBody リクエストのJSONを受け取り、入力チェックを実行する
ResponseEntity.created(...) 201 Created と Locationヘッダーを返す
ResponseEntity.noContent() 204 No Content を返す

@Valid を付け忘れると、入力チェックのアノテーションを書いていても実行されません。よくある見落としの1つです。

共通のエラー処理:ProblemDetailで返す

エラーのレスポンスは、アプリ全体で形を揃えます。Springには、RFC 9457(Problem Details for HTTP APIs)の形式でエラーを返すための ProblemDetail クラスがあります。

import java.util.List;
import java.util.Map;
import org.springframework.http.*;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {

    // 404:注文が見つからない
    @ExceptionHandler(OrderNotFoundException.class)
    public ProblemDetail handleNotFound(OrderNotFoundException e) {
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
        pd.setTitle("Not Found");
        return pd;
    }

    // 400:入力チェックのエラー(項目ごとのメッセージを追加)
    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex, HttpHeaders headers,
            HttpStatusCode status, WebRequest request) {
        ProblemDetail pd = ex.getBody();
        pd.setDetail("入力内容に誤りがあります");
        List<Map<String, String>> errors = ex.getBindingResult().getFieldErrors().stream()
                .map(fe -> Map.of("field", fe.getField(),
                                  "message", String.valueOf(fe.getDefaultMessage())))
                .toList();
        pd.setProperty("errors", errors);
        return ResponseEntity.status(status).headers(headers).body(pd);
    }
}

入力チェックでエラーになったときのレスポンスは、次のような形になります。

{
  "type": "about:blank",
  "title": "Bad Request",
  "status": 400,
  "detail": "入力内容に誤りがあります",
  "errors": [
    { "field": "item", "message": "商品名は必須です" },
    { "field": "quantity", "message": "数量は1以上で指定してください" }
  ]
}
仕組み 役割
@RestControllerAdvice すべてのControllerに共通の例外処理を1か所にまとめる
@ExceptionHandler 特定の例外が起きたときのレスポンスを決める
ResponseEntityExceptionHandler Spring標準の例外(入力チェック、JSONの形式エラー、未対応のメソッドなど)をProblemDetailの形で返す
ProblemDetail RFC 9457の形式のエラーを表すクラス。独自の項目も追加できる

Controllerの中で try-catch を書かずに、例外を投げて共通のハンドラーで受け止める形にすると、各メソッドが正常系の処理だけに集中できます。

MockMvcでテストする

// Spring Boot 4 の各アノテーションの import は、IDEの補完で追加してください
@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    MockMvc mockMvc;

    @MockitoBean
    OrderService service;

    @Test
    void 注文を作成すると201とLocationが返る() throws Exception {
        when(service.create(any())).thenReturn(new Order(1L, "マウス", 2, "pending"));

        mockMvc.perform(post("/api/orders")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"item": "マウス", "quantity": 2}
                                """))
                .andExpect(status().isCreated())
                .andExpect(header().string("Location", "http://localhost/api/orders/1"))
                .andExpect(jsonPath("$.item").value("マウス"));
    }

    @Test
    void 数量が0だと400と項目ごとのエラーが返る() throws Exception {
        mockMvc.perform(post("/api/orders")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                                {"item": "マウス", "quantity": 0}
                                """))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.errors[0].field").value("quantity"));
    }

    @Test
    void 存在しない注文は404が返る() throws Exception {
        when(service.get(999L)).thenThrow(new OrderNotFoundException(999L));

        mockMvc.perform(get("/api/orders/999"))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.detail").value("注文が見つかりません: id=999"));
    }
}
仕組み 役割
@WebMvcTest Web層(Controllerと例外ハンドラー)だけを起動する。起動が速い
MockMvc サーバーを起動せずに、HTTPリクエストを模擬して送る
@MockitoBean Serviceをモックに差し替える(Spring Boot 4では @MockBean は削除され、こちらを使う)
jsonPath レスポンスのJSONの中身を確認する

正常系だけでなく、入力エラー(400)と存在しないデータ(404)のテストも書くと、エラー処理の変更で動きが変わったときにすぐ気づけます。

Spring Boot 3から4での主な変更点

項目 Spring Boot 3 Spring Boot 4
Webのstarter spring-boot-starter-web spring-boot-starter-webmvc
テストのstarter spring-boot-starter-test 機能ごとのstarter(spring-boot-starter-webmvc-test など)
Beanのモック @MockBean @MockitoBean(@MockBean は削除)
JSONライブラリ Jackson 2(com.fasterxml.jackson) Jackson 3(tools.jackson)
Javaの最低バージョン 17 17

既存のプロジェクトを移行するときは、公式の移行ガイドで対象の項目を確認しながら進めます。

よくある質問

Q. @RestController と @Controller の違いは何ですか? A. @RestController は、@Controller に @ResponseBody を組み合わせたものです。戻り値をJSONなどのレスポンスの本文として返します。画面(HTML)を返す場合は @Controller を使います。

Q. Entity(データベースのクラス)をそのままレスポンスにしてもいいですか? A. 小さなアプリでは問題ないこともありますが、レスポンス用のクラス(DTO)を分けるのがおすすめです。データベースの項目を変えたときにAPIの形が意図せず変わったり、公開すべきでない項目が出てしまったりするのを防げます。

Q. APIの仕様書はどう作ればいいですか? A. springdoc-openapi を使うと、Controllerのコードから OpenAPI の仕様書と Swagger UI を自動で生成できます。仕様書を先に書いてからコードを生成する方法もあります。

まとめ

  • Spring Boot 4では、spring-boot-starter-webmvc と spring-boot-starter-validation を使う
  • Controller・Service・Repositoryに役割を分け、Controllerは薄く保つ
  • リクエストは record+入力チェックのアノテーションで定義し、@Valid を付ける
  • 作成は 201+Location、削除は 204 で返す
  • エラーは @RestControllerAdvice + ProblemDetail で、RFC 9457の形に揃える
  • MockMvc+@MockitoBean で、正常系と異常系の両方をテストする

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Spring BootでREST APIを作る|入力チェック・エラー処理・テストまで」

株式会社RJCでは、JavaとSpring Bootを使った開発を、研修から実際のプロジェクトまで、仲間と一緒に学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?