2025年10月03日·1 分

外側のデータと内側のデータ — アプリに役立つ Pat Helland の教訓

Pat Helland の「外側と内側のデータ」を学び、明確な境界を設定し、冪等な呼び出しを設計し、ネットワーク障害時に状態を照合して修復する方法を解説します。

外側のデータと内側のデータ — アプリに役立つ Pat Helland の教訓

「外側」と「内側」が意味すること(平易な言葉で)

アプリを作るとき、リクエストが順番通りにきれいに届くところを想像しがちです。現実のネットワークはそんなふうに振る舞いません。画面がフリーズしてユーザーが「支払う」を二回タップすることもあります。ボタンを押した直後にモバイル接続が切れることもあります。Webhook が遅れて届いたり、二回届いたり、そもそも届かないこともあります。

Pat Helland の data on the outside vs inside(外側と内側のデータ) の考え方は、その混乱を考えるための分かりやすい枠組みです。

「外側」がどういうものか

「外側」はあなたのシステムが制御できないすべてです。他者や他のシステムとやり取りする場所で、配達が不確実な領域です:ブラウザやモバイルアプリからの HTTP リクエスト、キューからのメッセージ、サードパーティの webhook(決済、メール、配送)、クライアントやプロキシ、バックグラウンドジョブによる再試行など。

外側では、メッセージが遅れること、重複すること、順序が入れ替わることを前提にします。普段は「だいたい信頼できる」ものでも、いつか信頼できなくなる日を想定して設計してください。

「内側」が意味すること

「内側」はあなたのシステムが信頼できるようにできるものです。保存する耐久的な状態、あなたが課すルール、後で証明できる事実です:

  • データベースのレコードとその履歴
  • ビジネスルール(例:「注文は一度だけ支払える」)
  • ステータスの真実の源(pending、paid、canceled など)

内側は不変条件を守る場所です。「注文につき一度の支払い」を約束するなら、その約束は内側で強制されるべきです。外側はそのように振る舞わないからです。

考え方の転換は単純です:配達やタイミングが完璧だと想定しない。外側のやり取りは繰り返されたり取り消されたりする「信憑性の低い提案」として扱い、それが内側で安全に処理されるように設計します。

これは小規模なチームや単純なアプリでも重要です。ネットワークの不具合で二重請求や処理が止まる注文が発生した瞬間、それは理論ではなく返金やサポートチケット、信用の失墜になります。

具体例:ユーザーが「注文を確定」ボタンを押し、アプリがリクエストを送ったあと接続が切れる。ユーザーが再試行すると、内側が「これは同じ試行だ」と認識できないと、二つの注文が作られ、在庫が二重に確保され、確認メールが二通送られるかもしれません。

Pat Helland の重要な教訓

Helland の要点は明快です:外側の世界は不確実だが、システムの内側は一貫性を保たなければならない。ネットワークはパケットを落とし、携帯は電波を失い、時計はずれ、ユーザーは再読み込みを押します。アプリが制御できるのはそうしたことではなく、データが明確な境界を越えたときに何を「真実」と受け入れるかです。

日常の一場面における時間と不確実性

建物内で Wi‑Fi が悪い中を歩きながらスマホでコーヒーを注文する人を想像してください。彼らは「支払う」をタップします。スピナーが回ります。ネットワークが切れます。もう一度タップします。

最初のリクエストはサーバーに届いたがレスポンスが戻らなかったかもしれません。あるいはどちらも届いていない可能性もあります。ユーザーから見れば両方とも同じに見えます。

それが時間と不確実性です:何が起きたかまだわからないし、後で分かることもある。システムは待っている間も合理的に振る舞う必要があります。

再試行、重複、順序の入れ替わり

外側が信頼できないと受け入れると、いくつかの「変な」振る舞いが普通になります:

  • 再試行が重複を生む(「支払う」が二回)
  • メッセージが順序を入れ替えて届く(「キャンセル」が「支払」より先に届く)
  • リクエストは処理されたがクライアントがレスポンスを見ていない

