1812 words, 10 min read

When you integrate an external service — a payment provider, a shipping API, a CRM — you face a quiet but persistent risk: the external system's model starts leaking into your own. Status strings, provider-specific IDs, and SDK structs end up scattered across your business logic. Changing providers later means touching code you should never have had to change.

The Anti-Corruption Layer (ACL) is a pattern from Domain-Driven Design that puts an explicit translation boundary between your domain and the external world. Everything provider-specific lives in one place. Your domain never knows it exists.

This post walks through implementing it in Elixir and Phoenix, using a payment integration as the running example.

The Problem

Suppose you're integrating Stripe directly. Without a boundary, it's easy to write something like this:

defmodule MyApp.Orders do
def complete_order(order) do
case Req.post("https://api.stripe.com/v1/payment_intents",
auth: {:basic, stripe_key()},
form: [amount: order.total_cents, currency: "eur", confirm: true]
) do
{:ok, %{status: 200, body: %{"status" => "succeeded"} = intent}} ->
Orders.mark_paid(order, intent["id"])
{:ok, %{body: %{"error" => %{"message" => msg}}}} ->
{:error, msg}
end
end
end

This works on day one. But now your Orders context knows that payments have a "succeeded" status string, that IDs come from intent["id"], and that Stripe's API lives at that specific URL. If you ever swap providers — or just want to test without hitting Stripe — you have to unpick all of this from business logic.

Core Concepts in Elixir

The ACL pattern maps cleanly to Elixir idioms:

DDD concept Elixir equivalent
Domain model Plain defstruct (not an Ecto schema)
Port / interface A @behaviour module
ACL implementation A module implementing the behaviour
Domain service A context module that only calls the behaviour
Wiring Application.get_env/2 and config files

Step 1: Define Your Domain Structs

These live in your domain and use your language — not Stripe's.

defmodule Billing.PaymentResult do
@type status :: :pending | :captured | :declined | :refunded
@enforce_keys [:id, :status, :amount_cents, :currency]
defstruct [:id, :status, :amount_cents, :currency]
end
defmodule Billing.PaymentEvent do
@type type :: :payment_captured | :payment_declined | :payment_refunded
@enforce_keys [:type, :payment_id, :amount_cents]
defstruct [:type, :payment_id, :amount_cents]
end

No Stripe types, no raw maps, no magic strings. If you switch providers, these structs stay exactly as they are.

Step 2: Define the Port Behaviour

The behaviour declares what the domain needs — a contract, not an implementation.

defmodule Billing.PaymentGateway do
@type charge_result :: {:ok, Billing.PaymentResult.t()} | {:error, String.t()}
@type refund_result :: {:ok, Billing.PaymentResult.t()} | {:error, String.t()}
@callback charge(amount_cents :: integer(), currency :: String.t(), source :: String.t()) ::
charge_result()
@callback refund(payment_id :: String.t()) :: refund_result()
end

The domain context will call through this behaviour. It never imports Stripe modules or touches raw HTTP responses.

Step 3: Implement the ACL

This is the only module allowed to know anything about Stripe. All HTTP calls and all translation logic live here.

defmodule Billing.Gateways.StripeGateway do
@behaviour Billing.PaymentGateway
@base_url "https://api.stripe.com/v1"
@impl true
def charge(amount_cents, currency, source) do
response =
Req.post!("#{@base_url}/payment_intents",
auth: {:basic, secret_key()},
form: [
amount: amount_cents,
currency: currency,
payment_method: source,
confirm: true,
"automatic_payment_methods[enabled]": true,
"automatic_payment_methods[allow_redirects]": "never"
]
)
case response.status do
200 -> {:ok, translate_intent(response.body)}
_ -> {:error, response.body["error"]["message"]}
end
end
@impl true
def refund(payment_id) do
response =
Req.post!("#{@base_url}/refunds",
auth: {:basic, secret_key()},
form: [payment_intent: payment_id]
)
case response.status do
200 -> {:ok, translate_refund(response.body)}
_ -> {:error, response.body["error"]["message"]}
end
end
# --- Translation layer ---
defp translate_intent(intent) do
%Billing.PaymentResult{
id: intent["id"],
status: translate_status(intent["status"]),
amount_cents: intent["amount"],
currency: intent["currency"]
}
end
defp translate_refund(refund) do
%Billing.PaymentResult{
id: refund["payment_intent"],
status: :refunded,
amount_cents: refund["amount"],
currency: refund["currency"]
}
end
defp translate_status("succeeded"), do: :captured
defp translate_status("processing"), do: :pending
defp translate_status("requires_payment_method"), do: :declined
defp translate_status("canceled"), do: :declined
defp translate_status(_), do: :pending
defp secret_key, do: Application.fetch_env!(:my_app, :stripe_secret_key)
end

Pattern matching on translate_status/1 makes the translation explicit and exhaustive. Adding a new Stripe status is a one-line change in exactly one place.

Step 4: Write a Clean Domain Context

The Billing context only speaks the behaviour. It has no idea which gateway is wired up.

defmodule Billing do
def process_payment(amount_cents, currency, source) do
gateway().charge(amount_cents, currency, source)
end
def issue_refund(payment_id) do
gateway().refund(payment_id)
end
defp gateway do
Application.get_env(:my_app, :payment_gateway, Billing.Gateways.StripeGateway)
end
end

