はじめに
この記事では、テーブル数が少ないのにアプリケーションの条件分岐が多い設計で、実際には何が起きているのかを整理します。
テーブルが少ないと、一見するとシンプルに見えます。
しかし実務では、DBが単純なのにアプリケーションの if だけが増え続けることがあります。
この状態は、複雑さが消えているのではありません。
DBで表現されるはずだった業務ルールが、アプリケーション側に逃げていることが多いです。
よくある誤解
まず押さえたいのは、テーブル数が少ないこと自体は良い設計の根拠にはならない、ということです。
例えば次のような見え方があります。
- テーブルが少ないのでER図がきれいに見える
- JOINが少なそうなので簡単に見える
- 1件のレコードに必要な情報がまとまっているように見える
ここまでは見た目の話です。
実際の複雑さは、更新時の分岐、状態遷移のルール、履歴の扱い、外部連携の吸収方法に現れます。
そのため、次の状態なら注意が必要です。
- テーブル数は少ない
- でもサービス層やユースケース層の
ifが多い - 更新SQLの前に状態判定が何段もある
- カラムの組み合わせで意味が決まる
このときは、単に「アプリ側が複雑なだけ」ではなく、データ構造で表現しきれていない可能性があります。
どんな設計で起きやすいか
典型例は、複数のイベントを1テーブルの状態カラムでまとめて管理しているケースです。
CREATE TABLE device_requests (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
request_status VARCHAR(20) NOT NULL,
approval_status VARCHAR(20) NULL,
shipment_status VARCHAR(20) NULL,
installed_flag TINYINT(1) NOT NULL DEFAULT 0,
canceled_flag TINYINT(1) NOT NULL DEFAULT 0,
requested_at DATETIME NOT NULL,
approved_at DATETIME NULL,
shipped_at DATETIME NULL,
installed_at DATETIME NULL,
canceled_at DATETIME NULL,
updated_at DATETIME NOT NULL
);
これだけ見ると、必要な情報が1つにまとまっていて扱いやすそうに見えます。
しかし実際には、次のような意味が1行に押し込まれています。
- 申請された
- 承認された
- 出荷された
- 設置された
- キャンセルされた
これらは本来、別々に起きるイベントです。
それを1行のカラム更新で表現すると、業務ルールの解釈がアプリ側に漏れます。
なぜアプリケーションの if が増えるのか
この種の設計では、「何が起きたか」を直接保存していません。
代わりに、「今どういう状態か」を複数カラムの組み合わせで推測することになります。
するとアプリ側では次のような判定が増えます。
if req.CanceledFlag {
return ErrCanceled
}
if req.ApprovalStatus != "approved" {
return ErrNotApproved
}
if req.ShipmentStatus == "shipped" && !req.InstalledFlag {
// 設置待ち
}
if req.InstalledFlag {
// 完了扱い
}
厄介なのは、if が多いこと自体ではありません。
同じ条件が画面ごと、バッチごと、APIごとに少しずつ違う形で複製されることです。
例えば次のようなことが実際に起きます。
- 画面Aの承認チェックでは
approval_status == "approved"を見ているが、バッチではapproved_at IS NOT NULLで判定している - APIは
canceled_flagを見るが、別のバッチはcanceled_atの有無で判定する - あるエンドポイントにだけ追加された条件が、他のエンドポイントに反映されていない
その結果、次の問題が起きます。
- 同じ状態の判定ロジックが複数箇所に散る
- 片方だけ修正されて挙動がずれる
- どの組み合わせが正しい状態なのかをDBだけ見ても分からない
- テストケースが状態の組み合わせ分だけ増える
複雑さはどこに逃げているのか
テーブルを増やさなかったからといって、業務イベントが減るわけではありません。
起きていることは変わらず存在します。
例えば、新機器の申請業務なら次のイベントがあるはずです。
- 申請する
- 承認する
- 却下する
- 出荷する
- 設置する
- 取消する
これらをイベントとして持たず、現在値だけで吸収しようとすると、複雑さは次の場所に移ります。
- アプリケーションの条件分岐
- 更新時の事前チェック
- バッチの再計算ロジック
- 画面表示用の状態変換処理
つまり、DBが単純なのではなく、複雑さの置き場所が変わっているだけです。
どう直すとよいか
方向性は、現在値とイベント記録を分けることです。
例えば次のように考えます。
-
device_requests: 現在の申請状態を持つテーブル -
device_request_events: 申請、承認、却下、出荷、設置、取消の履歴を持つテーブル
CREATE TABLE device_requests (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
current_status VARCHAR(20) NOT NULL,
requested_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE TABLE device_request_events (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
request_id BIGINT NOT NULL,
event_type VARCHAR(20) NOT NULL,
occurred_at DATETIME NOT NULL,
operator_id BIGINT NULL,
FOREIGN KEY (request_id) REFERENCES device_requests(id) ON DELETE CASCADE
);
この形にすると、次の責務が分かれます。
- 現在の状態を素早く見たい →
device_requests - 何が起きたかを追いたい →
device_request_events
device_requests を残す理由は、一覧表示や検索のたびにイベントを集約する処理を走らせると重くなるからです。
現在のステータスを1行で引ける場所を持つことで、読み取りの性能を確保しながら履歴も追えるようになります。
もちろん、テーブルが増えるので設計は少し重くなります。
ただし、その代わりに業務イベントの境界が見えるようになります。
現在値テーブルとイベントテーブルをどう使い分けるかは、DB設計のアプローチ:マスタ・トランザクション vs イミュータブルデータモデルで詳しく整理しています。
この責務分離の考え方については、状態遷移の制御はDBとアプリケーションで役割を分けるでより詳しく解説しています。
テーブルを増やすべきサイン
次の状態なら、テーブル追加や責務分離を検討した方がよいです。
- ステータス系カラムが増え続けている
-
*_atカラムが何本も並んでいる - フラグの組み合わせで意味が決まる
- 画面ごとに状態判定ロジックが少しずつ違う
- 履歴を後から見たくなったが追えない
- 「この更新をしてよいか」の判定がアプリ側にしか存在しない
逆に、単純な設定テーブルのように「常に最新値だけあればよい」ものまでイベント化する必要はありません。
重要なのは、状態変化そのものに業務的な意味があるかどうかです。
設計時の見方を変える
この問題を防ぐには、「何を保存するか」ではなく「この業務では何が起きるか」から考える方が有効です。
設計の順番は次の方が安定します。
- 起きるイベントを列挙する
- そのうち履歴として残すべきものを決める
- 現在値として高速参照したいものを決める
- 現在値テーブルとイベントテーブルを分けるか判断する
カラム一覧から設計を始めると、出来事がすべて「更新対象の項目」に見えてしまいます。
イベントから考えると、「これは事実の記録なのか」「現在状態なのか」を分けやすくなります。
まとめ
テーブル数が少ないのにアプリケーションが複雑な設計では、複雑さが消えているわけではありません。
DBで表現されていない業務ルールが、アプリケーションの if と更新条件に流れ込んでいることが多いです。
特に注意したい兆候は次のとおりです。
- ステータスやフラグの組み合わせで意味が決まる
- 状態判定ロジックが複数箇所に散る
- 履歴を後から追えない
- 現在値の更新に複数イベントの意味が混ざっている
テーブルを増やすこと自体が目的ではありません。
業務イベントと現在状態の境界をはっきりさせることが目的です。
その結果としてテーブルが増えるなら、それは複雑さを正しい場所に戻しただけです。