Skip to main content
ArticlesProjects

The Second Pattern: Anti-Corruption Layer

Carrier B speaks GraphQL, so the response arrives shaped like your query and feels like it belongs to you. It does not, and their vocabulary is now load-bearing in your domain.

Carrier B speaks GraphQL. That felt like good news when we picked them, because the alternative in this space is usually a SOAP endpoint with a WSDL that has not been regenerated since 2014.

So you write the query, and it is a nice query:

query ShipmentStatus($reference: String!) {
shipment(reference: $reference) {
reference
trackingEvents {
statusCode
occurredAt
location { city countryCode }
}
}
}

You get back what you asked for:

{
"data": {
"shipment": {
"reference": "CB-99182",
"trackingEvents": [
{
"statusCode": "OUT_FOR_DEL",
"occurredAt": "2026-01-14T08:22:00Z",
"location": { "city": "Cardiff", "countryCode": "GB" }
}
]
}
}
}

And then, because it is right there and it works, this happens:

public function shouldNotifyRecipient(array $response): bool
{
$latest = $response['data']['shipment']['trackingEvents'][0] ?? null;
return $latest !== null
&& $latest['statusCode'] === 'OUT_FOR_DEL';
}

Nothing about that is wrong, exactly. It works. It has a test. It shipped months ago and nobody has thought about it since.

Now go and grep your codebase for OUT_FOR_DEL.

What actually happened

OUT_FOR_DEL is not a concept in your business. Nobody in your company says it. It is Carrier B’s abbreviation, and at some point it stopped being a string in a client class and became a value your domain logic compares against.

Same for statusCode. Same for the shape: data, then shipment, then trackingEvents, then index zero. That path is not a fact about consignments. It is a fact about a GraphQL envelope and one particular carrier’s schema, and it is now load-bearing in code that has nothing to do with either.

Here is what makes GraphQL sneakier than the XML APIs everyone complains about. With SOAP, the mismatch is loud. Their vocabulary is obviously foreign, the response is obviously theirs, and nobody is tempted to pretend otherwise. You write a mapper on day one because the alternative is unbearable.

With GraphQL, you chose the shape. You wrote that query, you picked those fields, so the response feels like yours. It arrives looking almost exactly like something you would have designed. And so you skip the translation step, because what is there to translate?

The coupling is still total. It is just polite about it.

Worse, it runs the wrong way round. Because your objects mirror your query document, changing the query changes your domain. Add a field to the selection set and something downstream can now depend on it. Remove one to speed up a slow resolver and you find out at runtime, in a different part of the application, which is exactly the property you were hoping types would save you from.

The obvious move

Fine, you say. Map it. Tempest has a mapper, and it has an attribute for exactly this:

final class Consignment
{
#[MapFrom('statusCode')]
public string $status;
}

It will not work, and then it will not help.

Start with the not working, because it is quick. That attribute cannot reach the value at all. Here is how the name gets resolved in ArrayToObjectMapper:

$mapFrom = $property->getAttribute(MapFrom::class);
if ($mapFrom !== null) {
return arr($from)
->keys()
->intersect($mapFrom->names)
->first(default: $property->getName());
}

It intersects your candidate names against the keys of the array you passed in. Top-level keys. There is no path syntax, no dot notation, no walking into data.shipment.trackingEvents. For a flat response this is a genuinely nice piece of design, and it is the right amount of tool for that job. For a GraphQL envelope it simply does not apply.

Now the part that would still bite you even if the nesting worked. Read that class again. Consignment is your domain object, the thing the rest of your application reasons about, and it now has Carrier B’s field name written on it as an attribute. You have not removed the coupling. You have relocated it into the one class you most wanted to keep clean, and you have done it in a way that looks tidy, which means nobody will flag it in review.

If the corruption is now an annotation on your entity, what exactly did the mapping buy you?

The pattern

This one comes from Evans too, later in Domain-Driven Design, in the material on context mapping. His framing is blunt: when two models meet and one of them is not yours, put an Anti-Corruption Layer between them.

An ACL is three things, though most attempts only build two of them.

