STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

外部連携をイミュータブルに設計する:状態を持たず、事実を残す

はじめに

こんにちは。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_progressapproved

の順に届いてほしいですが、現実には

  • 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(集計・キャッシュ)を足す、という割り切りが現実的です。

外部リソースの取り込み設計を考えるときの一つの選択肢として、参考になれば嬉しいです。