メインコンテンツまでスキップ

ドメインモデルとテーブル設計 — マッピングとインデックス

設計の順序:ドメインモデルが先

DDDにおいて最も重要な原則の1つが「ドメインモデル優先」の考え方です。

なぜドメインモデルを先に設計するのか

テーブル設計から始めると、データ構造に意識が向きがちです。しかし、ビジネスの本質は「データの保存方法」ではなく「ビジネスルールとふるまい」にあります。

# アプローチ1:テーブルから考えた場合(❌ 避けるべき)
「ordersテーブルに何のカラムが必要?」
→ order_id, user_id, total_price, status, created_at, ...
→ 正規化は?インデックスは?

問題点:
- ビジネスルールが考慮されていない
- データベースの都合で設計が決まってしまう
- 後からビジネスルールを追加しづらい

# アプローチ2:ドメインから考えた場合(✅ 推奨)
「『注文』とは何か?どんなルールがある?」
→ 状態を持つ:下書き、確定、発送済み、キャンセル
→ 確定後は商品変更不可(不変条件)
→ 合計金額は注文明細から計算される(導出値)
→ 配送先は確定時点でスナップショットとして保存

利点:
- ビジネスルールが明確になる
- ドメインモデルがビジネスの言葉で表現される
- テーブル設計はドメインモデルを永続化する手段に過ぎない

重要な原則: ビジネスルールがテーブル設計を決めるのであり、その逆ではありません。

ドメインモデル優先のプロセス

結果として、次のメリットが得られます。

  • ドメイン層はビジネスルールに集中できる
  • テーブル構造の変更がビジネスロジックに影響しない
  • ビジネス要件の変更に強い設計になる

この順序を守ることで、「データベース主導の設計」ではなく「ビジネス主導の設計」が実現できます。

例:配送先住所のテーブル設計

同じ「配送先住所」でも、ビジネスルールによってテーブル設計が変わります。

ケースA:注文時点のスナップショットとして保存

ユーザーが後から住所を変更しても、注文時の住所は変わらないべき。

orders table
├── id
├── status
├── shipping_prefecture ─┐
├── shipping_city │ Embedded(埋め込み)
├── shipping_street ─┘
└── created_at

ケースB:ユーザーが管理する住所マスタを参照

住所はユーザーが管理し、注文はその住所を参照する。

addresses table orders table
├── id <───────────────── shipping_address_id
├── user_id ├── id
├── prefecture ├── status
├── city └── created_at
└── street

ドメインモデルとテーブルは1:1ではない

ドメインモデルとテーブル設計は独立しており、リポジトリ層がその差を吸収します。

マッピングパターン

ドメインモデルとテーブルの対応関係には、いくつかの典型的なパターンがあります。状況に応じて適切なパターンを選択しましょう。

マッピングパターンの判断基準

どのパターンを使うかは、以下の基準で判断します。

判断基準考慮すべき点選択するパターン
ライフサイクルオブジェクトが独立して存在できるか独立 → 別テーブル、従属 → 埋め込み
変更頻度別々に更新されるか別々 → 別テーブル、同時 → 同じテーブル
再利用性他のエンティティから参照されるか参照される → 別テーブル、されない → 埋め込み
データサイズデータ量が大きいか大きい → 別テーブル、小さい → 埋め込みもOK
パフォーマンスJOINのコストが問題になるか問題 → 埋め込み、問題ない → 別テーブル

例:配送先住所の判断

ケースA:注文時点のスナップショット
- ライフサイクル:注文に従属(単独では存在しない)
- 変更頻度:注文と同時にのみ更新
- 再利用性:その注文専用(他から参照されない)
→ 判断:埋め込み(パターン1)

ケースB:ユーザーが管理する住所マスタ
- ライフサイクル:独立して存在(注文前から存在)
- 変更頻度:ユーザーが独立して変更する
- 再利用性:複数の注文から参照される
→ 判断:別テーブル(参照)

パターン1:複数オブジェクト → 1テーブル

値オブジェクトをエンティティに埋め込む場合。

[Domain Model]
Order (Entity)
└── ShippingAddress (Value Object)
├── prefecture
├── city
└── street

│ Mapping


[Table]
orders
├── id
├── status
├── shipping_prefecture ─┐
├── shipping_city │ ShippingAddress embedded
└── shipping_street ─┘

パターン2:1集約 → 複数テーブル

集約内に複数のエンティティがある場合。

