DBをバリデーターにする ─ マルチドメイン決済を支える「クラス継承型テーブル」設計
はじめに
こんにちは、Unitoでバックエンドエンジニアをしている大村です。
AIの進化により、基本的なコードは爆速で生成できる時代になりました。ただ、AIにコードは書かせられても、ドメインの境界線をどこに引くかは人間にしかできない判断だと感じています。その判断を後回しにしてスピードに任せて開発を進めると、ある日気づいたときにはDBが「幽霊カラム(特定の条件下でしか値が入らない、大量のNULL)」で埋め尽くされている……なんてことが起きます。
私たちが携わる「Unito」は、一つの部屋が「賃貸物件」にもなれば「宿泊物件」にもなる、ユニークなサービスです。表向きはシンプルな切り替えに見えますが、その裏側はドメインの総合格闘技でした。
例を挙げると、
- 賃貸の中にも契約の種類によって、必要な契約手続きや税の扱いが変わる
- 賃貸と宿泊では、請求項目や決済頻度だけでなく、経理上の計上タイミングも法的に異なる
- 決済方法は、保証会社が複数契約を月毎にまとめて徴収するケースと、Stripeで1契約ごとに決済するケースなど複数ある
これらをどのような軸でテーブル設計するかによって、将来の拡張性が大きく変わってきます。
極端な例として、スピード優先で一つのテーブルに押し込み、type カラムの if 分岐で場当たり的に解決しようとすれば、あっという間にDBは破綻します。
そこで私たちがたどり着いたのが、「DB設計段階からStrategyパターンを組み込む」というアプローチです。アプリ層でのリファクタリングではなく、データ構造の段階から将来の拡張性をどう封じ込めたか。本記事では、決済基盤の実装を例にその設計判断をご紹介します。
設計方針:DBをバリデーターにする — 派生テーブルでドメインを物理的に分離する
Strategyパターンは、たいていアプリ層の話として語られます。インターフェースを切って実装クラスを差し替える、というアレですね。
ただ、ドメインごとに保持すべき項目そのものが違うケースだと、アプリ層だけStrategyにしても、DB側には全チャネル分のカラムを抱えたテーブルが残ります。
実際、見直し前の私たちは決済情報を initial_costs(初期費用)や invoices(月次請求)といった用途別テーブルで管理しており、それぞれにチャネル別カラム(Stripe用・保証用 等)を並べていました。レコードの多くがチャネル別NULLで埋まり、「どのカラムがどのチャネルにとって必須か」がスキーマを見ただけでは分からない状態です。
決定打となったのは、契約に紐づかない追加徴収(後から発生する費用など)を扱う必要が出てきたタイミングでした。既存の initial_costs も invoices も契約紐づきが前提だったため、新しい用途のたびに似た構造のテーブルとカラムが増えていく未来が見え、設計の見直しに踏み切りました。
行き着いた先が、「共通項目を持つ基底テーブル + ドメイン固有項目を持つ派生テーブル」 でDB側にもStrategy(クラス継承型テーブル)を組み込む方法です。
これにより、「そのドメインに無関係な項目を物理的に持てなくする」という制約がスキーマレベルで効きます。アプリ層のバリデーションを「最後の砦」にするのではなく、DB構造そのものをバリデーターにしてしまう。アプリ層のStrategyとDB側の構造が1:1で対応することで、開発者が「このチャネルならこの派生テーブルを見る」と迷う余地もなくなりました。
例1:決済チャネルをDB側のStrategyにする
Unitoの決済チャネルは、現在3つあります。
- Stripe: カード決済、または請求書発行による決済
- 保証会社への委託: 月次の口座引き落とし、人手の消込が前提
- 直接請求: 請求書を発行し、銀行振込で受け取る
「お金を受け取る」という目的は同じでも、「いつ決済が確定するか」「誰が消し込むか」「失敗時のリカバリをどうするか」が見事に全部違います。旧設計では用途別に作成していた initial_costs と invoices のそれぞれにチャネル別カラムを並べる構造で、新しいチャネルが増えるたびに両テーブルに同じようなNULL許容カラムが増えていく……心が折れそうになる構造でした。
新設計では、payments を基底として、チャネルごとに具象テーブル(stripe_payments / guarantee_payments / direct_payments)と、それらを繋ぐ中間テーブルで構成しました。図はStripeと保証会社の場合です。

