We added four nullable fields to preserve a pricing snapshot: net amount, tax rate, tax amount, and gross amount. Each field could now be absent. That did not make each field independently optional.
For older records, all four nulls meant one thing: this record predates the snapshot and does not contain captured pricing evidence. For new records, all four values had to arrive together. A row containing gross and net but no tax rate was not “partly known.” It was invalid.
The columns were nullable because old and new code had to coexist. The concept was optional only as a whole.
Null does not explain itself
A nullable column can represent many different stories:
not collected yet
not applicable
unknown
redacted
failed to calculate
legacy record
explicitly clearedStorage preserves the absence but loses the reason unless the model supplies one. Future readers then invent their own interpretation, and every one of those interpretations will look reasonable in isolation.
One report may turn null into zero. Another may exclude the row. An API may omit the field. A repair job may recalculate it. Each behavior can look reasonable because the database never said which absence it stored.
So “should this column be nullable?” is the wrong first question. Ask this instead:
Which named domain state does absence represent?
In that pricing change, null means “no captured pricing snapshot.” It does not mean free, tax-exempt, pending calculation, or corrupted. That one sentence gives every reader the same starting point.
Optional fields often form one optional fact
The four pricing values only make sense as a set:
snapshot absent:
net = null
rate = null
tax = null
gross = null
snapshot present:
net = integer
rate = decimal
tax = integer
gross = integerThere are sixteen possible present-or-absent combinations across four nullable columns. Only two represent intended states.
Treating the fields as independently optional admits fourteen combinations which every consumer must interpret or reject. Calling that flexibility is generous. It is an undocumented state space.
The write contract requires all four fields when any one is supplied. It then checks their arithmetic. Older callers may supply none during the compatibility window. New callers cannot manufacture a partial snapshot.
An aggregate absence rule is often clearer than four property comments:
has_snapshot = all four values are present
legacy_without_snapshot = all four values are absent
otherwise = invalidThe name “legacy” is specific to this transition. Another model might use
not_applicable, awaiting_measurement, or redacted. The label matters less
than the agreement that one state has one meaning.
Zero is a value
Null and zero answer different questions, even though turning one into the other makes a lot of code pleasantly disappear.
tax amount = 0:
a snapshot exists and records no tax amount
tax amount = null:
no pricing snapshot existsReplacing null with zero to simplify arithmetic destroys that distinction. A legacy row becomes indistinguishable from a recorded zero-tax transaction.
The same problem appears outside money:
delivery duration = 0
measured as immediate
delivery duration = null
not measured
discount = 0
explicitly no discount
discount = null
pricing snapshot absentDefaults are appropriate when the default is a true domain value. They are dangerous when they merely hide missing evidence and make the arithmetic easier to write.
Readers should branch on the named absence before performing arithmetic, not coerce missing data into a number and hope later calculations expose the difference.
Database nullability and domain optionality differ
The migration had to be rolling-compatible. Existing rows could not suddenly provide data they never captured, and older application versions could still write without the new fields. Nullable storage made that overlap possible.
That compatibility decision does not require the application model to expose four unrelated nullable properties forever.
At the boundary, parse the row into one of two shapes:
PricingSnapshot:
net
tax rate
tax
gross
MissingLegacyPricing:
no numeric fieldsWhether these become two types, one optional value object, or an explicit state enum depends on the language and codebase. The useful move is to validate the row once near hydration, not to let partial absence leak into every calculation and ask each caller to rediscover the rule.
If the database can enforce the all-or-none rule without breaking the rolling window, a check constraint protects lower-level writers too:
CHECK (
(net IS NULL AND rate IS NULL AND tax IS NULL AND gross IS NULL)
OR
(net IS NOT NULL AND rate IS NOT NULL
AND tax IS NOT NULL AND gross IS NOT NULL)
)The available implementation enforces completeness at the request boundary; it does not include such a database check. Direct writers therefore remain a known gap. The SQL above shows the shape of a stronger guard, not something the system can claim already exists.
Compatibility windows need an exit condition
Nullable fields are often introduced with a promise to make them required later. Without an exit condition, “later” usually means forever.
A useful transition states:
phase 1:
readers accept absent snapshot
new writer can send complete snapshot
phase 2:
every current writer sends complete snapshot
old rows remain explicitly legacy
phase 3:
policy decides whether to preserve, reconstruct, or exclude old rows
phase 4:
database constraint rejects partial combinations
API may require the complete snapshotNot every legacy absence should be backfilled. Reconstructing financial facts from current rates or mutable source data can create false history, which is worse than admitting the evidence was never captured. Sometimes the honest permanent state is exactly that: “not captured at the time.”
The exit condition may therefore be writer completeness rather than zero null rows. The system can retain old absence deliberately while refusing new absence.
Consumers must choose behavior explicitly
Once absence has a name, downstream behavior becomes reviewable:
| Consumer | Snapshot present | Legacy snapshot absent |
|---|---|---|
| invoice calculation | use captured components | use approved fallback or stop |
| reporting | report exact components | classify as missing evidence |
| reconciliation | compare captured values | resolve from a dated authoritative source |
| API | return complete object | return explicit absence state |
The fallback is a domain decision too. Current tax rates cannot silently stand in for historical rates. A total amount cannot reveal how much was net and how much was tax. A report may be allowed to group unknown legacy values while an invoice must stop.
Different consumers may choose different actions. They should at least be arguing about the same named absence rather than four loosely related nulls.
The fixture counts the states we almost admitted
The DATA-04 fixture enumerates all sixteen presence combinations for four fields. It accepts exactly two: all absent and all present.
It then verifies that a zero-valued tax component remains a present value, while a null tax component inside an otherwise present snapshot is rejected. Consumer examples classify legacy absence without inventing component values.
The fixture is a deterministic shape validator. It does not run a database, framework validator, migration, invoice, or private implementation. Its values and consumer policies are synthetic. The application code establishes the nullable schema and boundary validation; the public model just makes the otherwise easy-to-ignore fourteen invalid shapes visible.
More states may deserve more representation
All-or-none is not always enough. If the domain genuinely distinguishes “awaiting calculation,” “not applicable,” and “redacted,” forcing all three into null is the wrong model.
Possible representations include:
- an explicit status alongside nullable values;
- separate tables for facts with different lifecycles;
- a tagged value in application code;
- provenance and observation timestamps; or
- distinct events from which the current projection is built.
Extra representation has a cost. It earns that cost when consumers genuinely behave differently and the system can know which state it has. Adding a status because null feels impure only gives the ambiguity another column.
Reverse the all-or-none rule if partial knowledge is legitimate. Then name which combinations are meaningful, preserve provenance per component, and teach every consumer what it may infer. Do not call arbitrary partial rows “flexible.”
Null is useful when it marks one deliberate absence. It becomes dangerous when every future reader has to guess why the value is gone—and gets to guess differently.