外側のデータは事実ではなく主張です。「支払った」は信頼できないチャネル上のステートメントに過ぎません。それが事実になるのは、あなたのシステム内で耐久的かつ一貫した方法で記録したときだけです。

これにより、実務的な習慣が三つ生まれます:明確な境界を定義する、再試行を安全にする冪等性を持たせる、そして現実と合わないときに照合する計画を立てることです。

明確な境界:あなたのシステムが所有するものとしないもの

「外側と内側」の考え方は実用的な問いから始まります:あなたのシステムの真実はどこからどこまでか?

境界の内側では、データとルールをコントロールできるため強い保証ができます。境界の外側ではベストエフォートを行い、メッセージは失われたり重複したり遅延したり順序が入れ替わったりするものと仮定します。

実際のアプリでは、その境界は次のような箇所に現れます:

  • データベースにレコードを書き込む API エンドポイント
  • イベントを保存された変更に変えるキューのコンシューマ
  • プロバイダの発言を記録するコールバックハンドラ
  • 自分の状態をコミットした後に他システムへ通知する送信者

線を引いたら、その内側で譲れない不変条件を決めます。例:

  • 注文IDはデータベース内で一意である
  • 残高は負にならない
  • 状態は前進のみ(created -> paid -> shipped)
  • 受け入れた外部リクエストはすべて監査トレイルを持つ

境界には「今どこにいるか」を表す明確な言葉が必要です。「受け取りました」と「処理が完了しました」の間に多くの失敗が潜んでいます。役に立つパターンは三つの意味を分けることです:

  • Received: メッセージがエッジに到達した(まだ保存されているとは限らない)
  • Accepted: 保存され、後で安全に再試行できる状態
  • Processed: 意図した作業が完了し結果を記録した

チームがこれを飛ばすと、負荷が高いときや部分的な障害時にしか起きないバグが出ます。あるシステムは「paid」をお金を確保したことと定義し、別のシステムは支払い試行を開始したことと定義していると、重複やスタックした注文、再現不能なサポートチケットが生じます。

冪等性:再試行を安全にする方法

冪等性とは:同じリクエストが二度送られてもシステムはそれを一つのリクエストとして扱い、同じ結果を返すことです。

再試行は普通です。タイムアウトは起きます。クライアントは自分自身を繰り返します。外側が繰り返すなら、あなたの内側は状態変化を安定化させなければなりません。

簡単な例:モバイルアプリが「$20 支払う」を送って接続が切れる。アプリが再試行すると、冪等性がなければ顧客は二重に請求されるかもしれません。冪等性があれば、二回目のリクエストは一回目の課金結果を返します。

冪等性の一般的な実装方法

多くのチームは次のパターン(または混合)を使います:

  • Idempotency key:クライアントが意図ごとに一意のキーを送る(例:Idempotency-Key: ...)。サーバーはそのキーと最終レスポンスを記録する。
  • 重複排除テーブル(client_id, key)(order_id, operation) でキーを付けた行を保存し、二回目の副作用を拒否する。
  • 自然キー:ビジネス上の識別子が既に一意である場合、「支払い作成」は一度しか存在できないようにする。

重複が来たとき、最良の振る舞いは「409 Conflict」や汎用エラーを返すことではありません。最初に返した結果と同じレスポンス(同じリソースIDとステータス)を返すことです。これが再試行とバックグラウンドジョブを安全にします。

記録をどこに置き、どれくらい保持するか

冪等性の記録は境界内の耐久ストレージに置く必要があり、メモリではダメです。API が再起動して忘れてしまうと安全性は消えます。

記録は現実的な再試行と遅延配信をカバーする期間だけ保管してください。ウィンドウはビジネスリスクによります:低リスクの作成なら数分〜数時間、課金やメールや出荷のように重複が高コストなものは数日、パートナーが長期間再試行する場合はさらに長く。

