The Anti-Corruption Layer: How to Protect Your Domain from External APIs
There comes a point in almost every project when someone says, "We need to connect to API X." Everyone nods. It sounds simple enough. A few hours later, that API's response model has started appearing in controllers, business services, and tests. Before we know it, we've allowed an infrastructure decision to contaminate the core of our application.
This article is about how to prevent that. About a pattern called the Anti-Corruption Layer (ACL): where it comes from, why it matters, and how to apply it in practice with Symfony using IntegrationEngine — a bundle designed from the ground up to make this pattern not optional, but the only way to work.
The Problem: The Outside World Leaks In
Imagine we're integrating with Stripe to manage payments. The endpoint GET /v1/charges/{id} returns something like this:
{
"id": "ch_3abc",
"object": "charge",
"amount": 2000,
"currency": "eur",
"status": "succeeded",
"customer": "cus_xyz",
"payment_method_details": {
"card": {
"brand": "visa",
"last4": "4242"
}
}
}
Without thinking about architecture, we often end up with something like this in a business service:
// OrderService.php
public function confirmOrder(string $chargeId): void
{
$charge = $this->stripeClient->getCharge($chargeId);
if ('succeeded' !== $charge['status']) {
throw new PaymentNotConfirmedException();
}
$this->order->markAsPaid($charge['id'], $charge['amount'] / 100);
}
At first glance, this looks reasonable. But we've just introduced several problems.
First: the domain now speaks Stripe's language. It knows amounts are returned in cents (/ 100). It knows the success status is called succeeded. If Stripe changes its API, that change propagates straight into the heart of our business logic.
Second: if we want to test OrderService, we now need to mock Stripe. Infrastructure has leaked into the domain.
Third: if we switch payment providers tomorrow, we have to rewrite our business service. That's absurd. The domain shouldn't know which payment gateway we're using.
The Concept: Anti-Corruption Layer
The term comes from Domain-Driven Design by Eric Evans (2003). Evans describes it as a translation layer between two models that do not share the same language.
The idea is simple: whenever your system needs to communicate with an external system that has its own model (an API, a third-party service, a legacy system), don't allow that external model to enter your domain directly. Create an intermediate layer that translates the external model into your business language.
[ Domain ] <-> [ ACL ] <-> [ External API ]
The domain only sees its own objects. The ACL is the only component that understands the external system's details. If the external system changes, only the ACL changes.
In essence, it's the Dependency Inversion Principle applied to external integrations.
What This Looks Like in Code
Let's go back to Stripe. With an ACL, the business service becomes:
// OrderService.php — pure domain
public function confirmOrder(string $chargeId): void
{
$payment = $this->paymentGateway->getPayment($chargeId);
if (!$payment->isSuccessful()) {
throw new PaymentNotConfirmedException();
}
$this->order->markAsPaid($payment->id, $payment->amount);
}
$this->paymentGateway is a domain interface. Payment is a domain object. The service knows nothing about Stripe.
And in the infrastructure layer, the ACL:
// StripePaymentGateway.php — infrastructure
final class StripePaymentGateway implements PaymentGatewayInterface
{
public function getPayment(string $id): Payment
{
$response = $this->stripeIntegration->getCharge($id);
// Translation happens here
return new Payment(
id: $response->charge->id,
amount: $response->charge->amount / 100, // cents → euros
isSuccessful: 'succeeded' === $response->charge->status,
);
}
}
Payment is a domain object. StripePaymentGateway is pure infrastructure. The translation (/ 100, succeeded) lives here, not in the domain.
The Three Layers of a Proper ACL
In practice, a complete ACL for an external integration has three responsibilities.
1. The Transport Adapter
It knows how to communicate with the API: endpoints, HTTP methods, authentication, retries. It knows nothing about the domain.
2. The Integration DTO
It represents the API response exactly as it arrives. It is an infrastructure object, not a domain object. It reflects the provider's contract, not our business model.
final readonly class ChargeResponse
{
public function __construct(
public readonly string $id,
public readonly int $amount,
public readonly string $status,
public readonly string $currency,
) {}
}
3. The Translator (or Mapper)
It converts the raw API response into a typed DTO. This is where the provider's contract is captured faithfully, preparing the data for the final translation into the domain model.
return GetChargeResponse::create(
charge: Charge::create($response),
);
These three responsibilities are not abstract concepts. They are exactly the files that IntegrationEngine generates and structures for every integration. The bundle is not a convenience wrapper around HttpClient — it is the concrete implementation of this pattern in code.
IntegrationEngine: ACL as Mandatory Architecture
The most important design decision in IntegrationEngine is not technical. It's architectural: it makes it impossible to mix integration logic with domain logic, because its data model is designed so that such a mix simply doesn't make sense.
When you install the bundle and generate an integration, you get the exact structure of an ACL. Not as a suggestion — as the only way to work.
php bin/console make:integration Stripe GetCharge
src/Infrastructure/Integrations/Stripe/
├── StripeIntegration.php
├── Stripe.yaml
└── GetCharge/
├── Request/
│ └── GetChargeAction.php
└── Response/
├── GetChargeMapper.php
└── GetChargeResponse.php
Each file has a single responsibility. None of them know what the others do. And none of them know anything about the domain.
The Action: The Transport Contract
final class GetChargeAction extends AbstractAction
{
public static function getName(): string { return 'GetCharge'; }
public static function hasResponse(): bool { return true; }
public static function mapper(): ?string { return GetChargeMapper::class; }
}
The DTO: Stripe's Model, Faithfully Typed
final readonly class Charge
{
private function __construct(
public readonly string $id,
public readonly int $amount,
public readonly string $status,
public readonly string $currency,
) {}
public static function create(array $data): self
{
return new self(
id: (string) ($data['id'] ?? ''),
amount: (int) ($data['amount'] ?? 0),
status: (string) ($data['status'] ?? ''),
currency: (string) ($data['currency'] ?? ''),
);
}
}
The Mapper: The First Translation
final class GetChargeMapper extends AbstractMapper
{
public static function getAction(): string
{
return GetChargeAction::class;
}
protected static function transform(
AbstractAction $action,
array $response
): ResponseInterface {
return GetChargeResponse::create(
charge: Charge::create($response),
);
}
}
The Facade: The Boundary of the Integration Layer
final class StripeIntegration implements IntegrationName
{
public const string NAME = 'stripe';
private IntegrationEngine $engine;
public function __construct(IntegrationRegistry $registry)
{
$this->engine = $registry->get(self::NAME);
}
public function getCharge(string $id): GetChargeResponse
{
$response = $this->engine->send(
actionName: GetChargeAction::getName(),
context: DefaultActionContext::create(['id' => $id]),
);
\assert($response instanceof GetChargeResponse);
return $response;
}
}
The Second Half: Translating Into the Domain
The bundle takes the integration as far as Stripe's typed DTO. From there, the responsibility is ours.
final class StripePaymentGateway implements PaymentGatewayInterface
{
public function __construct(
private readonly StripeIntegration $stripe,
) {}
public function getPayment(string $chargeId): Payment
{
$response = $this->stripe->getCharge($chargeId);
return new Payment(
id: $response->charge->id,
amount: $response->charge->amount / 100,
isSuccessful: 'succeeded' === $response->charge->status,
currency: strtoupper($response->charge->currency),
);
}
}
The complete stack:
OrderService
└── PaymentGatewayInterface
└── StripePaymentGateway
└── StripeIntegration
└── IntegrationEngine
└── Stripe API
Each layer speaks the language of the layer above it.
Why This Separation Actually Matters
Switching Providers Without Touching the Domain
If we migrate from Stripe to Redsys tomorrow, we simply create another implementation of PaymentGatewayInterface. The domain remains untouched.
Domain Tests Without HTTP
$this->paymentGateway = new class implements PaymentGatewayInterface {
public function getPayment(string $id): Payment
{
return new Payment(
id: $id,
amount: 99.99,
isSuccessful: true
);
}
};
No HTTP. No Stripe. No fixtures.
API Knowledge Is Localized
The knowledge that Stripe uses cents or that succeeded means success exists in one place and one place only.
The Domain Speaks Its Own Language
The domain doesn't need to understand Stripe in order to reason about payments.
The Anti-Pattern to Avoid
// ❌ Wrong — domain depends on infrastructure DTOs
public function confirmOrder(string $chargeId): void
{
$response = $this->stripeIntegration->getCharge($chargeId);
if ('succeeded' !== $response->charge->status) {
throw new PaymentNotConfirmedException();
}
$this->order->markAsPaid(
$response->charge->id,
$response->charge->amount / 100
);
}
This recreates the original problem. The bundle did its job. We skipped ours.
An ACL is not about adding interfaces. It's about translating models.
When You Don't Need a Full ACL
A full ACL may be unnecessary when:
- The project is small.
- The API is unlikely to change.
- The integration is only used in one place.
- It's a one-off script or migration.
An ACL has a cost: more files, more layers, more indirection. Use it when the domain complexity justifies it.
Conclusion
The Anti-Corruption Layer is not a technology. It's a design decision: protecting your business model from the outside world.
What makes IntegrationEngine interesting is that it turns this decision into the default architecture. You don't need to remember the pattern or convince the team to follow it. The generated structure naturally guides you toward the correct separation of concerns.
The integration layer has its place. DTOs have their place. The facade has its place. Your responsibility is the final translation into the domain: the moment external data stops being external and becomes part of your business language.
A good sign that your ACL is working: if you can replace an external provider without the business team — or your domain tests — noticing, then the layer is doing its job.
The code samples in this article use IntegrationEngine, an MIT-licensed Symfony bundle available via composer require carlosgude/integration-engine.
Top comments (0)