inSmartBank

AI家計簿アプリ「ワンバンク」を開発・運営する株式会社スマートバンクの Tech Blog です。より幅広いテーマはnoteで発信中です https://note.com/smartbankinc

RDBとRailsで継承関係をどう扱うか ― ワンバンクに見るモデル設計の実例

はじめに

スマートバンク 新春エンジニア駅伝 2026第15区走者のnagasawaです。

前区のcapytanさんの「SREが取り組むデプロイ高速化 ─ Docker Build時間を半分にした話」からタスキを受け取り、このブログではRailsにて継承関係にあるデータモデルをどのように扱ったかについて書いています。

Railsアプリケーションを開発していると、「似ているけれど、微妙に振る舞いが異なるモデル」の扱いに悩むことはありませんか?「共通のカラムは多いけれど、バリデーションやメソッドの中身だけが違う」「タイプによる条件分岐がいたるところに溢れかえっている」——そんな悩みに対する一つの解として、今回はRailsの機能を使ってサブタイプをうまく表現する活用事例を紹介します。

この記事では、ワンバンクの機能開発においてどのようにモデルの継承関係をRDBとRailsで表現し、複雑になりがちなドメインロジックを実装したのか解説します。モデル設計の引き出しを増やしたい方や、Fatになったモデル/コントローラーのリファクタリングのヒントを探している方の参考になれば幸いです。

継承関係にあるモデル

具体事例に入る前に、継承関係にあるモデルをDBでどう表現するかの整理です。ご存じの方は読み飛ばしてください。 継承関係にあるモデルの表現には、主に3つのパターンがあります。

1. 単一テーブル継承(STI:Single Table Inheritance)

サブクラスを含めたすべてのデータを1つのテーブルで管理する手法です。

Railsガイド: 単一テーブル継承(STI)

  • 特徴: 親クラスと全てのサブクラスを1つのテーブルで管理します。
  • Railsでの実装: テーブルに type カラム(文字列)を用意するだけで、Railsが自動的にサブクラスへマッピングしてくれます。
  • 例:Vehicleクラスを継承した CarTruck

2. クラステーブル継承(CTI:Class Table Inheritance)

親クラスとサブクラスそれぞれにテーブルを作成する方法です。

Railsガイド: Delegated Types

  • 特徴: 親テーブルと、サブクラスごとのテーブルを作成します。親は共通カラムを持ち、子は固有カラムを持ちます。
  • Railsでの実装: Rails 6.1から導入された delegated_types を利用することでモデルクラスへのマッピングが行えます。

    過去の当社ブログでCTIの実装についてより詳細に紹介している記事がありますので、気になる方は合わせご参照ください! blog.smartbank.co.jp

  • 例:Entry(投稿)として振る舞う MessageComment

    また、これは設計パターンとしては脇道の話となりますが、具象テーブル継承はテーブルの物理設計レベルにおいてはポリモーフィック関連付けと同じ構造になっています。 Railsにおいてpolymorphicとdelegated_typeで関連付けにどのような違いがあるかと言うと、関連付けに合わせ定義してくれる便利メソッドに違いがあります。delegated_typeの場合、ポリモーフィック関連付けに加えメソッドの定義も行っています。

    https://github.com/rails/rails/blob/5721ac5aa18dbaaf6c3fc0a2e24b87f8b23dd3b2/activerecord/lib/active_record/delegated_type.rb#L231

3. 具象クラス継承(CCI:Concrete Class Inheritance)

親クラスのテーブルを作らず、サブクラスごとに独立したテーブルを作る方法です。

  • 特徴: サブクラスごとに独立したテーブルを作成します。共通フィールドはそれぞれのテーブルで重複して持ちます。
  • Railsでの実装: ActiveRecordで特別サポートはされていません。
  • 例:Animal 抽象クラスを継承するが、テーブルは独立している CatDog

各パターンのメリット・デメリット比較

