REST APIを書いていると、「201や404を返したいだけなのに、毎回ResponseEntityで包むのは面倒だな」と思うことがありますよね。そんなときに便利なのが @ResponseStatus です。

ただ、メソッドに付けるのか、例外クラスに付けるのか、それとも ResponseStatusException を投げるのかは意外と迷います。reason を付けたらJSONボディが消えた、という経験がある人もいるかもしれません。

この記事では、Spring Boot 3.x(Spring Framework 6.x、jakarta パッケージ)を前提に、3つの付け方、ResponseStatusException や ResponseEntity との使い分け、ハマりやすい落とし穴、MockMvc でのテストを順に見ていきます。

@ResponseStatusとは

@ResponseStatus は、返すHTTPステータスコードを宣言的に指定するアノテーションです。パッケージは org.springframework.web.bind.annotation です。

属性は2つだけです。

  • code(別名 value)… 返すステータスを HttpStatus で指定する
  • reason … 理由の文字列。付けると挙動がガラッと変わるので、後で詳しく説明します

付けられる場所は「コントローラーメソッド」「カスタム例外クラス」「@ExceptionHandler メソッド」の3つです。同じアノテーションでも、場所によって処理する仕組みが違うところがポイントです。

使い方1: コントローラーメソッドに付けて201 / 204を返す

いちばん基本的な使い方です。この記事で使うDTOは次の2つだけです。

public record CreateUserRequest(String name) {}
public record UserResponse(Long id, String name) {}

POST で 201 Created、DELETE で 204 No Content を返してみましょう。取得用の GET も一緒に置いておきます。

@RestController
@RequestMapping("/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/{id}")
    public UserResponse findById(@PathVariable Long id) {
        return userService.findById(id); // 200 OK
    }

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public UserResponse create(@RequestBody CreateUserRequest request) {
        return userService.create(request);
    }

    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable Long id) {
        userService.delete(id);
    }
}

reason を付けていなければ、内部では response.setStatus() が呼ばれるだけです。戻り値はいつもどおりJSONとして書き出されるので、ResponseEntity で包む必要はありません。戻り値が void なら、ボディなしの 204 になります。

CRUD全体の作り方は Spring BootでREST APIのCRUDを実装する方法 にまとめているので、ここではステータス指定だけに絞ります。

使い方2: カスタム例外クラスに付けて404を返す

次は例外クラスに付けるパターンです。投げるだけで決まったステータスに変換されます。

@ResponseStatus(HttpStatus.NOT_FOUND)
public class UserNotFoundException extends RuntimeException {
    public UserNotFoundException(Long id) {
        super("user id=" + id + " not found");
    }
}

// UserService
public UserResponse findById(Long id) {
    return userRepository.findById(id)
            .map(u -> new UserResponse(u.getId(), u.getName()))
            .orElseThrow(() -> new UserNotFoundException(id));
}

// UserController(使い方1の GET をそのまま使う)
@GetMapping("/{id}")
public UserResponse findById(@PathVariable Long id) {
    return userService.findById(id);
}

GET /users/99 で例外が飛ぶと、ResponseStatusExceptionResolver がアノテーションを読み取って 404 に変換してくれます。アノテーションは親クラスからも探されるので、親の例外に付けておけば子の例外も同じステータスになります。

気をつけたいのは、この方式は内部で response.sendError() を使う点です。ボディは Spring Boot の /error(BasicErrorController)が作るJSONになり、timestamp・status・error・path といった項目が並びます。中身を自分で決めたいなら、次の使い方3が向いています。

使い方3: @ExceptionHandlerメソッドに付けて独自ボディを返す

エラー時に独自のJSONを返しつつステータスも宣言したいなら、@ExceptionHandler メソッドに付けます。

public record ErrorBody(String code, String message) {}

