😶‍🌫️

区分値テーブルは本当に必要か?

に公開

区分値テーブルについて再考

DB の設計パターンとして、いわゆる「区分値」を区分値マスタテーブルとして保持する設計をみたことはありませんか?
鈴木商店ではこの設計パターンを多用してきた歴史があります。

この記事では、

  • 区分値テーブルをどういう前提で使ってきたか
  • どんなところでつらくなりやすいか
  • enum をどう組み合わせると楽になるか
  • 逆に、区分値テーブルのほうが素直なケースはどこか

あたりを整理してみます。


ここでの「区分値」の定義

まず、この記事でいう「区分値」の定義をざっくり決めておきます。
一般的な用語の使われ方と少しズレているかもしれませんが、ここでは次のようなものを指すことにします。

  • ユーザーが自由に追加することを想定していない

  • 値に依存したビジネスロジックがある

    • 例: 受注ステータスが「受注」に遷移する場合は納品日が必須、など

テーブル定義としては、例えばこんなイメージです。

-- 例: 受注ステータスマスタテーブル(MySQL っぽい例)
CREATE TABLE order_status (
  id            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'ステータスID',
  name          VARCHAR(64)     NOT NULL COMMENT '表示名',
  description   VARCHAR(255)             COMMENT '説明',
  sort_order    INT             NOT NULL DEFAULT 0 COMMENT '表示順',
  PRIMARY KEY (id)
);

鈴木商店では現在、このような区分値テーブルを持ちつつ、アプリケーション側で区分値による条件分岐などのロジックが必要になった場合は、区分値をテーブルに追加し、ID を定数としてアプリケーション側に持って条件分岐するようなパターンが多用されています。

Laravel っぽく書くと、こんな感じです。

<?php

use Illuminate\Database\Eloquent\Model;

class OrderStatus extends Model
{
    protected $table = 'order_status';

    public const ID_DRAFT   = 1;
    public const ID_ORDERED = 2;
    public const ID_SHIPPED = 3;
}

class Order extends Model
{
    protected $table = 'orders';

    public function status()
    {
        return $this->belongsTo(OrderStatus::class);
    }
}

/**
 * 区分値テーブル(order_status)のIDをアプリケーション側で定数として持ち、
 * そのIDで条件分岐しているパターン。
 */
function getNextAction(Order $order): string
{
    $statusId = $order->order_status_id;

    if ($statusId === OrderStatus::ID_DRAFT) {
        return '編集中';
    }

    if ($statusId === OrderStatus::ID_ORDERED) {
        return '在庫引当';
    }

    if ($statusId === OrderStatus::ID_SHIPPED) {
        return '請求処理';
    }

    // order_status テーブルに新しいステータスを追加しても、
    // 定数や分岐を追加し忘れた場合はここでは実行時まで気づけない。
    return '不明なステータス';
}

鈴木商店でこの設計が多用されてきた背景

鈴木商店でこの区分値テーブルパターンが多用されてきたのには、それなりの歴史的な背景があります。
過去にこの設計判断をした人たちを責めたいわけではなく、「当時はこういう前提だったよね」という整理として少し触れておきます。

  • そもそも PHP に enum が存在しなかった
    鈴木商店の古いシステムでは、CakePHP 2 + PHP 5.x のような構成が多く、言語そのものに enum がありませんでした。
    「区分値っぽいもの」は、配列や定数クラスで表現するのが限界で、いまのような enum + 静的解析という前提が取りづらい時代でした。

  • デプロイ作業工数 vs 区分値テーブル手メンテのコストがトントンだった
    当時はデプロイの自動化も今ほど整っておらず、アプリケーションコードを変更してリリースするコストと、DB に直接区分値を INSERT するコストがそれなりに近い世界観でした。
    「ちょっと区分を追加するくらいなら DB を直接触るほうが早いよね」という判断になりやすかった、という事情があります。

  • 単体テストや静的解析が前提になっていなかった
    いまほど PHPUnit や PHPStan が当たり前になっておらず、「開発環境で動かす」「ステージングや本番で動作確認する」ことが前提の開発フローでした。
    そうすると、アプリケーション側に区分値を寄せて型や静的解析の恩恵を最大化する動機は相対的に弱く、DB にマスタを置いておき、必要に応じて ID で条件分岐する設計が「シンプルでわかりやすい」選択肢でした。

