Index

Changing Existing Software

Stored Data Is a Caller You Cannot Find with Code Search

An enum migration looked mechanical. A package-backed value object became a native PHP backed enum. The domain concept stayed the same, the new cases were easy to search, and current code wrote the canonical values. It was exactly the kind of refactor that looks finished after a clean source search.

Then an older row loaded a lowercase value. The native enum expected the same word with different casing and rejected it during hydration.

The missing caller was not in the source tree. It was sitting in the database, quietly waiting for the new code to read it.

The retained correction was deliberately small: normalize that known historical value at the cast boundary before strict hydration, and write the canonical form from then on. Focused tests covered reading the old value and persisting its replacement. The history does not retain how many rows were affected or whether every historical variant was found.

That narrow failure contains a general refactoring lesson. Stored data calls today’s code long after the code which produced it has disappeared. Git cannot show that caller because Git does not own the history that matters here.

A row can call code years after it was written

We usually draw callers as functions:

controller -> service -> parser

For a parser at a durable boundary, the useful picture is larger:

current request --------\
current worker ----------> parser -> domain value
old database row --------/
delayed message ---------/
restored archive --------/
rollback release --------/

Those callers do not share a deployment. The current request was produced by today’s code. The row may have been produced by an importer from three versions ago. A delayed message may outlive both.

Changing the parser changes the contract for all of them at once. Only one of them appears in the pull request.

This is why a source search can give a dangerously complete-looking answer. It can find current constructors, factories, and writers, then confidently report zero old references. It cannot enumerate the byte sequences already stored in a table, queue, cache snapshot, file, or backup.

The absence of direct in current source says nothing about whether direct still exists at rest.

Equivalent types can accept different histories

Moving from a library enum to a native enum can improve type checking and remove a dependency. It can also change how input becomes a value.

Consider a simplified representation:

enum RouteKind: string
{
    case Direct = 'Direct';
    case Relay = 'relay';
}

$kind = RouteKind::from($storedValue);

from() is intentionally strict. It accepts Direct; it rejects direct. That is useful when unknown state should be impossible. It is not evidence that every existing row already satisfies the new rule. The type system can be right while the migration is still wrong.

The old implementation may have accepted method aliases, labels, loose coercion, or custom mappings. The new one may accept only backing values. Both types can describe the same business idea while recognizing different historical representations.

Review the conversion in two columns:

QuestionCurrent codeDurable history
What can be written?constructors and writersold releases and imports
What can be read?new parservalues actually at rest
What is canonical?declared backing valuepossibly several spellings
What is unknown?rejected new inputold value or actual corruption

The refactor is compatible only where both columns meet. Current writers prove what happens next; durable history proves what still has to load.

Inventory representations, not just symbols

Before replacing a parser, list every place which can retain its input:

  • database columns and JSON fields;
  • ready, delayed, reserved, and failed queue payloads;
  • cache values which survive a release;
  • object storage and generated files;
  • import and export formats;
  • audit records and event streams;
  • backups which can be restored;
  • other deployed versions which can still write; and
  • administrative tools with their own release cadence.

Then collect values, counts, oldest and newest timestamps, and producers where the environment allows it. The result should look more like a data inventory than a call graph:

RepresentationCountNewest writeKnown producerDecision
Directmeasuredcurrentcurrent writercanonical
directmeasuredhistoricalretired importermap
DIRECTmeasuredunknownunknowninvestigate

The words measured and unknown matter more than invented numbers. If production cannot be queried safely, say that the inventory is incomplete and keep the reader compatible until stronger evidence exists. Guessing that “it was probably only a few rows” is not a migration strategy.

Do not turn every surprising value into compatibility. A known former representation and an unexplained malformed value are different states. The first may deserve a mapping. The second may need quarantine, repair, or an explicit error.

Put translation at the boundary which owns it

The retained correction normalized the one known historical value inside the model cast:

$canonical = match ($storedValue) {
    'direct' => RouteKind::Direct->value,
    default => $storedValue,
};

return RouteKind::from($canonical);

That placement has two useful properties. The domain receives one canonical value, and every hydration path crosses the same translation.

