Why does a data-migration step written in application code against mapped classes break months later, and how is that prevented?
answer
- an applied step is a historical artifact
- the classes it imports keep moving
- loud break versus silent drift
- the step must carry its own shape
- explicit columns, copied types, no imports
basics
~20 sA data step must reproduce a shape frozen in history, but the classes it imports keep changing: it either stops building or silently writes different columns. Pin it to a copied shape, or write plain SQL over explicit columns.
solid answer
~50 sAn applied change is a historical artifact: it has to produce the same result on a database rebuilt from empty next year as it did the day it ran. Code that imports the live mapped classes breaks that, because those classes describe **today's** shape. Two failure modes follow. The loud one: a field or class the step referenced is deleted, so the step no longer builds or no longer loads. The quiet one: the class survives while its mapping moves — a column renamed, a field pushed to a child table, a new required column — so the step still runs and now writes somewhere else. The prevention is to cut the step off from the live model: write it as SQL over explicit column names, or, when code is genuinely needed, copy the shape it operates on into the step itself and read and write through explicit columns.
go deeper
Recall that a data step which uses the application's classes depends on code that keeps changing, while SQL over named columns does not. Prefer the second unless there is a real reason.
Explain both failure modes: the step that stops building, and the step that keeps running after a field was remapped and now writes elsewhere. Know why the second is the dangerous one.
Show how you prevent it in practice — explicit columns, a frozen local shape, disabled callbacks, an immutable applied step — and how you spot the smell in a code review before it merges.
Treat the change history as a reproducibility contract. Anything in it that depends on a moving code base means a rebuild from empty is no longer guaranteed to reconstruct the same data, which quietly undermines recovery and environment parity.
## A change step is a historical artifact An ordered change history has one defining property: replaying it from empty must produce the same database as the one that accumulated those changes over years. Every entry is therefore a statement about the shape of the world **at its own point in the sequence** — not about the shape of the world today. Structural steps satisfy this naturally, because they name tables and columns literally. A data step written in application code does not. It says "load the customer objects and set their region", and *what a customer object is* is decided by code that other people keep editing, entirely unaware that an old change step depends on it. ## The two ways it rots **Hard break — the step stops running.** Someone deletes a field, renames a class, moves a type into another module, or changes a constructor. The historical step referenced that thing, so it no longer builds or no longer loads. This is the *benign* failure: it is loud, it happens at build time, and someone must deal with it. The usual reaction is to edit the historical step to compile again — which quietly changes what a step that has already been applied elsewhere will do on a fresh database. **Silent drift — the step still runs and now does something else.** This is the dangerous one, because nothing fails. Examples: - A field is remapped to a different column. The step still sets that field; it now writes a different column than it wrote a year ago. - The value is moved into a child table with the association mapped as a cascade. The step now writes rows in a table that did not exist at its point in history — and on a rebuild from empty, it runs *before* that table is created and fails, or after and corrupts the ordering. - A required column with no default is added later. On a rebuild, the historical step now inserts objects that must populate it and cannot. - A lifecycle callback or automatic timestamp is introduced. Rows the historical step writes are now stamped with today's rules. - A validation rule is tightened. Legacy data the step was written to repair is now rejected by the very step meant to repair it. | What changed in the model | Effect on the old step | When you find out | | --- | --- | --- | | Field deleted | Does not build or load | Build time, loudly | | Field remapped to another column | Writes the wrong column | On a rebuild, if ever | | Value moved to a child table | Writes tables that did not exist yet | On a rebuild, as an ordering failure | | New required column added | Insert fails or fills a default | On a rebuild | | New callback or validation rule | Applies today's rules to historical data | Possibly never | The asymmetry is what makes this worth an interview question: the failures that hurt are the ones that do not announce themselves, and they surface where nobody is looking — a developer rebuilding a database from empty, a fresh environment, a recovery drill. ## Pinning the step to a frozen shape The cure is always the same idea: **the step must carry its own description of the shape it operates on.** 1. **Prefer statements.** SQL over explicit column names is self-describing and cannot be moved by a refactor. Most data steps need nothing more. 2. **When code is genuinely required** — the value needs a key, a parser, or a call the engine cannot make — read and write through explicit column lists with a thin row mapper. The step names its own columns; the model is not involved. 3. **If a typed shape is unavoidable, copy it into the step.** A small local record type declared inside the step, never imported from the live model, freezes the fields the step depends on. Duplication is the point: this copy is allowed to be stale, because history is stale. 4. **Turn off the behaviour that assumes today's rules** — validation, callbacks, automatic columns, cascades — or, where they cannot be disabled, use a path that does not run them. 5. **Treat an applied step as immutable.** If it is wrong, ship a new step; do not edit one that has already run somewhere, or two databases with the same recorded history will hold different data. ## Recognising the smell in review In review, the signal is an import: a data step that imports anything from the application's evolving model is a future defect, however small the step is today. The question to ask the author is "what happens to this line when someone renames that field next spring?" — and if the honest answer is "the step keeps running and writes a different column", the step must be rewritten against explicit columns before it merges.
- Which of the two failure modes is worse, and why?The silent one. A step that no longer builds is caught at build time by whoever broke it. A step that still runs after a field was remapped writes a different column than it did originally, so two databases with identical recorded history end up holding different data — and nobody discovers it until a rebuild from empty or a recovery.
- A historical step no longer compiles because a field it used was deleted. What should you do?Rewrite it against explicit columns as SQL, preserving exactly what it did when it ran; do not adapt it to the new model. The step's job is to reproduce a historical effect, so the fix is to express that effect in terms that no longer depend on the model at all, and record why in the step.
- Why does copying a small shape into the step, rather than importing it, not count as harmful duplication?Because the two copies are describing different moments. The live model must keep evolving; the step must not. A frozen local copy makes that divergence explicit and safe, whereas a shared import forces a historical step to track changes it can neither see nor be tested against.
- How does this argument apply to a data step written directly as SQL?It applies far more weakly, because SQL names columns literally, so nothing a code refactor does can change its meaning. It is not immune: a later step that renames a column breaks the earlier step's meaning on replay only if the earlier step is edited. Left alone and ordered correctly, statements stay valid.
saying these in an interview costs you the question
- Edits an already-applied step so it compiles against the current model again.
- Imports the live mapped classes into a change step and calls it code reuse.
- Assumes a step that still runs is still doing what it originally did.
- Thinks the risk is only that the step will fail to compile.
- Relies on validation and callbacks firing correctly over years-old data.
- Says replaying the history is unnecessary, so drift in old steps does not matter.