こういった前提条件のもとでは、「区分値テーブルを作って ID を定数として持ち、if で分岐する」という設計は、当時なりに筋が良かった部分もあります。

この記事でやりたいのは、過去の判断を否定することではなくて、

  • 言語機能(enum)
  • フレームワーク機能(Laravel の $casts
  • 静的解析(PHPStan など)
  • デプロイ基盤の整備

といった前提が変わってきた 2020 年代の今、改めて「どこまでをアプリで持ち、どこからを DB に任せるか」を整理し直してみよう、という話です。


このようなテーブルは本当に必要か?

では、ここから「いまの前提」でこの設計を見直していきます。

結論から言うと、まずは「enum としてアプリケーション側で管理すること」を検討するのが良いと考えています。

ここで注意しておきたいのは、

「区分値テーブルを使っている = 静的解析できない」

という話ではない、ということです。
区分値テーブルがあったとしても、アプリケーション側で enum やリテラルユニオンを定義し、それを経由して扱えば静的解析は可能です。

ただ、実務として「テーブルに追加 → ID 定数を増やす → あちこちに if を書く」という流れになりがちで、そのときに困りやすいポイントがいくつかあります。


区分値テーブルパターンの課題

区分値テーブルを主役にしてロジックを書いていくと、次のような課題が出やすいと感じています。

  • 自動増分 ID の場合、環境ごとに ID が変わるリスクがある
  • テーブルを見ないと全区分値がわからない(区分値の考慮漏れリスク)
  • データ投入タイミングを特に意識する必要がある
  • アプリケーション側と区分値テーブルで、区分値に関する情報を二重管理しがち

自動増分 ID の場合、環境ごとに ID が変わるリスク

ID を AUTO_INCREMENT にしていると、

  • 開発環境で INSERT した順番
  • 本番環境で INSERT した順番

が違うだけで、同じ「受注」でも ID が違う、という状況が発生し得ます。

ID を定数としてコードに持っていると、

  • 本番では 1 = Draft, 2 = Ordered
  • 開発では 2 = Draft, 3 = Ordered

みたいな状態が起きても、静的解析では検知できず、実行してみるまで気づけない、というつらさがあります。

対策としては、区分値テーブルの主キーを AUTO_INCREMENT ではなく不変なコード( 'draft', 'ordered' など)にする、あるいはコードに UNIQUE 制約を張ってそれを基準にアプリ側から参照する、という設計もあります。
ただ、その場合でも「アプリ側で enum を持っておく」と、静的解析や網羅性チェックの恩恵は受けやすくなります。

テーブルを見ないと全区分値がわからない

ID 定数を定義したとしても、

  • 「この定数は order_status テーブルの全レコードをカバーしているのか?」
  • 「テーブル側にだけ追加されている行はないか?」

といった情報は、結局テーブルを見に行かないとわかりません。
アプリケーションコードを見れば全パターンがわかる状態になっていない、というのがつらいポイントです。

データ投入タイミングを特に意識する必要がある

とある区分値を追加する場合、だいたい次のような作業が発生します。

  1. テーブルにデータを追加する(マイグレーション or 手作業)
  2. アプリケーションコード側で if / switch / match の分岐を追加する
  3. デプロイとデータ投入の順番・タイミングを調整する

この 1〜3 の順番が崩れると、

  • アプリ側が「まだ存在しない ID / code」を前提に動いてしまう
  • 逆に、DB にだけ新しいレコードが追加されていて、アプリはその存在を知らない

といった状態になりやすく、運用オペレーションを丁寧に設計しないと事故りやすいです。

アプリと DB で区分値情報を二重管理しがち

ロジックのためにアプリ側が ID 定数を持ち、表示名や並び順はテーブル側に持つ、となると、

  • 「どういう種類が存在するか」 → DB
  • 「どの ID がどんな意味か」 → コードの定数 + コメント
  • 「どう表示するか」 → テーブルの name / sort_order

のように情報が散らばりがちです。

さらに、「このステータス ID とときはこのボタンは非表示にして…」というようなフロントエンド側のロジックにまで ID の定数が侵食してきた日には大変です。(そして鈴木商店ではそのようなパターンが多かった)

DB 側の ID を変えたのに定数のほうを直し忘れる、テーブルに行を追加したのに定数を増やし忘れる、というのは、あるあるな事故だと思います。


enum を採用する理由

こういった課題は、enum を採用することでかなり緩和できると考えています。
特に、静的解析と組み合わせたときの嬉しさが大きいです。

PHP 8.1+ の enum と match を使う例

まずはシンプルに、OrderStatus を enum で表現する例です。

<?php

enum OrderStatus: string
{
    case Draft   = 'draft';
    case Ordered = 'ordered';
    case Shipped = 'shipped';
}

final class Order
{
    public function __construct(
        public int $id,
        public OrderStatus $status,
    ) {
    }
}

function getNextAction(OrderStatus $status): string
{
    return match ($status) {
        OrderStatus::Draft   => '編集中',
        OrderStatus::Ordered => '在庫引当',
        OrderStatus::Shipped => '請求処理',
        // default はあえて書かない。
        // enum に新しい case (例: Canceled) を追加して match の分岐を
        // 書き忘れると、PHPStan 等の静的解析ツールで
        // 「match が網羅的でない」として検知させることができる。
    };
}

ここでのポイントは、

  • OrderStatus という 1 箇所を見れば、「どんなステータスが存在するか」が全部わかる
  • match で全ての case を列挙し、default を書かないことで、
    「新しい case を追加したのに match に反映していない」という状態を PHPStan などで拾える

というところです。

TypeScript でも、

  • union 型("draft" | "ordered" | "shipped"
  • switch + never を使った網羅性チェック
  • @typescript-eslint/switch-exhaustiveness-check のようなルール

で同じようなことができます。

区分値テーブルがあっても enum は使える

ここで重要なのは、

区分値テーブルがあるかどうかとは別に、enum は導入できる

という点です。

DB に order_status テーブルが既にある前提でも、次のような形で enum を足していけます。

  • DB: これまで通り order_status テーブルを持つ(必要なら code カラムを追加)
  • アプリ: OrderStatus enum を定義し、DB の値から enum に変換する
  • ドメイン層やサービス層は enum 前提でロジックを書く

イメージとしてはこんな感じです。

<?php

// DB には order_status テーブルがあり、code カラムに
// 'draft' / 'ordered' / 'shipped' が入っている想定

enum OrderStatus: string
{
    case Draft   = 'draft';
    case Ordered = 'ordered';
    case Shipped = 'shipped';
}

/**
 * @throws RuntimeException 想定外のステータス値の場合
 */
function toOrderStatus(string $raw): OrderStatus
{
    // PHP の backed enum は from / tryFrom を持っているので、
    // 生の値から enum への変換は組み込みで書ける。
    return OrderStatus::tryFrom($raw)
        ?? throw new RuntimeException("Unknown order status: {$raw}");
}

final class OrderRecord
{
    public function __construct(
        public int $id,
        public string $statusCode, // DB の生値
    ) {
    }
}

final class Order
{
    public function __construct(
        public int $id,
        public OrderStatus $status,
    ) {
    }
}

function toDomain(OrderRecord $record): Order
{
    return new Order(
        $record->id,
        toOrderStatus($record->statusCode),
    );
}

このように、

  • 永続化レイヤーより下では「文字列」を扱い、
  • ドメイン層より上では enum を扱う

とレイヤーを分けることで、

  • DB の実装・履歴・既存データは尊重しつつ
  • enum ベースの静的解析の恩恵を少しずつ取り込んでいく

ことができます。

つまり、区分値テーブルパターンを採用しているプロジェクトでも、DB を変えずに段階的に enum を導入できる、というのがポイントです。

※ 一応補足ですが、Laravel なら $casts を使えば上記のような変換処理は不要です。


区分値テーブルが適切かもしれない場合とは

ここまで読むと「じゃあ全部 enum でよくない?」と思われるかもしれません。
おそらくそのとおりなのですが、一応適切かも知れないケースについても考えてみます。

最初に定義したとおり、この記事での「区分値」は、

  • ユーザーが自由に追加することを想定していない
  • 値に依存したビジネスロジックがある

というものを指していました。

この定義にできるだけ沿ったうえで、「それでもテーブルとして持っておいたほうが素直になりやすいケース」をいくつか挙げてみます。

1. 値ごとではなく「属性」に依存したロジックがメインな場合

この記事の前半で挙げたような「受注ステータス」は、

  • Draft / Ordered / Shipped といった“値”それ自体に対して
  • ifmatch で分岐を書く

というタイプのロジックが中心でした。
このパターンは enum と相性がよく、「まず enum から始めよう」という話になります。

一方で、例えば「会員ランク」のような区分を考えてみます。

  • BRONZE / SILVER / GOLD / PLATINUM

  • 各ランクごとに

    • discount_rate(割引率)
    • point_multiplier(ポイント倍率)
    • free_shipping_threshold(送料無料の金額)

のような属性を持っているイメージです。

PHP の enum でも、これは普通に表現できます。

enum MemberRank: string
{
    case Bronze   = 'bronze';
    case Silver   = 'silver';
    case Gold     = 'gold';
    case Platinum = 'platinum';

    public function discountRate(): float
    {
        return match ($this) {
            self::Bronze   => 0.00,
            self::Silver   => 0.05,
            self::Gold     => 0.10,
            self::Platinum => 0.15,
        };
    }
}

このように「属性を返すメソッド」を enum 側に持たせれば、

  • ドメインロジックの中では enum だけ見ていればよい
  • テーブルを増やさずに完結する

というメリットがあります。

一方で、

  • 「今月は discount_rate >= 0.1 のランクの売上集計を SQL で出したい」
  • free_shipping_threshold 以下の注文数を直接クエリしたい」

といった「属性を条件に含めた SQL をガッツリ書きたい」場面では、
属性をカラムとして持つ区分値テーブルのほうが、SQL としては直感的になることも多いです。

SELECT
  member_rank.name,
  SUM(order.total_amount) AS total_sales
FROM orders
JOIN member_rank ON orders.member_rank_code = member_rank.code
WHERE member_rank.discount_rate >= 0.10
GROUP BY member_rank.name;

enum のメソッドで属性を持つパターンと比べると、

  • SQL だけ見ればロジックの条件が分かる
  • BI ツールや他のクエリからも属性を使える

といったメリットがありますが、

  • インデックスを貼るカラムが増える
  • アプリとテーブルの二重管理になりがち

といったデメリットもあります。

なので、このパターンでも「まずは enum 前提で小さく始めてみて、SQL 中心の運用が多かったり、分析要件が強くなってきたら属性をテーブル側に寄せる」くらいの順番で考えるのがちょうどいいかな、という感覚です。

2. バージョンや有効期間を持たせたいマスタ

「いつからいつまで有効か」を持たせたい区分も、テーブルのほうが扱いやすそうです。

  • 税率の変更(○年○月から標準税率が変わる)
  • ポイント付与率の変更(キャンペーン期間だけ倍率アップ)
  • ステータス自体の定義が時期によって変わる

など、「過去の注文は当時のルールで再計算したい」タイプの要件があると、

  • enum は「今この瞬間の値の集合」を表すのは得意だが
  • 「昔はこういう値・設定だった」を表現するのは苦手

という性質が出てきます。

この場合は、

  • 区分値マスタに valid_from, valid_to を持たせる
  • あるいは履歴テーブルを別で持つ

など、時系列で変わるマスタとしてモデリングするのが現実的で、
テーブル中心の設計のほうが素直になることが多いかも知れません。


まとめ

僕がこれまで経験した会社では、このような区分値テーブルパターンを使用していることが多かったですし、鈴木商店でも長く採用してきたパターンです。

ただし、

  • 区分値テーブルを主役にして ID で条件分岐していくと、
    環境差やデータ投入のタイミング、二重管理などでつらくなりがち
  • 区分値テーブルがあるかどうかとは別に、
    アプリケーション側で enum を定義して静的解析の恩恵を受けることはできる
  • この enum パターンは、既存の区分値テーブル前提のプロジェクトでも、
    DB を変えずに段階的に導入できる(Laravel なら $casts でかなり楽に始められる)

というあたりは、一度立ち止まって考えてみる価値があるところだと思います。

一方で、

  • 値ごとではなく属性ベースで SQL を書きたいケース
  • バージョンや有効期間を持つマスタ

といったところでは、区分値テーブルのほうが素直なこともあるかも知れません。

会社で「昔から使っている設計パターン」が、本当にいまの自分たちにとってのベストプラクティスなのかどうか、
一度こうやって分解してみると得るものがあるかなと思いました。

それでは。

Discussion