[Domain Model]
Aggregate: Order
├── Order (Aggregate Root)
├── OrderLine (Entity) x N
└── ShippingAddress (Value Object)

│ Mapping


[Tables]
orders order_lines
├── id (PK) ├── id (PK)
├── status ├── order_id (FK) ──────┐
├── shipping_prefecture ├── product_id │
├── shipping_city ├── quantity │
├── shipping_street └── unit_price │
└── created_at │
^ │
└──────────────────────────────────────────┘

パターン3:1エンティティ → 複数テーブル(技術的理由)

パフォーマンスなどの技術的理由で分割することがあります。

[Domain Model]
User (Entity)
├── id
├── email
├── name
├── biography (long text)
├── profileImage (binary)
└── settings (JSON)

│ Split for performance


[Tables]
users user_profiles
├── id (PK) ├── user_id (PK, FK)
├── email ├── biography
└── name ├── profile_image
└── settings

これはドメインの都合ではなく、技術的な都合での分割です。ドメインモデル上はあくまで1つのUserエンティティです。

リポジトリが差を吸収する

リポジトリがドメインモデルとテーブル設計の差を吸収します。

// リポジトリ内でマッピングを行う
private function toEntity(OrderModel $model): Order
{
// 複数テーブル → 1集約への変換
$orderLines = $model->orderLines->map(fn($line) => new OrderLine(
new OrderLineId($line->id),
new ProductId($line->product_id),
$line->quantity,
new Money($line->unit_price, 'JPY'),
))->toArray();

// 埋め込みカラム → 値オブジェクトへの変換
$shippingAddress = new ShippingAddress(
$model->shipping_prefecture,
$model->shipping_city,
$model->shipping_street
);

return Order::reconstruct(
new OrderId($model->id),
OrderStatus::from($model->status),
$shippingAddress,
$orderLines,
);
}

インデックス設計

テーブル設計ではインデックスもパフォーマンスに大きく影響します。初学者はテーブル構造に注目しがちですが、インデックス設計を怠ると、データ量が増えた時に深刻なパフォーマンス問題が発生します。

インデックスとは

インデックスは、データベースの「目次」のようなものです。本の目次を使えば特定のページを素早く見つけられるように、インデックスを使えば特定のレコードを高速に検索できます。

インデックスなしの検索(フルテーブルスキャン)

SELECT * FROM orders WHERE status = 'confirmed';

インデックスなし:
orders table (100万件)
↓ 全件スキャン(100万行を順番に調査)
↓ 該当するレコードを抽出
→ 数秒〜数十秒かかる

インデックスありの検索

SELECT * FROM orders WHERE status = 'confirmed';

インデックスあり:
status_index (整理された目次)
↓ 'confirmed'の位置を高速検索(B-Treeアルゴリズム)
↓ 該当レコードに直接ジャンプ
→ 0.01秒以下で完了

インデックスが重要な理由

観点インデックスなしインデックスあり
検索速度データ量に比例して遅くなるデータ量が増えても高速
スケーラビリティ1万件→10万件で10倍遅くなる増加の影響が小さい(対数的)
ユーザー体験画面表示が遅い、タイムアウト即座に結果が表示される

具体例:100万件のordersテーブル

# インデックスなし
SELECT * FROM orders WHERE user_id = 123;
→ フルテーブルスキャン:3秒

# user_idにインデックスあり
SELECT * FROM orders WHERE user_id = 123;
→ インデックススキャン:0.01秒

300倍の差!

基本的なインデックス戦略

// マイグレーションでインデックスを定義
Schema::create('orders', function (Blueprint $table) {
$table->id(); // 主キー(自動的にインデックスが作成される)
$table->string('status');
$table->foreignId('user_id')->constrained();
$table->timestamps();

// 単一カラムインデックス:WHERE句で頻繁に使うカラム
$table->index('status'); // WHERE status = 'confirmed'
$table->index('created_at'); // ORDER BY created_at DESC

// 複合インデックス:複数カラムでの検索・ソートに使用
// 重要:カラムの順序が重要(最初のカラムで絞り込み、次のカラムでソート)
$table->index(['user_id', 'created_at']);
// → WHERE user_id = ? ORDER BY created_at に最適化

// ユニークインデックス:重複を許さないカラム
$table->unique('order_number'); // 注文番号は一意
});

