代表的な面接トピック

システム設計面接:サブスクリプション課金システムの設計

システム設計難しい
Offer.cc 編集チーム公開日 更新日

質問

月次・年次プラン、トライアル、アップグレード、ダウングレード、日割り計算、更新、支払い失敗時のリトライ、請求書、およびWebhookに対応したサブスクリプション課金システムを設計してください。ステートマシン、課金エンジン、支払い冪等性、イベント順序制御、キャパシティ、突合、および障害からの復旧について説明してください。

プロンプトとスコープ

SaaS、メディア、またはメンバーシップ製品向けに定期サブスクリプションを管理するサービスを設計します。顧客は月次または年次プランを選択し、トライアルから有料アクセスへ移行し、サイクル途中でプランを変更し、更新失敗から復旧できます。システムは監査可能な請求書を発行し、エンタイトルメントについて製品側へ通知し、支払いプロバイダーからの非同期イベントを処理しなければなりません。

公開されている面接資料では、「サブスクリプション課金システムの設計」を、subscription-created イベントや trial-expired イベント、ボトルネック、顧客指標、システム監視に関するシステム設計問題として取り上げています。Stripeのドキュメントでは、サブスクリプション、請求書、PaymentIntent、トライアル、日割り計算、収益回復、およびWebhookを同一ライフサイクルの中に位置づけています。本記事は、特定の企業が必ずこの問題を出題するとは主張しません。

アクティブなサブスクリプション100万件、平均してサブスクリプションあたり1日1件の課金関連イベント、更新ピーク時は通常トラフィックの最大10倍、金額は最小通貨単位で保存、そしてタイムアウト・イベント重複・ローカル状態変更後のイベント配信が起こりうるプロバイダーを前提とします。システムは二重請求を防ぎ、請求書の追跡可能性を維持し、リトライ後にエンタイトルメント状態を収束させなければなりません。

面接官が評価する観点

優れた回答は、プラン・サブスクリプション・請求書・支払い試行・エンタイトルメントを分離します。顧客行の paid=true に書き込むだけでは、日割り計算・猶予期間・返金・1つの請求書に対する複数の試行を表現できません。

次のシグナルは、明示的な冪等性の境界です。請求書の作成、支払い呼び出し、Webhookハンドリング、エンタイトルメント配信はいずれもリトライされえます。キューだけでは二重請求や二重承認を防ぐことはできません。

面接官はまた、時間と会計のセマンティクスをテストします。課金アンカー、トライアル終了、タイムゾーン、閏月、即時アップグレード、期末キャンセル、不変の金額などです。プランの価格が変わっても過去の請求書は変わってはなりません。

最後に、候補者はピーク、支払い失敗、順序の乱れたイベント、手動返金、突合のギャップ、および修復について説明できるべきです。有用な成果は、本来請求されるべきだった金額、実際に請求された金額、そして復旧後に有効なエンタイトルメントを把握することです。

回答前の確認事項

  • 課金アンカーは何か? 暦月か、ローリング日付か?これにより期末計算と2月や31日の扱いが変わります。
  • アップグレード時の請求タイミングは? 即時、次の期間、または顧客選択か?これにより日割り計算と支払い保留状態が変わります。
  • ダウングレードはどう機能するか? 即時返金、次期反映、またはアカウントクレジットか?これにより請求書明細と返金処理が変わります。
  • 支払い失敗時にいつアクセスを削除するか? 読み取り専用の猶予期間と即時停止ではエンタイトルメントルールが異なります。
  • 税金、割引、または従量課金はスコープ内か? そうであれば、請求書確定前に価格入力のバージョン管理が必要です。
  • エンタイトルメントの信頼できる情報源は? 製品側がプロバイダーイベントを直接消費するか、課金システムが entitlement.changed イベントを発行するか。この選択によりリプレイと一貫性が変わります。
  • 複数通貨、返金、または手動調整は必要か? 必要であれば、整数の最小単位を使用し、過去の請求書を上書きしないでください。

30秒回答

「プラン・サブスクリプション・請求書・支払い試行・エンタイトルメントを分離します。サブスクリプションステートマシンが現在の期間と次のアクションを決定し、課金エンジンが各境界で不変の請求書スナップショットを作成し、支払い冪等性キーとして invoice_id を使用します。署名付きWebhookとプロバイダークエリを通じて支払いを確認し、イベントIDで重複排除し、順序の乱れたイベントの到着を許容します。アップグレードとダウングレードは明示的な正負の請求書明細として表現します。支払い失敗は有界かつジッターを加えたリトライステートマシンに入り、エンタイトルメントは確認済み支払い状態と文書化された猶予ポリシーに従います。突合、状態不変条件、障害注入によって設計を検証します。」

