Rails(ActiveRecord)でテーブルを設計・運用するときの指針をまとめたものです。PostgreSQL を主な前提にしていますが、ほとんどは MySQL でも通用します。
目次
- 基本方針
- 命名規約
- 主キー
- データ型の選び方
- 制約は DB に持たせる
- インデックス設計
- 関連(アソシエーション)の設計
- 継承・ポリモーフィズムの扱い
- enum の扱い
- 論理削除
- 正規化と非正規化
- 整合性と並行性
- マイグレーション運用
- パフォーマンス
- スキーマファイルの管理
- 設計レビュー用チェックリスト
1. 基本方針
- Rails の規約に従う: 規約どおりに作れば設定がほとんど要らず、他の開発者も読みやすくなります。
- データの整合性は DB で守る: モデルのバリデーションは「ユーザーにわかりやすいエラーを返す」ためのもの、DB の制約は「最後の砦」です。バリデーションは
update_column、insert_all、生 SQL、並行リクエストなどで簡単にすり抜けられるため、両方を書きます。 - 後から変えにくいものほど最初に慎重に決める: 主キーの型、テーブル分割、関連の向きは、データが増えてからの変更コストが高くなります。
2. 命名規約
| 対象 | 規約 | 例 |
|---|---|---|
| テーブル名 | 複数形・スネークケース | users, order_items |
| モデル名 | 単数形・キャメルケース | User, OrderItem |
| 外部キー | 単数形_id | user_id |
中間テーブル(has_many :through) | 意味のある名詞にする | memberships, enrollments |
中間テーブル(has_and_belongs_to_many) | 両テーブル名を辞書順で連結 | categories_products |
| 真偽値カラム | 形容詞・状態を表す名前 | active, published, admin |
| 日時カラム | 〜_at | published_at, confirmed_at |
| 日付カラム | 〜_on | birth_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
end4. データ型の選び方
| 用途 | 推奨型 | 避けたい型・理由 |
|---|---|---|
| 金額 | decimal(precision:, scale:) または最小単位の integer/bigint(例: 円・セント) | float(丸め誤差が出る) |
| 短い文字列 | string | — |
| 長文 | text | — |
| 日時 | datetime(PostgreSQL では timestamptz 推奨) | 文字列で保存しない |
| 日付のみ | date | datetime で 0 時固定にしない |
| 真偽値 | boolean, null: false, default: false | NULL を許すと 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: trueforeign_key: trueを付けないと、親を消したときに孤児レコードが残ります。- 削除時の挙動は
on_deleteで DB に持たせることもできます。
| 方針 | DB 側 | Rails 側 |
|---|---|---|
| 子も消す(大量でも速い) | on_delete: :cascade | dependent: :delete_all 相当。コールバックは走らない |
| 子も消す(コールバックを走らせる) | 制約のみ | dependent: :destroy |
| 親を消せないようにする | 制約のみ(デフォルトの restrict) | dependent: :restrict_with_error |
| 外部キーを NULL にする | on_delete: :nullify | dependent: :nullify |
ユニーク制約
validates :email, uniqueness: true だけでは、同時リクエストで重複が入り得ます。必ずユニークインデックスと組み合わせます。
add_index :users, :email, unique: true
add_index :memberships, [:user_id, :team_id], unique: trueclass 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 }
endinverse_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を使い、discardgem のように明示的なスコープで扱う方式がおすすめです。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_tasksgem などで別途実行します。
本番で安全に変更する(ゼロダウンタイム)
大きなテーブルへの変更はロックで障害につながります。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
end14. パフォーマンス
N+1 クエリ
includes/preload/eager_loadで関連を先読みします。- 開発環境で検出する:
strict_loading、bulletgem、prosopitegem
# 遅延読み込みをしたら例外にする
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.sql | DB 固有の機能(トリガー、ビュー、関数、一部の制約など)も表現できる |
- 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:キャンセル"
end16. 設計レビュー用チェックリスト
テーブル・カラム
- テーブル名・カラム名が 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 の値がハッシュで明示されている
- ポリモーフィック関連や論理削除は、採用する理由がはっきりしている
マイグレーション
- ロールバックできる
- モデルクラスに依存していない
- 大きなテーブルへの変更が、ロックを起こさない手順になっている
- スキーマ変更とデータ移行が分かれている