Spring BootのControllerでやってはいけないことを解説する記事のアイキャッチ。若手エンジニアがJava道場風の学習空間でControllerとServiceの責務分離を学ぶイラスト。

設計・テスト・レビュー

Spring BootのControllerでやってはいけないこと

AIによる要約

ControllerはURLから注文IDを受け取り、Serviceへ渡し、結果をJSONとHTTPステータスに変えます。注文取消で、入口での400、業務結果に基づく404と409、成功の200を見分けます。

新人SE
新人SE
Controllerに取消のif文を書いたらAPIが動きました。レビューでは何を移せばいいですか?
ポンコツSE
ポンコツSE
注文が取り消せるかという条件と保存はService側です。ControllerにはIDを受け取り、結果をどのHTTP応答にするかを残します。

注文100は出荷待ち、101は出荷済み、102は取消済みです。URLのIDが文字列で数値にできない場合と、数値だが取消できない場合は、同じ失敗でも処理が止まる場所が違います。

同じ注文取消の例で、Controllerに残す条件とServiceへ移す条件を分けます。

この記事のポイント

  • URLとID型を入口で決める
  • 取消可否と保存はServiceへ任せる
  • 応答DTOとステータスをControllerで決める
  • 400・404・409・200をMockMvcで見分ける

実行用サンプル

同じ注文取消に対し、BootとMockMvcで6件のHTTPテストを実行できます。URL変換で止まる場合と、業務側で拒否する場合を比較します。

JDK 17、Maven 3.9系、Spring Boot 3.5.16。ZIPにはソース・JUnit・pom.xml・READMEが入っています。展開先で mvn test を実行します。

Controllerの役割は入口に集中すること

POST /orders/{id}/cancel はURLのIDを long で受け取り、OrderService.cancel(id) を呼びます。成功時は OrderResponse を返し、業務例外はController内の @ExceptionHandler でHTTPの応答へ変換します。

OrderController.java の全文です。

package example.orders;

import example.orders.OrderService.CannotCancelOrderException;
import example.orders.OrderService.OrderNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public final class OrderController {
    private final OrderService service;

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

    @GetMapping("/orders/{id}")
    public OrderResponse detail(@PathVariable("id") long id) {
        return OrderResponse.from(service.detail(id));
    }

    @PostMapping("/orders/{id}/cancel")
    public OrderResponse cancel(@PathVariable("id") long id) {
        return OrderResponse.from(service.cancel(id));
    }

    @ExceptionHandler(OrderNotFoundException.class)
    public ResponseEntity<ErrorResponse> orderNotFound() {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(new ErrorResponse("ORDER_NOT_FOUND"));
    }

    @ExceptionHandler(CannotCancelOrderException.class)
    public ResponseEntity<ErrorResponse> orderCannotBeCanceled() {
        return ResponseEntity.status(HttpStatus.CONFLICT)
                .body(new ErrorResponse("ORDER_CANNOT_BE_CANCELED"));
    }

    public record ErrorResponse(String code) {}
}

やってはいけない1:業務ロジックをControllerに書く

たとえばControllerに status == SHIPPED の分岐を書き、そこで保存を止めると、管理画面やバッチが同じ条件を使う際にルールが分かれます。この例は出荷待ちだけを取り消せるという規則を業務側に置き、Controllerは CannotCancelOrderException をHTTP 409へ変換します。

例外をHTTPへ翻訳する分岐はControllerの仕事です。「どの注文ならその例外になるか」はServiceと注文の状態規則の仕事です。

やってはいけない2:RepositoryをControllerから直接呼ぶ

ControllerがRepositoryを直接呼んで注文を取り出し保存すると、別の入口で取得と保存の順序を作り直すことになります。この取消では、Serviceが取得、状態確認、成功時の保存まで行います。例のRepositoryはメモリ実装で、永続化や同時更新は別の確認です。

やってはいけない3:Entityをそのまま返す

保存側の Order をそのまま返すと、内部照合に使う internalReference まで公開側の契約に入り得ます。ControllerはServiceの結果を OrderResponse.from() で変換し、id、status、cancelable を返します。

やってはいけない4:長いif文をControllerに置く

条件の数より、何を判定しているかで分けます。abc が long へ変換できるかはHTTP入力の話です。101 を取り消せるかは注文の状態を読まないと判断できません。後者を入力チェックとしてまとめないようにします。

OrderControllerTest.java の invalidPathIdReturns400WithoutReadingOrSaving() の抜粋です。

    @Test
    void invalidPathIdReturns400WithoutReadingOrSaving() throws Exception {
        var result = mvc.perform(post("/orders/abc/cancel"))
                .andExpect(status().isBadRequest())
                .andReturn();

        assertInstanceOf(MethodArgumentTypeMismatchException.class, result.getResolvedException());
        assertEquals(0, repository.reads);
        assertEquals(0, repository.saves);
    }

このテストは400だけでなく、原因が型変換の例外であり、Repositoryの読み込みと保存がともに0回であることを確認します。Spring MVCの入力型変換

現場レビューではどう指摘されるか

「出荷済みの条件と保存処理はOrderService.cancelへ移してください。Controller側には業務結果を409へ変換する処理を残し、IDの型違いで400になるケースとはテストを分けます。」

Controllerに書いてよいもの

POSTで指定するIDHTTP応答処理の結果
abc400型変換で停止。取得・保存なし
999404、ORDER_NOT_FOUND注文が見つからない。保存なし
101/102409、ORDER_CANNOT_BE_CANCELED出荷済み/取消済み。保存なし
100200、status=CANCELED出荷待ちを取り消し、1回保存

409は今回のAPIで「現在の注文状態と取消要求が衝突した」ことを表します。例外ハンドラは状態を見直さず、その結果をHTTPへ変換します。Spring MVCの例外ハンドラ、HTTP 409の定義

レビュー前のセルフチェック

  • URL・メソッド・ID型をControllerから読み取れるか。
  • 入力が壊れている400と、注文状態による409を分けたか。
  • 注文の取得・取消可否・保存がControllerに入っていないか。
  • 成功JSONに内部項目が含まれないことを確かめたか。

この記事とあわせて読みたい

関連記事Spring BootのServiceに業務ロジックを書く理由 Spring Bootの Service は、業務処理やユースケースを表す中心的な場所です。Controllerに業務ロジックを書くと入口ごとに処理が散らばります。 記事を読む 関連記事Spring BootのDTO変換はどこでやるべきか Spring BootのDTO変換は、必ずController、必ずServiceと決まるものではありません。大事なのは、Entityを外へ直接出さず、画面/API用の形に変換することです。 記事を読む

まとめ

Controllerが決めるのは、どのHTTP入力を受け、業務結果をどのHTTP応答へ渡すかです。注文状態の判断と保存をServiceに置き、HTTPの契約はMockMvcで確かめます。

-設計・テスト・レビュー
-