There is your model. There is a translator. And there is their model, which you write, in their vocabulary, deliberately. That third piece is the one almost everyone leaves out, and leaving it out is why most attempts at this end up as a mapper class that still falls over every time the vendor changes something.

Why bother modelling their side at all? Because it gives their weirdness a place to live. OUT_FOR_DEL belongs in a class called something like CarrierBTrackingEvent. So does the fact that the events come back in an array whose order you should not trust. So does the fact that this carrier returns a 200 with an errors key when a shipment does not exist. All of that is true, all of it needs to be somewhere, and the entire value of the pattern is that the somewhere is not your domain.

Once their model exists as a type, the translator becomes a small honest function with an obvious signature: theirs in, yours out. It ends up being the only place in your codebase that knows both vocabularies, which is the point of building it.

When this is worth it

A second provider settles it on its own. The moment two carriers do the same job, you need one shape to program against, and the ACL is what produces it. If you know a second provider is coming, build it before they arrive, because retrofitting one means finding every OUT_FOR_DEL you scattered while you had only one.

It is also worth it when their concepts differ from yours, not just their names. Renaming statusCode to status is not translation, it is typing. Real translation is when Carrier B models a shipment as a mutable record with an event list, you model it as a consignment with an immutable movement history, and something has to reconcile those. A mapper cannot help you there. A translator can.

And it is worth it when you do not control their release schedule, which is always. Somebody else decides when that schema changes, and your only real defence is that the change lands in one file.

When it is not

Skip it when the vendor’s model genuinely is your model. This is more common than purists admit. If you are integrating a payment processor and your business thinks in charges, refunds and disputes because that is what your business actually does, then inventing your own vocabulary to sit alongside theirs is translation for its own sake. You will maintain two names for one idea and gain nothing.

A single call, made once, in one place, feeding a report that no other code reads, does not need three classes either. Write the array access and get on with your day.

The last one is less obvious. If you control both sides, an ACL between two of your own services is telling you the service boundary is in the wrong place. Go and fix that instead of insulating yourself from a decision you are allowed to change.

Building it in Tempest

Start with their model, in their words.

namespace App\Carriers\CarrierB\Responses;
final readonly class CarrierBTrackingEvent
{
public function __construct(
public string $statusCode,
public string $occurredAt,
public ?string $city,
public ?string $countryCode,
) {}
}

statusCode is the right name here. This class exists to be Carrier B’s, and using your vocabulary in it would defeat the point. If a future reader wonders what OUT_FOR_DEL means, the namespace tells them who to ask.

Because the response is nested, this needs a real mapper rather than attributes. Tempest’s Mapper interface is two methods:

namespace App\Carriers\CarrierB;
use Tempest\Mapper\Mapper;
final readonly class CarrierBShipmentMapper implements Mapper
{
public function canMap(mixed $from, mixed $to): bool
{
return $to === CarrierBShipment::class
&& is_array($from)
&& array_key_exists('data', $from);
}
public function map(mixed $from, mixed $to): CarrierBShipment
{
if ($from['errors'] ?? false) {
throw CarrierBRejectedRequest::fromErrors($from['errors']);
}
$shipment = $from['data']['shipment']
?? throw new CarrierBShipmentNotFound();
return new CarrierBShipment(
reference: $shipment['reference'],
events: array_map(
fn (array $event) => new CarrierBTrackingEvent(
statusCode: $event['statusCode'],
occurredAt: $event['occurredAt'],
city: $event['location']['city'] ?? null,
countryCode: $event['location']['countryCode'] ?? null,
),
$shipment['trackingEvents'] ?? [],
),
);
}
}

MapperDiscovery finds that class for you, because it looks for anything implementing Mapper and adds it to the config. If the shape looks familiar, it should: it is the discovery class we hand-rolled last time, except this one ships with the framework. Thirty-odd lines, and a better model than mine if you go and read it.

Notice where the GraphQL partial error handling went. Carrier B returns a 200 with an errors array, sometimes alongside usable data, and a decision has to be made about whether partial data is acceptable. That decision is carrier-specific trivia. It belongs in the mapper, and now it is in exactly one place instead of being an if statement in four services.

Then the translator, which is the actual anti-corruption layer:

