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.

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.

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.

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.

This is why a source search can give a dangerously complete-looking answer. It can find current constructors, factories, and writers. 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 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.

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.

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.

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.

Choose migration from lifetime and authority

A compatibility reader is not always the best answer.

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.

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.

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. Give it 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.

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.