# Anti-corruption layer no Laravel: como integrar vários gateways de pagamento sem acoplar o domínio

> Status, valores, erros e webhooks de Pagar.me, Stripe e Asaas traduzidos para a língua do seu negócio: interface, adapters, enum próprio e testes de contrato no Laravel.

Por Lucas Roberto · Arquitetura · publicado em 2026-10-31 · 9 min de leitura
Versão web: https://lucasroberto.com/blog/anti-corruption-layer-no-laravel-como-integrar-varios-gateways-de-pagamento-sem-acoplar-o-dominio

Para integrar vários gateways de pagamento no Laravel sem espalhar o formato de cada provedor pelo código, coloque uma anti-corruption layer entre o domínio e as APIs. Ela é composta por uma interface na língua do seu negócio, um adapter por gateway que traduz status, valores e erros, e um enum de status próprio. O domínio nunca vê `paid`, `RECEIVED` ou `succeeded`, só `PaymentStatus::Paid`. O custo é manter os mapeamentos e aceitar que nem todo recurso de cada gateway cabe na interface comum.

Escrevo isso depois de ver o mesmo padrão se repetir em mais de um projeto de pagamento. A primeira integração entra rápido, direto no controller ou num service com o nome do gateway. Meses depois, aparece um segundo provedor, por taxa, por um método que o primeiro não tem ou por redundância, e o código do primeiro já está em todo lugar.

## Por que o código acoplado ao gateway vira problema?

O sintoma clássico é encontrar `if ($charge->status === 'paid')` em listeners, relatórios, jobs de conciliação e telas do painel. Esse `'paid'` é o status do Pagar.me. Quando entra o Asaas, o equivalente é `RECEIVED` ou `CONFIRMED`. No Stripe, um PaymentIntent pago fica em `succeeded`. Cada `if` espalhado vira um ponto onde o segundo gateway pode quebrar sem ninguém perceber.

E não é só status:

1. **Valores**: Pagar.me e Stripe trabalham em centavos inteiros, o Asaas recebe e devolve reais com casas decimais.
2. **Identificadores**: cada um tem seu formato de ID de cobrança, de cliente e de evento.
3. **Erros**: cartão recusado, requisição inválida e instabilidade chegam com códigos HTTP e corpos completamente diferentes.
4. **Webhooks**: payload, nomes de evento e forma de autenticação mudam de um provedor para outro.

Se o domínio conhece esses detalhes, trocar ou adicionar um gateway exige mexer no domínio. É exatamente isso que a camada evita.

## O que é uma anti-corruption layer em integração de pagamento?

Anti-corruption layer é um termo do Domain-Driven Design: uma camada de tradução que impede que o modelo de um sistema externo "contamine" o modelo do seu sistema. Na prática, em pagamentos, ela tem quatro peças:

| Peça | Responsabilidade |
|---|---|
| Interface `PaymentGateway` | Define as operações que o negócio precisa, com tipos do domínio |
| DTOs de entrada e saída | Carregam dados já normalizados (centavos, enums, IDs) |
| Adapters por provedor | Traduzem a chamada para a API do gateway e a resposta de volta |
| Exceções do domínio | Classificam falhas em categorias que o negócio sabe tratar |

A diferença para um simples "wrapper do SDK" é a direção: a interface é desenhada a partir do que o seu produto precisa, não a partir do que o gateway oferece.

## Como desenhar a interface do gateway no Laravel?

Comece pelas operações que o produto realmente usa. Para uma maquininha com link de pagamento e Pix, normalmente são criar cobrança, consultar, estornar e interpretar webhook.

```php
interface PaymentGateway
{
    public function name(): string;

    public function createCharge(ChargeRequest $request): ChargeResult;

    public function fetchCharge(string $providerChargeId): ChargeResult;

    public function refund(string $providerChargeId, int $amountCents, string $idempotencyKey): RefundResult;

    public function parseWebhook(Request $request): GatewayEvent;
}
```

Os DTOs são `readonly` e só aceitam tipos do domínio:

```php
final readonly class ChargeRequest
{
    public function __construct(
        public string $idempotencyKey,
        public int $amountCents,
        public PaymentMethod $method,
        public Customer $customer,
        public ?int $installments = null,
        public array $metadata = [],
    ) {}
}

final readonly class ChargeResult
{
    public function __construct(
        public string $provider,
        public string $providerChargeId,
        public PaymentStatus $status,
        public int $amountCents,
        public ?string $pixCopyPaste = null,
        public ?string $failureReason = null,
        public array $raw = [],
    ) {}
}
```

