Anti-Corruption Layer Implementation

How I actually build Anti-Corruption Layers between bounded contexts, with sync and async translator patterns drawn from real Payment-to-Order work.

We had two weeks to swap payment providers at a live-video creator platform I led engineering at. The old gateway stored amounts in cents as integers. The new one returned decimal strings with the currency code glued onto the same field. Refund states didn’t line up with our Order states either. And the team had shipped the integration as a thin “PaymentService” that called the gateway directly from inside the Order aggregate’s methods. Every Order knew, intimately, what shape the old gateway spoke. You can guess how the swap went.

That’s when I actually internalized why Anti-Corruption Layers exist. Not as a DDD purity argument, but as the thing that saves you when the upstream changes and you don’t want to repaint your domain.

Why ACLs exist at all

Two bounded contexts rarely speak the same language. Schemas drift, statuses drift, the notion of “completed” drifts. An ACL is the translation membrane that keeps your domain in its own language without bleeding the upstream’s choices into your code. People skip ACLs thinking “we own both sides, it’s fine.” I’ve watched that bet lose across an agency portfolio of legacy projects I led, where shared services were called directly everywhere. When those services migrated, every consumer broke.

Open Host, ACL, Customer-Supplier

Quick framing. If YOU’re upstream and want consumers to depend on a stable contract, you publish an Open Host Service. Versioned API, documented events. If you’re downstream and the upstream is messy, external, or older than your codebase, you build an ACL. You translate their language into yours at the boundary. They keep their mess, you keep your clarity. Customer-Supplier sits in between, mostly a team-relationship pattern. In practice you still write an ACL on the downstream side.

Sync ACL: Payment to Order translator

Here’s the shape I keep coming back to. The Payments context is external. The Order aggregate doesn’t know about gateways, doesn’t know about currency strings, doesn’t know about retries. It receives a PaymentRecord in its own language and decides what to do.

// src/order/acl/payment-gateway.translator.ts
import { Injectable } from '@nestjs/common'
import { Money } from '../domain/money.vo'
import { PaymentStatus } from '../domain/payment-status.enum'
import { PaymentRecord } from '../domain/payment-record'
import { GatewayPayment } from '../infra/gateway/gateway-payment.dto'

const STATUS_MAP: Record<string, PaymentStatus> = {
  succeeded: PaymentStatus.Captured,
  requires_action: PaymentStatus.Pending,
  processing: PaymentStatus.Pending,
  canceled: PaymentStatus.Voided,
  refunded: PaymentStatus.Refunded,
  partial_refund: PaymentStatus.PartiallyRefunded,
}

@Injectable()
export class PaymentGatewayTranslator {
  toPaymentRecord(raw: GatewayPayment): PaymentRecord {
    const status = STATUS_MAP[raw.status]
    if (!status) {
      // unknown upstream status, refuse to translate
      throw new UntranslatableGatewayStatusError(raw.id, raw.status)
    }

    return new PaymentRecord({
      externalId: raw.id,
      idempotencyKey: `gw:${raw.id}:${raw.event_id}`,
      amount: Money.fromMinorUnits(
        Math.round(parseFloat(raw.amount_decimal) * 100),
        raw.currency.toUpperCase(),
      ),
      status,
      capturedAt: raw.captured_at ? new Date(raw.captured_at) : null,
      raw,
    })
  }
}

Two things worth flagging. The idempotencyKey is composed inside the ACL, not somewhere upstream and not somewhere in the Order aggregate. It’s part of the ACL’s contract because retries from the gateway are an ACL concern. And an unknown upstream status throws. The ACL refuses to lie to the domain. Better to fail loudly than to silently map a new status to “Captured” because the gateway team added something last week.

The application service stays thin:

@Injectable()
export class CapturePaymentService {
  constructor(
    private readonly gateway: GatewayClient,
    private readonly translator: PaymentGatewayTranslator,
    private readonly orders: OrderRepository,
  ) {}

  async execute(orderId: string, gatewayPaymentId: string): Promise<void> {
    const raw = await this.gateway.fetchPayment(gatewayPaymentId)
    const payment = this.translator.toPaymentRecord(raw)

    const order = await this.orders.findByIdOrFail(orderId)
    order.recordPayment(payment) // domain method, raises invariants
    await this.orders.save(order)
  }
}

