Skip to main content

レスポンスの形

前の章までの API は、注文の集約 (Order) をそのまま応答の本文にしてきました。Jackson が集約のプロパティを書き出すので手軽ですが、何が応答に出るかを、集約の形が決めてしまいます。この章では、応答のための型を作り、クライアントに約束する項目を自分で選びます。あわせて、日時・金額・enum・消費税額を JSON でどう表すかを決めます。

この章のコードは tag api-13 にあります。

集約をそのまま返すと何が出るか​

12 章の注文の応答には、次のものが出ていました。

  • contactEmail: 注文の連絡先メールアドレス
  • version: 9 章で @Version を付けた、楽観ロックのための番号
  • status: "PENDING" のような、Kotlin の enum の定数の名前
  • totalAmount と taxAmounts: 12 章で集約に足した、getter だけのプロパティ

どれも、集約に置いた理由はあります。version は保存のときに同時の書き込みを見分けるため、contactEmail は注文の連絡のためです。ただ、応答に出すと決めて置いたわけではありません。集約にプロパティを足すたびに、応答の項目も黙って増えます。12 章で canCancel をプロパティにせず関数にしたのは、この経路を避けるためでした。

項目の並びも、集約の形で決まります。Jackson 3 は、コンストラクタの引数のプロパティを宣言の順に書き、残りのプロパティをアルファベット順に続けます。12 章の応答で taxAmounts が totalAmount より前に出たのは、どちらもコンストラクタの外のプロパティで、アルファベット順に並んだためです。

応答の DTO​

応答のための型を、集約とは別に作ります。このような、データを運ぶためだけの型を DTO (Data Transfer Object) と呼びます。

app/src/main/kotlin/com/example/shop/order/OrderResponse.kt (抜粋)
data class OrderResponse(
val id: UUID,
val status: OrderStatusResponse,
val orderedAt: Instant,
val lines: List<OrderLineResponse>,
val totalAmount: Int,
val taxAmounts: List<TaxAmountResponse>,
) {
companion object {
fun from(order: Order): OrderResponse = OrderResponse(
id = order.id,
status = OrderStatusResponse.from(order.status),
orderedAt = order.orderedAt,
lines = order.lines.map(OrderLineResponse::from),
totalAmount = order.totalAmount,
taxAmounts = order.taxAmounts.map(TaxAmountResponse::from),
)
}
}

出す項目は、すべて主コンストラクタの val にしています。こうすると、JSON の項目の並びがこの宣言の順に決まります。

from は、集約から DTO を作る関数です。明細と消費税額も、それぞれの DTO (OrderLineResponse・TaxAmountResponse) に写します。計算はすべて集約の側に残し、DTO は値を写すだけにしています。集約にプロパティを足しても、from で写さないかぎり応答には出ません。

ただし、DTO そのものに getter だけのプロパティや、get・is で始まる関数を足すと、それは応答の項目になります。コンストラクタの外のものは、アルファベット順で後ろに並びます。DTO には、出す値のほかは置きません。

コントローラは、集約でなく DTO を返します。

app/src/main/kotlin/com/example/shop/order/OrderController.kt (抜粋)
@GetMapping("/{id}")
fun find(@PathVariable id: UUID): ResponseEntity<OrderResponse> {
val order = orderService.find(id) ?: return ResponseEntity.notFound().build()
return ResponseEntity.ok(OrderResponse.from(order))
}

作成と取り消しも、同じく OrderResponse.from で写した値を返します。サービスは、これまでどおり集約を返します。HTTP の応答の形を知っているのはコントローラだけです。

出す項目を選ぶ​

DTO には、version と contactEmail を入れていません。

version は、サーバーが同時の書き込みを見分けるための内部の値です。クライアントが使う場面は、この部にはありません。一度応答に出すと、クライアントがその値に頼ることがあり、後から外せなくなります。

