A configuration lookup can succeed, return a perfectly valid object, and still give the wrong answer.
The problem appears when we ask a present-day source to explain a past decision. Configuration answers, “What is true now?” Historical records often need to answer, “What was the user told then?” Those questions look similar in code, but they have different integrity requirements.
The bug hiding inside a successful lookup
Imagine a C# service that creates user-visible hand-off instructions. At creation time it resolves a destination from configuration, generates a reference, saves an instruction record, and returns both values to the user.
Later, the destination changes. Perhaps a fulfilment route changes or an external service is replaced. When the user reopens the old instruction, the application resolves the destination again.
The lookup succeeds. The new destination is valid. The old reference is valid.
Together, however, they describe an instruction that never existed.
No exception is thrown. Monitoring stays quiet. Every component may satisfy its local contract while the system rewrites its own history.
A valid lookup can still be historically wrong.
Persist the issued output
The safer design is to store the minimum output that the user or another system acted on when the record is created.
Here is a deliberately invented example:
public sealed class InstructionRecord
{
public Guid Id { get; init; }
public string Reference { get; init; } = "";
public string? IssuedDestinationName { get; init; }
public string? IssuedDestinationCode { get; init; }
public bool HasIssuedSnapshot =>
!string.IsNullOrWhiteSpace(IssuedDestinationName) &&
!string.IsNullOrWhiteSpace(IssuedDestinationCode);
}
The write path resolves configuration once, then saves the resulting values with the record:
var destination = await destinationProvider.ResolveAsync(request.Route, ct);
var record = new InstructionRecord
{
Id = Guid.NewGuid(),
Reference = referenceFactory.Create(),
IssuedDestinationName = destination.DisplayName,
IssuedDestinationCode = destination.Code
};
db.Instructions.Add(record);
await db.SaveChangesAsync(ct);
The important detail is not the syntax. It is the source of truth. A later read reconstructs the old instruction from the stored snapshot, not from the current provider.
Persisting only the route or configuration key is often insufficient. That key tells us which branch was chosen, but the branch can still return different values later. If exact re-display matters, save the issued result.
Treat legacy nulls as honest uncertainty
Adding snapshots to an established table creates a difficult question: what should happen to existing rows?
An additive EF Core migration with nullable columns is often a practical deployment choice. New application code can write snapshots without requiring a risky backfill or a lockstep release. Old records remain readable.
But a null snapshot has meaning: the system cannot prove what was issued.
Backfilling those rows from current configuration would make the database look complete while inventing historical facts. That is worse than leaving the uncertainty visible.
A safer read model makes the distinction explicit:
DestinationSnapshot? snapshot = record.HasIssuedSnapshot
? new(record.IssuedDestinationName!, record.IssuedDestinationCode!)
: null;
The user experience can then behave conservatively. For a snapshotted record, offer exact re-display. For a legacy row, withhold that action and ask the user to create a fresh instruction. This is not a temporary fallback to delete after rollout. Unless trustworthy historical evidence becomes available, it is the permanent safe behaviour.
In data modelling, “unknown” is often a valuable state. Hiding it with a guessed value does not improve integrity.
The trade-off is real
Snapshots duplicate data, and duplicated data demands discipline. You need bounded field lengths, appropriate access controls, retention rules, and a clear decision about which values are necessary. If the generated output includes secrets or sensitive personal data, copying it casually may create a larger problem than the one being solved.
Snapshots are also stale by design. That is their purpose. Current configuration remains authoritative for new instructions; the stored snapshot is authoritative for explaining an old one.
The benefit is a self-contained historical record. Support teams do not need to search messages or logs to reconstruct what happened. Users see the same instruction again. Audits can distinguish two records created under different configuration, even if both are inspected after the configuration changed.
The cost is extra schema and a permanent compatibility path. The return is historical correctness.
Test the boundary, not only the calculation
Useful tests for this pattern focus on the seam between mutable configuration and durable history:
- the exact resolved values are persisted at creation;
- different configuration branches produce distinguishable stored snapshots;
- reads use the snapshot rather than resolving current configuration;
- a legacy row without a snapshot does not enable exact re-display;
- changing configuration after creation does not change the old instruction.
The lesson here came from read-only inspection of a recent committed change and its focused tests. I inspected what those tests assert, but I did not rerun them. That supports a design observation, not a claim about the current runtime or build state.
A practical decision rule
Before adding a snapshot, ask:
- Did a person or external system act on this output?
- Can the source values change independently?
- Must we reproduce the old output later?
- Would reconstructing it incorrectly cause harm or confusion?
If the answers point toward history, persist the minimum safe output at write time. Keep current configuration for new decisions, stored snapshots for past decisions, and null for history you genuinely cannot prove.
That separation is small in code, but it prevents a successful lookup from quietly changing the past.
Top comments (0)