ステップバイステップの解答

ステップ1:オブジェクトと不変条件を定義する。

text
Plan(id, version, currency, interval, unit_amount, trial_days)
Subscription(id, customer_id, plan_version, status, period_start, period_end)
Invoice(id, subscription_id, period_start, period_end, currency, total, status)
InvoiceLine(id, invoice_id, source, description, quantity, unit_amount, amount)
PaymentAttempt(id, invoice_id, attempt_no, provider_key, status, provider_id)
Entitlement(id, customer_id, feature, valid_until, source_invoice_id)

プランは変わることがありますが、発行済みの請求書はその価格バージョンと通貨スナップショットを保存します。主な不変条件は次のとおりです。サブスクリプション期間には有効な請求書が最大1つしかない。1つのプロバイダー結果が請求書を1回だけ進める。遅延した失敗が確認済み支払いを上書きできない。エンタイトルメント変更のリプレイが1つのバージョンに収束する。

ステップ2:ステートマシンで処理を駆動する。

text
trialing --trial_end--> active
active --renewal--> invoice_open
invoice_open --payment_succeeded--> paid
invoice_open --payment_failed--> past_due
past_due --retry_succeeded--> paid
past_due --retries_exhausted--> canceled
active --cancel_at_period_end--> canceling
canceling --period_end--> canceled

バージョン付きのコマンドまたはイベントがトランジションを実行します。任意のWebリクエストがstatusを直接変更すべきではありません。activepast_due、および canceled に対するエンタイトルメントポリシーを定義します。たとえば、past_due は3日間読み取り専用アクセスを維持し、canceled は有料機能を削除します。すべてのトランジションについて、理由・ソースイベント・アクター・時刻を記録します。

ステップ3:請求書を生成し、プラン変更を処理する。

課金ワーカーを period_end でパーティション分割します。まず (subscription_id, period_start) などのユニーク制約の下で請求書を作成し、次にその明細を計算します。15日目のアップグレードでは、未使用の旧プランを負の明細として、残余の新プランを正の明細として記録します。整数の最小単位と明示的な秒数または日数のルールを使用し、丸められた浮動小数点値を減算してはなりません。

請求書が確定したとき、金額・税金・割引・使用量スナップショット・プランバージョンを凍結します。期間ジョブは2回実行されることがありますが、ユニーク制約が元の請求書を返し、ワーカーは支払い試行がすでに存在するかどうかを確認します。期末キャンセルは cancel_at_period_end を変更しますが、請求書履歴は削除しません。即時キャンセルは返金またはクレジット調整を作成します。

ステップ4:支払い冪等性をネットワークタイムアウトから分離する。

text
provider_key = "invoice:" + invoice_id + ":attempt:" + attempt_no
POST payment-provider/charges
  Idempotency-Key: provider_key

プロバイダーを呼び出す前に、ローカルの PaymentAttempt を書き込みます。タイムアウト後は失敗と推測せず、同じキーでリトライするかプロバイダーにクエリします。Stripeのドキュメントでは、冪等なリクエストを繰り返すと最初の結果が返され、偶発的なキーの再利用を防ぐためにパラメータを比較すると説明されています。したがって、キーは1回のネットワーク試行ではなく、1つのビジネスオペレーションを表さなければなりません。

成功時には、Webhookまたはプロバイダークエリがprovider_id・金額・通貨・完了時刻を記録します。条件付きデータベース更新により、open または past_due のみが paid へ移行できます。遅延した payment_failed は監査レコードに記録され、確認済み支払いをロールバックできません。金額または通貨の不一致は、アクセスを自動的に付与または削除するのではなく、突合処理に回します。

ステップ5:受信ボックスでWebhookの重複と順序の乱れを処理する。

text
WebhookInbox(event_id PRIMARY KEY, received_at, payload_hash, processed_at)
Outbox(id, aggregate_id, event_type, payload, published_at)

署名を検証し、生のイベントとハッシュを WebhookInbox に保存し、重複には event_id で応答します。プロセッサはオブジェクトバージョン・プロバイダーステータス・ローカル請求書状態を使って処理を進めるかどうかを決定します。到着時刻は順序の保証ではありません。Stripeは非同期アクティビティとエンタイトルメント変更にサブスクリプションWebhookを推奨しているため、内部処理もリプレイ可能でなければなりません。