Scattering the mapping through controllers, jobs, and views would preserve only the paths somebody happened to remember. Making the enum itself accept arbitrary casing would move a persistence concern into every use of the type and could hide genuinely unknown input. One known historical spelling does not justify a case-insensitive garbage chute.

The narrow adapter says something precise:

direct is one recognized historical representation of Direct.

It does not say that case never matters. It does not accept DIRECT, trim arbitrary whitespace, or guess from a label. Compatibility should be as broad as the evidence, not as broad as the coercion function permits.

Writes need their own rule. Once the reader accepts the historical form, every new write should emit the canonical form:

read:  direct -> Direct
write: Direct -> Direct

Otherwise the system can keep producing the debt it is supposed to retire. A compatibility reader paired with a legacy writer is just permanent dual format.

Choose migration from lifetime and authority

A compatibility reader is not always the best answer. Sometimes the cleanest solution really is to update the rows and delete the branch; the important part is proving the old form cannot come back.

A database backfill can be cleaner when one application owns all writes, the table is bounded, the update can be verified, old releases cannot return, and archives will not reintroduce the value. Migrate the rows, deploy the strict reader, and remove the temporary machinery.

A bounded reader is safer when rows are numerous, several releases coexist, queues or imports can reintroduce the old form, rollback remains possible, or the value appears in long-lived history. It lets old and new representations cross one explicit seam while the system converges.

Dual reading with canonical writing is often the practical middle:

release A: read old + new, write new
measure:   old reads and old writes
migrate:   remaining durable values
release B: reject old after the counters reach zero

Immediate rejection is also legitimate when the value is unsafe or cannot be interpreted reliably. The error must then lead somewhere useful: quarantine the record, expose the identifier, preserve the raw value, and provide a repair path. A crash with no context converts questionable data into an operational mystery and leaves support to rediscover the raw value later.

The choice depends on who can still produce the old form and how long durable state lives, not on how untidy the compatibility branch looks.

Test the history on both sides of the seam

A parser refactor needs more than tests for its declared cases. Use a small contract matrix:

CaseRead resultNext write
canonical valuecanonical domain valuecanonical value
known historical valuecanonical domain valuecanonical value
unknown valueexplicit failure or quarantinenone
null, if supportednullnull

The old-read test prevents a future cleanup from deleting the branch while the representation still exists. The canonical-write test proves that new debt is not created. The unknown-value test stops a narrow migration adapter from becoming a permissive garbage collector. All three matter; a happy-path enum test covers only the least interesting one.

The public reproduction runs those distinctions with a synthetic native enum. Strict hydration accepts the canonical value and rejects the historical one. The compatibility reader maps only the known old form, persists the canonical form, and still rejects an unexplained variant.

That fixture proves PHP’s parsing behavior and the adapter’s boundary. It does not prove which values exist in a private database or whether a backfill is complete.

Compatibility code needs an exit condition

An unexplained branch can survive forever because nobody knows whether it is still needed. Then every cleanup either keeps folklore or risks breaking old data. Give the branch enough context to become removable:

representation: direct
canonical form: Direct
known producer: retired importer
remaining rows: measured periodically
old reads: counted
old writes: counted
removal gate:
    no supported producer
    no remaining durable values
    rollback window closed
    archive and replay policy decided

Telemetry turns the branch from folklore into a migration. If old reads fall to zero but old writes continue, a producer was missed. If rows remain after writes stop, a backfill or deliberate retention decision remains. If an archive can restore them next year, zero rows today is not sufficient.

Sometimes the cheapest permanent design is to keep a tiny decoder. Long-lived audit history may be more valuable untouched than normalized, especially when the representation itself is evidence. In that case, retain the adapter, document the format lifetime, and test it as a supported reader rather than calling it temporary.

Old code is not sacred. Neither is clean code. The question is whether the branch still carries a contract which durable state can invoke. Deleting a three-line compatibility mapping is not a win if it makes a million old rows unreadable.

Delete it when measurement shows that no supported producer or stored value can call it again. Until then, the branch is not dead code. It is a reader for history.