Duas decisões aqui merecem comentário. A `idempotencyKey` é obrigatória no request porque timeout ao criar cobrança é o cenário em que mais se duplica dinheiro; detalhei isso em [como evitar cobrança duplicada com Idempotency-Key](/blog/timeout-ao-chamar-o-gateway-de-pagamento-como-evitar-cobranca-duplicada-com-idempotency-key-no-laravel). E o campo `raw` guarda a resposta original: o domínio não lê, mas suporte e conciliação vão agradecer quando precisarem investigar um caso estranho.

## Como mapear os status de cada gateway para um enum próprio?

O enum do domínio é o coração da camada. Ele precisa ter os estados que o seu negócio distingue, não a união de todos os estados de todos os gateways.

```php
enum PaymentStatus: string
{
    case Pending = 'pending';
    case Processing = 'processing';
    case RequiresAction = 'requires_action';
    case Paid = 'paid';
    case Failed = 'failed';
    case Canceled = 'canceled';
    case Refunded = 'refunded';
    case Disputed = 'disputed';

    public function isFinal(): bool
    {
        return in_array($this, [self::Failed, self::Canceled, self::Refunded], true);
    }
}
```

Uma tabela de referência ajuda o time a revisar o mapeamento (simplificada, confira a documentação atual de cada provedor):

| Domínio | Pagar.me (charge) | Asaas (payment) | Stripe (PaymentIntent) |
|---|---|---|---|
| Pending | `pending` | `PENDING`, `OVERDUE` | `requires_payment_method` |
| Processing | `processing` | — | `processing` |
| RequiresAction | — | — | `requires_action` |
| Paid | `paid` | `RECEIVED`, `CONFIRMED` | `succeeded` |
| Failed | `failed` | — | — |
| Canceled | `canceled` | — | `canceled` |
| Refunded | — (via evento) | `REFUNDED` | — (via Charge/Refund) |
| Disputed | `chargedback` | `CHARGEBACK_REQUESTED` | — (via Dispute) |

No adapter, o mapeamento vira um `match`. O detalhe mais importante é o que acontece com status desconhecido:

```php
private function mapStatus(string $status): PaymentStatus
{
    return match ($status) {
        'PENDING', 'OVERDUE' => PaymentStatus::Pending,
        'RECEIVED', 'CONFIRMED', 'RECEIVED_IN_CASH' => PaymentStatus::Paid,
        'REFUNDED' => PaymentStatus::Refunded,
        'CHARGEBACK_REQUESTED', 'CHARGEBACK_DISPUTE' => PaymentStatus::Disputed,
        default => throw new UnknownProviderStatus('asaas', $status),
    };
}
```

Nunca coloque um `default => PaymentStatus::Pending`. Status novo do gateway que cai silenciosamente em "pendente" é cobrança paga que o lojista não vê. Falhar alto, com alerta, é muito melhor: o webhook volta para retry e alguém decide o mapeamento correto.

Repare também que `CONFIRMED` no Asaas, para cartão, significa pagamento confirmado com saldo ainda não disponível. Mapear para `Paid` é uma decisão de negócio, não técnica. Se o seu produto precisa diferenciar "pago" de "liquidado", isso pertence ao domínio (geralmente à agenda de recebíveis), e não deveria ser resolvido criando um status por gateway.

## Como normalizar valores, IDs e erros?

**Valores.** Dentro do domínio, tudo é centavo em inteiro. O adapter do Asaas converte na entrada e na saída:

```php
'value' => $request->amountCents / 100,
// ...
amountCents: (int) round($data['value'] * 100),
```

O `round` antes do cast não é enfeite: `19.99 * 100` em ponto flutuante dá `1998.9999...`, e o cast direto truncaria um centavo.

**IDs.** A tabela de cobranças guarda `provider` e `provider_charge_id` com índice único composto. O ID do gateway nunca vira chave primária nem aparece em URL do seu produto. Isso permite que a mesma cobrança lógica tenha uma nova tentativa em outro provedor sem colisão.