「分散トランザクション」の罠を避ける方法

ビルドを共有してクレジットを稼ぐ
コンテンツ作成や紹介で Koder.ai のクレジットを獲得しながら構築できます。

分散トランザクションは魅力的に聞こえます:サービスやキュー、データベースにまたがる一回の大きなコミット。しかし実際には利用不能だったり遅かったり脆弱すぎて頼れないことが多いです。ネットワークが絡むとすべてが同時にコミットされるとは期待できません。

よくある罠は、今すべてのステップが成功することを前提にワークフローを作ることです:注文を保存して、カードを課金して、在庫を確保して、確認を送る。もしステップ3がタイムアウトしたら、それは失敗なのか成功なのか?再試行すると二重課金や二重確保になるかもしれません。

回避する実用的なアプローチ二つ:

  • Outbox/inbox:状態変更と同じトランザクションで送信したい意図(outbox 行)を書き、ワーカーがそれを送信する。受信側はメッセージIDで inbox を管理して同じメッセージが再配信されても安全に処理できるようにする。
  • サーガ風の補償ステップ:ワークフローを小さな独立したステップに分け、後段のステップが失敗したら補償(在庫を戻す、未決済の注文をキャンセルする等)を実行して履歴を巻き戻そうとしない。

ワークフローごとに一つのスタイルを選び、それを守ってください。「時々 outbox を使う」と「時々同期成功を前提にする」を混ぜるとテストしにくいエッジケースが生まれます。

簡単なルール:境界をまたいで原子的にコミットできないなら、再試行、重複、遅延を前提に設計すること。

照合:不一致から回復する方法

照合は基本的な事実を認めることです:ネットワーク越しに他システムとやり取りすると、何が起きたかで食い違いが起きることがある。リクエストはタイムアウトするし、コールバックは遅れて届くし、人は再試行する。照合は不一致を検出し、時間をかけて修復する仕組みです。

外部システムを独立した真実の源と見なしてください。あなたのアプリは内部で独自の記録を持ちますが、パートナーやプロバイダ、ユーザーが実際に何をしたかと比較する方法が必要です。

よく使われる照合の仕組み

多くのチームは地味だが効果的な道具を使います:保留中アクションを再試行するワーカー、矛盾を探す定期スキャン、サポートが再試行・キャンセル・レビュー済みにできる小さな管理用修復アクション。

何を比較し何を記録するか

照合は比較対象が分かっていないと機能しません:内部台帳 vs プロバイダ台帳(決済)、注文状態 vs 発送状態(フルフィルメント)、サブスクリプション状態 vs 課金状態など。

状態を修復可能にしておきましょう。直接「created」から「completed」に飛ばすのではなく、pending や on hold、needs review のような保留状態を使って「まだ確定できない」と表明できるようにします。これにより照合の着地点ができます。

重要な変更には小さな監査トレイルを残してください:

  • リクエストを送った時刻と最後に返答を聞いた時刻
  • あなたのレコードを外部イベントに結びつける相関ID
  • 最後に知られている外部のステータス(どこから来たか)
  • 手動オーバーライドの理由欄(誰が、何を、なぜ)

例:配送ラベルをリクエストしてネットワークが切れた場合、内部的には「ラベルなし」になっているがキャリア側ではラベルが作られていることがある。照合ワーカーは相関IDで検索してラベルを見つけ、注文を前進させる(あるいは詳細が合わなければレビューに送る)。

ステップ・バイ・ステップ:ネットワーク障害を生き延びるワークフロー設計

生成されたコードを自分で所有する
準備ができたらソースコードをエクスポートして完全に制御できます。

ネットワークが失敗することを前提にすると、目標が変わります。すべてのステップを一発で成功させることではなく、各ステップを繰り返しても安全で修復しやすくすることです。

