ドメイン理解が鍵!データモデリングの失敗事例3選
はじめに
こんにちは!ダイニーでソフトウェアエンジニアをしている ta21cos です。
データベース設計をする際、ドメインモデリングと密接に関係することはよく知られています。適切なデータモデルがあれば、スムーズな機能開発やパフォーマンスの最適化が可能になります。一方で、ドメイン理解が不十分なまま設計を進めると、開発が進むにつれ「このモデルでは要件を表現できない」といった問題が発生してしまいます。
特に、ビジネスの要望が変化し、新たな機能が要求されるたびに、「これまでのモデルで対応できるのか?」といった課題に直面することが多くなります。
今回は、私たちが実際に直面した「データモデリングの失敗事例」を3つ紹介し、なぜその設計が問題だったのか、そしてどうすればより良い設計になったのかを考察していきます。
「技術的には問題なく見えるデータモデルでも、ドメインの視点から見ると誤っていることがある」――この記事を通じて、データベース設計における落とし穴を共有し、より良い設計のヒントになれば幸いです。
事例1:えっ、そんな機能あったの…? 〜リサーチ不足の例〜
ダイニーでは、飲食店向けに決済端末を提供するサービス、ダイニーキャッシュレスを運営しています。このサービスでは、各決済を正しく集計し、適切に送金できるようにするために、決済のライフサイクルをデータベースに記録しています。
クレジットカード決済の基本的なフローは、ざっくり次のような形になっています。
まず、カード会社にこの決済を処理しても大丈夫かの確認を取り、利用可能枠を確保します(オーソリ)。その後、実際に決済を確定する処理(キャプチャ)を行います。そして、何らかの理由で返金が必要な場合は、決済を取り消す(返金)ことになります。
このフローに沿って、私たちはデータベースの設計を行いました。基本的な考え方として、決済の各イベントを immutable(不変)な形で記録するようにし、以下のような3つのテーブルを作成しました(テーブル名は仮のものです、カラムはかなり省略しています)。
また、チームでは immutable モデリング(データを更新せず、新しいレコードを追加して状態を表現する手法)を積極的に採用しています(参考:イミュータブルデータモデル)。
設計当初は、このモデルで特に問題はなく、決済データの一貫性も保たれていました。
想定外のケース:「Partial Refund(一部返金)」
しかし、運用が始まってしばらくした頃、チームでは考慮しきれていなかったケースがあることに気づきました。それが、 Partial Refund(一部返金)の存在です。
この問題に気づいたのは、ある店舗から「決済の金額を変更してほしい」という依頼を受けたときでした。そこで初めて、部分返金を考慮していない設計の問題に直面することになったのです。
私たちは、「返金=決済を取り消すもの」と考えていたため、paymentRefundedEvent には「この決済が返金された」という事実しか記録しておらず、返金額を指定するカラムが存在しませんでした。そのため、一部返金を処理しようとすると、「決済が全額返金された」という記録になってしまうのです。
ドメイン(決済業界)のリサーチが不十分で、「返金 = 全額返金」と決めつけてしまっていたため、このような設計ミスをしてしまったのです。
修正案
直感的な解決策としては、paymentRefundedEvent に 「返金額」 のカラムを追加することが考えられます。
しかし、この方法だと「複数回の(一部)返金」に対応できないという課題があります。複数回の返金を可能にするには、元の取引と 1:1 ではなく 1:多 の対応にする必要があります。
一方「複数回の部分返金は本当に行われるのか?」「そのためにモデルを複雑にして、バグを生み金額の計算を間違えてしまうことはないか?」など考える点はたくさんあります。
まとめ
このケースでは、決済の基本フローは押さえていたものの、Partial Refund という存在を考慮していなかったことでミスが発生しました。
技術的に正しく見えるモデルでも、実際のビジネスのルールや業界の仕様を十分に考慮しなければ、思わぬ落とし穴にはまる――この経験を通じて、よりドメイン知識を深めながら設計を行う意識が高まりました。
事例2:この決済ってどの店舗のもの? 〜紐づけの履歴〜
ダイニーキャッシュレスでは、飲食店向けに決済端末を提供しています。そのため、決済データを適切に管理するには、端末と店舗の紐づけを正しく記録する必要があります。
この紐づけのデータは非常に重要です。なぜなら、決済イベントには端末のIDが含まれているため、これを元に決済と店舗を紐づける必要があるからです。決済データは一日一回、レポートという形でまとめて送られてくるので、そのタイミングで決済と店舗を紐づける処理を行います。
設計当初、私たちは次のようなデータモデリングをしていました(こちらもテーブル名は変えており、またカラムも大きく省略しています)。
- shop:店舗の基本情報
- shopConfig:店舗の決済設定を管理するデータ。terminalIdを持つ
- terminal:端末を表すエンティティ。データとしては持っていませんが、便宜上ここに書いています
この設計では、shopConfig に店舗ごとの端末IDを格納することで、各決済がどの店舗で行われたものなのかを判断できるようにしました。最初の実装では特に問題なく動作し、設計としても違和感はありませんでした。
しかし、その後決済データを集計する機能の設計を行っていた際に、ある問題が発覚しました。
店舗と端末の紐づけ変更時に発生した問題
本サービスでは、店舗で使う端末を交換するケースがあります。例えば、端末に初期不良があり、決済はできるものの伝票の印刷に問題があるので交換するという場合です。この場合、異なる端末を改めて店舗に紐づけることになります。
この変更を行ったあとに、過去の決済データを処理しようとすると、ある問題が発生します。過去の決済イベントが、店舗に紐づかないものとして処理されてしまうのです。
初期の状態では、初期不良のあった端末 terminal1 は店舗A(shopA)に紐づいていました。
しかし、端末の紐づけ変更後は、terminal2 が shop にひも付きました。
graph LR
terminal1;
terminal2 --> shopA;
この状態で、紐づけ変更前の時点での決済イベントが送られてきた場合、データベース上では「terminal1 はどこの店舗にも紐づいていない」と判断されるため、本来は店舗Aの決済だったはずのデータがどこにも紐づかないものとして扱われてしまいました。
これは、データモデルが「現在の状態のみを持つ」構造になっていたために起こった問題です。端末と店舗の紐づけ履歴を持っていなかったため、過去のデータが正しく解釈できなくなったのです。
修正方法:端末の紐づけ履歴を保存する
この問題を解決するため、端末と店舗の紐づけを履歴として管理することにしました。
このモデルでは、端末の紐づけ変更が発生した際に、新しい履歴レコードを追加することで、「いつ、どの店舗が、どの端末を使用していたか」を記録できるようにしました。
- assignedAt:この端末がこの店舗に割り当てられた日時
- releasedAt:この端末の紐づけが解除された日時(null の場合は使用中)
決済データを処理する際は、決済日時が assignedAt から releasedAt の間にある履歴を探し、その時点での店舗を取得することで、正しい店舗との紐づけが可能になりました。
まとめ
今回のケースでは、端末と店舗の関係を「現在の状態」だけで管理していたため、時間の経過による変化を考慮できていなかったことが問題でした。
データモデリングでは、過去のデータをどのように扱うかを意識することが重要です。特に、履歴が必要なデータを適切に保存しておくことで、後からのデータ処理が正しく行えるようになります。
「過去の状態を振り返る必要があるデータか?」 という視点を持つことで、適切なデータ設計ができるようになると改めて実感しました。
事例3:本当に今回だけ…? 〜短期施策と思い込んでしまうケース〜
ダイニーキャッシュレスでは、ローンチ記念として料率が安くなるキャンペーンを実施しました。
このキャンペーンでは、最初の決済を行った時点から約半年間、通常よりも低い決済手数料が適用されるという仕組みでした。特定の期間だけの施策と認識していたため、私たちは「一時的なもの」としてキャンペーンのデータモデルを設計しました。
実際、当時の Pull Request にも「なおこのキャンペーンはそのうち利用されなくなるので、後から削除しやすいような実装を心がけている」というコメントを記載していました。
その結果、次のようなデータモデルを作成しました。
このモデルでは、shopConfig に paymentMethod という形でカードブランドごとの料率を紐づけ、それに対して paymentMethodCampaign というキャンペーン情報を1対1の関係で持つようにしました。
キャンペーンのデータが存在し、startAt と endAt の期間内であればそちらの料率を適用し、キャンペーンが終了すれば通常の料率に戻るという設計です。シンプルで分かりやすく、またキャンペーンが終われば削除しやすい構造になっていました。
実際に運用している間、このモデルで特に問題はありませんでした。
「キャンペーンはまたやりうる?」
しばらく経った後、エンジニア内の雑談で新メンバーから「キャンペーンってまたやるんですかね?」という話題が出ました。最初は「いえ、あれは一度きりの施策です」と返そうとしたのですが、ビジネスへの理解が深まったとともに、キャンペーンはまたやりうるのでは?という考えに変わりました。
決済端末ビジネスにおいては、決済手数料が自社にも、顧客にも大きく影響します。なのでいろいろな都合で「料率を下げます・上げます」という機会が発生し得ます。実際弊社も12月に主に中小企業向けの「個店プラン」という手数料の異なる新規プランを公開しました。これに類似する形で、いつ「キャンペーンをまたやります」と言われてもおかしくありません。
ここで初めて、「このキャンペーンは一度きり」という前提が崩れました。
現在のデータモデルでは、キャンペーンを「サービスローンチキャンペーン」として1対1で紐づけており、別のキャンペーンを導入するには大きな変更が必要になります。
例えば、新しいキャンペーンが追加された場合、次のような課題が発生します。
- 1対1の制約により、新しいキャンペーンを追加するには既存のキャンペーンを削除する必要がある
- キャンペーンの種類を拡張するには、paymentMethodCampaign の構造自体を変更する必要がある
- 将来的に複数のキャンペーンを同時に適用する可能性が出てきた場合、さらに大きな改修が必要になる
現時点ではまだ具体的なキャンペーンの計画はないため、すぐに改修する必要はありませんでしたが、もし次のキャンペーンが実施されることになった場合、作り直す必要がありそうです。
まとめ
今回のケースでは、「一時的な施策だから、シンプルな設計で問題ない」という前提でデータモデルを作成しました。
しかし、ビジネスの方向性は常に変化するものであり、「この機能は今回限り」 という前提で設計したものは、意外と長く使われることが多いということを痛感しました。
もちろん、将来のすべての可能性に対応するような設計をするのは現実的ではありません。しかし、少なくとも「この機能が将来的に拡張される可能性はあるか?」という視点を持ち、ある程度の柔軟性を持たせておくことは重要だと感じました。今回のケースであれば、少なくともキャンペーンが店舗に紐づくものとしてテーブル設計していれば、新たなキャンペーンを追加する際の差分は小さくできそうです。
今回は、設計時に「後から削除しやすいように」と意識していたことは良い判断でした。このおかげで、作り直すことになったとしても、影響を最小限に抑えられそうです。
まとめ
今回紹介した3つの事例は、それぞれ異なる観点でデータモデリングがうまくいかなかったケースでした。
-
ドメインの理解不足
- 返金処理を「全額返金のみ」と考えてしまい、一部返金に対応できないモデルを設計してしまった。
- 事前のリサーチを十分に行わなかったことが原因。
-
過去データの扱いを考慮しなかった
- 端末と店舗の関係を「現在の状態のみ」で管理していたため、過去のデータを正しく処理できなくなった。
- 履歴データを適切に保存することで、過去の状態を正しく参照できるように改善。
-
機能の持続性を考慮しなかった
- 「一時的な施策」としてデータモデルを設計したが、ビジネスの状況が変化し、長期間使い続ける可能性が出てきた。
- 拡張性を意識せずに作ると、後からの変更がコストになる。
どの事例も、技術的に見れば明らかなミスというわけではないと思います。しかし、ドメインという視点から見ると、最適なモデルではなかったことが分かります。
データベース設計では、技術的な要件だけでなく、ビジネスのルールや将来的な変更の可能性も考慮することが重要です。もちろん、完璧な設計を最初から作るのは難しいですが、ドメイン理解を深め、過去の失敗から学ぶことで、より適切なモデリングができるようになるはずです。
We’re hiring!
ダイニーでは、ビジネスの変化に柔軟に対応し、ドメインを深く理解しながらシステムを設計していく仲間を募集しています。
興味がある方は、ぜひ一度お話しましょう!
Discussion