ブログ一覧

金額・ID・日時がAPIを通ると壊れる理由:JSONの精度と部分更新の契約

· 約8分で読めます

DBでは正しい値が、ブラウザや更新APIで変わってしまう。受注アプリを例に、金額の丸め、大きなID、業務日と瞬間、未指定とnullを4件のTipsから解説します。

金額・ID・日時がAPIを通ると壊れる理由

架空の受注アプリで、DBでは正しい注文IDなのにブラウザのリンク先が違う。合計に端数が残り、深夜の注文が前日の売上へ入り、表示名の変更で通知までオフになる。どれもJSONの構文エラーとは限りません。保存型、転送形式、クライアントの型、業務上の意味を1本の契約として確認します。

値を守る境界。各段で意味と表現を照合する

DBの列と制約 → APIの値・単位・形式 → クライアントの解析と変数 → 画面の表示・部分更新 → サーバーで再検証して保存

金額は浮動小数点の誤差と業務上の丸めを分ける

2進浮動小数点では0.1などの10進小数を正確に表せず、JavaScriptの0.1+0.2は0.30000000000000004になります。金額は最小単位の整数か、NUMERIC/DECIMALのような10進数型を選びます。ただし整数にも桁の上限があり、APIを通す際の安全範囲まで確認します。表示だけ丸めても、中間の比較や合計は直りません。

通貨コードと単位を一緒に定義します。USDの10.99ドルは1,099セント、JPYの500円は500円です。これはStripeの一般的な最小単位の例ですが、通貨や操作ごとの例外も公式仕様で確認します。独自APIでは金額の最小単位を10進整数の文字列として運ぶ契約も選べます。文字列だから何でも許可せず、符号・桁数・上限を検証します。

割り算の余りは別の業務判断です。1,000セントを3人へ分け、各333セントにすると999セントです。独自の配分規則で最初の1人へ1セントを足し、334・333・333とすれば合計は1,000になります。この順序に意味があるか、配分の公平性が必要かも決めます。10進数型に変えるだけでは配分ルールは決まりません。

大きな整数IDは、丸められる前に文字列で渡す

RFC 8259は数値の範囲と精度に実装上の制限を認め、広く一致できる整数範囲として±(2^53−1)を挙げます。JavaScriptのNumberで9007199254740993を読むと9007199254740992へ丸まります。安全範囲を超える整数の一部は表現できますが、連続する整数を区別できる保証は失われます。

JavaScriptの再現例。最後の行では元の桁を保持する
JSON.parse('{"id":9007199254740993}').id
// 9007199254740992

JSON.parse('{"id":"9007199254740993"}').id
// "9007199254740993"

JSON.parseの後でStringへ変換しても、既に失われた桁は戻りません。IDは計算対象にせず、中身を数値へ変換したり分解したりせず、文字列のままURL・比較・ログへ引き継ぎます。このように中身を解釈せず扱う値を、不透明な識別子と呼びます。数値を許す既存APIを移行する場合も、丸まった入力を元IDだと推測して補正せず、対応クライアントと形式の切り替えを管理します。

日時は瞬間、請求日や誕生日は日付として扱う

2026年10月27日00:30 JSTは、UTCでは10月26日15:30です。同じ瞬間でも日付は地域で変わります。日本の27日の売上は、JSTの27日0時から28日0時をUTCへ変換し、開始時刻を含み、終了時刻を含まない範囲(半開区間)で読みます。誕生日のような日付だけの値をUTCの深夜へ変換すると、別地域で前日になる不具合を作ります。

日本の2026年10月27日の集計範囲。PostgreSQL 18のWHERE条件の抜粋。$1・$2へそれぞれ開始・終了のUTC時刻をバインドする
created_at >= $1::timestamptz
AND created_at < $2::timestamptz
-- $1 = 2026-10-26T15:00:00Z
-- $2 = 2026-10-27T15:00:00Z

PostgreSQLのtimestamptzは瞬間を扱い、入力に使った地域名をそのまま保持しません。毎日9時の予約なら、瞬間だけでなく地域の時刻規則も必要です。夏時間がある地域の翌日境界は、UTCで24時間を足すのではなく地域の日付から作ります。APIでは瞬間にオフセットを要求し、日付だけの値はYYYY-MM-DDの契約にします。

部分更新では未指定・null・falseを区別する

入力このAPIでの意味検証
項目がない現在値を保持デコード時に存在を残す
false・0・空文字明示した値許容する値かを検証
null採用する形式で意味を定義Merge Patchならメンバー削除
未知または更新禁止の項目拒否認可と許可項目を確認

JSON Merge Patchではnullはメンバー削除、配列は全体の置き換えです。JSON Patchならremoveやreplaceの操作を明示できます。PATCHというメソッドだけではこの意味は決まりません。どちらでも更新可能な項目を制限し、適用後の業務状態を検証します。同時更新があるなら版の条件も別に設計します。

注文APIの契約を1つの例にまとめる

独自APIの応答例。金額文字列の単位はUSDのセント、billing_dateは日付
{
  "id": "9007199254740993",
  "amount_minor": "1099",
  "currency": "USD",
  "created_at": "2026-10-26T15:30:00Z",
  "billing_date": "2026-10-27",
  "notifications_enabled": true
}

このJSONなら注文IDと金額の桁、瞬間と請求日の意味を分けて受け取れます。ただし入力の型が合うだけでは正当な注文にはなりません。IDへの認可、金額のサーバー側計算、通貨との対応、更新の条件を確認します。契約の変更時は保存済みデータや古いクライアントとの互換性も調べます。

境界値をクライアントまで通して確かめる

  1. 安全な整数の境界付近と64ビットIDを、保存・JSON・ブラウザ・更新要求まで往復させる。
  2. 配分の余りと丸めの境界を試し、明細合計と注文合計の契約を確認する。
  3. JSTの日付境界と夏時間の境界を別に試し、サーバーの地域設定を変えても結果が同じか確認する。
  4. 未指定・null・false・0・空文字・配列を別々の部分更新として試し、無関係な項目が変わらないか確認する。

参考にした一次資料

以下の公式資料は2026年10月27日に確認しました。数値例とシナリオは独自の仮定です。設定例は説明用で、実サービスでの実行や性能測定は行っていません。

この記事で深掘りしたTips