Rails(ActiveRecord)でテーブルを設計・運用するときの指針をまとめたものです。PostgreSQL を主な前提にしていますが、ほとんどは MySQL でも通用します。


目次

  1. 基本方針
  2. 命名規約
  3. 主キー
  4. データ型の選び方
  5. 制約は DB に持たせる
  6. インデックス設計
  7. 関連(アソシエーション)の設計
  8. 継承・ポリモーフィズムの扱い
  9. enum の扱い
  10. 論理削除
  11. 正規化と非正規化
  12. 整合性と並行性
  13. マイグレーション運用
  14. パフォーマンス
  15. スキーマファイルの管理
  16. 設計レビュー用チェックリスト

1. 基本方針

  • Rails の規約に従う: 規約どおりに作れば設定がほとんど要らず、他の開発者も読みやすくなります。
  • データの整合性は DB で守る: モデルのバリデーションは「ユーザーにわかりやすいエラーを返す」ためのもの、DB の制約は「最後の砦」です。バリデーションは update_column、insert_all、生 SQL、並行リクエストなどで簡単にすり抜けられるため、両方を書きます。
  • 後から変えにくいものほど最初に慎重に決める: 主キーの型、テーブル分割、関連の向きは、データが増えてからの変更コストが高くなります。

2. 命名規約

対象規約例
テーブル名複数形・スネークケースusers, order_items
モデル名単数形・キャメルケースUser, OrderItem
外部キー単数形_iduser_id
中間テーブル(has_many :through)意味のある名詞にするmemberships, enrollments
中間テーブル(has_and_belongs_to_many)両テーブル名を辞書順で連結categories_products
真偽値カラム形容詞・状態を表す名前active, published, admin
日時カラム〜_atpublished_at, confirmed_at
日付カラム〜_onbirth_on, due_on

ポイント

  • 真偽値の「状態」は、日時カラムで表したほうが情報が多いことがあります。published: boolean より published_at: datetime(NULL なら未公開)にすると「いつ公開されたか」も残せます。
  • type、id 以外の予約語(class、hash、attributes など)をカラム名に使わないこと。type は STI 用に予約されています。

3. 主キー

  • デフォルトの bigint 自動採番で十分なケースがほとんどです(Rails 5.1 以降のデフォルト)。
  • UUID / ULID を検討する場面
    • ID を URL などで外部に出したくない(連番から件数や推測ができてしまうため)
    • 複数の DB やサービスで ID を生成する
    • クライアント側で ID を先に生成したい
  • UUID を使う場合の注意
    • ランダムな UUID v4 はインデックスの局所性が悪く、大きなテーブルで挿入性能が落ちます。時系列順に並ぶ UUID v7 や ULID を検討します。
    • 主キーが UUID だと Model.first / last の並びが作成順になりません。implicit_order_column = :created_at を設定します。
  • 外部公開用の ID と内部の主キーを分けるのも有効です(主キーは bigint、外部向けには public_id などのユニークなカラムを別に持つ)。
# UUID を主キーにする例(PostgreSQL)
create_table :orders, id: :uuid do |t|
  t.references :user, type: :uuid, null: false, foreign_key: true
  t.timestamps
end

4. データ型の選び方

用途推奨型避けたい型・理由
金額decimal(precision:, scale:) または最小単位の integer/bigint(例: 円・セント)float(丸め誤差が出る)
短い文字列string—
長文text—
日時datetime(PostgreSQL では timestamptz 推奨)文字列で保存しない
日付のみdatedatetime で 0 時固定にしない
真偽値boolean, null: false, default: falseNULL を許すと true / false / NULL の三値になってしまう
状態・種別integer か string(enum 化。9 章)自由入力の文字列
構造が不定の付加情報jsonb検索・結合・制約に使う値を入れない
IP アドレスinet(PostgreSQL)string

jsonb の使いどころ

  • 向いているもの: 外部 API のレスポンス保存、ユーザーごとの設定、スキーマが頻繁に変わる付加情報
  • 向いていないもの: 検索条件・JOIN・集計・外部キーに使う値。こうした値は通常のカラムにします。

PostgreSQL の timestamp 系

  • Rails 7 以降は、イニシャライザで ActiveRecord::ConnectionAdapters::PostgreSQLAdapter.datetime_type = :timestamptz と設定すると、datetime カラムを timestamptz で作れます。タイムゾーン付きで保存すると、DB を直接操作するときの事故が減ります。