// UserController の中に書く
@ExceptionHandler(UserNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorBody handleNotFound(UserNotFoundException ex) {
    return new ErrorBody("USER_NOT_FOUND", ex.getMessage());
}

戻り値の ErrorBody がそのままボディになるので、/error 形式よりも形を細かく決められます。

ここではコントローラー内の局所的なハンドラとして書きました。アプリ全体でエラー形式を揃える設計は REST APIで統一的なエラーレスポンスを返す方法 を見てください。

ResponseStatusExceptionとの違い

「この1か所でだけ404を返したい」というときに、わざわざ例外クラスを作るのは大げさですよね。そんなときは ResponseStatusException を投げます。

@GetMapping("/search")
public UserResponse findByEmail(@RequestParam String email) {
    return userService.findByEmail(email) // Optional<UserResponse> を返す想定
            .orElseThrow(() -> new ResponseStatusException(
                    HttpStatus.NOT_FOUND, "user email=" + email + " not found"));
}

専用の例外クラスがいらず、理由の文字列も実行時に組み立てられます。ただ、あちこちで投げると「どの状況で何番を返すか」がコード中に散らばるので、使い回すエラーは使い方2や3にまとめるのがおすすめです。

なお Spring Framework 6 の ResponseStatusException は ErrorResponseException を継承し、ErrorResponse を実装しています。spring.mvc.problemdetails.enabled=true にすると、RFC 9457 の ProblemDetail 形式で返せます(reason は detail に入ります)。詳しくは Problem Details(RFC 9457)でエラーレスポンスを標準化する方法 で解説しています。

ResponseEntityとの比較と使い分け

Location ヘッダー付きの 201 を返したい、条件によって 200 と 404 を切り替えたい、といった場合は ResponseEntity の出番です。

@PostMapping
public ResponseEntity<UserResponse> create(@RequestBody CreateUserRequest request) {
    UserResponse created = userService.create(request);
    URI location = ServletUriComponentsBuilder.fromCurrentRequest()
            .path("/{id}")
            .buildAndExpand(created.id())
            .toUri(); // http://localhost:8080/users/1 のような絶対URI
    return ResponseEntity.created(location).body(created);
}

ServletUriComponentsBuilder を使うと、ホスト名やパスを現在のリクエストから組み立ててくれるので、URLを手書きせずに済みます。

ここまでの手段を表にまとめるとこうなります。

手段ステータスボディヘッダー向いている場面
メソッドに @ResponseStatus固定戻り値そのままHttpServletResponse経由なら可成功時の 201 / 204
例外クラスに @ResponseStatus固定/error 任せ不可使い回す業務エラー
@ExceptionHandler + @ResponseStatus固定自由に決められるHttpServletResponse経由なら可独自JSONのエラー
ResponseStatusException実行時に指定/error か ProblemDetail不可その場限りのエラー
ResponseEntity実行時に指定自由に決められる自由(推奨)Location や条件分岐

ちなみに、同じメソッドで @ResponseStatus と ResponseEntity を併用するのはやめておきましょう。どちらが効くのかを意識しないと読めないコードになるので、どちらか一方に揃えるのが無難です。

落とし穴1: reasonを付けるとJSONボディが消える

いちばんハマりやすいのがこれです。使い方3のハンドラに、気を利かせて reason を足してみます。

@ExceptionHandler(UserNotFoundException.class)
@ResponseStatus(code = HttpStatus.NOT_FOUND, reason = "user not found")
public ErrorBody handleNotFound(UserNotFoundException ex) {
    return new ErrorBody("USER_NOT_FOUND", ex.getMessage()); // 書き出されない
}

reason があると、Spring は setStatus() ではなく response.sendError(404, "user not found") を呼び、戻り値は書き出しません。リクエストは /error にフォワードされ、ボディは BasicErrorController のJSONに置き換わります。

# reason なし
$ curl -i localhost:8080/users/99
HTTP/1.1 404
{"code":"USER_NOT_FOUND","message":"user id=99 not found"}

# reason あり
$ curl -i localhost:8080/users/99
HTTP/1.1 404
{"timestamp":"2026-11-13T01:00:00.000+00:00","status":404,"error":"Not Found","path":"/users/99"}

独自ボディを返したいなら reason は外しましょう。メッセージを伝えたいなら、reason ではなくボディ(DTO や ProblemDetail)の中に入れるのが正解です。

もう1つ、Spring Security を入れている場合は /error へのフォワード(ERROR ディスパッチ)も認可の対象になります。/error を permitAll() にしていないと、404 のはずが 401 や 403 になったり、ボディが空になったりするので注意しましょう。

落とし穴2: messageが出てこない(server.error.include-message)

「reason を付けたのに /error のJSONに message が出てこない」という声もよく聞きます。これは Spring Boot 2.3 以降(3.x も同様)、server.error.include-message のデフォルトが never だからです。2.2 以前のプロジェクトから移行してきて急に出なくなった場合は、Spring Boot 2.x から 3.x への移行ガイド もあわせて確認してみてください。

# application-dev.yml(開発プロファイルだけで有効にする)
server:
  error:
    include-message: always   # on_param なら ?message=true のときだけ含める

ただし本番で always にすると、例外メッセージ経由で内部情報が漏れるおそれがあります。開発環境に限定するか、使い方3のような独自ボディ方式に切り替えるほうが安心です。

落とし穴3: @RestControllerAdviceがあると例外の@ResponseStatusが無視される

「例外に404を付けたのに効かない」なら、まずこれを疑いましょう。@ExceptionHandler を処理する ExceptionHandlerExceptionResolver は、ResponseStatusExceptionResolver より先に評価されます。そのため advice が同じ例外や親クラス、Exception 全体を捕まえると、例外クラス側のアノテーションは参照されません。

よくあるのは、@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) 付きの Exception ハンドラだけを置いているケースです。UserNotFoundException もそこに吸い込まれ、404 のはずが 500 になります。ハンドラにステータス指定がなければ 200 で返ってきます。ResponseStatusException も同じように吸い込まれるので気をつけましょう。

