Index

Integration Without Illusions

Validate at the Boundary You Control

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 required

The 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 optional

That 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 error

Normalize it when multiple representations have the same meaning:

"   "
    -> null

Enrich it only from an authoritative source:

country code absent
account has a verified booking country
policy explicitly permits account fallback
    -> use verified country

Guessing 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 policyRequested behaviorEmailPhoneResult
either contactordinarypresentabsentaccept
either contactordinaryabsentpresentaccept
either contactordinaryabsentabsentreject
either contactphone-specificpresentabsentreject
either contactphone-specificpresentpresentaccept
phone requiredordinarypresentabsentreject

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->phone

If 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 omitted

Also 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.