contactEmail は、個人を特定しうる値です。この部の API には、誰の注文かを確かめる仕組み (認証) がありません。注文の ID を知っている人なら誰でも、取得の API でその注文を読めます。そこに連絡先メールアドレスが入っていると、他人の連絡先まで読めてしまいます。

API のセキュリティの問題をまとめた OWASP の API Security Top 10 は、オブジェクトを丸ごと書き出す汎用の方法を避け、返す項目を選んで最小限にすることを防ぎ方に挙げています1。DTO を分けておけば、集約にどんな値を足しても、応答に出すかどうかを別に決められます。

Jackson 3 と Kotlin​

Spring Boot 4 は、JSON の読み書きに Jackson 3 を使います2。Jackson 2 から、ライブラリの group と package の名前が com.fasterxml.jackson から tools.jackson に変わりました。ただし、注釈 (@JsonValue・@JsonProperty・@JsonInclude など) だけは、Jackson 2 と同じ com.fasterxml.jackson.annotation の package のままです3。この章の OrderResponse.kt の import が com.fasterxml.jackson.annotation.JsonValue なのはこのためです。

Kotlin のクラスを正しく読み書きするには、Jackson の Kotlin のモジュール (jackson-module-kotlin) が要ります。サンプルリポジトリは app/ の最初の雛形の時点から依存に入れていて、Spring Boot はこれを見つけて自動で登録します。このモジュールが無いと、Jackson は Kotlin のプロパティを Java の getter と同じ規則で読みます。Boolean の isActive が active という項目になったり、項目がすべてアルファベット順に並んだりして、応答の形が変わります。

日時​

orderedAt は、Instant のまま DTO に入れています。Jackson 3 は日時を既定で文字列にします (Jackson 2 の既定は数でした)4。Instant は 2026-10-24T01:00:00Z のような、UTC の時刻に Z を付けた文字列になります。

時刻帯を必ず付けた UTC の文字列にしているのは、受け手が解釈を誤らないためです。日時の文字列の形を決めた RFC 3339 は、夏時間などの地域の規則は複雑で予告なく変わるので、相互運用には UTC を使うのがよいとしています。時差を付けない地方時の文字列は、世界のほとんどの場所で解釈を誤るとして認めていません5。

application.yaml で spring.jackson.time-zone を設定しても、Instant の出力は変わりません。日本時間で出したくなったら、UTC の時刻を受け手の側で変えます。

小数部の桁は、値によって変わります。秒ちょうどなら小数部は無く、0.5 秒なら .500、マイクロ秒まであれば .123456 のように、3 桁ずつ増えます。9 章で時計をマイクロ秒で刻むようにしたので、手元で作った注文の orderedAt は、値によって 0 桁・3 桁・6 桁のどれかになります。受け手は、この文字列を日時として読んでから比べる必要があります。文字列のまま並べ替えると何が起きるかは、章末の練習問題で確かめます。

金額と消費税額​

金額 (unitPrice・subtotal・totalAmount) と消費税額は、円の整数のまま JSON の数で出しています。

JSON の仕様 (RFC 8259) は、数の範囲と精度に実装が上限を設けてよいとしたうえで、整数なら -(253)+1 から 253-1 の範囲で実装の間の値が一致する、としています6。JavaScript の数はこの範囲を超える整数を正確に持てません。このガイドの金額は Kotlin の Int で、最大でも 231-1 なので、この範囲に収まります。範囲を超えうる整数を出すときは、JSON の文字列にします7。

金額に小数の型 (Double) は使いません。1 円未満の端数を持たない円の金額は、整数で表せば誤差が出ません。

消費税額は、税率ごとの値のリストにしています。

