Spring BootのDTO変換はどこでやるべきかを解説する記事のアイキャッチ。若手エンジニアがJava道場風の学習空間でDTOとEntityの変換を学ぶイラスト。

設計・テスト・レビュー

Spring BootのDTO変換はどこでやるべきか

AIによる要約

注文の照会・取消では、内部の注文、Serviceの結果、公開JSONを分けます。取消可否を決めるのは業務側で、DTOは決定済みの値を公開する3項目へ変換します。

新人SE
新人SE
注文のJSONに「取消できるか」を出したいです。DTOの変換でステータスを見ればいいですか?
ポンコツSE
ポンコツSE
取消できるかは業務の判断です。Serviceが渡した結果から、DTOにはAPIで約束した項目だけを入れます。

注文データには、画面へ返さない internalReference があります。一方、APIで公開するのは id、status、cancelable です。

DTO変換の置き場所は、条件の長さより「表示だけを変えるのか、業務上の答えを決めるのか」で考えます。

この記事のポイント

  • APIへ返す項目を3つに絞る
  • 業務上の取消可否をServiceの結果にする
  • Controller側では決まった結果をDTOへ移す
  • JSON比較で内部項目が入らないことを確認する

実行用サンプル

注文照会と取消の成功レスポンスをJSONの厳密比較で検証できます。公開3項目の値と、内部参照がレスポンスに現れないことを確かめます。

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

まずEntityをそのまま返さない

保存側の Order には id と status に加えて、社内参照用の internalReference があります。この例の Order は保存データを表すJavaのrecordです。JPA Entityの関連や遅延読み込みは使いません。

外部へ返すクラスは OrderResponse に固定します。照会 GET /orders/100 の実行で確認するJSONは次の形です。

{"id":100,"status":"WAITING","cancelable":true}

テストはJSONを厳密比較するため、internalReference を追加して返すと失敗します。キーの順序は契約に含めません。

単純な変換ならControllerでも読める

ControllerはServiceから OrderResult を受け取り、OrderResponse.from() を呼びます。変換は3項目を選び、状態をAPIで約束した文字列にするだけです。

OrderResponse.java の全文です。

package example.orders;

import example.orders.OrderService.OrderResult;

public record OrderResponse(long id, String status, boolean cancelable) {
    public static OrderResponse from(OrderResult result) {
        String status = switch (result.status()) {
            case WAITING -> "WAITING";
            case SHIPPED -> "SHIPPED";
            case CANCELED -> "CANCELED";
        };
        return new OrderResponse(result.id(), status, result.cancelable());
    }
}

switch で文字列を明示しているので、内部の列挙値を改名する改修で、APIの文字列まで意図せず変わることを避けられます。

業務判断を含むならServiceで扱う

出荷待ちのときだけ取消できるという規則は Order.canCancel() にあります。Serviceは実際の取消でもこの規則を使い、照会の OrderResult を作るときにもその結果を使います。DTO側に同じ条件を書くと、画面では取消可能と表示したのに実行時には拒否されるずれが起きます。

このサンプルの cancelable は注文の状態で決まる値です。利用者の権限は別の条件で、ここでは検証していません。

複雑ならMapperへ切り出す

同じ応答形式を複数のControllerで返し始めたり、項目の階層や日時表示の変換が増えたりしたら、HTTP側に専用Mapperを置く候補です。今回の変換は2つのハンドラが同じ OrderResponse.from() を呼ぶだけなので、独立したクラスを増やす前にこの形で共通化しています。

Request DTOからEntityへ直接詰め替えない

この取消APIはURLのIDだけを受け取るため、Request DTOは作っていません。将来、取消理由を本文から受け取る仕様になったら、入力DTOには受け付ける理由だけを定義し、Serviceへ明示的に渡します。利用者に状態や内部参照を書き換えさせる入力契約にしないためです。

どこに書くかの判断基準

変更内容最初に直す箇所
JSONのフィールド名・公開する項目が変わるOrderResponseとJSONの契約テスト
出荷済みでも取消を認める条件が加わる業務規則とServiceのテスト
入力として取消理由を受け取るRequest DTOとServiceの入力契約

このように変更理由を分けると、画面の項目名変更で取消規則のテストを直す必要がなくなります。

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

「公開するのはid・status・cancelableです。保存側のOrderを返す代わりに応答DTOに詰め替え、3項目だけになることをJSON比較で確認してください。取消可否の条件は照会と取消処理で共有します。」

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

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

    @Test
    void detailPublishesExactlyThreeFields() throws Exception {
        mvc.perform(get("/orders/100"))
                .andExpect(status().isOk())
                .andExpect(content().json("""
                        {"id":100,"status":"WAITING","cancelable":true}
                        """, JsonCompareMode.STRICT));

        assertEquals(0, repository.saves);
    }
  • 返却項目がAPIの仕様に含まれ、内部参照が出ていないか。
  • 取消可否がDTOの変換に重複していないか。
  • 公開項目が増えたときに契約テストで検知できるか。
  • 入力項目が保存モデルの全項目と連動していないか。

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

関連記事Spring BootのFormとDTOの違い|Serviceへ渡すクラスを分ける判断 先に結論FormをServiceへ直接渡してよいかは、画面の入力契約とServiceの業務処理の契約が同じかで判断します。 記事を読む 関連記事Spring BootのControllerでやってはいけないこと Spring Bootの Controller は、リクエストを受け取り、入力を確認し、 Service を呼び出し、レスポンスを返す入口です。業務ロジック、DBアクセス、長い条件分岐、Entityの直接返却を詰め込むと、レビューで指摘されやすくなります。 記事を読む 関連記事Spring BootのServiceに業務ロジックを書く理由 Spring Bootの Service は、業務処理やユースケースを表す中心的な場所です。Controllerに業務ロジックを書くと入口ごとに処理が散らばります。 記事を読む

まとめ

Serviceが取消可否を決め、DTOがAPIとして公開する値を選びます。GETの成功JSONを3項目で固定しておくと、保存側の項目が増えても公開範囲を確認できます。

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