A wallet reservation held one amount while a booking was in progress. When the booking later reached the billing system, an imported record could contain no price. The reservation knew how much money had been held, but not how that amount divided into net price and VAT.
There was no authoritative way to create the final sale from the hold alone. Inferring a tax rate would turn a guess into accounting data.
The retained repair added a booking-time snapshot with four related values: net amount, VAT rate, VAT amount, and gross amount. New callers had to send all four together and satisfy two equations. Legacy callers could omit the entire snapshot. They could not send an arbitrary subset.
That last distinction is the design. Four nullable columns permit sixteen presence combinations. The application supports two states: absent legacy snapshot or complete consistent snapshot.
Write the supported states before the fields
Starting from columns makes partial state look natural:
net_amount nullable
vat_rate nullable
vat_amount nullable
gross_amount nullableStarting from the business states produces a different model:
LegacyReservation
no pricing snapshot
QuotedReservation
net
VAT rate
VAT amount
grossThe four fields are not independently optional. They belong to one fact: the price which was quoted when the funds were reserved.
For the retained boundary, a complete snapshot also had to satisfy:
gross = held amount
gross = net + VAT amount
VAT amount = round(net * VAT rate)Presence is one invariant. Arithmetic agreement is another. Checking only that all four values exist still permits a snapshot which tells three conflicting stories about the same charge.
This is the useful first question for any related field set:
Which combinations does the domain actually support?
Do not make every combination representable and ask downstream code to decide later.
Legacy absence is not a partial snapshot
Rolling changes often need to preserve old clients and rows. That does not mean the new state must be partially valid.
The retained endpoint treated the two supported cases differently:
| Input state | Meaning | Action |
|---|---|---|
| no snapshot fields | legacy reservation | accept, use authoritative later pricing |
| all snapshot fields, consistent | quoted reservation | accept and retain quote |
| some snapshot fields | incomplete quote | reject |
| all fields, inconsistent | contradictory quote | reject |
This avoids a common trap:
missing one field
-> calculate it from the others
-> continue as if it was suppliedCalculation may be valid when the missing field is defined as derived data. It was not valid here because the snapshot existed to preserve what the caller actually quoted. Reconstructing historical VAT from a current tax rule would erase that distinction.
Legacy reservations followed a separate recovery path. Reconciliation used authoritative imported pricing when it existed and requested a price refetch when it did not. The code explicitly avoided completing a legacy reservation from an inferred VAT rate or amount.
Compatibility remained, but uncertainty stayed visible.
Enforce the group where it first becomes yours
The application owned the HTTP boundary where a new reservation was created. That was the first place it could require the four values together.
In simplified form:
if any snapshot field is present:
require every snapshot field
require gross == held
require gross == net + VAT
require round(net * rate) == VATAfter those checks, later operations can consume a complete snapshot instead of repeating defensive combinations:
if net exists but rate does not ...
if gross exists but VAT does not ...
if amount and gross disagree ...Repeated checks are not merely verbose. Different consumers eventually choose different fallbacks. One treats missing VAT as zero, another uses today’s rate, and a third abandons reconciliation. The system then has several definitions of a valid reservation.
Validate at the earliest boundary you own, construct one coherent value, and pass that value inward.
This does not make external input trustworthy. It gives untrusted input one place to become either valid domain state or an explicit rejection.
A value object should close the door
Once the boundary has validated input, a value object can prevent internal code from taking the fields apart:
final readonly class PriceSnapshot
{
private function __construct(
public int $net,
public float $vatRate,
public int $vat,
public int $gross,
) {}
public static function fromQuote(
int $net,
float $vatRate,
int $vat,
int $gross,
): self {
// reject disagreement, then construct
}
}The private constructor is not the important part. The important part is that there is no public path to a half snapshot.
A bag with four nullable properties and an isValid() method leaves the
invalid state alive. Every caller can forget the check. A complete value object
makes validation a construction cost paid once.
The retained implementation did not introduce this value object. It validated the group at the request boundary and stored four nullable model attributes. Presenting the stronger shape as the historical outcome would overstate the evidence. It is the next design step the existing invariant makes possible.
Nullability can be a rollout tool
Why not declare every column NOT NULL?
Because old rows did not have a pricing snapshot, and old clients still needed to create reservations during the rolling change. Database nullability represented compatibility, not partial domain validity.
That is a defensible intermediate design:
database:
four nullable columns for old rows
new creation boundary:
none or all four
domain consumption:
complete snapshot or explicit legacy pathIt is also incomplete protection. A maintenance command, direct model write, or
another endpoint can bypass request validation and persist two of the four
fields. The model’s hasPricingSnapshot() check will call that state legacy
absence even though it contains partial new data.
The retained artifacts do not show a database constraint rejecting partial rows. They show application-boundary enforcement. That proof should not be stretched further.
When old writers and partial rows have been measured away, persistence can be tightened. Options include:
- a separate snapshot table whose row requires every field;
- a check constraint requiring all four columns to be null or non-null;
- a structured document with a versioned schema; or
- non-null columns after a complete migration.
Choose from data lifetime and rollout needs. “Nullable in SQL” does not have to mean “optional in the domain.”
Equations belong to the contract
Financial snapshots need an explicit representation and rounding policy.
The retained request expressed VAT as a percentage, converted it to a ratio, multiplied the integer net amount, rounded, and compared the result with the integer VAT amount. Gross also had to equal the top-level amount being held.
Those choices are part of the contract:
input rate: percentage
stored rate: ratio
money: integer minor units
rounding point: after net * rate
gross authority: must match held amountMove any one of them and a formerly valid snapshot may fail. A client sending
0.255 when the endpoint expects 25.5 is not a small formatting mistake; it
changes the tax by two orders of magnitude.
The boundary should therefore name units in field documentation and tests, not rely on a familiar-looking number.
This article is not an argument that every four-field calculation deserves a class. The snapshot earns a boundary because it is durable, reused later, and must preserve a historical decision exactly.
Test states, not fields
A useful test matrix follows supported states:
| Case | Expected result |
|---|---|
| no snapshot | accepted legacy state |
| complete consistent snapshot | accepted quoted state |
| one field missing | rejected partial state |
| gross differs from held amount | rejected authority conflict |
| net plus VAT differs from gross | rejected arithmetic conflict |
| rounded rate differs from VAT | rejected calculation conflict |
The public reproduction executes this matrix with synthetic integer amounts. Only a complete valid input can produce the frozen snapshot.
One control supplies an unrelated field but no snapshot fields. It remains legacy absence. Group presence must be keyed to the group’s own fields, not to whether a request happens to contain extra data.
The retained integration tests prove the positive path: a complete snapshot is stored and later reconciles a zero-priced import using the quoted net and VAT. I did not find retained negative integration tests for every invalid combination. The validation rules support the rejection claim; the missing tests remain a gap in the executable evidence.
Construction cannot eliminate every invalid state in a system with migrations, old rows, and bypass paths. It can make the supported entry point precise.
Allow legacy absence for as long as it is real. Reject partial new state. Once the boundary accepts a quote, leave later code one honest object rather than four nullable clues.