app/src/main/kotlin/com/example/shop/order/OrderResponse.kt (抜粋)
/** 1 つの税率の消費税額の応答。 */
data class TaxAmountResponse(
val taxRate: Int,
val amount: Int,
) {

{"10": 60, "8": 94} のように税率を項目名にする形も考えられますが、JSON の項目名は文字列なので、税率が数でなくなります。リストの要素に taxRate と amount を並べれば、どちらも数のまま扱えます。並びは、集約の taxAmounts が決めたとおり、税率の高い順です。

enum​

Jackson 3 は、enum を既定で toString() の値で書きます (Jackson 2 の既定は定数の名前でした)8。Kotlin の enum は、toString() を上書きしなければ定数の名前を返すので、12 章までの status は "PENDING" でした。

この書き方のままだと、集約の OrderStatus の定数の名前が、そのまま API の値になります。定数の名前を変えると応答の値が変わり、ログのために toString() を上書きしても応答の値が変わります。データベースの側は、Spring Data JDBC が定数の名前 (name()) で書くので、toString() を上書きすると、応答とデータベースの値が黙ってずれます。

そこで、応答のための enum を別に作り、JSON の値を明示しました。

app/src/main/kotlin/com/example/shop/order/OrderResponse.kt (抜粋)
enum class OrderStatusResponse(@get:JsonValue val value: String) {
PENDING("pending"),
CANCELLED("cancelled"),
;

companion object {
fun from(status: OrderStatus): OrderStatusResponse = when (status) {
OrderStatus.PENDING -> PENDING
OrderStatus.CANCELLED -> CANCELLED
}
}
}

@JsonValue を付けたプロパティの値が、JSON の値になります。読み込みでも同じ値を受けるので、"pending" から PENDING に戻せます。@JsonValue は getter かフィールドに付ける注釈なので、@get: で getter に付けることを明示しています。

集約の状態から応答の状態への写しは when で書き、else を書いていません。集約に状態を足すと、ここがコンパイルエラーになり、応答での値を決め忘れません。

応答の形を確かめるテスト​

応答の JSON の形は、Spring Boot が組み立てた JsonMapper (Jackson 3 の、JSON を読み書きする部品) を使うテストで確かめます。@JsonTest を付けると、JSON の読み書きに要る部品だけで Spring を起動し、DB を使わずに試せます。

app/src/test/kotlin/com/example/shop/order/OrderResponseJsonTest.kt (抜粋)
@Test
fun `宣言の順の項目だけを出し、状態は応答の値、日時は UTC の文字列、金額は数にする`() {
assertEquals(
"""{"id":"019a1c00-0000-7000-8000-000000000001","status":"cancelled",""" +
""""orderedAt":"2026-10-24T01:00:00.123456Z",""" +
""""lines":[{"productId":"0199f3a8-0000-7000-8000-000000000005","quantity":1,"unitPrice":1280,""" +
""""taxRate":8,"subtotal":1280}],"totalAmount":1280,"taxAmounts":[{"taxRate":8,"amount":94}]}""",
jsonMapper.writeValueAsString(OrderResponse.from(order)),
)
}

テストの中で JsonMapper を自分で作らず、注入されたものを使っているのは、前の節の Kotlin のモジュールが入った、本番と同じ JsonMapper で確かめるためです。

応答の形が変わったので、10 章と 12 章の記事の例を確かめるテストの期待値 (.out) も、api-13 の commit で直しています。10 章と 12 章の記事は、それぞれの章の tag の結果を載せたままです。

curl で試す​

アプリを起動して、10 章と同じ注文を作ります。手元の DB が初期データだけの状態から打つことを前提にしています。

curl -s -w '\n%{http_code}\n' http://localhost:8080/api/v1/orders \
-H 'Content-Type: application/json' \
-d '{
"contactEmail": "taro@example.com",
"lines": [
{"productId": "0199f3a8-0000-7000-8000-000000000001", "quantity": 2},
{"productId": "0199f3a8-0000-7000-8000-000000000005", "quantity": 1}
]
}'
結果
{
"id" : "019a1c00-0000-7000-8000-000000000001",
"status" : "pending",
"orderedAt" : "2026-10-24T01:00:00Z",
"lines" : [ {
"productId" : "0199f3a8-0000-7000-8000-000000000001",
"quantity" : 2,
"unitPrice" : 330,
"taxRate" : 10,
"subtotal" : 660
}, {
"productId" : "0199f3a8-0000-7000-8000-000000000005",
"quantity" : 1,
"unitPrice" : 1280,
"taxRate" : 8,
"subtotal" : 1280
} ],
"totalAmount" : 1940,
"taxAmounts" : [ {
"taxRate" : 10,
"amount" : 60
}, {
"taxRate" : 8,
"amount" : 94
} ]
}
201