**Erros.** Aqui está o ganho que mais aparece no dia a dia. Cada gateway falha de um jeito, mas o negócio só precisa saber três coisas: foi recusado (não adianta tentar de novo), a requisição está errada (bug nosso) ou o gateway está instável (retry depois).

```php
abstract class GatewayException extends RuntimeException
{
    public function __construct(
        public readonly string $provider,
        string $message = '',
        ?\Throwable $previous = null,
    ) {
        parent::__construct($message, 0, $previous);
    }
}

final class PaymentDeclined extends GatewayException {}
final class GatewayRejectedRequest extends GatewayException {}
final class GatewayUnavailable extends GatewayException {}
```

No adapter:

```php
try {
    $response = $this->http
        ->withHeaders(['Idempotency-Key' => $request->idempotencyKey])
        ->post('/payments', $payload)
        ->throw();
} catch (ConnectionException $e) {
    throw new GatewayUnavailable('asaas', 'timeout', $e);
} catch (RequestException $e) {
    if ($e->response->serverError() || $e->response->status() === 429) {
        throw new GatewayUnavailable('asaas', 'server error', $e);
    }

    throw new GatewayRejectedRequest(
        'asaas',
        (string) $e->response->json('errors.0.description'),
        $e,
    );
}
```

Com essa classificação, o job que cria a cobrança sabe que `GatewayUnavailable` merece `release()` com backoff, e o [circuit breaker com Redis](/blog/circuit-breaker-no-laravel-com-redis-como-isolar-um-gateway-de-pagamento-fora-do-ar) conta só essas falhas, sem abrir o circuito por causa de cartão recusado. Recusa de cartão, aliás, muitas vezes vem com HTTP 200 e status de falha no corpo; o adapter precisa olhar o corpo e lançar `PaymentDeclined` (ou devolver `Failed` com `failureReason`, escolha uma convenção e mantenha em todos os adapters).

## Os webhooks passam pela mesma camada?

Passam, e é onde a camada mais se paga. Cada adapter implementa `parseWebhook`, que faz duas coisas: autentica a requisição do jeito daquele provedor e devolve um `GatewayEvent` normalizado.

```php
final readonly class GatewayEvent
{
    public function __construct(
        public string $provider,
        public string $eventId,
        public string $providerChargeId,
        public PaymentStatus $status,
        public CarbonImmutable $occurredAt,
        public array $raw,
    ) {}
}
```

A autenticação é genuinamente diferente entre eles: o Stripe assina o corpo com HMAC no header `Stripe-Signature`, o Asaas envia um token que você configura no painel no header `asaas-access-token`. Se estiver depurando uma assinatura HMAC que não bate, o [testador de webhook](/ferramentas/testador-webhook) mostra no navegador se o segredo e o payload geram a assinatura recebida.

Depois do `parseWebhook`, o fluxo é único para todos os gateways: gravar o evento, deduplicar por `(provider, eventId)`, processar em fila. O controller fica com uma rota por provedor, mas uma única implementação, e todo o cuidado com perda e duplicidade, que descrevi em [como receber webhooks de gateway sem perder nem duplicar eventos](/blog/como-receber-webhooks-de-gateway-de-pagamento-no-laravel-sem-perder-nem-duplicar-eventos), é escrito uma vez só.

## Como escolher o gateway por cliente ou por método de pagamento?

Com a interface no lugar, o roteamento vira configuração. Um resolver decide qual adapter usar para criar uma cobrança nova:

```php
final class GatewayResolver
{
    public function __construct(private Container $container) {}

    public function forNewCharge(Merchant $merchant, PaymentMethod $method): PaymentGateway
    {
        $provider = $merchant->gatewayFor($method)
            ?? config("payments.default.{$method->value}");

        return $this->byName($provider);
    }

    public function byName(string $provider): PaymentGateway
    {
        $class = config("payments.providers.{$provider}")
            ?? throw new InvalidArgumentException("Gateway desconhecido: {$provider}");

        return $this->container->make($class);
    }
}
```

A regra que evita o bug mais sutil: **roteamento vale só para cobrança nova**. Consulta, estorno e webhook sempre usam `byName($charge->provider)`. Se o lojista migrou do Pagar.me para o Asaas ontem, o estorno de uma venda da semana passada precisa ir para o Pagar.me. Parece óbvio escrito assim, mas é fácil cair nisso quando o código de estorno chama `forNewCharge` por preguiça.

