skip to content

A long-lived order service is half-migrated from mutable records to immutable values, with both styles live - why is that state more expensive than either endpoint?

level: seniorimportance: should knowfreq 44%

answer

  1. both bills, neither guarantee
  2. the front line is a long boundary
  3. invariants hold only inside a closed region
  4. benefit arrives per region, cost per file
  5. close one boundary before opening another

basics

~20 s

Both bills, neither guarantee. Seams appear wherever the front line runs instead of at one edge, invariants hold in only part of the graph so nobody can rely on them, and every reviewer carries two models.

solid answer

~50 s

The endpoints are each coherent: one style means one model and no conversion, the other means one model and a boundary at the edge. Half-done means neither. The front line between the styles is long and irregular, so conversions appear in the middle of call chains rather than at one edge, and each one is a place fields get dropped. The reasoning win of immutability only lands when a whole region is closed - if half of an order's graph can still be edited, you cannot say "this cannot change underneath me" anywhere. Meanwhile every feature has to be written twice-aware, review takes longer because conventions differ by file, and onboarding doubles. The trap is that each increment costs real work and pays nothing until a boundary closes, so the half state is self-sustaining unless you migrate by closed regions.

code

pseudocode · 7 lines
pseudocode
function applyDiscount(orderRecord)
    view = toImmutable(orderRecord)
    priced = discountRules(view)
    rebuilt = toMutable(priced)

    orderRecord.total = rebuilt.total
    return orderRecord

go deeper

for a junior

Recall that when one codebase holds two styles at once, every change has to ask which style its data is in, and that conversions between the two are where fields quietly go missing.

for a middle

Explain why the benefit is a property of a finished region rather than of converted files, and name what the mixed state adds: scattered crossings, partial invariants, two conventions in review.

for a senior

Demonstrate the diagnosis and the way out: locate the front line, count the crossings, close one nameable boundary at a time, and refuse to open a second front. Say what evidence tells you a region is actually closed.

for a principal

Own the stopping rule. Decide which regions will never be closed, choose between finishing them and reverting them, and be explicit that a permanent island is a standing tax on review and onboarding rather than a neutral outcome.

## Why the middle is the worst place to stand Both endpoints of this migration are coherent positions. A fully mutable service has one model, one set of conventions and no conversion code; its costs are the familiar ones of shared editable state. A fully migrated service has an immutable interior, a thin effectful shell and one boundary between them. The half-migrated state has the conversion cost of the second and the reasoning uncertainty of the first, and it is the state most long-lived services actually occupy. The key insight an interviewer is listening for is **why the benefit is not proportional to the progress**. Conversion cost accrues linearly with how much you have converted. The benefit - being able to say that nothing in this region can change underneath you - is a property of a *closed region*, and it arrives only when a boundary is complete. Fifty percent converted is not fifty percent of the benefit; it is close to none of it, at half the cost. ## What gets more expensive 1. **Seams multiply.** In the finished state there is one boundary at the edge of the module. Mid-migration the boundary is wherever the front line happens to be, which is many places, none of them designed. Conversions land in the middle of call chains, sometimes twice in one path. 2. **Invariants become partial.** If an invariant is enforced by the immutable half but the mutable half can still reach the same graph, the invariant is not a guarantee, it is a habit. Code cannot rely on a habit, so defensive checks stay in place on both sides. 3. **Every change is twice-aware.** A new feature has to answer which style its data is in, which style its callers are in, and what happens at the crossing - three decisions that neither endpoint requires. 4. **Review slows down.** A reviewer must know which convention applies to the file in front of them, and cannot flag a pattern as wrong without checking which half it lives in. 5. **Onboarding doubles.** A newcomer learns two models plus the map of which is where, and the map is only in people's heads. 6. **Tests fork.** Fixtures exist in both shapes, and a bug reproduced in one shape has to be re-expressed to be fixed in the other. ## The costs against the endpoints | | All mutable | Half migrated | Fully migrated | |---|---|---|---| | Conversion code | None | Many ad-hoc crossings | One boundary, owned | | Reasoning guarantee | None, and everyone knows it | None, but people assume there is one | Holds across the interior | | Review cost | One convention | Two conventions plus a map | One convention | | Risk of silent field loss | Low - nothing is rebuilt | Highest - partial write-backs | Low - one mapping, tested | The most dangerous row is the second. In the all-mutable world nobody believes data is stable, so they check. Mid-migration, people see immutable types and assume the guarantee that is not actually there yet. ## Why it persists The half state is self-sustaining for structural reasons, not because anyone is lazy: - Each increment costs work and pays nothing until a boundary closes, so there is never a good quarter to fund the next slice. - The easy files go first, leaving the hard ones - the ones with the most aliasing - as the remaining work, so the cost per slice rises as the enthusiasm falls. - The conversions that were supposed to be temporary get tests written against them, which makes them look permanent and load-bearing. ## Getting out - **Migrate by closed regions, not by file count.** Pick a subgraph whose boundary you can name, convert everything inside it, and put the conversion at that boundary. Then the region can be reasoned about, and progress is measured in closed regions rather than percentages. - **Make the remaining front line visible.** If the crossings are named functions in known places, the work left is countable; if they are scattered inline conversions, it is not. - **Define done as a boundary**, and refuse to open a second region before the first one closes. - **Be willing to roll one back.** A region that cannot be closed is better returned to the old style than left as a permanent island - the island is what costs. - **Never write a partial write-back** that rebuilds a record and copies only some fields onto the stored one; that is where data disappears quietly during migrations. The judgement being tested is that you can distinguish a migration that is *behind schedule* from one that is *structurally stalled*, and that your first move is to close a boundary rather than convert more files.

  • You inherit this service tomorrow - what is your first move?
    Find the front line and make it visible: list every place a conversion happens, because a migration you cannot count is one you cannot finish. Then pick the smallest subgraph with a nameable boundary, close it, and move its conversions to that boundary. Converting more files before a boundary closes adds cost with no return.
  • How do you tell a slice is genuinely finished rather than mostly finished?
    Nothing inside the region accepts or returns the editable shape, all crossings happen in the named boundary functions, and no code inside re-checks an invariant the region now guarantees. If defensive checks are still needed inside, the boundary is not closed and the region is still paying both bills.
  • When is abandoning the migration the right call?
    When the remaining regions are the ones with the most aliasing and no team can fund closing them, leaving permanent islands. Returning those islands to the original style costs a rewrite once, whereas leaving them costs every reviewer, every newcomer and every feature indefinitely.

A bridge built from both banks costs both crews for years and carries nobody until the spans meet. Half a bridge is not half a crossing.

saying these in an interview costs you the question

  • Assumes benefit grows in proportion to the percentage of files converted
  • Treats immutable types in the code as proof the guarantee already holds
  • Measures progress in files converted rather than boundaries closed
  • Opens a second migration front before the first region is closed
  • Calls the temporary conversions harmless because they are covered by tests
  • Rebuilds a record and copies back only the fields it remembered