contactEmail と version が無くなり、status は "pending" になりました。項目は DTO の宣言の順に並び、totalAmount が taxAmounts の前に来ています。ノートは 10% の税率、コーヒー豆は 8% の軽減税率なので、消費税額は税率ごとに 2 つです (660 × 10 ÷ 110 = 60、1280 × 8 ÷ 108 の 1 円未満を切り捨てて 94)。

作った注文を取得しても、同じ形で返ります。URL の注文 ID は、手元で作った注文の id に置き換えてください。

curl -s -w '\n%{http_code}\n' http://localhost:8080/api/v1/orders/019a1c00-0000-7000-8000-000000000001
結果
{
"id" : "019a1c00-0000-7000-8000-000000000001",
"status" : "pending",
"orderedAt" : "2026-10-24T01:00:00Z",
"lines" : [ {
"productId" : "0199f3a8-0000-7000-8000-000000000001",
"quantity" : 2,
"unitPrice" : 330,
"taxRate" : 10,
"subtotal" : 660
}, {
"productId" : "0199f3a8-0000-7000-8000-000000000005",
"quantity" : 1,
"unitPrice" : 1280,
"taxRate" : 8,
"subtotal" : 1280
} ],
"totalAmount" : 1940,
"taxAmounts" : [ {
"taxRate" : 10,
"amount" : 60
}, {
"taxRate" : 8,
"amount" : 94
} ]
}
200

本番で効く注意点​

応答から項目を消すと、クライアントが壊れる​

この章では、contactEmail と version を応答から外しました。このガイドの API はまだ公開していないので、頼っているクライアントはいません。公開した後の API で項目を消したり、名前や値を変えたりすると、その項目を読んでいるクライアントが動かなくなります。

公開した後は、項目を足す変更にとどめ、消す・変える変更は、10 章で URL に入れた版 (/api/v1) を上げて別の API として出します。項目を足す変更も、受け手が知らない項目を無視する作りでなければ壊れるので、クライアントにそう作ってもらうことを、API の約束として伝えておきます。

Spring Boot 3 の Jackson の設定の書き方は、起動を止める​

Spring Boot 4 で Jackson 3 に変わったときに、日時に関わる設定の名前も変わりました。Spring Boot 3 の記事や回答にある spring.jackson.serialization.write-dates-as-timestamps を Spring Boot 4.1 で書くと、設定を読み込めずに、アプリの起動が止まります。日時の設定は spring.jackson.datatype.datetime.* に移っています9。古い設定を書き写すときは、Spring Boot 4 の文書で名前を確かめます。

まとめ​

  • 応答の本文は、集約をそのまま返さず、応答のための型 (DTO) に写します。出す項目は主コンストラクタの val に並べ、宣言の順を JSON の順にします
  • サーバーの内部の値 (version) と、認証の無い API で読める個人の値 (contactEmail) は応答に出しません
  • Spring Boot 4 は Jackson 3 を使います。package は tools.jackson ですが、注釈は com.fasterxml.jackson.annotation のままです。Kotlin のモジュールは自動で登録されます
  • 日時は Instant のまま、UTC の Z 付きの文字列で出します。小数部の桁は値によって変わります
  • 金額と消費税額は円の整数のまま JSON の数で出します。消費税額は税率ごとのリストにします
  • enum は、応答のための enum に @JsonValue で値を明示し、集約の状態からは else の無い when で写します
  • 応答の形は、Spring Boot が組み立てた JsonMapper を使う @JsonTest で確かめます

