Index

Data That Must Survive

Null Is a Domain Decision

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 mean each field was 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 for compatibility. The concept was optional 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 cleared

Storage preserves the absence but loses the reason unless the model supplies one. Readers then invent their own interpretation.

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.

The first design question is therefore not “should this column be nullable?” It is:

Which named domain state does absence represent?

In the retained pricing change, null means “no captured pricing snapshot.” It does not mean free, tax-exempt, pending calculation, or corrupted.

That 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 = integer

There 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. That is not flexibility. It is an undocumented state space.

The retained 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 = invalid

The name “legacy” is specific to this transition. Another model might use not_applicable, awaiting_measurement, or redacted. The important part is that one state has one meaning.

Zero is a value

Null and zero answer different questions.

tax amount = 0:
    a snapshot exists and records no tax amount

tax amount = null:
    no pricing snapshot exists

Replacing 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 absent

Defaults are appropriate when the default is a true domain value. They are dangerous when used to hide missing evidence.

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.

This 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 fields

Whether 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, instead of letting partial absence spread through every calculation.

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 retained artifact enforces completeness at the request boundary; it does not retain such a database check. That leaves direct writers as a known boundary to review rather than something this article may claim is protected.

Compatibility windows need an exit condition

Nullable fields are often introduced with a promise to make them required later. Without an exit condition, “later” becomes permanent.

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 snapshot

Not every legacy absence should be backfilled. Reconstructing financial facts from current rates or mutable source data can create false history. Sometimes the honest permanent state is “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:

ConsumerSnapshot presentLegacy snapshot absent
invoice calculationuse captured componentsuse approved fallback or stop
reportingreport exact componentsclassify as missing evidence
reconciliationcompare captured valuesresolve from a dated authoritative source
APIreturn complete objectreturn 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, but they should branch on the same absence state.

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 retained implementation. Its values and consumer policies are synthetic. The retained code establishes nullable schema and boundary validation; the public model exposes the state space created by those choices.

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 behave differently for the states and the distinction can be known reliably.

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 it asks every future reader to guess why the value is gone.