- Published on
Provider Adapters Should Translate Behaviour, Not Only JSON
- Authors

- Name
- Mehdi Akiki
Article · Derived state
The first version of a provider adapter often looks like a JSON mapper:
provider customer → product customer
first_name → firstName
updated_at → updatedAt
This is necessary, but it is not the hard part. Two APIs can expose identical JSON shapes and still behave differently under pagination, deletion, retries, null values, rate limits, or concurrent updates.
When I build integrations, I make the adapter translate behaviour into an explicit internal contract. I do not let provider semantics leak as assumptions spread across jobs and controllers.
Shape translation is only one layer
Suppose two providers return:
{
"id": "42",
"email": null,
"updated_at": "2026-09-21T10:00:00Z"
}
For provider A, email: null means the user explicitly cleared the address. For provider B, it means the caller lacks permission to read it. Replacing the local value with null is correct for A and data loss for B.
The adapter must know the contract behind the field:
Present(value)
ExplicitlyCleared
Unavailable(permission)
NotReturned(partial_response)
Converting all four states to string | null removes information the product needs.
The same issue appears with timestamps. One provider's updated_at changes for every relevant mutation. Another updates it only for user-facing fields. The strings have the same ISO format; their synchronization meaning is different.
I define the internal port first
The core application should ask for capabilities it understands:
interface CustomerSource {
capabilities(): SourceCapabilities
listSnapshot(request: SnapshotRequest): AsyncIterable<SourcePage>
listChanges(request: ChangeRequest): AsyncIterable<ChangePage>
getCurrent(id: ExternalId): Promise<SourceObservation | NotFound>
apply(command: SourceCommand): Promise<CommandResult>
}
The types returned by these methods describe guarantees, not raw HTTP responses.
For example, a page can include:
type SourcePage = {
observations: SourceObservation[]
next: Checkpoint | null
consistency: "snapshot" | "moving-view"
completeScope: boolean
sourceHighWater?: string
}
Now the caller does not assume that every cursor represents a snapshot. The provider adapter states whether absence can eventually prove deletion and whether a high-water mark exists.
Microsoft describes this general boundary as an anti-corruption layer: an adapter translates between subsystems that do not share the same semantics so the external model does not shape the internal application. See the Anti-Corruption Layer pattern.
Translate identity explicitly
An external ID belongs to a namespace. The adapter returns identity containing:
provider
provider account
environment
object type
external ID
generation, when IDs can be reused
I never let one provider's bare id become the product's primary identity. The identity mapping table owns the relationship to the internal entity.
The adapter also documents whether IDs are stable after merge, export/import, account reconnection, or deletion. If the provider can reuse an ID, the internal namespace needs another generation signal.
Pagination is behaviour
The adapter owns details such as:
- offset, keyset, opaque cursor, or sync token;
- filters bound to the cursor;
- cursor expiration;
- stable or moving page membership;
- tie-breaking order;
- maximum and default page size;
- empty final pages;
- rate-limit cost per page;
- restart behaviour after invalidation.
The core sync engine receives a durable checkpoint and a consistency description. It does not know that provider X calls it after, provider Y returns a full next_url, and provider Z requires the original request body unchanged.
If a cursor expires, the adapter returns a typed result:
CheckpointExpired { full_snapshot_required: true }
It does not throw a generic BadRequest that the retry loop repeats forever.
Error translation drives recovery
Raw status codes do not have consistent meaning across providers. I translate responses into a small internal taxonomy:
Throttle { retry_after, scope }
TransientUnavailable
AuthenticationExpired
AuthorizationDenied
CheckpointExpired
Conflict { current_version }
ValidationRejected { fields }
NotFound { permanence }
ProviderContractViolation
OutcomeUnknown
This taxonomy controls retry, credential refresh, reconciliation, and operator action.
I preserve the provider request ID and a safe diagnostic code for investigation, but I do not expose raw secret-bearing response bodies everywhere.
The translation is conservative. An unknown error does not become retryable because retrying feels helpful. It becomes a visible unknown until the contract is understood.
Retry hints belong to the adapter
Some providers use Retry-After; others return reset epochs or quota headers. Some scopes apply per account, endpoint, resource, or credential.
The adapter parses the provider format and returns:
earliest retry time
throttle scope key
whether the operation outcome is known
whether the same idempotency key must be reused
The shared retry policy then applies deadlines and budgets. This keeps provider parsing local while keeping reliability policy consistent. Retries That Respect Retry-After, Deadlines, and a Retry Budget covers the common control loop.
Writes need a capability contract
I do not give every adapter the same optimistic updateCustomer method when providers offer different guarantees.
Capabilities can state:
supports idempotency keys
supports conditional version writes
returns committed version
accepts partial patch or complete replacement
distinguishes null from omission
supports batch atomicity
provides read-after-write consistency
supports reversible delete
The application can then select a safe workflow. If a provider has no idempotency key and a write times out, the adapter returns OutcomeUnknown; it does not quietly retry a possibly committed effect.
Capabilities are versioned and tested. They are not a marketing feature table.
Avoid the lowest-common-denominator trap
One generic interface can become so small that it throws away valuable provider guarantees. Another can become a large union where every caller switches on provider name.
I use a stable common core plus explicit optional capabilities:
common: identity, observations, typed errors, checkpoints
optional: ordered change feed, conditional writes, bulk export, tombstones
Core application code asks for a capability rather than a provider:
if source supports ordered changes:
use durable incremental path
else:
use overlapping poll plus reconciliation
This keeps behaviour visible without letting provider-specific names spread through the domain.
Sometimes a provider genuinely requires a unique workflow. I represent it as an explicit extension instead of pretending the common abstraction fits.
Field ownership stays outside mapping code
The adapter reports what the provider observed and what the provider can write. The domain decides which source is authoritative for a canonical field.
For example, the adapter may translate provider status paying to a canonical subscription observation. The field-ownership matrix decides whether that observation may replace current product state.
This separation prevents adapters from silently embedding business policy that changes when a second provider is added.
I keep transformations with real information loss explicit. If three provider states collapse into one canonical state, I record the original state or a lossiness reason when future round-trip behaviour may need it.
The adapter contract tests I run
Every adapter implements the same behavioural suite using recorded or synthetic provider fixtures.
Identity namespace
Return the same external ID from two connected accounts. Assert the canonical identities remain distinct.
Null versus omission
Send a partial response without a field, an explicit null, and a permission-redacted value. Assert three different observations when the provider contract distinguishes them.
Page restart
Crash after applying a page but before checkpoint commit. Assert replay gives idempotent observations and does not skip the page.
Mutation during pagination
Insert and delete records between page requests. Assert the adapter reports its moving-view or snapshot guarantee accurately; it must not claim completeness it cannot prove.
Checkpoint expiration
Return the provider's invalid-token response. Assert typed CheckpointExpired, not generic retry.
Rate limit
Return every documented throttle form. Assert scope and earliest retry time are normalized correctly.
Timeout after write
Simulate a committed write with a lost response. Assert the result preserves uncertainty and the next step follows provider idempotency or read-back support.
Unknown enum value
Add a provider status the code has never seen. Assert it becomes an explicit unknown with raw provenance, not a default canonical state.
Delete and restore
Exercise event tombstones, absence from a complete scan, soft delete, hard delete, and restored IDs according to the provider's documented lifecycle.
Contract fixture drift
Replay sanitized real response shapes from supported API versions. Assert unknown fields are tolerated where appropriate and removed/changed required fields fail visibly.
These tests are reusable because they express internal promises. Only the provider fixtures and expected capability set change.
Test against a provider sandbox, but do not depend only on it
Recorded fixtures are fast and deterministic. They can become stale. Sandbox tests prove authentication, serialization, headers, and basic live behaviour, but sandboxes often have different rate limits or incomplete event support.
I combine:
- unit tests for pure transformations;
- contract tests against fixtures and a fake provider;
- scheduled sandbox smoke tests;
- canary production checks with safe read-only operations;
- monitoring for previously unseen response variants.
When provider documentation changes, I update the declared capability and add a fixture before changing domain behaviour.
Observe semantics, not only request success
An adapter dashboard should show:
- observations by result category;
- unknown enum and field states;
- cursor expiration and full-sync recovery;
- throttle scope and retry delay;
- stale source versions ignored;
- uncertain write outcomes;
- null/omission translation counts;
- provider contract violations;
- reconciliation differences after apply.
A 99.9% HTTP success rate can still hide wrong null semantics or silent missing deletes.
My practical adapter rule
I ask one question for every external behaviour:
What must the rest of the application know to handle this safely without knowing the provider's name?
The answer becomes a typed observation, capability, checkpoint, or error. Field renaming remains inside the adapter, but so do cursor expiration, error meaning, retry hints, identity namespace, deletion evidence, and write guarantees.
This produces more code than a JSON mapper. It also gives the product one place where an external contract can be read, tested, and changed.
The next design risk is information that no canonical type can preserve. That deserves its own lossiness ledger rather than another nullable field.