Domain Model and Table Design — Mapping and Indexes
The order of design: the domain model comes first
One of the most important principles in DDD is the idea of domain model first.
Why design the domain model first
When you start from table design, your attention tends to go toward the data structure. However, the essence of a business is not "how to store data" but "the business rules and behavior."
# Approach 1: Starting from the table (❌ to avoid)
"What columns does the orders table need?"
→ order_id, user_id, total_price, status, created_at, ...
→ Normalization? Indexes?
Problems:
- Business rules are not taken into account
- The design is determined by database convenience
- It becomes hard to add business rules later
# Approach 2: Starting from the domain (✅ recommended)
"What is an 'order'? What rules does it have?"
→ It has states: draft, confirmed, shipped, cancelled
→ Items cannot be changed after confirmation (invariant)
→ The total amount is calculated from the order lines (derived value)
→ The shipping address is saved as a snapshot at the time of confirmation
Benefits:
- Business rules become clear
- The domain model is expressed in business language
- Table design is merely a means of persisting the domain model
An important principle: business rules determine table design, not the other way around.
The domain-model-first process
As a result, you gain the following benefits.
- The domain layer can focus on business rules
- Changes to the table structure do not affect the business logic
- The design becomes resilient to changes in business requirements
By keeping this order, you achieve "business-driven design" rather than "database-driven design."
Example: table design for a shipping address
Even for the same "shipping address," the table design changes depending on the business rules.
Case A: Saved as a snapshot at the time of the order
Even if the user later changes their address, the order's address should not change.
orders table
├── id
├── status
├── shipping_prefecture ─┐
├── shipping_city │ Embedded
├── shipping_street ─┘
└── created_at
Case B: Referencing an address master that the user manages
The user manages their addresses, and the order references one of them.
addresses table orders table
├── id <───────────────── shipping_address_id
├── user_id ├── id
├── prefecture ├── status
├── city └── created_at
└── street
The domain model and tables are not 1:1
The domain model and table design are independent, and the repository layer absorbs the gap.
Mapping patterns
There are several typical patterns for the correspondence between the domain model and tables. Choose the appropriate pattern for the situation.
Decision criteria for mapping patterns
Decide which pattern to use based on the following criteria.
| Decision criterion | What to consider | Pattern to choose |
|---|---|---|
| Lifecycle | Can the object exist independently | Independent → separate table, dependent → embedded |
| Change frequency | Is it updated separately | Separately → separate table, together → same table |
| Reusability | Is it referenced by other entities | Referenced → separate table, not → embedded |
| Data size | Is the data volume large | Large → separate table, small → embedded is also fine |
| Performance | Does the JOIN cost become a problem | Problem → embedded, no problem → separate table |
Example: the decision for a shipping address
Case A: A snapshot at the time of the order
- Lifecycle: dependent on the order (does not exist on its own)
- Change frequency: updated only together with the order
- Reusability: dedicated to that order (not referenced by others)
→ Decision: embedded (Pattern 1)
Case B: An address master that the user manages
- Lifecycle: exists independently (exists before the order)
- Change frequency: the user changes it independently
- Reusability: referenced by multiple orders
→ Decision: separate table (reference)
Pattern 1: Multiple objects → 1 table
When embedding a value object into an entity.
[Domain Model]
Order (Entity)
└── ShippingAddress (Value Object)
├── prefecture
├── city
└── street
│ Mapping
▼
[Table]
orders
├── id
├── status
├── shipping_prefecture ─┐
├── shipping_city │ ShippingAddress embedded
└── shipping_street ─┘
Pattern 2: 1 aggregate → multiple tables
When there are multiple entities within an aggregate.
[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 │
^ │
└──────────────────────────────────────────┘
Pattern 3: 1 entity → multiple tables (for technical reasons)
You may split for technical reasons such as performance.
[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
This is a split for technical convenience, not domain convenience. In the domain model it is still a single User entity.
The repository absorbs the gap
The repository absorbs the gap between the domain model and table design.
// Perform the mapping inside the repository
private function toEntity(OrderModel $model): Order
{
// Convert multiple tables → 1 aggregate
$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();
// Convert embedded columns → value object
$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,
);
}
Index design
In table design, indexes also greatly affect performance. Beginners tend to focus on the table structure, but if you neglect index design, serious performance problems occur as the data volume grows.
What is an index
An index is like the "table of contents" of a database. Just as a book's table of contents lets you quickly find a specific page, an index lets you search for a specific record quickly.
A search without an index (full table scan)
SELECT * FROM orders WHERE status = 'confirmed';
Without an index:
orders table (1,000,000 records)
↓ full scan (examine 1,000,000 rows in order)
↓ extract the matching records
→ takes several seconds to tens of seconds
A search with an index
SELECT * FROM orders WHERE status = 'confirmed';
With an index:
status_index (an organized table of contents)
↓ quickly find the position of 'confirmed' (B-Tree algorithm)
↓ jump directly to the matching records
→ completes in under 0.01 seconds
Why indexes are important
| Aspect | Without an index | With an index |
|---|---|---|
| Search speed | Slows down in proportion to the data volume | Fast even as the data volume grows |
| Scalability | Becomes 10x slower from 10,000 to 100,000 records | Small impact from growth (logarithmic) |
| User experience | Slow screen rendering, timeouts | Results are displayed immediately |
Concrete example: an orders table with 1,000,000 records
# Without an index
SELECT * FROM orders WHERE user_id = 123;
→ full table scan: 3 seconds
# With an index on user_id
SELECT * FROM orders WHERE user_id = 123;
→ index scan: 0.01 seconds
A 300x difference!
Basic index strategy
// Define indexes in a migration
Schema::create('orders', function (Blueprint $table) {
$table->id(); // primary key (an index is created automatically)
$table->string('status');
$table->foreignId('user_id')->constrained();
$table->timestamps();
// Single-column index: columns frequently used in WHERE clauses
$table->index('status'); // WHERE status = 'confirmed'
$table->index('created_at'); // ORDER BY created_at DESC
// Composite index: used for searches/sorts on multiple columns
// Important: the column order matters (narrow down with the first column, sort with the next)
$table->index(['user_id', 'created_at']);
// → optimized for WHERE user_id = ? ORDER BY created_at
// Unique index: a column that does not allow duplicates
$table->unique('order_number'); // the order number is unique
});
Schema::create('order_lines', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained()->cascadeOnDelete();
// ↑ foreignId() automatically creates an index
$table->foreignId('product_id')->constrained();
$table->integer('quantity');
$table->integer('unit_price');
// An index is created automatically for foreign keys (MySQL, PostgreSQL, etc.)
// This speeds up JOINs and ON DELETE CASCADE
});
Guidelines for index design
| Target | Index | Reason | Example |
|---|---|---|---|
| Primary key | Created automatically | Uniqueness guarantee and fast search | id |
| Foreign key | Required | Speeds up JOINs and ON DELETE CASCADE | order_id, user_id |
| Columns used in WHERE clauses | Recommended | Faster searches | status, email |
| Columns used in ORDER BY clauses | Recommended | Faster sorts | created_at, price |
| Unique constraints | Required | Fast duplicate checks and uniqueness guarantee | order_number, email |
The order of a composite index
In a composite index, the column order is very important.
// Case 1: the order user_id, created_at
$table->index(['user_id', 'created_at']);
// Optimized for this query
SELECT * FROM orders WHERE user_id = 123 ORDER BY created_at DESC; // ✅ fast
// Not used for this query
SELECT * FROM orders WHERE created_at > '2024-01-01'; // ❌ index not used
The leftmost-prefix rule
A composite index is used from the leftmost column in order.
$table->index(['user_id', 'status', 'created_at']);
// Patterns where it is used
WHERE user_id = 123 // ✅
WHERE user_id = 123 AND status = 'confirmed' // ✅
WHERE user_id = 123 AND status = 'confirmed' ORDER BY created_at // ✅
// Patterns where it is not used
WHERE status = 'confirmed' // ❌ no user_id
WHERE created_at > '2024-01-01' // ❌ no user_id
The trade-offs of indexes
Indexes are not a silver bullet. Without proper design, they backfire.
| Benefit | Drawback |
|---|---|
| Faster SELECTs | Slower INSERT/UPDATE/DELETE |
| Faster WHERE clauses | Increased disk usage |
| Faster JOINs | Increased memory usage |
| Faster ORDER BY | The cost of index maintenance |
Problems when you create too many indexes
// Bad example: an index on every column
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'); // this may be unnecessary
$table->index('note'); // this is definitely unnecessary
// ... 10 or more indexes in total
// Problems:
// - Update 10+ indexes on every INSERT
// - Disk usage more than doubles
// - The optimizer can no longer choose the right index
});
- Analyze your actual query patterns: understand the WHERE clauses, JOINs, and ORDER BYs your application uses frequently
- Verify with EXPLAIN: check the query's execution plan and whether the index is used
- Avoid excessive indexes: delete indexes that are not used
- Maintain regularly: re-evaluate performance as the data volume grows
// Check the query's execution plan with EXPLAIN
DB::select('EXPLAIN SELECT * FROM orders WHERE user_id = 123');
// Detect slow queries with Laravel Telescope
// Configure in config/telescope.php
- Manually creating an index for the primary key: unnecessary (created automatically)
- An index on every column: excessive (write performance drops)
- Not considering the order of a composite index: the effect is halved
- Developing without indexes: adding them later makes migration painful
Design appropriate indexes from the start.
Design points
| Point | Description |
|---|---|
| Design the domain model first | Understand the business rules, then design tables |
| Do not insist on 1:1 | Choose the mapping pattern appropriately |
| Absorb the gap with the repository | The domain layer does not know the table structure |
| Allow technical splitting | Splitting for performance is fine |
| Design indexes appropriately | Indexes that match the search patterns |
Reference resources
If you want to learn more deeply about the domain model and table design, as well as Laravel's migrations and index design, refer to the resources below.
Laravel official documentation
- Laravel Migrations - migrations from basics to advanced
- Laravel Migrations - Indexes - the official guide to index design
Design principles
- Design the domain model first, then design the tables
- Choose mapping patterns based on business rules
- Design indexes based on actual query patterns
In the next chapter, we will look at transaction management.