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か所に揃えておくと、後から読む人も楽になりますよ。