Some defects announce themselves through a failed total. More dangerous ones preserve the total while changing what the data means.
A recent committed change reminded me how easily a new domain discriminator can be mistaken for a small edit. In a C# service, adding a payment method may appear to require one enum member, one input option, and one stored value. The compiler goes quiet, the happy path works, and the aggregate still matches.
Yet the value can control behaviour across several modules. If it is captured correctly but later defaulted, omitted from a grouping key, or mapped to an older value, the system can remain arithmetically correct while routing the transaction incorrectly.
The lesson is simple: trace a new discriminator through its complete lifecycle, then test its identity as carefully as its amount.
What the committed change showed
The evidence came from a recent vertical change. A new method was captured where it became known, stored with the state change, and cleared when that state was reversed. Existing generic aggregation accepted it without a code change—a useful sign that the core abstraction held.
The strict outbound adapter needed explicit mapping and configuration. If the value occurred without a destination, the adapter refused the output rather than borrowing a route. Committed tests covered persistence, clearing, separate aggregation, missing-configuration refusal, the exact destination, and mixed-category balance.
The commit record also describes targeted mutation checks. One wrong-destination mutation initially survived because the test asserted a label and balanced totals, but not the destination. Strengthening that assertion is the heart of this lesson.
I reviewed source and tests read-only. I did not run the tests, render the inputs, call a downstream service, or inspect production data. The change supports the pattern; it does not prove every consumer is covered.
Why this defect is easy to miss
Consider a generalised example. A system adds BankTransfer to an existing PaymentMethod enum. A request arrives with that value and the amount is stored. Later, a mapper built before the addition sends every unknown value to Card.
The total remains unchanged. Reconciliation says that 100 units entered and 100 units left. A test that asserts only the grand total passes.
But the semantic result is wrong. The item may enter the wrong clearing batch, receive the wrong fee treatment, appear in the wrong report, or be exported with an incorrect category. Once that export is accepted downstream, repair may require a compensating record rather than a simple edit.
This is a data-integrity failure even though no amount disappeared.
Map the value across five checkpoints
Before changing code, draw the shortest end-to-end path. I use five checkpoints.
1. Capture
Confirm that the API, form, message, or import can express the value. Check validation, binding, defaults, and serialisation. An option on screen does not prove the payload carries it.
2. Persistence
Verify writes and reads. Check ORM mappings, converters, constraints, defaults, migrations, and older rows. Persist the exact value, reload it, and compare identity—not merely a non-null result.
3. Reversal or clearing
Undo paths often retain yesterday's assumptions. Trace reversals, clearing, cancellation, and retries. If the state no longer applies, clear its discriminator too.
4. Aggregation
Inspect grouping keys, projections, caches, and reports. Amount-only aggregation can make categories look interchangeable. Assert each category and the overall total.
5. Irreversible hand-off
Treat queues, files, APIs, and signed exports as consequence boundaries. Validate immediately before hand-off. Unknown values should stop or quarantine the item rather than borrow a route.
Make C# expose omissions
An enum is compact, but it does not automatically make every consumer exhaustive. A generic default branch can turn a missing case into plausible data.
return method switch
{
PaymentMethod.Card => Route.Card,
PaymentMethod.BankTransfer => Route.BankTransfer,
_ => throw new UnmappedPaymentMethodException(method)
};
The important decision is where an unknown value is dangerous enough to fail loudly.
At compatibility seams, tolerance may be appropriate for legacy records or overlapping versions. Make the fallback explicit, observable, and temporary; remove it when the transition ends.
The trade-off is rollout resilience versus omission visibility. Strict handling can interrupt processing, but silent fallback can produce valid-looking, wrongly routed output. The higher the consequence, the stronger the case for exhaustive handling.
Test meaning, not only arithmetic
A regression test should use the exact new discriminator across the real seams. For the example above, assert that:
- The request binds to
BankTransfer. - Persistence round-trips
BankTransfer. - Reversal or clearing removes the stale method.
- Aggregation places the amount in the correct bucket.
- The outbound record uses the intended route.
- An unknown value cannot reach the irreversible hand-off.
Focused unit tests are useful, but add at least one journey test across the modules most likely to erase meaning. A test using Card as a representative value cannot prove the new value is wired correctly. Nor can a total-only assertion.
The pattern applies beyond payments to notification channels, document kinds, tax categories, and workflow states. If a value changes routing, grouping, or side effects, its identity is part of data integrity.
A practical review checklist
When a pull request introduces a domain value, search for the enum or token, then search for the behaviours it controls. Review switches, mappers, serializers, converters, projections, grouping keys, defaults, reversal paths, and export builders.
Ask three questions:
- Where is the value first known?
- Where could it be replaced by a default?
- What is the last safe point to reject an unknown value?
That review takes longer than adding an enum member, but usually much less time than repairing a semantically wrong downstream record.
Balanced totals are valuable evidence. They are simply incomplete evidence. Preserve both quantity and meaning, especially where a small domain value decides a large operational route.
Top comments (0)