ポイントは責務の分離です。payments は「いくらの請求が立っているか」というビジネス側の関心、stripe_payments は「Stripe APIとどう通信したか」という技術側の関心。それぞれにステータスを持たせることで、業務の状態とAPIの状態が混ざらない設計になっています。
おかげで新規チャネルを追加する際も、既存の基底テーブルや他チャネルのロジックには一切触れず、新しい具象テーブルと中間テーブルを足すだけで実装が完結します。
副次的に大きかったのが、Stripe webhookの処理が劇的にシンプルになったことです。旧設計では payment_intent.succeeded を受けたとき「これは初期費用?月次請求?」とテーブルを判別してそれぞれ更新する必要がありましたが、新設計では stripe_payments から stripe_payment_relations 経由で対応する payments を引くだけ。テーブル分岐のIF文が丸ごと消え、一括決済の場合もwebhook 1発で複数の payments をまとめてpaid状態にできるようになりました。
例2:月次まとめ請求を中間テーブルで吸収する
例1の図にこっそり登場していた guarantee_payment_relations — この中間テーブルこそ、もう一つの設計判断の鍵でした。
決済チャネルには「集計の仕方」が根本から違うものがあります。
- Stripe: 1件の請求につき1件の決済(個別)
- 保証会社: 月毎に1つの請求ファイルを作り、その月に発生する複数の請求を1ファイルにまとめて保証会社に送る
「月次まとめ請求」を素直に実装すると、payments 側に「どのファイルに含まれるか」を示すカラム(guarantee_file_id 等)を生やしたくなります。ただこれだと、将来別のまとめ徴収チャネルが増えるたびに payments にカラムが追加され、関心事の混在が起きてしまいます。
そこで、guarantee_payments(月次ファイル)と payments(個々の請求)を、中間テーブルで多対多として表現しました。
これにより:
-
paymentsは「集計単位がどう変わろうと自分は変わらない」基底テーブルとして保たれる - 「ファイル単位の消込」も
guarantee_payments側のステータスで完結 - 同じ構造で他チャネルにも適用でき、将来「四半期まとめ請求」のようなチャネルが増えても基底テーブルは無傷
トレードオフ
もちろん良いことばかりではないので、運用上ハマったポイントも書いておきます。
JOINが3段になる: payments + stripe_payment_relations + stripe_payments のような3段JOINが当たり前のように出てきます。最初は「これ大丈夫か?」と不安でしたが、Eager Loadingで吸収できる範囲で、運用に乗せてからは特に問題ありません。むしろ各テーブルが自分の関心事の情報だけを持つので、ドメイン単位の処理が驚くほど書きやすくなりました。
「どの派生に居るか」を取る処理が必要: 基底テーブルだけでは派生先が分からないので、最初はちょっと迷いました。Laravelの morphTo でポリモーフィックに繋ぐ案もありましたが、FK制約が使えず、保証会社によるまとまった決済のような1対多の関連も扱いにくいため不採用。Unitoでは基底に type カラム(Enumベース)を持たせ、それを見て派生をロードする方式に統一。type と派生テーブルの不一致を防ぐアサーションを足せば、安定して運用できています。
まとめ
DB側でStrategyを表現することの本質は、「そのドメインに無関係な項目を物理的に持てなくする」 制約をDB構造に組み込むことだと思っています。
- アプリ層Strategyだけでは、DBは結局NULLだらけになる
- チャネルごとの集計単位の違い(個別 vs 月次まとめ等)は、中間テーブルの多対多で基底テーブルを汚さず吸収できる
「広く想像して、狭く作る」という方針 — 業界として漏れのない抽象の型を頭に置きつつ、現状ビジネスの最小限を実装する。この方針に一番忠実な実装手段が、DB側のStrategyパターンだった、というのがUnitoでの学びです。
We Are Hiring!
Unitoでは「住む」と「泊まる」の境界をなくすプロダクトを一緒に作るエンジニアを募集しています。ドメインの複雑さを楽しめる方、お待ちしています!
Discussion