namespace App\Carriers\CarrierB;
final readonly class CarrierBTranslator
{
public function __construct(
private CarrierBStatusMap $statuses,
) {}
public function toMovements(CarrierBShipment $shipment): MovementHistory
{
$movements = array_map(
fn (CarrierBTrackingEvent $event) => new Movement(
status: $this->statuses->toConsignmentStatus($event->statusCode),
occurredAt: DateTime::parse($event->occurredAt),
location: $this->toLocation($event),
),
$shipment->events,
);
return MovementHistory::fromUnordered($movements);
}
}

Two details in there are the point of the whole exercise.

CarrierBStatusMap is where OUT_FOR_DEL finally lives, and it lives as data rather than as a comparison scattered through your services. When Carrier B adds a status next quarter, you will know where to go.

MovementHistory::fromUnordered() is the more interesting one. Carrier B does not guarantee event order. That is a fact about them, and rather than letting it become a fact about you, the translator states it at the boundary and hands your domain something sorted. Your domain never learns that some carriers are untidy.

Finally, bind it behind an interface so callers cannot tell the carriers apart:

use Tempest\Container\Container;
use Tempest\Container\Initializer;
use Tempest\Container\Singleton;
final readonly class CarrierGatewayInitializer implements Initializer
{
#[Singleton]
public function initialize(Container $container): CarrierGateway
{
return new CarrierGatewayRouter(
fn (CarrierCode $code) => $container->get(match ($code) {
CarrierCode::A => CarrierAGateway::class,
CarrierCode::B => CarrierBGateway::class,
CarrierCode::C => CarrierCGateway::class,
}),
);
}
}

Everything above the gateway now talks about consignments and movements. The GraphQL, the abbreviations and the unordered arrays stop at the boundary.

What it costs

You roughly double the classes involved in an integration, and you pay for that up front, before the second carrier exists to justify it. I am not going to pretend the ceremony is free. It is why the section above exists, and why my advice for a genuinely one-provider-forever integration is to not bother.

The subtler cost is that an ACL can rot without failing. Their response model drifts from what the API actually returns, and because your mapper only reads the fields it knows about, nothing breaks. You quietly stop seeing new data.

Rot comes in two directions and they are not equally easy to catch. If Carrier B renames or removes a field you depend on, #[Strict] will tell you. Applied to a class or a property, it turns a value that has no match and no default into an error rather than leaving the property unset, which on a vendor response model is usually what you want. Be aware that it only applies when you let the framework do the array-to-object work, so a hand-written mapper like the one above opts out of it unless you delegate the inner objects.

The other direction has no framework answer. If Carrier B adds a field, nothing anywhere will mention it, and you will find out when someone asks why the new delivery-window data is not on your dashboard. GraphQL at least gives you a schema to diff, which is more than most REST vendors offer, so a scheduled introspection query compared against a committed snapshot is a cheap way to be told.

Then there is the leak. Six months in, somebody needs one field the translator does not expose, and the fastest fix is to pass the carrier response through. One call site, tiny diff, entirely reasonable in isolation. It is also the moment the layer stops working, because a boundary with one hole in it is not a boundary.

That failure is a code review problem rather than a design problem, and the review question is easy to remember: does anything outside App\Carriers\CarrierB mention Carrier B’s vocabulary? An architecture test asserting that no class outside the namespace references those response types costs you ten minutes and catches the leak on the pull request rather than in a year.

Where this leaves us

An Anti-Corruption Layer is worth building when a second provider is coming, when their concepts differ from yours rather than just their names, or when you cannot control their release schedule. It is not worth building when their model is honestly yours already.

The trap specific to GraphQL is that the response arrives shaped like your query, so it feels like it belongs to you. It does not. You picked which of their fields to receive, and that is a very different thing from having defined them.

Next in the series: the booking commits, the webhook fails, and nothing anywhere records that it happened. The Transactional Outbox, and why an event bus that runs inside your transaction cannot save you.

Part of a Series

The Second Pattern

You are reading Part 2 of 10 in this learning series.

View Full Series

Share

XLinkedIn

Related

Keep Reading

All posts →