• Backend

実践イミュータブルデータモデル — NEWTポイント機能の設計

notion image
 
こんにちは、バックエンドエンジニアの飯沼です。かんたんに海外ツアーを予約できるNEWT(ニュート)と、それを支える社内向けのツアー入稿・予約管理システムのバックエンド開発を担当しています。
先日、ツアーの旅行代金の5%分のポイントを還元する「NEWTポイント」をリリースしました。1ポイント=1円として次回の旅行予約で使えるおトクな機能となっておりますので、海外旅行をご検討の方はぜひご活用くださいポイント機能の思想やサービスとしての設計についてはPMの藤沼の記事をご覧ください。
この記事では、イミュータブルデータモデルをNEWTポイントの設計でどのように活用したかを紹介します。ポイント機能の設計がメイントピックとなっており、イミュータブルデータモデルそのものについては記事の最後で紹介している資料を参考にしてください。

イミュータブルデータモデルとは

発生した事実として変わることのないイミュータブルな「イベント」と、状態が変化する「リソース」を区別することで、モデルの複雑性を増す更新処理(CRUDのうちのUPDATE)を極力減らそう、という考え方です。ポイント機能においては、ツアーの予約、キャンセルなどの「イベント」をトリガーとして、カスタマーが保有するポイントという「リソース」を増減する、という捉え方をします。

前提:ポイント機能の要件

前提となるポイント機能の要件を紹介します。データモデルの構造に大きく影響しそうな要件に🚨を付けています。
  • ポイントの付与:NEWTでツアーの予約をして、旅行から帰ってきた日の翌月7日にポイントを付与する。ポイント数は旅行代金の5%、有効期限は付与日から12か月目の月末までとする。(例:1/15〜20に旅行 → 2/7にポイント付与 → 翌年1/31まで有効)
    • notion image
  • ポイントの確認:カスタマーは現在有効なポイント数、有効期限、付与予定のポイント数、付与予定日を確認できる。
  • ポイントの利用:1ポイント=1円としてツアーの予約時に利用できる。
  • 獲得・利用による有効期限の延長:ポイントが付与されたタイミング、ポイントを利用したタイミングで有効なポイントの有効期限を、その時点から12か月目の月末まで延長する。
    • notion image
  • 🚨 キャンセルによる有効期限短縮:ポイントを利用した予約をキャンセルした場合は有効期限を延長前に戻す。
  • キャンセル料金への充当と返還:ポイントを利用した予約をキャンセルする場合、予約時に利用したポイントをキャンセル料金に充て、余った分をカスタマーに返還する。ただし、キャンセルの結果有効期限が短縮され即時失効となるケースがある。
  • 退会による失効:退会時にすべての保有ポイントを失効する。
  • 🚨 期間限定ポイント(リリース未定):任意のタイミングでカスタマーに対してポイントを付与する。このポイントの有効期限はツアー予約により付与される通常のポイントとは分けて管理したい。(販促施策に利用)
  • 社内での会計処理:ポイント発行額、利用額、返還額、失効額を月次で集計できる。
※ 2023/1/12時点での情報です。最新の情報をお探しの方はこちらをご確認ください。

エンティティを抽出し、「リソース」と「イベント」に分類する

要件からエンティティを抽出・分類します。今回は「ツアー予約」イベントをトリガーに「ポイント付与」イベントが発生して「保有ポイント」リソースが生成され、「ポイント利用」、「ポイント返還」、「ポイント失効」イベントにより「保有ポイント」が更新される、という構造として捉えました。
※ これは論理的なモデルであって、具体のテーブル構造を表しているわけではありません。また、見やすさのため「保有ポイント」と「ポイント増減のイベント」間のリレーションは一部省略してます。
notion image
ここで、イベントには1つの日時属性のみをもたせることが重要です。複数の日時属性を持っている場合、1つのエンティティに隠れたイベントが混入している可能性があるので、そういったイベントがないかチェックして分解しましょう。
要件のうちもっとも厄介かつデータモデルに影響を与えたのが次の2つです。
  • (A) 期間限定ポイント(リリース未定):任意のタイミングでカスタマーに対してポイントを付与する。このポイントの有効期限はツアー予約により付与される通常のポイントとは分けて管理したい。(販促施策に利用)
  • (B) キャンセルによる有効期限短縮:ポイントを利用した予約をキャンセルした場合は有効期限を延長前に戻す。