メリット デメリット
単一テーブル継承(STI) ・JOINが不要で高速。
・スキーマが単純なため、管理・実装コストは低い。
・Railsでサポートされている
・サブモデル間で差分が増えると、Nullableなカラムが増えやすい。
・他のパターンに比べ1テーブルのレコード数が増えやすい
・カラムにクラス名が入るため、クラス名の変更が容易に行えなくなる
クラステーブル継承(CTI) ・正規化され、無駄なカラムが生じない。
・Railsでサポートされている
・サブモデルのカラムを取得する場合JOINが必要になるため、クエリの複雑さとパフォーマンスへの考慮が必要。
・STIと同じくカラムにクラス名が入るため、クラス名の変更が容易に行えなくなる。
具象クラス継承(CCI) ・テーブル間の結合が完全に独立している ・共通の属性を一括で検索・集計するのが難しく、UNIONが必要になる
・Railsでサポートされていない

ワンバンクの実例紹介

レベル体験機能の紹介

今回このサブタイプを取り扱った事例として、私たちが開発した「家計管理レベル体験機能」を紹介します。これは、ユーザーの家計管理スキルをステップアップさせていくための機能です。AI Agentが伴走し、ユーザーは「Lv1:日次振り返り」「Lv2:週次振り返り」といったチャレンジを踏んでいき家計管理をゲーム感覚で楽しめる機能となっています。

onebank.jp

体験の基本的なフローは以下の通りです。

  1. チャレンジ開始: ユーザーが特定のレベル(Lv1・Lv2)のチャレンジを開始する。
  2. 日々の継続: 指定された期間(7日間など)、支出振り返りや予算意識などのアクションを行う。
  3. 最終結果確認: 期間終了後、AIから成果に対するフィードバックと「星(Rating)」を受け取る。
    毎日支出の振り返りを継続し、最終結果を確認する

上記はLv1のイメージ図でこれがLv2となると、

  • 振り返りのタイミングが日次から週次となる
  • チャレンジの期間も1週間から4週間となる
  • レーティングの判定条件が変わってくる

のような差分があります。

システムとして求められる要件を整理すると、以下のようになります。

  • 共通の属性: 「開始日」「終了日」「どのユーザー」等の要素は全チャレンジ共通。
  • チャレンジの種類毎にいくつか振る舞いが異なる部分がある。
    • 開始前のバリデーション
      • Lv1はいつでも始められるが、Lv2は月曜始まり固定など。
      • また、チャレンジ期間も異なる(Lv1は1週間、Lv2は4週間)
    • 結果の判定ロジック
      • Lv1は振り返り回数で判定、Lv2は予算遵守率で判定など

テーブル設計

この要件に対し、今回はSTI(単一テーブル継承)を採用しました。 各チャレンジで開始条件や判定ロジックは異なりますが、保存すべきデータにはほとんど差分がなく固有のテーブルに分ける必要性は薄いと判断しました。実際のテーブル構成は以下のようになります。pfm_challenges テーブル一つで Lv1 型も Lv2 型も管理しており、最終結果のみが別テーブルとしてぶら下がる形です。

実装例:振る舞いの違いをサブクラスに閉じ込める

STIを採用することで、振る舞いの違いを表現しやすくなったポイントをいくつか紹介します。

1.親クラスでの共通化 (Scope / Validation)

親クラス PfmChallenge::Challenge に共通の検索条件やバリデーションを定義します。これで、どの種類のチャレンジであっても一貫したルールが適用されます。 また、サブクラス固有のvalidation等をサブクラスに実装することもできるので、validationの実装制御などをケアする必要がなくなります。

# app/models/pfm_challenge/challenge.rb
module PfmChallenge
  class Challenge < ApplicationRecord
    # 共通のscope
    scope :in_progress, ->(now) { where(start_at: ..now).where(end_at: now..).final_review_incomplete }
    scope :latest, -> { order(start_at: :desc) }
    scope :final_review_incomplete, -> { where.missing(:result) }
  
    # 共通のバリデーション
    validates :start_at, :end_at, presence: true
    validates :end_at, comparison: { greater_than: :start_at }
  
    # ...
  end