Schema::create('order_lines', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained()->cascadeOnDelete();
// ↑ foreignId()は自動的にインデックスを作成する
$table->foreignId('product_id')->constrained();
$table->integer('quantity');
$table->integer('unit_price');

// 外部キーには自動でインデックスが作成される(MySQL、PostgreSQLなど)
// これにより JOIN の高速化、ON DELETE CASCADE の高速化が実現される
});

インデックス設計の指針

対象インデックス理由具体例
主キー自動作成一意性保証と高速検索id
外部キー必須JOINやON DELETE CASCADEの高速化order_id, user_id
WHERE句で使うカラム推奨検索の高速化status, email
ORDER BY句で使うカラム推奨ソートの高速化created_at, price
ユニーク制約必須重複チェックの高速化と一意性保証order_number, email

複合インデックスの順序

複合インデックスでは、カラムの順序が非常に重要です。

// ケース1:user_id, created_at の順
$table->index(['user_id', 'created_at']);

// このクエリに最適化される
SELECT * FROM orders WHERE user_id = 123 ORDER BY created_at DESC; // ✅ 高速

// このクエリでは使われない
SELECT * FROM orders WHERE created_at > '2024-01-01'; // ❌ インデックス未使用

インデックスの左端原則(Leftmost Prefix)

複合インデックスは、左側のカラムから順番に使われます。

$table->index(['user_id', 'status', 'created_at']);

// 使われるパターン
WHERE user_id = 123 // ✅
WHERE user_id = 123 AND status = 'confirmed' // ✅
WHERE user_id = 123 AND status = 'confirmed' ORDER BY created_at // ✅

// 使われないパターン
WHERE status = 'confirmed' // ❌ user_idがない
WHERE created_at > '2024-01-01' // ❌ user_idがない

インデックスのトレードオフ

インデックスは万能ではありません。適切に設計しないと逆効果になります。

メリットデメリット
SELECT の高速化INSERT/UPDATE/DELETE の遅延
WHERE句の高速化ディスク容量の増加
JOIN の高速化メモリ使用量の増加
ORDER BY の高速化インデックスメンテナンスのコスト

インデックスを作りすぎた場合の問題

// 悪い例:すべてのカラムにインデックス
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->index('status');
$table->index('total_amount');
$table->index('shipping_prefecture');
$table->index('shipping_city');
$table->index('shipping_street'); // これは不要かも
$table->index('note'); // これは確実に不要
// ... 合計10個以上のインデックス

// 問題:
// - INSERTのたびに10個以上のインデックスを更新
// - ディスク容量が2倍以上になる
// - オプティマイザが適切なインデックスを選べなくなる
});
インデックス設計のベストプラクティス
  1. 実際のクエリパターンを分析: アプリケーションでよく使うWHERE句・JOIN・ORDER BYを把握する
  2. EXPLAIN で確認: クエリの実行計画を確認し、インデックスが使われているかチェック
  3. 過剰なインデックスは避ける: 使われないインデックスは削除する
  4. 定期的にメンテナンス: データ量が増えたらパフォーマンスを再評価する
// EXPLAINでクエリの実行計画を確認
DB::select('EXPLAIN SELECT * FROM orders WHERE user_id = 123');

// Laravel Telescopeでスロークエリを検出
// config/telescope.php で設定
初学者が陥りやすい罠
  1. 主キーのインデックスを手動で作る: 不要(自動作成される)
  2. すべてのカラムにインデックス: 過剰(書き込み性能が低下)
  3. 複合インデックスの順序を考えない: 効果が半減
  4. 開発時はインデックスなしで進める: 後から追加すると移行が大変

最初から適切なインデックスを設計しましょう。

設計のポイント

ポイント説明
ドメインモデルを先に設計ビジネスルールを理解してからテーブルを設計
1:1にこだわらないマッピングパターンを適切に選択
リポジトリで差を吸収ドメイン層はテーブル構造を知らない
技術的分割は許容パフォーマンスのための分割はOK
インデックスを適切に設計検索パターンに応じたインデックス

参考リソース

ドメインモデルとテーブル設計、およびLaravelのマイグレーション・インデックス設計について、さらに深く学びたい方は以下のリソースを参照してください。

Laravel公式ドキュメント

設計原則

  • ドメインモデルを先に設計し、テーブル設計はその後
  • マッピングパターンはビジネスルールに基づいて選択
  • インデックスは実際のクエリパターンに基づいて設計

次のチャプターでは、トランザクション管理について見ていきます。