Spring BootのServiceに業務ロジックを書く理由を解説する記事のアイキャッチ。若手エンジニアがJava道場風の学習空間でServiceの責務を学ぶイラスト。

設計・テスト・レビュー

Spring BootのServiceに業務ロジックを書く理由

AIによる要約

注文取消では、対象を取り出し、取り消せる状態かを判断し、成立した場合だけ保存します。この順番をServiceのユースケースにまとめると、HTTPを介さず同じ取消と保存の契約を確認できます。

新人SE
新人SE
管理画面にも注文取消を追加します。既存APIのControllerにある条件をコピーすればいいですか?
ポンコツSE
ポンコツSE
取消の可否と保存順は、どの入口からも同じです。OrderService.cancelにそろえ、入口側では入力の受け取り方と結果の返し方を決めます。

注文100は出荷待ち、101は出荷済み、102は取消済みです。この機能では出荷待ちだけを取り消します。状態を変えられない注文と、存在しない注文も、それぞれ結果を決めます。

ここでは、HTTPコードを使わずに「どの状態なら変わるか」「失敗した注文は保存されないか」を調べます。

この記事のポイント

  • 出荷待ちの取消だけを保存する
  • Controllerと別の入口が同じServiceを使う
  • Repositoryは取得と保存に集中する
  • 業務ルールをHTTPなしのJUnitで確かめる

実行用サンプル

OrderServiceを直接呼び、正常・出荷済み・取消済み・未登録の4件で、結果と保存回数を確かめます。同じServiceをHTTPから呼ぶMockMvcの6件も収録しています。

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

Serviceは業務処理の置き場所

取消ユースケースの順は、注文を取得し、現在の状態で取消できるか確かめ、成立した注文を保存する、です。Order.canCancel() は出荷待ちだけを許可し、OrderService.cancel() はその規則を呼んでから保存します。

OrderService.java の全文です。

package example.orders;

import org.springframework.stereotype.Service;

@Service
public final class OrderService {
    private final OrderRepository repository;

    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }

    public OrderResult detail(long id) {
        return OrderResult.from(requireOrder(id));
    }

    public OrderResult cancel(long id) {
        Order order = requireOrder(id);
        if (!order.canCancel()) {
            throw new CannotCancelOrderException();
        }
        Order canceled = order.canceled();
        repository.save(canceled);
        return OrderResult.from(canceled);
    }

    private Order requireOrder(long id) {
        return repository.findById(id).orElseThrow(OrderNotFoundException::new);
    }

    public record OrderResult(long id, Order.Status status, boolean cancelable) {
        private static OrderResult from(Order order) {
            return new OrderResult(order.id(), order.status(), order.canCancel());
        }
    }

    public static final class OrderNotFoundException extends RuntimeException {}
    public static final class CannotCancelOrderException extends RuntimeException {}
}

OrderResult は呼び出し側へ返す結果です。取得や保存に使う注文の全項目と、HTTPのステータスは渡しません。

Controllerに書くと入口ごとに散らばる

既存のAPIに加えてバッチで取消を行うとき、Controllerにある条件をコピーすると、片方だけ「取消済みも処理する」変更が入ることがあります。入口が増えても cancel(id) を使えば、出荷済みと取消済みを保存しない判断を一箇所で変更できます。

HTTP側は業務例外を404や409へ変換します。バッチ側なら同じ業務例外を処理結果へ記録できます。どちらもServiceの処理にHTTPの有無を渡す必要はありません。

Serviceに書くとテストしやすい

出荷済みのケースは、例外だけでなく元の状態が残り、保存が0回であることを確かめます。誤って例外の前に保存した場合、このテストは失敗します。

OrderServiceTest.java の shippedOrderIsRejectedWithoutSaving() の抜粋です。

    @Test
    void shippedOrderIsRejectedWithoutSaving() {
        repository.reset(new Order(101, SHIPPED, "REF-101"));

        assertThrows(CannotCancelOrderException.class, () -> service.cancel(101));

        assertEquals(SHIPPED, repository.stored(101).status());
        assertEquals(0, repository.saves);
    }
対象期待結果保存回数
100 出荷待ちCANCELEDへ更新1
101 出荷済みCannotCancelOrderException0
102 取消済みCannotCancelOrderException0
999 未登録OrderNotFoundException0

RepositoryはDBアクセスに集中する

OrderRepository の契約はIDで見つけることと、受け取った注文を保存することです。テストはメモリ実装へ差し替えています。

OrderRepository.java の全文です。

package example.orders;

import java.util.Optional;

public interface OrderRepository {
    Optional<Order> findById(long id);
    void save(Order order);
}

実システムで取消と在庫の復元を同時に行うなら、どの更新を一緒に確定・取消するかを設計します。このサンプルは保存の呼び出しまでで、DBのトランザクションや競合制御は扱いません。

Serviceは何でも入れる場所ではない

取消可否は注文の状態に関わるので Order に置き、取得と保存の順をServiceに残しました。通知や料金計算が独立して変わる規模になったら、単に行数で切る前に、どの変更がどこへ影響するかを確認します。

DIとServiceの関係

実行時はSpringがコンストラクタにRepositoryを注入します。単体テストでは同じコンストラクタへメモリ実装を渡すので、Springの起動やHTTPを使わず取消規則を確認できます。MockMvc側ではテスト用のBootコンテキストにControllerとServiceを登録しています。

現場レビューではどう見られるか

「出荷済みで例外が出ることに加え、保存が0回で状態も残ることをテストしてください。管理画面で同じ取消を追加しても、この規則はServiceで一度だけ直せるようにします。」

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

  • 出荷待ちだけがCANCELEDに変わり、保存が1回か。
  • 出荷済み・取消済み・未登録は保存が0回か。
  • Controller以外の入口からも同じServiceメソッドを使えるか。
  • Serviceの結果や例外にHTTPステータスを持ち込んでいないか。

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

関連記事Spring BootのControllerでやってはいけないこと Spring Bootの Controller は、リクエストを受け取り、入力を確認し、 Service を呼び出し、レスポンスを返す入口です。業務ロジック、DBアクセス、長い条件分岐、Entityの直接返却を詰め込むと、レビューで指摘されやすくなります。 記事を読む 関連記事Spring Bootの「No qualifying bean of type」原因|DIできないときの対処法 Spring BootのNo qualifying bean of typeは、コンストラクタや@Autowiredで要求された型に合うBeanをSpringコンテナから見つけられないときに発生します。最初にエラーメッセージから「必要とされた型」と「要求したクラス」を特定し、その型の実装クラス、@Service・@Component・@Repository・@Bean、コンポーネントスキャン範囲を確認します。 記事を読む 関連記事Spring Bootの@Transactionalはどこに付ける? Spring Bootの @Transactional は、基本的にRepositoryではなく、業務処理のまとまりを表すServiceメソッドに付けることが多いです。どこからどこまでを1つの更新単位にするかを決めるためのアノテーションであり、何となく付けるものではありません。 記事を読む

まとめ

Serviceに残すのは、注文を取り出し、状態の規則を適用し、成立した場合だけ保存する順番です。HTTPを通さない4件で、結果と保存の両方を確認できます。

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