skip to content

You're designing the internal structure of an anti-corruption layer that sits between a 'Billing' microservice and a third-party payment provider's SDK. Concretely, what components would you put inside it, and how do you decide where the boundary between 'translated domain model' and 'raw provider model' sits?

level: middleimportance: must knowfreq 55%

answer

  1. adapter=protocol, translator=semantics
  2. port defined in own vocabulary
  3. grep test for leaked provider names
  4. asymmetric translation failure
  5. one seam to swap providers

basics

~20 s

Split it into an adapter (talks the provider's protocol/SDK) and a translator (converts its data into your own domain types). The boundary sits exactly where the provider's names, codes, or shapes would otherwise leak into your business logic.

solid answer

~50 s

Concretely I'd split the ACL into two roles, even if they live in one module: an adapter that handles protocol concerns (SDK calls, retries, auth, pagination) and a translator/mapper that converts the provider's DTOs into the Billing service's own domain types - e.g. mapping the provider's PaymentIntent with its own status strings into Billing's Payment aggregate with a PaymentStatus enum it defines. I'd also define the interface the rest of Billing depends on (a port, in hexagonal-architecture terms) purely in Billing's vocabulary, so the adapter/translator is the only implementation of that port and is swappable. The boundary is wherever a provider-specific name, code, or object would otherwise show up in Billing's domain or application layer - if a class name, field, or exception type traces back to the provider's SDK, it hasn't crossed the ACL yet.

go deeper

for a junior

Should be able to describe that there's a translation step and roughly why, without necessarily naming 'adapter vs translator' as distinct pieces.

for a middle

Should design the adapter/translator split concretely, place the boundary correctly (no provider names outside the seam), and reason about a simple worked example like a payment provider.

for a senior

Should discuss the trade-off of splitting vs merging adapter and translator for a given integration's size, and know common failure modes like asymmetric translation or information loss during mapping.

for a principal

Should connect the port design to long-term provider-swap or multi-provider strategy, and make a cost-justified call on how much structure a given integration actually warrants.

## The two responsibilities inside it Concretely, building an ACL means separating two responsibilities that are easy to tangle together: talking to the outside system, and translating what it says. | Piece | What it owns | |---|---| | **The adapter** (sometimes called a gateway or client wrapper) | owns the protocol-level concerns — HTTP calls or SDK invocations, authentication, retries, timeouts, pagination, and error codes specific to the provider | | **The translator** (or mapper) | owns the semantic concerns — converting the provider's response objects into the consuming service's own domain types, and converting the consuming service's domain commands into whatever shape the provider expects on the way out | In a hexagonal/ports-and-adapters style codebase this maps directly onto a **port** (an interface defined in the consumer's own domain language, e.g. `PaymentGateway.charge(amount: Money): PaymentResult`) and an adapter that implements that port using the provider's SDK internally. The rest of the Billing service only ever depends on the port; it never imports the provider's SDK types, never catches the provider's specific exception classes, and never branches on the provider's status strings. ## Where the boundary goes The reason to draw the line exactly there is that any provider-specific name, code, or type that crosses into the domain or application layer becomes a hidden dependency the rest of the codebase has to know about, and a thing that breaks or requires a rewrite if the provider is ever swapped. - A useful **litmus test**: if you grep the domain and application layers for the provider's package name, class names, or documented field names and get zero hits outside the adapter/translator module, the boundary is correctly placed. - This is also where **open-host service and published-language thinking** becomes relevant from the consumer's side: a well-designed provider exposes a stable public contract specifically so consumers don't need heavy translation, but even then, an ACL is worth keeping as a seam, because it turns 'the provider changed their API' from an emergency spread across the codebase into a localized, planned update. ## What the split costs Splitting adapter and translator (versus one big blob doing both) costs a bit of extra structure and indirection for a small integration; for a simple, stable, low-traffic third-party call it can feel like over-engineering, and a lean team might reasonably merge the two into one file. But as the integration grows — more endpoints, more edge cases, or the provider's SDK carrying its own retry/backoff behavior that conflicts with the service's own resilience policy — the separation earns its keep: - the adapter's protocol logic can be tested with fakes/contract tests independent of the translator's mapping logic; - either can change without touching the other. The **port** abstraction also has a real cost: it adds one more interface and implementation to maintain, and if the team never actually swaps providers, some engineers will see it as ceremony. The counter-argument is that the port earns value even without a swap, purely from making the domain layer's dependencies explicit and testable via a fake implementation. ## Failure modes 1. **A common production failure mode is asymmetric translation**: the team carefully translates the provider's response shapes coming in, but forgets to shield outbound calls the same way, so the service's domain commands directly reference provider-specific enums when calling out, quietly reintroducing coupling on the write path even though the read path looks clean. 2. **Another is swallowing information the translator didn't know it needed** — e.g. mapping the provider's twelve possible decline reason codes down to a single generic `PaymentFailed`, which works until the business needs to distinguish 'insufficient funds' from 'card expired' for a retry policy, and the ACL has already thrown that information away at the seam. 3. **A third is adapter logic silently reintroducing tight coupling to SDK version quirks** — e.g. relying on a provider SDK's internal retry behavior instead of the service's own, so upgrading the SDK changes latency/error behavior in ways the rest of the team doesn't expect. ## A worked instance A concrete, common instance is exactly a payments integration: a payment provider's SDK returns intent objects with provider-specific status strings (e.g. 'requires_payment_method', 'requires_confirmation', 'succeeded') and its own error taxonomy. A Billing service's ACL would define its own `Payment` aggregate and `PaymentStatus` enum (e.g. `PENDING`, `CONFIRMED`, `FAILED`) and have the adapter/translator pair be the only code that ever imports the provider's SDK, so if the team later adds a second payment provider or migrates away from the first, only that one module needs to change — the rest of Billing, including its tests, keeps working against the same domain contract.

  • Should the ACL's port/interface be designed around the provider's capabilities or the consumer's needs?
    Around the consumer's needs. The port should read like a natural part of the consuming service's domain - e.g. charge(amount, customer) returning a domain PaymentResult - not a thin pass-through of whatever methods the provider's SDK happens to expose. Designing from the provider outward is exactly how provider vocabulary leaks into the domain.
  • What happens to the ACL if the team later adds a second, different payment provider?
    If the port was designed around the consumer's own domain concepts, adding a second provider just means writing a second adapter/translator implementing the same port - the rest of the codebase doesn't change at all. If the port had accidentally been shaped around the first provider's model, the second integration exposes that leak immediately, because the new provider's concepts won't fit cleanly.
  • How do you keep the translator in sync when the provider changes its API?
    Contract or integration tests against the provider's actual (or recorded/mocked) responses catch shape changes early, and treating provider SDK/API version bumps as a reviewed change to the adapter module specifically - not a silent dependency update - keeps drift from becoming a surprise. Some teams also log/alert on unmapped/unknown values the translator encounters in production so drift is caught before it causes a failure.

Like a diplomatic interpreter plus a protocol officer at a summit: one handles the literal logistics of the meeting (transport, seating, timing), the other converts what's actually said into language the delegation understands - and the delegation never has to learn the other side's dialect.

saying these in an interview costs you the question

  • Puts provider-specific enums or exception types directly in domain/application code
  • Thinks the ACL is only about incoming responses, not outbound calls too
  • Can't distinguish protocol concerns (retries, auth) from semantic mapping concerns
  • Assumes one giant pass-through function counts as an ACL
  • No answer for what happens on an unmapped/unexpected value from the provider

context