end
# app/models/pfm_challenge/lv2_challenge.rb
module PfmChallenge
  class Lv2Challenge < Challenge
    # サブクラス固有のバリデーション
    validates :cannot_start_except_on_monday
  end
end

2.サブクラスへの振る舞いの委譲

この設計では、最終結果の判定といった「チャレンジ固有の振る舞い」を、親クラスではなく各サブクラスの責務として定義できます。これにより、同じインターフェースを持つ複数のチャレンジをそれぞれの振る舞いに応じて実装できます。

例えば、「最終結果の判定(final_review!)」というメソッドを、各サブクラスで次のように実装しています。

# Lv1チャレンジ
class PfmChallenge::Lv1Challenge < Challenge
  def final_reviewable?
    # Lv1固有の最終評価ができるか判定を行う
  end

  def final_review!(user, now = Time.current)
    result = build_result(user:)
    # Lv1独自の評価ロジック(振り返り回数ベース)
    result.rating = PfmChallenge::Result.calculate_level1_rating(self, now)
    result.save!
    result
  end
end
# Lv2チャレンジ
class PfmChallenge::Lv2Challenge < Challenge
  def final_reviewable?
    # Lv2固有の最終評価ができるか判定を行う
  end

  def final_review!(user, now = Time.current)
    result = build_result(user:)
    # Lv2独自の評価ロジック(予算などを考慮)
    result.rating = PfmChallenge::Result.calculate_level2_rating(self)
    # ...達成に伴うその他の処理
    result.save!
    result
  end
end

このように振る舞いをサブクラスに閉じた結果、呼び出し側はチャレンジの種類を意識する必要がなくなります。次の章では、その結果としてControllerがどのようにシンプルになったかを見ていきます。

3.Controllerの実装例

上記のように振る舞いをサブクラスに委譲した結果、APIのControllerはシンプルになります。識別子から取得したレコードがLv1なのかLv2なのかを、Controllerは気にする必要がありません。

# 最終結果を確定するAPI
# POST api/v1/pfm_challenge/:id/results
module Api::V1::PfmLevel::Challenges
  class ResultsController < Api::BaseController
    def create
      # 1. 親クラス経由で取得(STIなのでどのサブクラスでも取得できる)
      challenge = current_user.pfm_challenges.find(params[:id])
      # 2. 共通インターフェースを呼び出すだけ
      #    実体がLv1ならLv1のロジックが、Lv2ならLv2のロジックが走る
      result = challenge.final_review!(current_user, Time.zone.now) if challenge.result_committable?
    end
  end
end

この設計では、Controllerはどのチャレンジかを判断する責務を持たず、チャレンジに最終評価を依頼することだけに集中できます。振る舞いの違いは各サブクラスに閉じているため、条件分岐の追加や修正が呼び出し側に波及しない点がメリットです。

おわりに

今回の事例では、STIを利用することで以下のメリットを享受できました。

  1. DBスキーマの簡素化: 管理項目の差分が少ないため、1テーブルで効率よく管理できた。
  2. ロジックの凝集: type ごとの分岐を散らかさず、各モデルクラス内に閉じ込めることができた。
  3. 拡張性: 新しいレベル(Lv3など)を追加する際も、新しいサブクラスを作り final_review! などのメソッドを実装するだけで済み、呼び出し側の変更が不要。

Railsの標準機能であるSTIは、RDB上で継承関係をどう表現するかという問いに対する、ひとつの解になります。特にカラムの差分は少なく振る舞いの差分が大きいケースでは、クラス設計をそのまま活かせる有効な選択肢です。

複雑になりがちな条件分岐をスーパークラスや呼び出し側に散らかすのではなく、サブクラスの責務として整理できないか、設計の一案として検討してみてはいかがでしょうか。

明日の駅伝第16区走者は、サーバーサイド部のkaoruさんです! お楽しみに!

We create the new normal of easy budgeting, easy banking, and easy living.
In this tech blog, engineers and other members will share their insights.