ローカルトランザクションは、課金状態・エンタイトルメント変更インテント・Outboxレコードをまとめて書き込めます。パブリッシャーがその後レコードをエンタイトルメントサービスへ送信します。コンシューマーは (aggregate_id, version) で重複排除するため、プロバイダーのリトライ・キューのリトライ・コンシューマーの再起動があっても、有効な承認が2つ作成されることはありません。

ステップ6:キャパシティを見積もり、ピークを分離する。

アクティブなサブスクリプション100万件、1日あたりサブスクリプションあたり1つの課金イベントとすると、定常レートは約11.6イベント/秒、10倍の更新ピーク時は約116イベント/秒です。より困難な負荷は、同時発生する請求書明細・支払い呼び出し・Webhook・通知です。期間処理を時間でバケット分割し、各バッチに上限を設け、単一の毎時0分スパイクを避けるためにジッターを加えます。

ワークロード主なボトルネック制御方法
期間スキャンホットインデックスとロック競合period_end でバケット分割、短いトランザクション、ユニーク制約
支払い呼び出しプロバイダーのレイテンシと制限有界な並行数、冪等性キー、指数バックオフ
Webhook重複および順序の乱れたイベント受信ボックスによる重複排除、条件付きバージョン更新、リプレイキュー
エンタイトルメント同期下流のバックログOutbox、コンシューマーラグ、テナントクォータ

サブスクリプションIDと期間終了でサブスクリプションにインデックスを付け、支払いと通知の処理を非同期化します。大規模テナント・更新日が集中するテナント・高使用量アカウントには個別のクォータを設け、1つのテナントがすべての支払い並行数を消費しないようにします。これらの数値は出発点の仮定であり、プロバイダーの制限と障害テストで設計を調整する必要があります。

ステップ7:突合、修復、および観測。

請求書・プロバイダートランザクション・決済エクスポートに対して毎日突合を行います。各請求書について、明細項目の合計が合計金額と一致するか、支払い金額が請求金額と等しいか、エンタイトルメントの有効期限が確認済み支払い時刻を超えていないかを確認します。差異に対しては不変の調整レコードまたは返金レコードを作成し、古い金額を上書きしてはなりません。

期間スキャンのラグ・請求書レイテンシ・不明な支払い結果・リトライ回数・past_due の経過時間・Webhook重複率と処理ラグ・エンタイトルメント伝播遅延・突合の金額差・テナントごとの支払い失敗を追跡します。監査レコードは subscription_idinvoice_idpayment_attempt_id・イベントID・トレースIDをリンクし、問い合わせからプロバイダーレスポンスまで追跡できるようにします。

ステップ8:障害マトリクスで境界を検証する。

繰り返される期間ジョブ、請求書作成後のクラッシュ、プロバイダーへの課金後にレスポンスが失われた場合、重複および順序の乱れたWebhook、日割り支払いの失敗、支払い方法なしのトライアル終了、キャンセルと更新の同時発生、Outbox発行後のコンシューマークラッシュ、手動返金と自動リトライの競合を注入します。

受け入れ確認は次のとおりです。(subscription_id, period_start) あたり有効な請求書が1つ;1つの provider_key に対して二重請求なし;確認済み支払いが遅延した失敗で上書きされない;リプレイされたエンタイトルメントイベントが同一バージョンを生成する;すべての突合差異に追跡可能な調整がある;すべての自動アクションが監査レコードから再構築できる。

高品質なサンプル回答

「プラン・サブスクリプション・請求書・支払い試行・エンタイトルメントを分離し、請求書作成時にプラン価格をスナップショットします。サブスクリプションステートマシンがトライアル・更新・猶予期間・キャンセルを処理します。期間ワーカーがユニークキーの下で1つの請求書を作成し、プラン変更は整数の最小単位と明示的な日割り計算ルールを使った正負の請求書明細になります。

プロバイダーを呼び出す前に試行を書き込み、安定した invoice_id + attempt_no 冪等性キーを導出します。タイムアウト時は同じキーまたはプロバイダークエリを使用し、新しいキーを作ることは絶対にしません。Webhookは検証され、受信ボックスに保存され、イベントIDで重複排除されます。条件付きバージョン更新により、順序の乱れた失敗が確認済み支払いを上書きするのを防ぎます。Outboxがエンタイトルメントインテントを確実に下流へ送信します。

