Index

Integration Without Illusions

Map External Data Before It Enters Your Model

A payment callback arrives with these fields:

{
  "status": "COMPLETED",
  "amount": 7.79,
  "currency": "EUR",
  "reference": "session-demo-8",
  "transaction": "remote-demo-31",
  "message_time": "2026-04-01T09:15:00Z"
}

The application does not need a COMPLETED string or a floating-point amount. It needs a trusted result: which local operation completed, for how much, in which currency, under which remote identity, and at what instant.

Passing the decoded array into business code postpones that translation. It does not avoid it.

Authenticity comes before interpretation

The retained callback boundary receives a JSON string and a message authentication code. It rejects a missing or invalid code before the payment driver finalizes anything.

That order is non-negotiable:

receive bytes
    -> verify authenticity
    -> decode representation
    -> validate required shape
    -> map external meaning
    -> apply business transition

Mapping an unverified payload into a well-typed object only gives untrusted input a nicer outfit.

The exact signature algorithm and canonical representation belong to the remote protocol. Verification must use the representation the protocol defines—often the raw body—before parsing or re-encoding can change it. The public fixture signs its synthetic raw JSON and proves that changing the amount after signing is rejected before mapping.

Decoding is not mapping

JSON decoding changes bytes into language values:

"amount": 7.79
    -> floating-point number

Mapping decides what those values mean locally:

7.79 major currency units
    -> 779 integer minor units

The same distinction applies to every field:

External fieldExternal meaningInternal result
status: COMPLETEDprovider lifecycle stateaccepted
amount: 7.79major unitsamount_minor: 779
referencecaller-provided correlationlocal operation identity
transactionprovider recordexternal transaction identity
message_timeprovider timestampparsed instant

This table is the anti-corruption layer in miniature. It prevents provider vocabulary and representation choices from becoming the application’s default language.

The mapper should reject unknown required values. Treating every status other than COMPLETED as “cancelled” collapses pending, expired, failed, reversed, and genuinely cancelled states into one answer. That may match a narrow remote contract, but it must be a deliberate exhaustive decision.

One internal result gives downstream code a smaller world

Instead of passing the remote array, return a local result:

type AcceptedPayment = {
  kind: 'accepted'
  operationId: string
  amountMinor: number
  currency: string
  externalTransactionId: string
  occurredAt: string
}

Downstream code no longer knows:

  • whether the provider called success COMPLETED, SETTLED, or paid;
  • whether money arrived in major units, minor units, or a decimal string;
  • whether correlation was named reference, order, or merchant_id; or
  • which timestamp syntax the callback used.

Those differences remain real. They are contained in one translator with contract tests.

This does not require one universal payment model for every capability. Disputes, partial captures, asynchronous refunds, and authorization expiry may need distinct internal results. The goal is not to flatten provider behavior. It is to translate each behavior into terms the application deliberately owns.

Replacing an SDK does not complete the boundary

A retained migration removed several vendor SDKs while preserving existing client and factory seams. For one payment integration, callback extraction and MAC verification moved into owned code. Existing driver tests continued to pin charge and refund behavior.

That is useful progress. The application controls transport code and no longer depends on the SDK for callback verification.

The driver still reads provider-shaped arrays and objects. It compares provider status strings, multiplies a decimal amount by one hundred, reads the remote reference and transaction fields, and parses the remote timestamp.

So the retained change proves a partial boundary:

owned:
    transport
    request construction
    callback extraction
    signature verification

still leaking into payment driver:
    provider status vocabulary
    provider response shape
    provider monetary units
    provider timestamp fields

Calling this a completed anti-corruption layer would hide the most important remaining work.

Put unit conversion beside the source representation

Money translation is easy to scatter:

'amount' => $remote['amount'] * 100

Now every caller must remember that this provider uses major units. A refund path may round differently. A later provider may already use minor units.

The mapper should declare:

source unit:
    decimal major currency units

target unit:
    integer minor currency units

rounding:
    explicit for the supported currency scale

For the synthetic EUR fixture, the string "7.79" maps exactly to 779. That fixture does not claim every currency has two decimal places. Currency scale belongs to the money policy, not a hard-coded multiplication copied through adapters.

DATA-05 owns monetary arithmetic in depth. Here the point is ownership: external units must stop at the mapper.

Preserve both local and remote identity

The callback contains two useful identities.

The reference associates the callback with the local operation which initiated payment. The remote transaction identifier supports reconciliation, support, refunds, and provider queries.

Neither should replace the other:

operation_id:
    which local intent is being completed?

external_transaction_id:
    which remote record produced this result?

Mapping them into named fields prevents later code from treating a provider transaction as the application’s durable identity or trusting a caller-controlled reference as proof of remote uniqueness.

Identity also participates in verification. The authenticated payload proves what the provider signed; the application must still confirm that the local operation exists, expects this currency and amount, and has not already reached an incompatible terminal state.

Contract tests should straddle the boundary

Transport tests alone prove the request URL, authentication, and wire body. Driver tests alone can hide a mapper behind mocks. The translation needs its own table:

valid signed completed callback
    -> accepted result with integer minor units

tampered signed body
    -> authentication rejection, no mapping

unknown status
    -> explicit unsupported-state result

missing identity or time
    -> shape rejection

valid remote result for mismatched local amount
    -> business reconciliation failure

The retained tests establish signature extraction and selected payment behavior. They do not prove that the current driver has eliminated provider terminology, because it has not.

An adapter lets the application talk to a remote system. A mapper decides what that conversation means. The boundary is complete only when business code receives a trusted result in units, identities, states, and time semantics the application owns.