5. 制約は DB に持たせる

NOT NULL

  • 原則すべてのカラムに null: false を付け、NULL が意味を持つ場合だけ外すくらいの姿勢がおすすめです。
  • belongs_to は Rails 5 以降デフォルトで必須ですが、DB 側にも null: false を付けます。

デフォルト値

add_column :users, :role, :integer, null: false, default: 0
add_column :posts, :view_count, :integer, null: false, default: 0

外部キー制約

t.references :user, null: false, foreign_key: true
  • foreign_key: true を付けないと、親を消したときに孤児レコードが残ります。
  • 削除時の挙動は on_delete で DB に持たせることもできます。
方針DB 側Rails 側
子も消す(大量でも速い)on_delete: :cascadedependent: :delete_all 相当。コールバックは走らない
子も消す(コールバックを走らせる)制約のみdependent: :destroy
親を消せないようにする制約のみ(デフォルトの restrict)dependent: :restrict_with_error
外部キーを NULL にするon_delete: :nullifydependent: :nullify

ユニーク制約

validates :email, uniqueness: true だけでは、同時リクエストで重複が入り得ます。必ずユニークインデックスと組み合わせます。

add_index :users, :email, unique: true
add_index :memberships, [:user_id, :team_id], unique: true
class User < ApplicationRecord
  validates :email, presence: true, uniqueness: { case_sensitive: false }
end
  • 大文字小文字を区別しない一意性は、PostgreSQL なら citext 型や lower(email) の式インデックスで DB 側でも保証します。

チェック制約

add_check_constraint :products, "price >= 0", name: "products_price_non_negative"
add_check_constraint :events, "ends_at > starts_at", name: "events_valid_period"

6. インデックス設計

付けるべき場所

  • 外部キー(t.references なら自動で付く。add_column ... :user_id で追加した場合は付かないので注意)
  • WHERE、ORDER BY、JOIN に頻繁に使うカラム
  • ユニーク制約が必要なカラム
  • ポリモーフィック関連の [xxx_type, xxx_id]

複合インデックス

  • 列の順番が重要です。等価条件(=)で絞る列を先に、範囲条件や並び替えの列を後に置きます。
  • [a, b] のインデックスは a 単独の検索にも使えますが、b 単独の検索には使えません。
# WHERE user_id = ? ORDER BY created_at DESC
add_index :posts, [:user_id, :created_at]

部分インデックス(PostgreSQL)

# 未削除レコードの中だけで一意
add_index :users, :email, unique: true, where: "deleted_at IS NULL"

付けすぎない

  • インデックスは書き込みを遅くし、容量も使います。使われていないインデックスは定期的に見直します(PostgreSQL なら pg_stat_user_indexes、ツールなら pghero など)。

7. 関連(アソシエーション)の設計

多対多は has_many :through を基本に

has_and_belongs_to_many は中間テーブルに属性(作成日時、役割など)を持てず、後から拡張しにくいため、最初から has_many :through を選ぶのが無難です。

class User < ApplicationRecord
  has_many :memberships, dependent: :destroy
  has_many :teams, through: :memberships
end
 
class Membership < ApplicationRecord
  belongs_to :user
  belongs_to :team
  enum :role, { member: 0, admin: 1 }
end

inverse_of と dependent

  • 規約どおりの関連なら inverse_of は自動推論されますが、foreign_key: や class_name: を指定したときは明示します。
  • has_many / has_one には必ず dependent: を検討して書きます。書き忘れると孤児レコードの温床になります(外部キー制約があればエラーで気付けます)。

1 対 1 の分割

  • 巨大な users テーブルに何でも足していくのではなく、まとまりのある属性群は user_profiles などに分けると見通しがよくなります。
  • 逆に、常に一緒に読むデータを無理に分けると JOIN が増えるだけなので、アクセスパターンで判断します。

8. 継承・ポリモーフィズムの扱い

STI(単一テーブル継承)

  • 向いているもの: サブクラス間でカラムがほぼ共通で、振る舞いだけが違う場合
  • 向いていないもの: サブクラスごとに固有のカラムが多い場合(NULL だらけのテーブルになる)

delegated_type(Rails 6.1 以降)

共通部分を親テーブル、固有部分を個別テーブルに持たせられます。STI で NULL が増えそうなときの第一候補です。

