Lesson 0058 · System Architect · Module 3
Map the Domain Model to a Data Model
The domain model decided what facts exist and which must change together. The data model decides how those facts are stored — and the choice is driven by access patterns, not fashion.
Mission tie-in: storage is where invariants get their final guard and queries get their shape. Choosing it after the domain, not before, is the sequence this phase teaches.
Knowledge: the model first, storage second
Lesson 0055 found which facts must change atomically. Those clusters are natural units for repository operations and transactions, but they do not require one row, one document, or one physical record: a relational implementation may normalize a cluster across several tables while committing it in one transaction. A return's decision and required evidence may belong to one boundary; the customer's tier does not. References between boundaries use explicit identities rather than object graphs that callers traverse freely.
Separate two decisions that are often mixed together: the data model used for query and update patterns, and the persistence strategy used to retain current state or history:
| Choice | Strength | Cost to investigate | Fits |
|---|---|---|---|
| Relational model | Constraints, joins, ad-hoc queries, mature transactions. | Mapping object-shaped updates; deliberate schema evolution. | Related facts, reporting, and varied queries. |
| Document model | One tree reads and writes naturally as a unit. | Cross-document constraints, joins, and product-specific transaction limits. | Tree-shaped access with strong locality. |
| Event sourcing | History is the source of truth; state can be replayed. | Read models, event evolution, tooling, and operational learning. | Only when audit, temporal queries, or replay justify it; events can be stored in several technologies. |
Storage is the one design decision with a population. Code has a single copy: change it, deploy, done. A schema has as many copies as you have rows, and every row is a commitment already made. So the cost of changing a data decision scales with rows × writers × external readers, and — unlike a code decision — it grows on its own while you are not looking. A choice that is trivially reversible on the day you make it becomes a project eighteen months later without anyone doing anything wrong.
That is precisely what expand/contract buys, and it is worth naming the mechanism rather than the steps: it converts one irreversible change into a sequence of individually reversible ones by keeping the old and the new forms simultaneously true. At every point in the sequence you can stop and go back with a deploy instead of a restore. The dual-write window is not overhead; it is the reversibility, and paying for it is the point.
The boundary condition falls out of the same arithmetic. Ceremony has to be paid for by the population. A table with ten thousand rows, one writer, and no external reader can be migrated in a maintenance window at a cost measured in minutes; wrapping that in a four-stage choreography spends a week to protect against a risk you could have absorbed. Count the rows, the writers, and the readers before deciding how careful to be.
Use access patterns and required guarantees to choose, not category slogans. A return's decision and evidence have tree-shaped locality, so a document model is credible; reporting across returns and customer tiers favors relational queries. Start with the simplest store that satisfies the important writes, reads, constraints, scale, and team-operability needs. Add a separate read model only when measured query or scale pressure justifies duplicated data and convergence work. Polyglot persistence is an architectural cost, not the default conclusion of having several boundaries.
Whatever the store, the repository (repository, lesson 0013) keeps storage details behind a domain-facing port. The schema is a contract with its own evolution: an expand/contract migration adds the new form, writes both, backfills and verifies, switches reads, then stops old writes. Dropping the old form is a later, deliberately destructive step taken only after a compatibility and retention window; recovery may require backup, restore, or forward repair.
# One cluster persists as one unit; the repository hides the store.
from dataclasses import dataclass
@dataclass(frozen=True)
class Refusal:
reason: str
decided_on: str
decided_by: str
@dataclass(frozen=True)
class ReturnRecord:
return_id: str
accepted: bool
refusal: Refusal | None = None
class ReturnStore:
"""Port: load and save one whole cluster. Storage is behind this."""
def load(self, return_id: str) -> ReturnRecord: ...
def save(self, record: ReturnRecord) -> None: ...
Worked: splitting one column into a state machine
The transition table from lesson 0054 has to land in storage. Today the legacy table has one status column holding strings; the model needs a state plus a refusal reason plus the transition history. The architect's opening questions were the three that price the change: how many rows, how many writers, and who reads this besides us?
| Fact | Number | What it implies |
|---|---|---|
| Rows | 1.9 M after the legacy import | A backfill cannot hold a lock; it must be batched. |
| Writers | 1 (the returns module, after lesson 0054) | Dual-write is feasible: there is one place to change. |
| External readers | 2 (support tool, nightly analytics extract) | The status string is a published contract, not an internal detail. |
| Write rate at peak | ≈ 40 requests/second on the portal | No maintenance window is available inside the pilot's guardrails. |
The naive plan — ALTER TABLE, backfill, deploy the new code, drop the old column — fails on two of those four rows, and each failure is instructive. Backfilling 1.9 M rows in one statement holds locks long enough to stall the write path. And the analytics extract, owned by another team on a monthly release, contains SQL that reads status; dropping it breaks a report the finance team sees before anyone on the engineering side notices.
# The mapping is a design decision, written down, not an ORM accident.
from dataclasses import dataclass
from enum import Enum
class State(Enum):
REQUESTED = "requested"
APPROVED = "approved"
REFUSED = "refused"
REFUNDED = "refunded"
@dataclass(frozen=True)
class ReturnRecord:
return_id: str
state: State
refusal_reason: str | None
# Expand/contract: each step is reversible with a deploy, not a restore.
MIGRATION = [
("expand", "add nullable state and refusal_reason; no default, no rewrite"),
("dual", "write both forms; keep reading the old status column"),
("backfill", "5,000 rows per batch, ~380 batches, verified between runs"),
("verify", "comparison query returns zero mismatches on three consecutive runs"),
("switch", "read the new columns behind a flag; old column still written"),
("compat", "publish a status view for the analytics extract, one quarter"),
("contract", "stop writing status; drop it after the retention window"),
]
def write_both(row: dict, record: ReturnRecord) -> dict:
"""The dual-write window: one writer, two truths, deliberately."""
row["state"] = record.state.value
row["refusal_reason"] = record.refusal_reason
row["status"] = _legacy_status(record) # the old contract, still honoured
return row
def _legacy_status(record: ReturnRecord) -> str:
return {State.REQUESTED: "pending", State.APPROVED: "approved",
State.REFUSED: "refused", State.REFUNDED: "refunded"}[record.state]
The batches matter more than they look. At 5,000 rows a batch the backfill is about 380 batches; run with a short pause between them it finishes in roughly half an hour without competing with the portal's traffic, and it can be stopped after any batch. The comparison query is what turns “we think it worked” into evidence: it counts rows where _legacy_status(state) disagrees with status, and the switch does not happen until three consecutive runs return zero.
The one step that is genuinely irreversible is the last. Dropping status cannot be undone with a deploy — only with a restore, and a restore loses everything written since. So it was scheduled eleven weeks after the switch, once the analytics extract had migrated off the compatibility view and the retention window had passed. The alternative — dropping it the same week to “finish the migration” — buys a tidy schema and sells the ability to roll back the whole change.
| You see | It hides | The move |
|---|---|---|
| A table per value object | Possibly incidental mapping complexity. | Check whether one repository and transaction hide it. |
| One critical query is too costly | The current read shape may not fit. | First tune it; justify a read model if needed. |
| A status column plus a "history" table | The domain needs the past. | Start with an audit trail (lesson 0054). |
| One schema shared by contexts | Ownership is fiction (lesson 0053). | Name the writer per table or split. |
- “The ORM's schema is the domain model.”
- The mapping is a design act with its own trade-offs. A table per class is a coincidence when it happens and a constraint on the model when it is enforced — value objects, state machines, and history usually map to something other than one row per object.
- “Normalize until it hurts, denormalize until it works.”
- Both directions are driven by access patterns and rules, not by a rule of thumb. Normalization exists to protect an invariant by having one place to write a fact; denormalization buys a read at the price of a convergence problem. Say which one you are doing and what it costs.
- “Expand/contract is only for zero-downtime shops.”
- Its real payoff is that each step is reversible with a deploy. Even with a maintenance window available, that is the difference between rolling back an artifact in two minutes and restoring a backup while the business waits.
- “Event sourcing gives you history for free.”
- History yes; everything else no. You take on read models, event versioning, replay tooling, and the fact that a badly designed event is permanent. Start with an audit trail of transitions and let a real need for replay or temporal queries earn the upgrade.
When this is the wrong move. A new table with zero rows should just be changed — there is nothing to migrate and no reader to protect. A table under ten thousand rows with one writer, no external consumer, and an available window is cheaper to migrate in a single transaction than to choreograph. Purely additive changes with no backfill (a nullable column nobody reads yet) need no dual-write window at all. The sequence in this lesson is priced for a population; when there is no population, skip it and spend the time on something with risk in it.
Skill: choose the shape from the pattern
Choose a document model when…
A read model exists to serve…
Expand/contract migrates data by…
Practice: map your clusters to storage
Use your consistency boundaries or the supplied returns case. List the important writes, reads, constraints, scale assumptions, and team-operability needs. Compare a relational and document model for the whole system; treat event sourcing as a separate history strategy. Choose the simplest adequate starting point and name the evidence that would justify a second store or read model later. Finally, write an expand/contract sequence including verification, retention window, the destructive step, and recovery if contraction proves premature.
In the lab and the capstone: the companion lab's phase-5 checkpoint stores its state in a shape the domain model has outgrown. Write the mapping you want, then plan the change as an expand/contract sequence and name which step is the irreversible one — that identification is the skill. The worked capstone carries the storage decision in its state and contracts artifact and the reasoning that rejected the alternatives in options and decision records, while the brownfield case walks a larger version of the same migration in expand and contract.
Reveal: the read-model test
If a query needs fields from three boundaries, first ask whether an indexed join, view, cached response, or batch report is already adequate. A separate read model earns its duplication only when freshness, latency, scale, or ownership evidence rules out the simpler choices. If it may lag, state how much and how users will understand the delay.
- Explain to a senior engineer, in two sentences, why expand/contract is about reversibility rather than about uptime.
- Take one table you own this week: count its rows, its writers, and its readers outside your team, and say how careful its next schema change has to be.
- Predict the damage: if a team ships a schema change as one irreversible step, name what they will be doing at 02:00 and what they will lose.
Your win
You can choose a data model from access patterns and guarantees, preserve atomic change without dictating one physical shape, and evolve schemas through compatible stages with an explicit recovery decision.
Read and watch deeper
- Designing Data-Intensive Applications, Kleppmann — ch. 3: data models and query languages; ch. 4: encoding and evolution; ch. 5 (2nd ed. ch. 4): schema evolution and compatibility.
- Applying UML and Patterns, Larman — ch. 9–10: mapping the domain model to persistent design (optional; ch. 16: mappings to relational schemas).
- Learning Domain-Driven Design, Khononov — ch. 7 (optional deep-dive: event sourcing as a persistence choice).
- Refactoring Databases: Evolutionary Database Design, Scott Ambler & Pramod Sadalage — the catalogue of schema refactorings with their transition periods, which is expand/contract worked out in detail.
- Related lessons: 0013, 0023; glossary expand/contract, repository.
Ask your agent-teacher to design the schema for your core cluster in all three shapes, then challenge each with your real query list — the weakest shape usually shows itself immediately.