はじめに
こんにちは。STORES でエンジニアをしているmochizukiです。
Webアプリケーションの開発では、外部システムと連携した機能を実装することがあると思います。
例えばAPIで外部システムのリソースを取得したり、Webhookで通知を受け取ったりして、業務ロジックに組み込むようなケースです。
このような開発を行う上で、「取得した情報をどう扱うか」は重要な論点の一つです。 少し分解すると、「取得した情報をこちらのシステムでも保持するのか」「保持するならどのような形で持つのか」といったことを考える必要があります。
私は今回、外部リソースの"状態"を「上書きして持つ」のではなく、受け取った情報を「イベントとして積み上げる」形に寄せる方針を取りました。
この記事では、そのときに考えたこと(なぜそうしたか、どうやって扱うか)を、実装例を交えながら紹介します。
前提:こちらで持つ情報はキャッシュでしかない
まず前提として、外部システムから得た情報は、その時点での情報に過ぎません。 外部システムのリソースは、あくまで外部システム側が真実(source of truth)です。
例えば以下のようなことが想定できます。
- APIでデータを取得した直後に、更新がかかっている可能性がある
- Webhook通知は再送されることもあるし、ネットワークや非同期処理の都合で順番が前後することもある
つまり、こちら側で情報を保持したとしても、それは外部リソースの「真実」ではなく「スナップショット(キャッシュ)」です。
この「データの鮮度」の問題を前提に置くと、設計上のスタンスとして以下の点を念頭におくことになります。
- こちらで保持するのはキャッシュであり、リアルタイムでの真実としては扱わない
- “今の状態” を厳密に知りたいなら、そのタイミングで外部システムを見に行く(APIや管理画面)
状態を保持してUPDATEしていく設計
外部リソースを扱うとき、つい以下のような設計に寄せたくなります。
- 外部リソースを保持するテーブルを作り、
statusのようなカラムを持たせる - WebhookやAPIで情報を取得したら、その行をUPDATEして"最新状態"を保存する
このような状態を保持するテーブル設計にしたくなる理由としては以下が挙げられます。
- 読み取りが簡単:「今どうなってる?」が1カラムで分かる
- 検索が簡単:「この状態(status)のもの一覧」が
.where(status: hoge)で出せる - 条件分岐が書きやすい:画面表示やバッチ処理が素直になる
ただ、外部連携においては(外部連携でなくても言えることですが)この「簡単さ」が、運用フェーズに入ると壊れやすい、というのが今回のポイントです。
「状態を保持してUPDATEしていく設計」で起こる問題
1) Webhookは再送(retry)が前提
多くのWebhookは at-least-once delivery(最低1回は届くが、重複して届く可能性がある)を前提にしており、 受信側が200を返せなければ同じ通知がもう一度届きます。
状態テーブルの上書きだけで設計していると、
- 同じ通知を2回受けたことを区別しにくい
- 「いつ、何回届いたか」が残らない
- 後から調査するときに根拠が弱い
といった問題が起きがちです。
2) 順不同(out-of-order)が起こり得る
ネットワーク遅延や非同期処理で、通知が順不同に届くことがあります。
理想は
in_progress→approved
の順に届いてほしいですが、現実には
approvedが先に届き、後からin_progressが届く
といったことも起こり得ます。 状態を上書きしていると、古い通知で新しい状態が塗り潰される事故が起こります。
3) “例外” が将来追加されやすい(=要件が増えるほど壊れる)
外部連携は、運用の都合で例外が増えがちです。
- 手動で"一時的に"状態を更新したい
- 別経路での更新を行いたい
- 特定条件だけ別扱いしたい
この「例外」を状態テーブルに押し込めると、状態遷移や上書きルールが複雑化しやすいです。 要件が増えるほど “状態をどう更新するべきか” の条件分岐が増えていくことが予想できます。
上書きしない形で「事実」を残す
今回の設計は次の方針に寄せました。
- 外部リソース自体を参照できるように、IDなどの最小情報は保存する
- Webhookで得たリソースの状態は、「イベント」として新規レコードで保存する(UPDATEしない)
- “最新状態” はイベント列から導出する(最新イベントを見る / 集計する)
ここから先は、実装例を交えつつ紹介します。
実装例:
外部サービスと連携して「状態が進んでいくリソース」を扱うときの実装例を紹介します。
イメージとしては、外部サービス側で対象リソースの検証が進み、in_progress のような途中状態を経て、最終的に approved/denied のように結果が確定していくものです。
自社側では、その状態に応じて「ある操作を許可する/止める」などの業務ロジックを動かします。
このとき、外部リソースの状態を“上書きして保持する”のではなく、受け取った事実を“イベントとして積み上げる”形に寄せています。
以下のような流れになります。
- 事業者のある情報を提供して検証をAPIリクエストし、レスポンスを事業者と紐づけて保存
- Webhookで検証の進捗更新を受け取り、イベントとして保存
- イベントを集計して"状態"を算出し、その結果によって業務ロジックに制御を入れる
ここからは、この方針を Rails で実装するときの例を示します。
実際のプロダクトコードとは異なりますが、ポイント(状態を持たずイベントを積む・そこから導出する)が伝わるように簡略化しています。
テーブル1:外部リソース(IDを保持するだけ)
# external_checks: 外部システム側のリソースを表す(IDだけ持つ) # # id :string primary key # 外部リソースID(Webhookの data.id など) # created_at :datetime # updated_at :datetime # class ExternalCheck < ApplicationRecord has_many :external_check_events def tenant external_check_events.select(:tenant_id).distinct.to_a.sole.tenant end end
ここには、外部が発行したIDを保存するだけです。状態は持ちません。
テーブル2:イベントログ(append-only)
# external_check_events: 受け取った事実を積む(INSERTしかしない) # # id :uuid primary key # tenant_id :uuid not null # external_check_id :string null/ not null(イベント種別によって変わる想定) # version :integer not null # tenant内で単調増加(並び順のため) # event_type :string not null # created_at :datetime # updated_at :datetime # # index: (tenant_id, version) UNIQUE # class ExternalCheckEvent < ApplicationRecord belongs_to :tenant belongs_to :external_check, optional: true enum :event_type, { requested: "requested", in_progress: "in_progress", approved: "approved", denied: "denied", }, validate: true before_validation :assign_next_version, on: :create private def assign_next_version return if version.present? max = self.class.where(tenant_id:).maximum(:version) || 0 self.version = max + 1 end end
状態をUPDATEせず、その都度イベントとしてINSERTします。
外部通知の順不同は一旦気にせず、バージョンで順番を管理しています。(順序を厳密に扱う必要がある場合は、別途 occurred_at や external_sequence のような軸を足す設計も検討します)。
外部リソースの作成
APIでPOSTして外部システムのリソースを作成します。
class ExternakCheckCreator def initialize(tenant_id:) @tenant_id = tenant_id end def call response = Client.new.post_check ApplicationRecord.transaction do external_check = ExternalCheck.create!( id: response[:id], ) ExternalCheckEvent.create!( external_check:, tenant_id: @tenant_id, event: response[:type] # requested ) end end end
Webhookでイベント取得 → 保存
Webhookで取得した情報をイベントとして保存します。
class ExternalWebhookController < ApplicationController def create payload = JSON.parse(request.raw_post) External::EventHandler.new(payload).handle head :ok end end
typeをイベントにマッピングして「積む」だけのハンドラです。
module External class EventHandler def initialize(payload) @payload = payload end def handle case payload_type when "in_progress" create_event(:in_progress) when "completed" create_event(result == "approved" ? :approved : :denied) else Rails.logger.warn("unknown type: #{payload_type}") end end private attr_reader :payload def payload_type = payload["type"] def data = payload["data"] def result = data["result"] def create_event(type) external_check = ExternalCheck.find(id: data["id"]) ExternalCheckEvent.create!( tenant: external_check.tenant external_check: external_check, event_type: type ) end end end
「上書きして最新状態を作る」処理はありません。とにかく事実を積みます。
“現在どう扱うべきか” はイベントから導出する
例えば「ある事業者が、今この外部チェックの観点で許可状態かどうか」を判定したい場合、状態テーブルに state を持たずに、イベント列から導出できます。
class Tenant < ApplicationRecord has_many :external_check_events def latest_external_check_event external_check_events.order(version: :desc).first end # 例:業務上の「許可/不許可」をイベントから導出する def allowed? latest = latest_external_check_event # 未実施時を許可/不許可どちらにするかは業務要件次第。今回は検証を実施していなければ許可とする仕様でした。 return true if latest.nil? return true if latest.approved? return false # in_progress / denied end end
重要なのは「allowed?」が 状態テーブルの値 を読んでいるのではなく、イベント列から導いた値 だという点です。
例外(手動操作など)をどう扱うか
運用が始まると「例外」が出ます。典型は「手動で一時的に許可する」などです。
allowed?(許可状態)の判定に追加する要件はこんな感じです。
- 手動で許可している場合は
trueとなる - 手動の許可を取り消した場合は、外部での検証状況での制御に戻る
- 何度でも手動での許可・取り消しを行える
この設計では、例外を“状態の上書きロジック”に押し込めるのではなく、
- manual_override_approved
- manual_override_revoked
のように イベントとして追加します。
class ExternalCheckEvent < ApplicationRecord enum :event_type, { in_progress: "in_progress", approved: "approved", denied: "denied", manual_override_approved: "manual_override_approved", manual_override_revoked: "manual_override_revoked", }, validate: true end
class ExternalCheckEvent < ApplicationRecord def allowed? latest = latest_external_check_event return true if latest.nil? return true if manual_override_approved? # 手動で許可しているか return true if latest.approved? return false # in_progress / denied end def manual_override_approved? latest_approved = external_check_events.find(&:manual_override_approved?) latest_revoked = external_check_events.find(&:manual_override_revoked?) # 手動で許可したことがない return false if latest_approved.nil? # 「手動許可取り消し」がない、もしくは「手動許可」が「手動許可取り消し」以降にある場合は、手動で許可している状態 latest_revoked.nil? || latest_approved.version > latest_revoked.version end end
イベントを最新のものから見て、手動許可が先にあれば「許可状態」、「手動取り消し」が先にあれば通常の外部での検証に戻る、といったロジックが追加されました。
結果として、
- “いつ何をしたか” がイベントとして残る
- 「今どうなってる?」は最新イベントから導出できる
- “例外が増えた”ときも、イベント種類を追加する方向で拡張しやすい
という形になります。
検索・一覧要件が出たらどうするか
「検証中の事業者を検索したい」「許可されている事業者を検索したい」といった要件が増えることもあります。
選択肢はいくつかあります。
- まずは外部システム側の管理画面(検索UI/詳細画面がある場合)があり、それで要件を満たせるのであればそれを利用する
- 今回の私の開発ではこの判断ができたので、検索機能は実装せずに済みました
- 内部で必要になったら、イベントから導出して愚直に絞り込む(件数が小さいうちはこれで十分なことが多い)
- 件数やクエリが厳しくなってきたら、導出結果を格納する キャッシュテーブル(read model) を別途導入する
最初から状態テーブルを正にするのではなく、必要になった時に “読み取り最適化” を後付けできる構造にしておく、という整理です。
まとめ
外部サービスと連携するとき、こちらが持てる情報は基本的に「その時点で観測できた結果」に過ぎません。
再送・順不同・例外追加といった現実を踏まえると、状態を“上書きして管理する”設計では「どちらを正とみなすか」「古い通知で塗り潰さないか」といったガードが必要になったり、「更新ルール」を守るための工夫が増えやすく、扱いが難しくなりがちです。
そこで今回は、
- 外部リソースを参照できるように最小限の識別子だけを持ち
- 状態変化はイベントとして都度INSERTし
- “今どうなっているか”はイベント列(最新イベント or 集計)から導出する
という形に寄せました。
この設計にしておくと、
- 同じ通知が複数回届いても「事実」として残せる
- 順番が前後しても、上書き事故ではなく扱いの工夫の問題にできる
- 手動対応などの例外要件が増えても、イベント追加で拡張しやすい
といったメリットが出ます。
もちろん、一覧検索や状態での絞り込みは難しくなるので、必要になった段階で read model(集計・キャッシュ)を足す、という割り切りが現実的です。
外部リソースの取り込み設計を考えるときの一つの選択肢として、参考になれば嬉しいです。