SODA Engineering Blog
🛍️

購入・決済のDDD/モジュール化で考えたこと

に公開

この記事について

SNKRDUNKの購入処理が非常に複雑度の高いモノリスとして実装されていた。

  • 変数のライフサイクルが長い。変更による副作用が怖い
  • 処理が長すぎてテストが書きにくい
  • 各処理の責任・所有が不明。処理の全貌を把握できる人が実質いない
  • 手続き的。正常に購入できている状態がどのようなものかが分かりにくい

そこで、「購入」およびそれに依存される手続きをドメインごとにモジュール・サービスに切り出してカプセル化するということを行った。

今回は「購入」「決済」にフォーカスして、その過程で検討したことを紹介する。

(1) 決済を購入から分離する

購入処理の中で最も複雑で依存度の高い処理が決済である。まず決済を購入処理から分離することを検討した。分離の境界は以下のようなものである。

お互いの認知負荷を下げるための分割ルール

  • 購入は外部決済サービスのことを知らない
  • 決済は購入のことを知らない
  • 依存関係は「購入→決済」(購入が決済のことを知るのはOK)

なぜ購入・決済の分離をしたいか

  • 購入・決済ともにビジネスインパクトが大きく改修頻度が高く複雑度も大きくなりやすい。分離をすることでそれぞれの複雑度を低減することが期待できる。
  • 決済は外部サービスの知識を強く求められる領域であり、購入は自社サイト側の知識を強く求められる領域であり、別の知識が必要である。分離して管理者を分けることでお互いの認知負荷を下げることが期待できる。
  • 決済のアーキテクチャは外部サービスの要件(セキュリティやのレートリミット等)によって制約を受けることがある。よって決済を分離することでその要件を満たしやすくなるし、購入処理がその要件の影響を受けずに済ませることが期待できる。

(2)購入はオーケストレータ

「購入」自体はビジネスロジックをほとんど持たず、購入にまつわる様々なモデルの手続きを順に実行していくのが「購入」の責務。

(3)購入のアトミック性を守るには?

一連のトランザクションが「全て成功する」か「全て失敗する」かのどちらかに倒さないといけない。もしどこかで中断したら、バッチで検出して完了するまでリトライする。

トランザクションを繋げるかどうか?

  • モジュール間でもトランザクションを繋げればアトミック性はDBのレベルで担保されるのでリコンサイルが不要になる
  • 一方でモジュール間で結合度が上がる、ロック時間が延びるなどデメリットもある
  • また購入の場合は決済=外部TXが挟まるので、全てを1TXで完結させることはできない。ただそれでも繋げられるところは繋げておくことで状態管理がシンプルになる。

購入に状態管理テーブルを持たせるか?

  • 購入が1TXごとに進捗を自身のデータストアに永続化して、中断してしまった購入データがあれば、進捗を参照して中断したところから処理を再開できるようにする
  • 結論、これはオーバーエンジニアリングと判断して棄却した
    • 購入処理の複雑度の急上昇、データマイグレーションの問題、進捗とTXの間の整合性が取れないことなど
  • ワークフローの個々の処理が全て非同期処理になっているようなアーキテクチャなら採用できるかもしれない

結論: 冪等性を守る

よって最終的に強く採用したのは、個々の処理に冪等性を守らせるというものである。

これによりどこまで進んだかを細かく管理する必要がなくなり、中断時にどこから再開しても、結果成功・失敗まで完了させることができるようになる。

冪等なAPIの提供方法については、下記が詳しい。

https://docs.aws.amazon.com/ja_jp/wellarchitected/2023-10-03/framework/rel_prevent_interaction_failure_idempotent.html

決済をpivotal eventとする

中断した購入をどこから再開させるかだが、決済のチェックから行う。ユーザが決済したかどうかをpivotal eventとして、決済していたら成功に倒し、していなかったら失敗に倒す。

購入者との通信が切れてしまった場合も、裏側で成功して購入成功を通知させるか、失敗して状態を元に戻すかを確定させる。

(4)支払い方法別の処理をどこまで抽象化可能か?

結論:決済が購入に公開するメソッドだけファサードで統一する

  • 理想は支払い方法によらず呼び出し元の購入のコードを統一できることだが、現実はかなり厳しい。
    • 決済手段ごとに決済に必要な入出力パラメータがバラバラなので「paymentインターフェースを作って、支払い方法ごとに実装を作る」というようなパッと思いつく抽象化を実現するのは難しい。
    • 支払い方法ごとに手続きも大小異なるので、ストラテジーパターンも採用しにくい。
  • よって「initiatePayment」「completePayment」「cancel」「capture」というファサードを公開し、IOはバラバラ、内部処理もswitch文で支払い方法別の処理をベタ書きするに留めている。
  • 決済は常に外部APIの仕様に引っ張られるので、その時可能だからと強引な抽象化を検討するのは避けるべきと考える。

(5) 外部在庫連携のトレードオフ

自社ECサイト外の在庫から購入するケースを考える(実店舗など)。

この場合、購入された際に注文を連携して在庫を抑えておく必要がある。

理想形はECサイトと同様に購入開始時に在庫を押さえておいて、決済が完了したら注文を確定させるというものだ。

しかし外部在庫連携は外部在庫が提供するAPIの様々な制約を受ける。以下はその例である。

  • 在庫確保APIが提供されていない
  • 一度注文連携したらキャンセルできない
  • APIの可用性が低い

そこで外部への注文連携を非同期で行うことも考えうる。

このケースの欠点は「ユーザが決済をしたのにも関わらず在庫を抑えることができずキャンセル」というリスクがある。

逆に大きなメリットとしては、購入側・連携側がお互いの制約に集中できるというものである。新しい種類の外部連携ができるごとに購入側の購入フローを慎重に組み直すのはかなり手間がかかる作業であり、そこから解放される。

どちらを選択するか(またはさらに別の選択肢を取るか)はビジネス要件次第である。

(6) ユーザへの購入完了通知を冪等にする

通知は非同期ジョブで送信しているが、モジュール如何によらず非同期処理は二重実行のリスクがある。DB更新は冪等にできるが、通知は明示的に2回送らないようにしなければならない。

通知のログをDBに保存して状態管理するアプローチが考えられる。ただし通知ログのためだけに永続化DBを使うのはコスパが悪くなることも多い。そこで弊社では折衷案として揮発性のDBに短めのTTLを設定して入れるようにしている。大抵の二重実行はこれで防ぐことができている。

SODA Engineering Blog
SODA Engineering Blog

Discussion