実践的なワークフロー

  1. 一文で境界の宣言を書く。あなたのシステムが所有するもの(真実の源)、鏡写しにするもの、他から単に要求するだけのものを明示する。

  2. ハッピーパスの前に失敗モードを列挙する。最低限:タイムアウト(成功したか分からない)、重複リクエスト、部分成功(あるステップだけ成功した)、順序の入れ替わり。

  3. 各入力に対して冪等性戦略を選ぶ。同期 API なら idempotency キー + 保存された結果が多い。メッセージ/イベントなら一意のメッセージIDと「これを処理したか?」の記録。

  4. 意図を永続化してから実行する。まず “PaymentAttempt: pending” や “ShipmentRequest: queued” のような耐久的なものを保存し、外部呼び出しを行い、結果を保存する。再試行が同じ意図を指すように安定した参照IDを返す。

  5. 照合と修復パスを作り、それらを可視化する。照合は「長時間保留」レコードを再チェックするジョブにできる。修復パスは「再試行」「キャンセル」「解決済みとしてマーク」など安全な管理アクションで、監査メモを残す。相関IDや明確なステータスフィールド、いくつかのカウント(保留、再試行、失敗)を追加して基本的な可観測性を持たせる。

例:チェックアウトが決済プロバイダ呼び出し直後にタイムアウトしたら推測しない。試行を保存し、試行IDを返し、ユーザーは同じ idempotency キーで再試行できるようにする。後で照合がプロバイダが課金したかどうかを確認し、二重課金を避けつつ試行を更新する。

例:再試行と遅延コールバックのある注文フロー

顧客が「注文を確定」ボタンを押す。あなたのサービスは決済プロバイダに支払いリクエストを送るが、ネットワークが不安定だ。プロバイダ側にも独自の真実があり、あなたのデータベースにも独自の真実がある。設計しないと両者はずれていきます。

外側で起きること(あなたが制御できないイベント)

外側は遅れて届き、繰り返され、欠落する可能性のあるメッセージの流れです:

  • 「注文を送信」が API に届く
  • あなたの決済リクエストがプロバイダに送られる
  • プロバイダが「承認済み」の webhook を送る
  • プロバイダが webhook を再試行して同じコールバックを再送する
  • クライアントがタイムアウトして「注文を確定」を再送する

これらのどれも「一度だけ」を保証しません。せいぜい「たぶん」です。

内側で保持するもの(あなたが制御できる記録)

境界内には耐久的な事実と、外部イベントをつなぐために必要最小限の情報を保存します。

顧客が最初に注文したら、order レコードを pending_payment のような明確な状態で作成します。同時に、プロバイダ参照と顧客アクションに紐づく idempotency_key を持った payment_attempt レコードを作ります。

クライアントがタイムアウトして再試行しても、あなたの API は二つ目の注文を作るべきではありません。idempotency_key を見て同じ order_id と現在の状態を返すべきです。これだけでネットワーク障害時の重複を防げます。

Webhook が二度届いたら、最初のコールバックで payment_attemptauthorized にして注文を paid に進めます。二回目のコールバックは同じハンドラに届いても、プロバイダのイベントIDを保存しておけば既に処理済みと検出できて何もしません。それでも 200 OK を返して問題ありません。結果は既に真であるからです。

最後に、照合が雑然としたケースを処理します。注文が長時間 pending_payment のままならバックグラウンドジョブが保存した参照でプロバイダに問い合わせます。プロバイダが「承認済み」と答えれば内部を更新する。プロバイダが「失敗」と答えているのに内部で paid としていればレビュー対象にするか補償アクション(例:返金)を実行します。

重複やスタックを引き起こす一般的なミス

より安全な決済フローを出荷する
タイムアウトで二重請求が起きないように、チェックアウトを耐久的なインテントに変えます。