支払い並行数・期間スキャン・Webhook・通知・テナントクォータを分離し、更新ピークにジッターを加えます。毎日の突合でローカル請求書・プロバイダートランザクション・決済を比較します。期間ジョブの重複、レスポンスが失われた成功した課金、順序の乱れたWebhook、日割り失敗、キャンセルと更新の同時発生、リプレイ後のエンタイトルメント収束をテストします。」

よくある間違い

  • 間違い:サブスクリプションに paid だけを保存する → なぜ失敗するか:請求書・試行・猶予期間・返金が消える → 修正:請求書・支払い試行・エンタイトルメントを個別にモデル化する。
  • 間違い:タイムアウトリトライごとに新しい支払いIDを作成する → なぜ失敗するか:1つのビジネス上の課金が2回実行されることがある → 修正:オペレーションあたり1つの冪等性キーを維持し、不明な結果をクエリする。
  • 間違い:到着時刻でイベントを適用する → なぜ失敗するか:Webhookは重複・順序の乱れ・遅延が起こりうる → 修正:受信ボックスを保存し、オブジェクトバージョンと条件付きトランジションを使用する。
  • 間違い:今日のプラン価格から古い請求書を再計算する → なぜ失敗するか:価格変更が履歴を書き換える → 修正:請求書に価格・税金・割引・使用量・プランバージョンを凍結する。
  • 間違い:1回の失敗後に即座にキャンセルする → なぜ失敗するか:一時的なプロバイダー障害がエンタイトルメント障害になる → 修正:past_due 猶予ポリシーと有界な督促ステートマシンを使用する。
  • 間違い:キャンセル時に期間レコードを削除する → なぜ失敗するか:監査・返金・突合の証拠が失われる → 修正:履歴を保持しつつキャンセルレコードと調整レコードを追記する。
  • 間違い:データベーストランザクションを支払いトランザクションとして扱う → なぜ失敗するか:プロバイダーはローカルトランザクションの外部にある → 修正:冪等な呼び出し・Webhook・クエリ・突合でループを閉じる。
  • 間違い:すべてのサブスクリプションを1つの正確な時刻にスキャンする → なぜ失敗するか:ロック・支払い呼び出し・通知が同時にスパイクする → 修正:処理をバケット分割・ジッター化・キューイング・クォータ制御する。

フォローアップと回答

フォローアップ1:アップグレードの支払いが失敗した場合、アクセスはどうなるか?

古いエンタイトルメントを維持し、pending_update と新しい請求書を作成し、支払い確認後にのみプランを切り替えます。製品が「回収前のアップグレード」を許可する場合は、新しいエンタイトルメントを明示的な有効期限付きの猶予制限済みとしてマークします。どちらのポリシーでも、請求書とエンタイトルメントのバージョンは追跡可能なまま維持されます。plan_id だけを変更するのでは不十分です。

フォローアップ2:プロバイダーのWebhookが3日間届かない場合、どう検知するか?

ローカルの請求書状態とプロバイダークエリから突合ジョブを実行します。ターミナルイベントのない古い openpast_due・または期限切れ請求書についてアラートを出し、クエリが依然として不明であれば自動キャンセルを一時停止して手動レビューへルーティングします。Webhookは低レイテンシの通知であり、唯一の信頼できる情報源ではありません。

フォローアップ3:キャンセルと更新が同時に届いた場合、どちらが勝つか?

サブスクリプションコマンドに単調増加するバージョンを割り当て、条件付き更新または順序付きパーティションでシリアル化します。cancel_at_period_end は現在の更新と共存できます。即時キャンセルは返金またはボイドを発行する前にオープンな請求書または支払い試行の有無を確認します。監査イベントは最終的な課金とエンタイトルメントの結果を説明しなければなりません。

フォローアップ4:二重カウントなしに従量課金を追加するにはどうするか?

不変の使用量イベントIDを重複排除し、請求期間ごとに使用量スナップショットを作成します。重複があっても受信メトリクスは増加しますが、課金対象数量は増加しません。決済時にスナップショットを凍結し、遅延イベントは次の期間または手動調整へルーティングします。再計算を比較できるよう、請求書明細にソースと集計バージョンを保存します。

公開情報ソース

関連する質問

関連面接ツール

システム設計の回答には「回答する」を使用

まず要件を明確にし、スケール、アーキテクチャ、コンポーネント選定、トレードオフの順に進めます。

ツールを見る