
そのテーブル、typeカラムで種類分けてるのにNULLだらけじゃない?🫠
これ絶対あるじゃん??
- テーブルに
typeカラムがあって、"article""video""podcast"みたいに種類を分けてるのに、カラムの半分以上がNULL - 「このカラム、videoのときだけ使うやつだから」って言われたけど、articleのレコードにも空のまま存在してる
- 新しい種類を追加するたびにALTER TABLE ADD COLUMNが走って、既存の全レコードにまたNULLが増える
- 「typeが"podcast"のときだけ
show_notesを入れる」みたいなルールを、アプリ側でif文で頑張ってる
あるじゃん?? マジで絶対あるじゃん??
これ全部、**種類が違うデータを1つのテーブルに全部突っ込んでるのが原因。**以降「typeカラム地獄」って呼ぶね。
てかシンプルに言うとね
「typeカラムで種類を分けてるのに、テーブルは1個しかない」設計のこと。 種類によって使うカラムが全然違うのに、全カラムを1テーブルに並べてる状態。
「それの何がまずいの?」ってなる気持ち、わかる!
JOINしなくていいし、1テーブルでSELECTするだけだし。でもね、新しい種類が増えるたびにテーブルがどんどん横に伸びて、**NULLだらけのカラムの海になる。**その話をちゃんとするね。
ちょっと待って、先に全然関係ない話していい?🏠
田中さんって事務長がいる病院の話、していい?
この病院、患者カルテの書式が1種類しかないの。入院患者も、外来患者も、救急で運ばれてきた患者も、全員同じカルテ用紙を使ってた。
カルテの項目はこう。
患者名・生年月日・連絡先・病室番号・担当看護師・入院日・退院予定日・予約時間・次回予約日・紹介元クリニック・搬送時間・重症度・搬送元
入院患者が使うのは「病室番号〜退院予定日」の4つ。外来は「予約時間〜紹介元」の3つ、救急は「搬送時間〜搬送元」の3つだけ。
でも全員が13項目ぜんぶ書かされるの。 外来で来た鈴木さんのカルテには「病室番号」「担当看護師」「搬送時間」「重症度」が空欄のまま並んでる。
ある日、上から通達が来た。
「来月から健康診断も始めるから、カルテに『健診コース』『前回受診日』『心電図データ』の欄を追加して」
田中さん、カルテ用紙を改訂した。全患者共通のカルテに3項目追加。これで16項目。 入院患者は相変わらず外来と救急と健診の9項目が空欄。
翌週、事故が起きた。
救急で運ばれてきた佐藤さんのカルテ。看護師の山田さんが急いで記入してたんだけどさ。
「田中さん、このカルテ、『次回予約日』って欄があるんですけど、救急の患者さんにも書くんですか?」
「えーっと...あ、それは外来のやつだから空欄でいいよ」
でも山田さん、夜勤明けで疲れてたの。30分後、別の患者のカルテを書いた勢いで、佐藤さんのカルテの「次回予約日」に翌月の日付を書き込んじゃった。
空欄でいいって言われたのに、欄があるから手が滑った。
翌月、病院の予約システムが佐藤さん宛に自動リマインドを送信した。
「次回の外来予約は4月15日です。ご来院をお待ちしております」
佐藤さんの奥さんから電話が来た。
「うちの主人、救急で運ばれてきただけなんですけど、外来の予約なんて取ってません。何かの間違いですか?」
受付の子が「大変申し訳ございません、確認いたします」って謝って予約を消したんだけどさ。翌日また別の救急患者で同じことが起きたの。欄があるから間違って書く。欄がなければ起きない事故。
院長に呼ばれた田中さん、「カルテの書式を作ったのは誰だ」って聞かれて何も言えなかった。
カルテ改訂しただけなのに...
翌月、田中さんは事務長を降ろされたんだって。
これが本番で起きてるやつが、typeカラム地獄ってこと。
じゃあコードで見てみよっか👀
さっきの病院の話、コードに戻すとこうなるんだよね。田中さんのカルテ = このテーブル。
CREATE TABLE patients (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
birth_date DATE NOT NULL,
patient_type VARCHAR(20) NOT NULL, -- 'inpatient' | 'outpatient' | 'emergency'
-- 入院だけ使う
room_number VARCHAR(10), -- 外来・救急はNULL
nurse_name VARCHAR(100), -- 外来・救急はNULL
admitted_at TIMESTAMP, -- 外来・救急はNULL
discharge_date DATE, -- 外来・救急はNULL
-- 外来だけ使う
appointment_time TIME, -- 入院・救急はNULL
next_appointment DATE, -- 入院・救急はNULL
referral_clinic VARCHAR(100), -- 入院・救急はNULL
-- 救急だけ使う
transported_at TIMESTAMP, -- 入院・外来はNULL
severity INTEGER, -- 入院・外来はNULL
transported_from VARCHAR(100) -- 入院・外来はNULL
);
これ、何が起きるかっていうとね。
patient_type = 'outpatient'のレコードに、room_numberもseverityもtransported_atもNULLで並んでる。カラム10個のうち7個がNULL。行の7割がゴミ。- 「健診」を追加するとき、
ALTER TABLE ADD COLUMN checkup_course VARCHAR(50)、ALTER TABLE ADD COLUMN last_checkup DATE...既存の全レコードにまたNULLカラムが増える - 外来患者の
severityにうっかり値が入っても、DBはエラーを出さない。DBが「ここに入れちゃダメ」って止めてくれるルールがない。
わたしも昔これやったから言えるんだけど、「1テーブルのほうがJOIN要らなくて楽じゃん」って思って全部突っ込んだら、3ヶ月後にカラムが30個超えて、NULLチェックのif文だけで50行あるコードになってた。まじで。
やばいのまだあんだけど、バグに気づけない問題😭
typeカラム地獄のほんとにヤバいとこは、「NULLが多い」じゃなくて、「入っちゃいけない値が入ってても気づけない」のほうがヤバい。
田中さんの病院で考えてみて? 救急患者のカルテに「次回予約日」の欄がある。欄があるから看護師が間違えて書く。でも書いたこと自体は誰にもわからない。翌月のリマインド送信で初めてバレるの。
「欄がなければ起きない事故」なのに、欄があるから起きる。これがヤバい。
コードでも同じことが起きるんだよね。ちょっと見てみて?
# 外来患者を登録するコード
db.insert(patients).values(
name="鈴木太郎",
birth_date="1990-01-01",
patient_type="outpatient",
appointment_time="10:30",
next_appointment="2026-05-01",
severity=3, # 外来なのに重症度を入れてる。DBはエラーにならない
)
severity=3が入ってるのに、INSERTは成功する。外来患者に重症度なんていらないのに、カラムがあるから入れられちゃう。
これが本番で起きたらどうなる? 重症度で救急の人だけ取ろうとしたら、外来の鈴木さんまで混ざってくる。WHERE severity IS NOT NULLで「重症度が入ってる人=救急の人でしょ」って取ったつもりが、外来患者まで入ってくるの。
テストでWHERE patient_type = 'emergency' AND severity IS NOT NULLって丁寧に書いてたら通る。でもWHERE severity IS NOT NULLだけで探してるコードがどこかに1箇所でもあったら、外来の鈴木さんが混ざる。テーブルの作りが嘘のデータを通しちゃうから、嘘がどこに紛れ込んでるかわからないんだよね。
分けてた会社の話もするね〜✨
田中さんの病院とは別に、最初からカルテを種類ごとに分けてた隣の病院の話もするね。
この病院、カルテの書式が種類ごとに別々なの。
- 入院カルテ:病室番号・担当看護師・入院日・退院予定日だけ
- 外来カルテ:予約時間・次回予約日・紹介元だけ
- 救急カルテ:搬送時間・重症度・搬送元だけ
どのカルテにも書いてある情報(患者名・生年月日・連絡先)は「患者基本情報カード」として別に1枚ある。入院カルテにも外来カルテにも、この基本カードの患者番号だけ書いてある。
ある日、健康診断を始めることになった。この病院はどうしたと思う?
「健診カルテ」の書式を1つ新しく作っただけ。
入院カルテも外来カルテも救急カルテも、1枚も変わってない。 新しい書式が増えただけで、既存の書式には何も足されてない。
何が起きなかった?
救急カルテに「次回予約日」の欄がないから、看護師が間違えて書くことがなかった。リマインドの誤送信も起きなかった。佐藤さんの奥さんから電話が来ることもなかった。書けない欄は、間違えようがない。
コードに戻すとこうなるんだよね。隣の病院 = このテーブル群。
-- 全患者に共通する基本情報
CREATE TABLE patients (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
birth_date DATE NOT NULL
);
-- 入院患者だけのカラム。全部NOT NULL
CREATE TABLE inpatients (
id SERIAL PRIMARY KEY,
patient_id INTEGER NOT NULL REFERENCES patients(id),
room_number VARCHAR(10) NOT NULL,
nurse_name VARCHAR(100) NOT NULL,
admitted_at TIMESTAMP NOT NULL,
discharge_date DATE
);
-- 外来患者だけのカラム
CREATE TABLE outpatients (
id SERIAL PRIMARY KEY,
patient_id INTEGER NOT NULL REFERENCES patients(id),
appointment_time TIME NOT NULL,
next_appointment DATE,
referral_clinic VARCHAR(100)
);
-- 救急患者だけのカラム。全部NOT NULL
CREATE TABLE emergencies (
id SERIAL PRIMARY KEY,
patient_id INTEGER NOT NULL REFERENCES patients(id),
transported_at TIMESTAMP NOT NULL,
severity INTEGER NOT NULL,
transported_from VARCHAR(100) NOT NULL
);
外来患者のテーブルにseverityカラムは存在しない。 だから「外来なのに重症度が入ってる」っていうバグがそもそも起きない。カラムがないから入れられない。
入院患者のテーブルにnext_appointmentは存在しない。 だから「入院患者に次回予約リマインドが飛ぶ」っていう事故も起きない。
健診を追加したくなったら?
CREATE TABLE checkups (
id SERIAL PRIMARY KEY,
patient_id INTEGER NOT NULL REFERENCES patients(id),
checkup_course VARCHAR(50) NOT NULL,
last_checkup DATE,
ecg_data TEXT
);
テーブルを1個作るだけ。 inpatientsもoutpatientsもemergenciesも、1カラムも変わってない。
ALTER TABLE ADD COLUMNも走らない。既存のレコードにNULLが増えることもない。既存のクエリが壊れることもない。新しい種類の患者を追加するのに、既存のテーブルを触る必要がゼロっつーこと。
ちゃんとした名前もあるから一応言うね📚
ここまで話してきた「typeカラムで全部1テーブルに突っ込む設計」と「種類ごとにテーブルを分ける設計」、ちゃんとした名前がある。
Single Table Inheritance(STI、単一テーブル継承)。「種類が違うデータを、typeカラムで分けて1つのテーブルに全部入れる」設計のこと。
Class Table Inheritance(CTI、クラステーブル継承)。「共通部分は1つのテーブルに、種類ごとの固有部分は別テーブルに分ける」設計のこと。
この2つはMartin Fowlerって人が2002年に書いた本で定義されたやつ(日本語版は『エンタープライズアプリケーションアーキテクチャパターン』、2005年に翔泳社から出てる)。RailsのActiveRecordでSTIが有名だから「Railsの機能」って思われがちなんだけど、実はDB設計パターンとしてはどの言語でも使える考え方なんだよね。LaravelのMorphモデル、DjangoのContentType、TypeORMでも同じことができる。
難しそうに聞こえるけど、要は「このテーブル、typeで分けてるのにNULLだらけじゃない? NULLが多いなら、種類ごとにテーブルを分けたほうがよくない?」って問い続けるだけなんだよね。
てかこれ、第4回のゴッドテーブルと同じ話じゃない?ってなった人へ
それ思ったよね? 似てるんだよね、実は。
第4回で直したのは、同じデータが何行にも散らばってるやつ。顧客名と注文履歴とクーポンコードが全部1テーブルにあって、住所を1箇所直したら他の37行も手で直さなきゃいけなかった。あれは正規化で重複を消すのが正解だったじゃん。
今回のtypeカラム地獄は、データは重複してないのにNULLだらけになるやつ。typeで種類を分けてるのに、種類ごとのカラムまで1テーブルに全部並べてるから、使わない欄が空欄だらけになる。直し方はテーブルを種類ごとに分けること。
「1テーブルに全部入ってる」って症状は同じなんだけど、**壊れ方が全然違う。**ゴッドテーブルは「1箇所直したら他の行も直すの?」で壊れる。typeカラム地獄は「入っちゃダメな値が入ってても誰も気づかない」で壊れる。壊れ方が違うから、直し方も違うっつーこと。
これだけ覚えて帰って🥺
テーブル設計するとき・レビューするとき、これだけ問いかけてみて。
「このテーブルのカラム、全レコードが使ってる? NULLだらけのカラムない?」
patientsテーブルのroom_numberが外来患者では常にNULL。severityが入院患者では常にNULL。種類によって使うカラムと使わないカラムがハッキリ分かれてるなら、テーブルを分ける合図。
もう1個、これも聞いてみて。
「新しい種類を追加するとき、ALTER TABLE ADD COLUMNが必要?」
答えが「新しいカラムを3つ足す必要がある」なら、typeカラム地獄。
答えが「新しいテーブルを1個作るだけ」なら、ちゃんと分けられてる状態。
まとめるとこういうことね
| 観点 | typeカラム地獄(STI) | テーブル分割(CTI) |
|---|---|---|
| 種類が増えたとき | ALTER TABLE ADD COLUMNで既存テーブルにカラム追加 | 新しいテーブルを1個作るだけ |
| NULLの量 | 種類ごとに使わないカラムが全部NULL | NULLカラムがそもそも存在しない |
| 不正データ | 入っちゃいけない値が入ってもDBはエラーにしない | カラムがないから入れられない |
| NOT NULL制約 | 種類によって必須/不要が変わるからつけられない | 種類ごとにNOT NULLをつけられる |
| クエリ | WHERE type = 'x' AND ... を毎回書く | テーブル名だけで種類が決まる |
「typeカラムがあるから大丈夫」じゃなくて、**「NULLだらけのカラムが種類ごとに偏ってたら、テーブルを分ける合図」**っつーこと。
Dev-Here「ギャルでも分かる設計の勘ドコロ!」シリーズ 第17回
沼りたい人はこっちも読んで📖
書籍
- Martin Fowler 著『エンタープライズアプリケーションアーキテクチャパターン』(2005, 翔泳社) — Single Table Inheritance / Class Table Inheritance / Concrete Table Inheritance の3パターンを定義した本
- Bill Karwin 著『SQLアンチパターン』(2013, オライリー・ジャパン) — Polymorphic Associations、EAVなどDB設計のアンチパターンを網羅
一次資料
- Martin Fowler "Single Table Inheritance": https://martinfowler.com/eaaCatalog/singleTableInheritance.html
- Martin Fowler "Class Table Inheritance": https://martinfowler.com/eaaCatalog/classTableInheritance.html
無料で読める入門記事
- Martin Fowlerのカタログページから各パターンの概要と図解が読める(上記URL)
- Zenn・Qiitaで「Single Table Inheritance デメリット」「テーブル継承」で検索すると日本語の実例が見つかる