ほとんどの重複レコードや「処理が止まる」ワークフローは、外側で何が起きたか(リクエストが届いた、メッセージを受け取った)と、内側で安全にコミットした事実を混同することから生じます。

典型的な失敗:クライアントが「注文を確定」を送る、サーバーが作業を開始する、ネットワークが落ちてクライアントが再試行する。各再試行を新しい真実として扱うと二重請求や重複注文、複数のメールが生まれます。

よくある原因:

  • 受信リクエストを早すぎに信頼する:データベースコミット前にメール送信や「注文作成」のログを出す。
  • 再試行が新しい行を作る:試行ごとに新しい注文IDを生成し、再試行を一つの結果にマッピングしない。
  • 「一度だけ」を前提にする:キューやコールバックはその保証をしない。重複や遅延、順序入れ替えが起きる。
  • 安定した識別子がない:「この意図を以前に見たか?」に答えられないと重複を防げない。
  • 成功/失敗しかない、中間状態がない:保留や待機状態がないとタイムアウトが謎になる。

さらに悪化させるのは 監査トレイルがないこと です。フィールドを上書きして最新状態だけを保持すると、後で照合するための証拠が失われます。

良い検査は:「このハンドラを二回実行して同じ結果が得られるか?」です。もし答えが No なら、重複は稀なエッジケースではなく保証された事象です。

すぐ使えるチェックリストと次の実践ステップ

一つだけ覚えておくといいこと:メッセージが遅れて届く、二回届く、あるいは届かない場合でもアプリは正しく動いていなければならない。

弱点を発見するためのチェックリスト:

  • 真実の源が明確:各ワークフローについて一つの「真実の場所」が指せる(多くはデータベース)。
  • すべての書き込みは安全に再試行できる:各コマンド/API に冪等性キー(または自然のユニークキー)がある。
  • 安定したIDと相関IDがエンドツーエンドにある:ログ、テーブル、コールバックを横断して一つのビジネスアクションを追跡できる。
  • 照合が自動で動く:「我々が信じていること」と「実際に起きたこと」を定期的に比較して修復かアラートを出す。
  • ロールバックが状態を壊さない:状態変更は監査可能でバージョンを跨いでも整合的である。

これらのうち一つに即答できないなら、それは境界があいまいか状態遷移が欠けていることを意味します。

実践的な次のステップ:

  1. まず境界と状態をスケッチする。ワークフローごとに小さな状態セットを定義する(例:Created, PaymentPending, Paid, FulfillmentPending, Completed, Failed)。

  2. 最もリスクが高い書き込みに冪等性を追加する。まずは注文作成、決済確定、返金などに着手する。PostgreSQL にユニーク制約付きで idempotency キーを保存すると安全に重複を拒否できる。

  3. 照合を通常機能として扱う。長時間保留のレコードを検索し外部を再確認してローカル状態を修復するジョブをスケジュールする。

  4. 安全に反復する。遷移と再試行ルールを調整し、同じリクエストを故意に再送したり同じイベントを再処理してテストする。

もしあなたが Koder.ai (koder.ai) のようなチャット駆動プラットフォームで素早く構築しているなら、これらのルールを早めに生成されたサービスに組み込む価値があります:スピードは自動化から来ますが、信頼性は明確な境界、冪等なハンドラ、照合から来ます。

よくある質問

「外側と内側のデータ」は簡単に言うと何を意味しますか?

「Outside」はあなたが制御しないすべてです:ブラウザ、モバイルネットワーク、キュー、サードパーティの webhook、再試行、タイムアウトなど。メッセージは遅延、重複、消失、順序の入れ替わりが起きると仮定してください。

「Inside」はあなたが制御できるものです:保存された状態、ルール、後で検証できる事実(通常はデータベースにあるもの)。

なぜ受信リクエストや webhook を一度だけ実行されると信頼できないのですか?

ネットワークはあなたに嘘をつきます。