The Order aggregate’s recordPayment doesn’t import anything from infra/gateway. It can’t. The ACL is the only seam.

Async ACL via domain events

The sync version is fine when the upstream answers fast. It falls apart when the upstream is human-moderated (App Review, Play Review, billing dispute), rate-limited, or slow. Then you go async. The shape I use is an inbox table plus a Kafka or SNS/SQS consumer that lands the raw event, then translates inside the same transaction. The inbox lets you re-translate without re-reading the upstream, which is huge when the translation logic ships a bug at 2am.

// src/billing/acl/subscription-renewal.consumer.ts
import { Controller } from '@nestjs/common'
import { EventPattern, Payload } from '@nestjs/microservices'
import { DataSource } from 'typeorm'
import { SubscriptionRenewalTranslator } from './subscription-renewal.translator'
import { SubscriptionsService } from '../app/subscriptions.service'

@Controller()
export class SubscriptionRenewalConsumer {
  constructor(
    private readonly ds: DataSource,
    private readonly translator: SubscriptionRenewalTranslator,
    private readonly subs: SubscriptionsService,
  ) {}

  @EventPattern('apple.subscription.renewed.v1')
  async handle(@Payload() raw: AppleRenewalPayload): Promise<void> {
    const key = `apple:${raw.original_transaction_id}:${raw.notification_uuid}`

    await this.ds.transaction(async (tx) => {
      const inserted = await tx.query(
        `insert into inbox(source, external_id, payload)
         values ($1, $2, $3)
         on conflict (source, external_id) do nothing`,
        ['apple', key, raw],
      )
      if (inserted.rowCount === 0) return // already processed

      const event = this.translator.toRenewedEvent(raw)
      await this.subs.applyRenewal(event, tx)
    })
  }
}
-- migrations/2024XXXXXX_create_inbox.sql
create table inbox (
  source        text        not null,
  external_id   text        not null,
  payload       jsonb       not null,
  received_at   timestamptz not null default now(),
  primary key (source, external_id)
);

create index inbox_received_at_idx on inbox(received_at);

The inbox’s primary key IS the idempotency contract. Apple retries, the second insert hits the conflict, the handler returns early. No second applyRenewal, no duplicate subscription row. The ACL is the translator and the inbox together. Pull them apart and you lose the property.

Where ACLs go wrong

Native IAP renewals that weren’t idempotent

A creator opened a support ticket at the creator economy platform I worked at. “All my customers got charged twice this month and the app shows two active subs each.” Apple’s server-to-server SubscriptionRenewal had been retried after our endpoint returned 200 OK slightly after Apple’s 30-second deadline. The renewal handler had no idempotency check. Each retry created a new creator_subscriptions row. A few thousand customers across dozens of branded apps. Apple had already billed every card.

First wrong fix was a frontend patch that hid the duplicate rows. Visible-only, didn’t refund anyone. The creator escalated to legal.

Real fix had two parts. A cleanup script that deduped rows using apple_original_transaction_id + notification_uuid as the idempotency key. And the structural change: rewrote the handler as a Sidekiq job behind a unique constraint on (apple_original_transaction_id, notification_uuid), and made the endpoint return 200 OK within five seconds by enqueueing the work. Apple’s retries became idempotent at the queue layer. The handler had been built as a thin HTTP endpoint, not as an ACL. No boundary, no translator, no inbox. Apple’s mental model leaked all the way into our subscriptions table.

Pragmatic ACL rules I actually use

A few things I’ve stopped negotiating on. Don’t generalize the translator. Two upstream gateways means two ACLs. Sharing a base translator between Stripe and Apple feels DRY, ends up coupled.

Idempotency keys are part of the ACL contract. Compose them inside the translator, persist them at the boundary. Test the ACL with recorded real upstream payloads. Hand-crafted DTOs in test fixtures are wishful thinking. And treat the translation schema like a public API. Inbox row shape, projected event shape, cache-key composition. Any change there is a schema migration in spirit, even when no DB table changes.

Takeaways

  • An ACL is not a utilities folder. It’s a bounded translation owned by the downstream context.
  • One translator per upstream. Sharing them across providers is how mental models leak.
  • Idempotency keys live inside the ACL contract, not bolted on afterwards.
  • Async ACLs need a freshness metric, not just a throughput metric.
  • Treat translation schemas like a public API. Version them, migrate them deliberately.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.