class Entry < ApplicationRecord
  delegated_type :entryable, types: %w[Message Comment], dependent: :destroy
end
 
class Message < ApplicationRecord
  has_one :entry, as: :entryable, touch: true
end

ポリモーフィック関連の注意点

  • 外部キー制約が張れないため、整合性はアプリ側頼みになります。
  • [xxx_type, xxx_id] の複合インデックスが必須です(t.references :xxx, polymorphic: true なら自動)。
  • type カラムにクラス名がそのまま入るため、クラス名を変えるとデータ移行が必要になります。
  • 関連先が 2〜3 種類に限られるなら、外部キーを個別に持つ設計(post_id、photo_id のどちらか一方だけ埋まるようチェック制約を付ける)も検討します。

9. enum の扱い

class Order < ApplicationRecord
  enum :status, { pending: 0, paid: 1, shipped: 2, canceled: 9 }, validate: true
end
  • ハッシュで値を明示する: 配列(%i[pending paid])だと、途中に値を追加したときに既存データの意味がずれます。
  • 間を空けた番号にしておくと、後から並び順の途中に値を追加しやすくなります。
  • integer と string の比較
    • integer: 容量が小さい。DB を直接見ると意味がわからない
    • string: DB を直接見て読める。名前を変えるときにデータ移行が必要
    • PostgreSQL のネイティブ enum 型(create_enum)も使えますが、値の削除や並べ替えがしにくい点に注意
  • null: false とデフォルト値を付けます。
  • validate: true(Rails 7.1 以降)を付けると、不正な値の代入で例外ではなくバリデーションエラーになります。

10. 論理削除

論理削除は本当に必要かを最初に問うべき設計です。すべてのクエリに「削除済みを除く」条件が付き、ユニーク制約や外部キーとの相性も悪くなります。

代替案

  • 削除時に別テーブル(deleted_users など)やログに退避する
  • archived_at / canceled_at のように、業務上の意味を持つ状態として扱う

採用する場合

  • deleted_at / discarded_at を使い、discard gem のように明示的なスコープで扱う方式がおすすめです。default_scope で自動的に除外する方式(paranoia など)は、意図せず削除済みデータが見えない・見えてしまうという事故が起きやすくなります。
  • ユニーク制約は部分インデックス(where: "deleted_at IS NULL")で「未削除の中で一意」にします。

11. 正規化と非正規化

  • 基本は第 3 正規形まで正規化し、性能上の理由がはっきりしてから非正規化します。
  • Rails でよく使う安全な非正規化
    • counter_cache: 件数を親に持たせる(comments_count など)

      class Comment < ApplicationRecord
        belongs_to :post, counter_cache: true
      end
    • 集計値や最新日時(last_commented_at など)を親に持たせる

    • 注文時点の価格・住所のスナップショット(履歴として残すべき値は正規化しないのが正解)

  • 非正規化したら、どこで更新するかを 1 か所に寄せ、ズレたときに再計算する手段(rake タスクなど)を用意します。

12. 整合性と並行性

トランザクション

複数レコードをまとめて更新するときは transaction で囲みます。

ActiveRecord::Base.transaction do
  order.update!(status: :paid)
  order.user.increment!(:points, order.point_amount)
end
  • 失敗時にロールバックさせるため、中では save! / update! など例外を出すメソッドを使います。
  • トランザクション内で外部 API 呼び出しやメール送信をしないこと。after_commit やジョブに逃がします。

ロック

方式使い方向いている場面
楽観ロックlock_version カラムを追加するだけ編集画面など、競合がまれな更新
悲観ロックrecord.with_lock { ... } / lock.find(id)在庫の引き当て、残高の更新など競合しやすい処理

「なければ作る」の競合

find_or_create_by は同時実行で重複を作ることがあります。ユニークインデックスがあれば create_or_find_by が使えます。

Tag.create_or_find_by(name: "rails")  # ユニーク制約違反なら既存を取得

アトミックな更新

# NG: 読んでから書くので同時実行で値が失われる
post.update(view_count: post.view_count + 1)
 
# OK: DB 上で加算
Post.update_counters(post.id, view_count: 1)

13. マイグレーション運用

