ドメインモデルとテーブル設計 — マッピングとインデックス
設計の順序:ドメインモデルが先
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倍以上になる
// - オプティマイザが適切なインデックスを選べなくなる
});
- 実際のクエリパターンを分析: アプリケーションでよく使うWHERE句・JOIN・ORDER BYを把握する
- EXPLAIN で確認: クエリの実行計画を確認し、インデックスが使われているかチェック
- 過剰なインデックスは避ける: 使われないインデックスは削除する
- 定期的にメンテナンス: データ量が増えたらパフォーマンスを再評価する
// EXPLAINでクエリの実行計画を確認
DB::select('EXPLAIN SELECT * FROM orders WHERE user_id = 123');
// Laravel Telescopeでスロークエリを検出
// config/telescope.php で設定
- 主キーのインデックスを手動で作る: 不要(自動作成される)
- すべてのカラムにインデックス: 過剰(書き込み性能が低下)
- 複合インデックスの順序を考えない: 効果が半減
- 開発時はインデックスなしで進める: 後から追加すると移行が大変
最初から適切なインデックスを設計しましょう。
設計のポイント
| ポイント | 説明 |
|---|---|
| ドメインモデルを先に設計 | ビジネスルールを理解してからテーブルを設計 |
| 1:1にこだわらない | マッピングパターンを適切に選択 |
| リポジトリで差を吸収 | ドメイン層はテーブル構造を知らない |
| 技術的分割は許容 | パフォーマンスのための分割はOK |
| インデックスを適切に設計 | 検索パターンに応じたインデックス |
参考リソース
ドメインモデルとテーブル設計、およびLaravelのマイグレーション・インデックス設計について、さらに深く学びたい方は以下のリソースを参照してください。
Laravel公式ドキュメント
- Laravel Migrations - マイグレーションの基礎から応用まで
- Laravel Migrations - Indexes - インデックス設計の公式ガイド
設計原則
- ドメインモデルを先に設計し、テーブル設計はその後
- マッピングパターンはビジネスルールに基づいて選択
- インデックスは実際のクエリパターンに基づいて設計
次のチャプターでは、トランザクション管理について見ていきます。