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.

There is no honest global rule such as “phone is nullable” or “phone is required.” The invariant 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 called valid.

The retained 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:

what value did the caller provide?

Validation answers:

is that normalized value sufficient for this operation?

Keep the order visible. Validation against raw input can accidentally accept whitespace. Normalization after validation means different layers reason 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
}

It does not permit this state in every workflow:

{
  "email": null,
  "phone": null
}

The retained 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. An email cannot satisfy it merely because another variant of the service accepts email-only contact.

This is a conditional invariant: at least one contact method exists, and sometimes one particular method exists.

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 another entry point books the same service through an import, queue, administration tool, or API version. It also misses the options which restore the phone requirement.

The retained 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 may still validate syntax for fast feedback, but they cannot be the only guardian of the invariant.

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 for this post performs only normalization and validation. It never creates a missing contact value.

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: 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 but loses the distinction between “not supplied” and “supplied as text.” Remote systems may validate them differently. Logs and tests also become harder to interpret because the original absence has been rewritten.

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.

This is where validation and mapping meet without becoming the same job. Validation establishes that the business operation has enough information. 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 does not prove the accepted request carries the intended value.

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 retained artifacts do not establish how often these inputs occurred or whether a production incident prompted the change. They establish the policy, the constructed value, and the outbound representation.

Boundary validation should not force every external variant into one blunt schema. Normalize equivalent representations, then enforce exactly the facts the selected operation needs—no fewer, and none invented.