基本

  • change メソッドで書き、ロールバックできるようにする。自動で逆算できない操作は reversible か up / down を使います。
  • remove_column には型を書く(remove_column :users, :nickname, :string)とロールバック可能になります。
  • マイグレーション内でモデルクラスを使わない。後でモデルが変わると古いマイグレーションが壊れます。必要ならマイグレーション内に最小限のクラスを定義するか、SQL で書きます。
  • スキーマ変更とデータ移行を分ける。大量データの書き換えは rake タスクや maintenance_tasks gem などで別途実行します。

本番で安全に変更する(ゼロダウンタイム)

大きなテーブルへの変更はロックで障害につながります。strong_migrations gem を入れると、危険な操作を検出して安全な手順を教えてくれます。

操作安全な手順
インデックス追加(PostgreSQL)algorithm: :concurrently と disable_ddl_transaction!
カラム削除① モデルに self.ignored_columns += ["col"] を書いてデプロイ → ② カラムを削除
カラム名変更新カラム追加 → 両方に書き込み → データ移行 → 読み込みを切り替え → 旧カラム削除
NOT NULL 追加チェック制約を validate: false で追加 → validate_check_constraint → change_column_null
外部キー追加validate: false で追加 → 別マイグレーションで validate_foreign_key
デフォルト値付きカラム追加PostgreSQL 11 以降・MySQL 8 以降なら基本的に安全
class AddIndexToPostsUserId < ActiveRecord::Migration[8.0]
  disable_ddl_transaction!
 
  def change
    add_index :posts, :user_id, algorithm: :concurrently
  end
end

14. パフォーマンス

N+1 クエリ

  • includes / preload / eager_load で関連を先読みします。
  • 開発環境で検出する: strict_loading、bullet gem、prosopite gem
# 遅延読み込みをしたら例外にする
class ApplicationRecord < ActiveRecord::Base
  self.strict_loading_by_default = true  # 新規プロジェクトなら有効にしてもよい
end

大量データの扱い

  • 全件ループは find_each / in_batches でバッチ処理します。
  • 大量挿入・更新は insert_all / upsert_all(バリデーションとコールバックは走らないので、DB 制約が重要になる)
  • 必要なカラムだけ取るなら select / pluck

クエリの確認

  • explain(Rails 7.1 以降は explain(:analyze) も可)で実行計画を確認します。
  • 本番のスロークエリを定期的に確認します(pg_stat_statements、APM ツールなど)。

読み書き分離

  • Rails 6 以降はマルチ DB が標準機能です。読み込みが多い場合はレプリカを使い、connects_to と自動切り替え(database_selector)で振り分けます。

15. スキーマファイルの管理

形式特徴
schema.rb(デフォルト)DB 非依存の Ruby DSL。差分が読みやすい
structure.sqlDB 固有の機能(トリガー、ビュー、関数、一部の制約など)も表現できる
  • DB 固有の機能を使い始めたら config.active_record.schema_format = :sql に切り替えます。
  • スキーマファイルは必ずリポジトリにコミットし、マイグレーションとあわせてレビューします。
  • テーブル・カラムには comment: を付けておくと、DB ドキュメントとして役立ちます。
create_table :orders, comment: "注文" do |t|
  t.integer :status, null: false, default: 0, comment: "0:未払い 1:支払済 2:発送済 9:キャンセル"
end

16. 設計レビュー用チェックリスト

テーブル・カラム

  • テーブル名・カラム名が Rails の規約に沿っている
  • NULL を許すカラムには、NULL である理由がある
  • 真偽値カラムに null: false とデフォルト値がある
  • 金額に float を使っていない
  • 検索や JOIN に使う値を jsonb に入れていない
  • timestamps がある

制約・インデックス

  • belongs_to の外部キーに null: false と foreign_key: true がある
  • uniqueness バリデーションにユニークインデックスが対応している
  • 値の範囲や前後関係にチェック制約がある
  • 外部キー・頻出の検索条件にインデックスがある
  • 複合インデックスの列の順番がクエリに合っている

関連・モデル

  • has_many / has_one に dependent: が指定されている
  • 多対多は has_many :through になっている
  • enum の値がハッシュで明示されている
  • ポリモーフィック関連や論理削除は、採用する理由がはっきりしている

マイグレーション

  • ロールバックできる
  • モデルクラスに依存していない
  • 大きなテーブルへの変更が、ロックを起こさない手順になっている
  • スキーマ変更とデータ移行が分かれている

0件のページ