A booking request contains a recipient email and a phone number made entirely of spaces.
For one delivery service, email is enough. For the same service with a particular handoff option, a phone number is mandatory. For another service, phone is always mandatory.
So is the phone optional? That question is already too vague. There is no honest global rule such as “phone is nullable” or “phone is required.” The answer depends on the operation the request asks the system to perform.
Normalize representation before deciding validity
These inputs carry the same useful phone value:
null
""
" "Leaving them distinct pushes accidental representation into every later decision. One validator treats spaces as present, a payload builder sends an empty string, and a remote API rejects what the application confidently called valid. Nothing useful was gained by preserving three spellings of “missing.”
The booking boundary converts a trimmed empty phone to null while
constructing its party data. A downstream provider factory then preserves that
null for an email-only booking instead of substituting an empty string.
Normalization answers one question:
what value did the caller provide?Validation answers another:
is that normalized value sufficient for this operation?The order is not cosmetic. Validation against raw input can accidentally accept whitespace; normalization afterward then leaves different layers reasoning about different values.
Optional fields do not imply optional facts
Making both contact properties nullable in a type is a representation choice:
type Recipient = {
email: string | null
phone: string | null
}That keeps the type honest about how data arrives. It does not permit this state in every workflow:
{
"email": null,
"phone": null
}The policy distinguishes three cases:
selected services:
email or phone is required
selected behavior on those services:
phone is required
ordinary services:
phone is requiredThe stricter behavior is not an arbitrary validation exception. It needs the phone-specific capability, and an email cannot satisfy it merely because another variant accepts email-only contact. That is the conditional invariant: at least one contact method must exist, and sometimes one particular method must exist.
Put the condition where all entry points meet
The tempting implementation adds an exception in one controller:
if service is email-friendly:
make phone optionalThat works until an import, queue, administration tool, or another API version books the same service. It also misses the options which restore the phone requirement. A controller-local exception is easy to ship and equally easy to bypass.
The change moved the policy into shared booking validation and exercised it at both request and data-construction boundaries. Tests cover:
- email-only contact for supported services;
- neither email nor phone;
- a phone-specific handoff option;
- an additional enabled shipment;
- ordinary services outside the exception;
- whitespace-only phone normalization; and
- the final provider payload retaining null.
The shared boundary owns a business-ready booking request. Controllers can still validate syntax for fast feedback, but making them the only guardian of the invariant means every new entry point gets another chance to forget it.
Reject, normalize, or enrich are different actions
External input which does not match the internal contract has three common paths. Reject it when the missing fact is required and cannot be derived:
phone required for selected behavior
phone absent
-> reject with a field-specific errorNormalize it when multiple representations have the same meaning:
" "
-> nullEnrich it only from an authoritative source:
country code absent
account has a verified booking country
policy explicitly permits account fallback
-> use verified countryGuessing is not enrichment. Copying a sender’s phone to the recipient or inventing a default email would make the request structurally complete while changing its meaning. The public fixture therefore performs only normalization and validation; it never creates a missing contact value just to make a type checker or remote schema happy.
A rule table is easier to inspect than nested exceptions
Conditional validation becomes unreadable when expressed as a growing chain of negated booleans. Write the policy as a decision table first:
| Service policy | Requested behavior | Phone | Result | |
|---|---|---|---|---|
| either contact | ordinary | present | absent | accept |
| either contact | ordinary | absent | present | accept |
| either contact | ordinary | absent | absent | reject |
| either contact | phone-specific | present | absent | reject |
| either contact | phone-specific | present | present | accept |
| phone required | ordinary | present | absent | reject |
The implementation can use sets, rules, or small policy objects. The table is the review artifact because every row names the inputs which change the answer. When a new service arrives, adding it to “phone optional” is not enough; review which behavior flags can strengthen the requirement, how blank values normalize, and what the downstream payload accepts.
Preserve absence across the next boundary
A common adapter habit turns null into an empty string:
'phone' => $recipient->phone ?? ''That avoids a type error and quietly creates a data problem. “Not supplied” and “supplied as text” are no longer distinguishable, remote systems may validate them differently, and logs and tests now describe a value the caller never provided.
Prefer a typed boundary which permits null when the remote contract permits absence:
'phone' => $recipient->phoneIf the remote schema requires omission rather than explicit null, remove the field in the adapter. That is translation of a known absence, not a fabricated value. Validation and mapping meet here without becoming the same job: validation establishes that the operation has enough information, while the adapter expresses that valid state in the remote protocol. The next post owns that translation in detail.
Test the accepted value, not only the error
An error assertion proves one invalid case was rejected. It says nothing about the value carried by an accepted request, which is usually where the next bug is waiting.
For each meaningful policy row, verify three boundaries:
normalized input:
whitespace phone became null
validation result:
accepted or rejected for the declared reason
outbound representation:
null remained null or was intentionally omittedAlso preserve a negative control outside the special service set. Otherwise a change intended for one provider can quietly relax every booking.
The repository evidence does not establish how often these inputs occurred or whether a production incident prompted the change. It establishes the policy, the constructed value, and the outbound representation. That is enough to make the decision without inventing a dramatic origin story.
Boundary validation should not force every external variant into one blunt schema. Normalize equivalent representations first, then require exactly the facts the selected operation needs—no fewer, and none invented.