次に読む​

前の章は サービス層と DI です。

次章 一覧 API では、商品の一覧を返す API を作り、ページング・フィルタ・ソートを扱います。

練習問題​

集約の OrderStatus に、発送した注文を表す状態 (たとえば SHIPPED) を足すと、応答のコードのどこで何が起きますか

解答例

OrderStatusResponse.from の when がコンパイルエラーになります。when に else が無く、新しい状態を受ける枝が無いので、Kotlin のコンパイラは 'when' expression must be exhaustive と報告します。

このエラーで、新しい状態を応答でどう表すか ("shipped" にするかなど) を決める必要があると気づけます。else を書いていたら、コンパイルは通り、新しい状態は else の枝の値になって、応答に誤った状態が出ます。

コードのほかにも、8 章で orders.status の列に付けた check 制約が 'PENDING' と 'CANCELLED' しか認めていないので、制約を直すマイグレーションも要ります。こちらはコンパイルでは気づけず、新しい状態の注文を保存したときに、データベースが断ります。

orderedAt の文字列をそのまま並べ替えると、時刻の順にならないことがあります。どんな値のときですか

解答例

同じ秒の中で、小数部の桁の数が違う時刻を並べたときです。秒ちょうどの 2026-10-24T01:00:00Z は、0.5 秒後の 2026-10-24T01:00:00.500Z より早い時刻ですが、文字列として比べると後ろに並びます。

InstantStringOrderTest.kt (抜粋)
@Test
fun `秒ちょうどの時刻は小数部を持たず、同じ秒の 0_5 秒後の時刻より文字列では後ろに並ぶ`() {
val onTheSecond = jsonMapper.writeValueAsString(Instant.parse("2026-10-24T01:00:00Z"))
val halfSecondLater = jsonMapper.writeValueAsString(Instant.parse("2026-10-24T01:00:00.500Z"))

assertEquals("\"2026-10-24T01:00:00Z\"", onTheSecond)
assertEquals("\"2026-10-24T01:00:00.500Z\"", halfSecondLater)
// Z (U+005A) は . (U+002E) より後ろの文字なので、早い時刻のほうが文字列では大きくなる。
assertTrue(onTheSecond > halfSecondLater)
}

2 つの文字列は、01:00:00 までが同じで、その次の文字が Z と . で分かれます。文字の並びでは Z が . より後ろなので、秒ちょうどの時刻のほうが後ろに来ます。RFC 3339 も、文字列の並べ替えが時刻の順になるのは、時刻帯と小数部の桁などの書き方がそろっているときだけだとしています5。受け手は、文字列を日時として読んでから並べ替えます。


Footnotes​

  1. 出典: API3:2023 Broken Object Property Level Authorization (OWASP API Security Top 10 2023)。 ↩

  2. 出典: Spring Boot 4.1.2 の文書の JSON の節 ("Jackson 3 is the preferred and default library.")。 ↩

  3. 出典: Migrating to Jackson 3 (Jackson の移行の手引き)。 ↩

  4. 出典: Jackson 3.1.7 の DateTimeFeature の WRITE_DATES_AS_TIMESTAMPS の説明。 ↩

  5. 出典: RFC 3339 の 4.1 節・4.4 節・5.1 節。 ↩ ↩2

  6. 出典: RFC 8259 の 6 節。 ↩

  7. 出典: RFC 7493 (I-JSON) の 2.2 節。 ↩

  8. 出典: Jackson 3.1.7 の EnumFeature の WRITE_ENUMS_USING_TO_STRING の説明。 ↩

  9. 出典: Spring Boot 4.1.2 の JacksonProperties のソース。 ↩