@RestControllerAdvice
public class GlobalExceptionHandler {

    // 個別のハンドラを用意し、ステータスもハンドラ側で明示する
    @ExceptionHandler(UserNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ErrorBody handleNotFound(UserNotFoundException ex) {
        return new ErrorBody("USER_NOT_FOUND", ex.getMessage());
    }

    // これだけだと UserNotFoundException も 500 になる
    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ErrorBody handleAll(Exception ex) {
        return new ErrorBody("INTERNAL_ERROR", "unexpected error");
    }
}

より具体的な例外のハンドラが優先されるので、これで 404 と 500 が正しく分かれます。例外クラスのアノテーションを活かしたいなら、ハンドラの中で AnnotatedElementUtils.findMergedAnnotation(ex.getClass(), ResponseStatus.class) で読み取り、ResponseEntity でそのステータスを返す方法もあります。

MockMvcでステータスコードを検証する

最後に、意図したステータスが返るかをテストで確かめておきましょう。使い方1〜3の UserController が対象です。

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.springframework.test.context.bean.override.mockito.MockitoBean;

@WebMvcTest(UserController.class)
class UserControllerTest {

    @Autowired
    MockMvc mockMvc;

    @MockitoBean // Spring Boot 3.3以前は @MockBean
    UserService userService;

    @Test
    void createReturns201() throws Exception {
        when(userService.create(any())).thenReturn(new UserResponse(1L, "taro"));
        mockMvc.perform(post("/users")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{\"name\":\"taro\"}"))
                .andExpect(status().isCreated());
    }

    @Test
    void deleteReturns204() throws Exception {
        mockMvc.perform(delete("/users/1"))
                .andExpect(status().isNoContent());
    }

    @Test
    void notFoundReturns404() throws Exception {
        when(userService.findById(99L)).thenThrow(new UserNotFoundException(99L));
        mockMvc.perform(get("/users/99"))
                .andExpect(status().isNotFound());
    }
}

MockMvc では /error へのフォワードは実行されません。落とし穴1の reason 付きハンドラに切り替えた場合は、ボディではなく理由の文字列を検証します。

mockMvc.perform(get("/users/99"))
        .andExpect(status().isNotFound())
        .andExpect(status().reason("user not found"));

advice 経由のエラーレスポンスをしっかりテストしたいときは、MockMvcで@RestControllerAdviceの例外ハンドラをテストする方法 が参考になります。

まとめ

成功時の固定ステータスはメソッドに @ResponseStatus、使い回す業務エラーは例外クラスか @ExceptionHandler、その場限りのエラーは ResponseStatusException、ヘッダーや条件分岐が必要なら ResponseEntity、と覚えておけば迷うことは少ないはずです。

あとは「独自ボディを返したいなら reason は付けない」「advice を入れたら例外クラス側のアノテーションは効かなくなる」の2点を押さえておきましょう。ステータスをどこで決めるかをチームで1か所に揃えておくと、後から読む人も楽になりますよ。