Aに関しては、ポイント付与イベントごとに保有ポイントのレコードを追加していく構造にしました。これによって期間限定以外の通常のポイントも付与タイミングごとにレコードが存在することになり、あるカスタマーの現在有効なポイント数は複数レコードを集計して取得することになりますが、許容できる複雑性と判断しました。
Bに関しては、有効期限の履歴管理が一瞬頭をよぎりましたが、旅行というフリークエンシーの比較的低いサービスであり、ポイント増減の一連のイベントから現在の残高と有効期限を導出する方法(いわゆるイベントソーシングパターンと呼ばれる手法)でも十分なパフォーマンスが得られると判断し、有効期限の変更履歴はモデルに含めませんでした。以下はキャンセルにより有効期限を延長前に戻す例です。一連のイベントを時系列でトレースすることで現在の残高、有効期限を導出できることがわかります。
  • 1/10:ツアー予約(1/20 ~ 1/25の旅行)
  • 2/7:ポイント付与 +5,000ポイント → 残高5,000ポイント、有効期限は翌年1月末
  • 3/10:ツアー予約
    • ポイント利用 -2,000ポイント、利用により有効期限延長 → 残高3,000ポイント、有効期限は翌年2月末
  • 3/20:3/10のツアー予約をキャンセル
    • ポイント返還 +2,000ポイント → 残高5,000ポイント、有効期限は翌年1月末

データモデルからテーブル設計へ

ここまでは論理的なデータモデルとしてポイント機能を整理しました。これをもとに、私たちの環境(利用しているデータベースやアプリケーションの構造)にフィットするかたちで具体的なテーブル設計をします。
今回は以下のように整理しました。論理的なデータモデルからの大きな差分は以下の通りです。
  • ポイント増減イベントを1テーブルにまとめる:種別ごとの属性の差異が少なく、今後新しい種別が増えることも考えにくいので1テーブルにまとめました。
  • 「ポイント付与」イベントは「保有ポイント」の属性としてマージ:具体的には付与日時、発行ポイント数を保有ポイントの属性に追加しています。こちらの理由は後述します。
notion image
また、以下の点を考慮して設計しました。
 
時間トリガーのバッチ処理への依存性を減らす:
具体的には、旅行から帰ってきた日の翌月7日にポイントを付与する処理のことです。バッチ処理でポイント付与する場合、もし処理に失敗したら本来使えるようになるべきだったポイントが使えないということになります。このようなサービスへの悪影響を避けるために、予約時点で「保有ポイント」を未来の付与日時とともに記録し、ポイントを利用する際には付与日時を見て有効性をチェックする作りにしました。これが「ポイント付与」を「保有ポイント」の属性としてマージした理由でもあります。
そして予約キャンセル時にポイントの付与もキャンセルしなければならないので、付与キャンセル(Boolean)の属性を追加しています。後から振り返って、削除フラグアンチパターンに近い構造になっていてSELECT時の有効性確認漏れに注意する必要があります。意図としては付与されなかったポイントを記録することなので、別テーブルに退避するのが良いでしょう。
 
イベントをすべてトレースしなくても現在の残高、有効期限を導出可能にする:
論理的なデータモデルの説明部分で、イベントをトレースすることで残高と有効期限を導出できると説明しましたが、長期的にはどこかでパフォーマンスが問題になることが予想できます。これを防ぐために「保有ポイント」にポイント残高を保持し、「ポイント取引」が発生する際にUPDATEする構造にしました。ポイント残高が0のレコードはトレースしないなどの工夫をすることでデータベースから取得するデータ量を削減可能です。また、「保有ポイント」の集計のみで全カスタマーの現在有効なポイント数を算出できるため会計処理も簡単になります。

おわりに

この記事では、ポイント機能を例にイミュータブルデータモデルの活用事例を紹介しました。紹介しきれてない部分や私の勉強不足で説明が不正確な部分もあると思いますので、ぜひ以下の資料に目を通してみてください。今後、私たちの開発チームでデータモデルを検討する際に「それはリソース?イベント?」といったように共通言語として議論の際に役立ててきたいと考えています。
 
また、令和トラベルでは、一緒に働く仲間を募集しています。興味を持っていただいた方がいれば、ぜひご連絡をお待ちしております!