## Como lidar com recursos que só um gateway tem?

Esse é o principal trade-off da abordagem. Uma interface única tende ao mínimo denominador comum: se você só expõe o que todos têm, perde split, pré-autorização ou parcelamento específico de quem oferece.

A saída que funciona melhor para mim são interfaces de capacidade separadas:

```php
interface SupportsSplit
{
    /** @param list<SplitRule> $rules */
    public function createChargeWithSplit(ChargeRequest $request, array $rules): ChargeResult;
}

interface SupportsPreAuthorization
{
    public function authorize(ChargeRequest $request): ChargeResult;

    public function capture(string $providerChargeId, int $amountCents): ChargeResult;
}
```

O adapter implementa só o que o provedor suporta, e o código de negócio pergunta antes:

```php
if (! $gateway instanceof SupportsSplit) {
    throw new CapabilityNotSupported($gateway->name(), 'split');
}
```

Isso deixa explícito, no tipo, que o split depende do gateway, em vez de um método que existe em todos e lança exceção em metade deles. E o roteamento pode levar a capacidade em conta: lojista com split configurado só pode ser roteado para gateway que implementa `SupportsSplit`.

O que não faço é tentar emular no adapter um recurso que o provedor não tem (por exemplo, simular split fazendo transferências depois). Isso é regra de negócio com risco financeiro, e pertence ao domínio, com saga e compensação próprias, não escondida dentro de um adapter.

## Como testar que todos os adapters cumprem o contrato?

Com vários adapters, o risco é cada um interpretar a interface de um jeito. Um teste de contrato abstrato, herdado por um teste de cada provedor, resolve:

```php
abstract class PaymentGatewayContractTest extends TestCase
{
    abstract protected function gateway(): PaymentGateway;

    abstract protected function fakeApprovedPix(int $amountCents): void;

    abstract protected function fakeServerError(): void;

    public function test_cobranca_pix_volta_em_centavos_e_status_do_dominio(): void
    {
        $this->fakeApprovedPix(1999);

        $result = $this->gateway()->createCharge(ChargeRequestFactory::pix(1999));

        $this->assertSame(1999, $result->amountCents);
        $this->assertSame($this->gateway()->name(), $result->provider);
        $this->assertNotEmpty($result->providerChargeId);
        $this->assertNotNull($result->pixCopyPaste);
    }

    public function test_erro_5xx_vira_gateway_unavailable(): void
    {
        $this->fakeServerError();

        $this->expectException(GatewayUnavailable::class);

        $this->gateway()->createCharge(ChargeRequestFactory::pix(1000));
    }
}
```

Cada `AsaasGatewayTest` ou `PagarmeGatewayTest` só implementa os fakes com `Http::fake` e payloads reais gravados da sandbox. Além disso, mantenho um teste por adapter que percorre a lista de status documentada do provedor e garante que nenhum cai no `UnknownProviderStatus` sem querer. Quando o gateway anuncia um status novo, o primeiro passo é adicioná-lo a essa lista e ver o teste falhar.

## Quando essa camada não vale a pena?

Se o produto tem um gateway só e nenhuma perspectiva real de um segundo, a interface completa com capacidades e resolver é custo antecipado. Mesmo nesse caso, eu faria duas coisas desde o início, porque são baratas e evitam a maior parte da dor depois:

1. Enum de status próprio, com o mapeamento num único lugar.
2. Valores em centavos e colunas `provider` e `provider_charge_id` na tabela de cobranças.

Com isso, extrair a interface quando o segundo gateway chegar é refatoração localizada, não caça a `'paid'` no código inteiro.

## Conclusão prática

Se você está para adicionar o segundo gateway, a ordem que eu seguiria é: criar o enum `PaymentStatus` e migrar todos os `if` de status para ele; extrair a interface a partir do que o primeiro adapter já faz; classificar erros em recusado, requisição inválida e indisponível; mover a autenticação e o parse de webhook para dentro de cada adapter; e só então escrever o adapter novo, guiado pelo teste de contrato. Duas regras não são negociáveis: status desconhecido falha alto, e qualquer operação sobre uma cobrança existente vai para o gateway que a criou. O resto, incluindo quanto de capacidade específica expor, você ajusta conforme o produto pede.

---

Lucas Roberto é desenvolvedor back-end e fundador da Granpag. Mais textos: https://lucasroberto.com/blog