クライアントがタイムアウトしたからといってサーバーが処理していないとは限りません。 webhook が二回届いてもプロバイダが二回処理したとは限りません。各メッセージを「新しい真実」と扱うと、重複注文、二重課金、処理が止まるワークフローを作ってしまいます。

典型的なアプリでは「境界」はどこに引くべきですか?

不確かなメッセージが耐久的な事実になる点が明確な境界です。

よくある境界例:

  • データベースにコミットする API エンドポイント
  • イベントを状態変更として保存するキューのコンシューマ
  • プロバイダの主張を記録する webhook ハンドラ

データが境界を越えたら、内部で不変条件(例:「注文は一度だけ支払われる」)を強制します。

ユーザーが “Pay” を再送したときに二重課金を防ぐには?

冪等性を使ってください。原則は:同じ意図は何度送られても同じ結果を生むべきということです。

実践的なパターン:

  • クライアントがアクションごとに idempotency キーを送る
  • サーバーはキーと最終レスポンスを耐久的に保存する
  • 重複が来たら最初のリクエストと同じリソースID/ステータスを返す
冪等性の記録はどこに保存し、どれくらい保持すべきですか?

メモリだけに保管しないでください。境界内(例:PostgreSQL)の耐久ストレージに置き、再起動で消えないようにします。

保持期間の目安:

  • 低リスク操作:数分〜数時間
  • 高コスト操作(支払い、返金、出荷、メール):数日以上

現実的な再試行や遅延コールバックをカバーする長さに設定してください。

「わからない」バグを避けるにはどんな状態を追加すべきですか?

不確実さを認める状態を使ってください。

シンプルで実用的なセット:

  • pending_*(意図は受け入れたが結果は不明)
  • succeeded / failed(最終結果を記録)
  • needs_review(不整合が検出され人手または特別なジョブが必要)

これによりタイムアウト時の推測を避け、照合を容易にします。

なぜ分散トランザクションはアプリワークフローで罠になることが多いのですか?

ネットワーク越しに複数のシステムにまたがって原子性を保証するのは現実的ではないことが多いからです。

「注文を保存 → カードを課金 → 在庫を確保」を同期で行い、途中で 2 がタイムアウトした場合、再試行で二重課金や二重確保が起きるか不明になります。

部分成功を前提に設計してください:まず意図を永続化し、その後外部操作を行い、結果を記録します。

outbox/inbox パターンとは何で、いつ使うべきですか?

outbox/inbox パターンは、ネットワークが完璧でないことを前提にクロスシステムのメッセージングを信頼できるものにします。

  • Outbox:状態変更と同じトランザクションで送信予定のメッセージ行を書く。
  • ワーカーが outbox を読みメッセージを送る。
  • Inbox(受信側):処理したメッセージIDを保存し再配信で副作用が重複しないようにする。

外部とのやり取りがあるときに使うと安全です。

照合とは何で、簡単に実装するには?

照合は、あなたの記録と外部システムの記録が食い違ったときに回復する方法です。

簡単な実装:

  • 「保留が長すぎる」アイテムを再チェックする定期ジョブ
  • (あなたの状態)と(プロバイダの状態)の比較
  • 修復アクション:再試行、キャンセル、返金、または needs_review としてマーキング

支払い、出荷、サブスクリプション、Webhook を扱うなら必須の機能です。

Koder.ai のようなプラットフォームで素早く作っている場合でもこれは重要ですか?

速く作ることはネットワーク障害を取り除きません。速さは早く問題に到達させるだけです。

Koder.ai でサービスを生成する場合でも、早い段階でこれらを組み込んでください:

  • 明確な境界(いつ意図が耐久化されるか)
  • 「作成/キャプチャ/返金」系の操作に対する冪等ハンドラ
  • 外部参照と一緒に保存される相関ID
  • 保留レコードを再チェックする照合ジョブ

そうすれば再試行や重複コールバックは面倒ではなくなります。

Related posts