Nothing in here ties you to Stripe. The context is pure business logic.

Step 5: Wire It Up in Config

# config/config.exs
config :my_app, :payment_gateway, Billing.Gateways.StripeGateway
config :my_app, :stripe_secret_key, System.get_env("STRIPE_SECRET_KEY")
# config/test.exs
config :my_app, :payment_gateway, Billing.Gateways.FakeGateway

Switching providers in production means changing one config line and shipping a new gateway module. Nothing else changes.

Handling Webhooks

Webhooks are where external models are most tempting to let through. A Phoenix controller should translate immediately and dispatch a domain event — it should not pass raw Stripe payloads any further.

defmodule MyAppWeb.StripeWebhookController do
use MyAppWeb, :controller
def handle(conn, params) do
case translate_event(params) do
{:ok, event} ->
Billing.handle_payment_event(event)
send_resp(conn, 200, "")
{:error, :unhandled} ->
send_resp(conn, 200, "")
{:error, reason} ->
send_resp(conn, 400, reason)
end
end
defp translate_event(%{
"type" => "payment_intent.succeeded",
"data" => %{"object" => %{"id" => id, "amount" => amount}}
}) do
{:ok, %Billing.PaymentEvent{type: :payment_captured, payment_id: id, amount_cents: amount}}
end
defp translate_event(%{
"type" => "payment_intent.payment_failed",
"data" => %{"object" => %{"id" => id, "amount" => amount}}
}) do
{:ok, %Billing.PaymentEvent{type: :payment_declined, payment_id: id, amount_cents: amount}}
end
defp translate_event(_), do: {:error, :unhandled}
end

The controller is the ACL for inbound Stripe events. Once translated, Billing.handle_payment_event/1 receives only %Billing.PaymentEvent{} structs — never raw Stripe data.

Testing Without a Real Gateway

Because the gateway is a behaviour, you can swap in a fake implementation for tests without mocks or HTTP stubs.

defmodule Billing.Gateways.FakeGateway do
@behaviour Billing.PaymentGateway
@impl true
def charge(_amount_cents, _currency, "fail_card") do
{:error, "Card declined"}
end
def charge(amount_cents, currency, _source) do
{:ok, %Billing.PaymentResult{
id: "fake_#{System.unique_integer([:positive])}",
status: :captured,
amount_cents: amount_cents,
currency: currency
}}
end
@impl true
def refund(payment_id) do
{:ok, %Billing.PaymentResult{
id: payment_id,
status: :refunded,
amount_cents: 0,
currency: "eur"
}}
end
end

Your tests call Billing.process_payment/3 exactly as production code does — there is no test-only branch in the domain logic, and no HTTP traffic.

test "successful payment returns a captured result" do
assert {:ok, %Billing.PaymentResult{status: :captured}} =
Billing.process_payment(1999, "eur", "tok_visa")
end
test "declined card returns an error" do
assert {:error, "Card declined"} =
Billing.process_payment(1999, "eur", "fail_card")
end

Elixir-Specific Considerations

Behaviours vs. protocols. Use a @behaviour when you have multiple implementations of the same concept (payment gateways, SMS providers). Use a protocol when you want polymorphism across data types you don't control. For the ACL pattern, behaviours are the right tool.

Pattern matching is your translation engine. The translate_status/1 clauses above are cleaner than a case block or a map lookup. If Stripe adds a new status you haven't handled, the _ clause captures it safely, and you can add a specific clause when you care about it.

Keep Ecto schemas out of the ACL. It's tempting to map directly from a Stripe response onto an Ecto schema. Resist this. Plain structs are easier to reason about, test, and evolve independently from your database shape. Persist only after your domain logic has run.

Stateful gateways. If your gateway implementation needs its own process — say, to maintain an authenticated session or a connection pool — start it in your application's supervision tree. The behaviour contract stays the same; the implementation just has a start_link/1 that gets wired into the supervisor.

Common Pitfalls

Returning raw maps from the gateway. If charge/3 returns %{"id" => ..., "status" => "succeeded"}, you've moved the contamination rather than removed it. Always return your own structs.

Putting translation in the Phoenix controller for everything. The controller is the right place for webhook translation (it's the entry point). But if you're doing translation inside action handlers for outgoing calls, extract it into the gateway module where it belongs.

One ACL module for all external systems. If you add a shipping provider, create Shipping.Gateways.DHLGateway with its own behaviour. Don't grow StripeGateway into a general-purpose external-systems module.

Letting provider IDs become first-class domain concepts. Storing a Stripe payment intent ID as billing_stripe_id in your domain is a smell — the domain now knows about Stripe. Store it as external_payment_id or in a separate gateway-specific record that your ACL manages.

Conclusion

Elixir's behaviours give you a clean, compiler-checked way to define the boundary between your domain and the outside world. Pattern matching makes the translation logic readable and exhaustive. Swapping configs gives you test isolation without mocks.

The ACL pattern keeps your core business logic stable regardless of what external APIs do. When Stripe changes a status code or you decide to try a different provider, you update one module. Your domain, your tests, and everything that depends on them stays untouched.