DEV Community

Cover image for An Anti-Corruption Layer for a Messy Third-Party PHP API
Gabriel Anhaia
Gabriel Anhaia

Posted on

An Anti-Corruption Layer for a Messy Third-Party PHP API


You integrate a shipping vendor. Their sandbox docs promise a clean JSON
response. Then the first real payload lands and it looks like this:

{
  "shipment_id": "SHP-90211",
  "is_delivered": "0",
  "weight": "1.4",
  "delivered_at": "",
  "recipient": { "name": null },
  "events": [
    { "code": "IN_TRANSIT", "ts": "2026/06/30 14:02:11" },
    { "code": "DELIVERED", "ts": 1719849600 }
  ]
}
Enter fullscreen mode Exit fullscreen mode

A boolean sent as the string "0". A weight sent as a string. An
empty string standing in for null. Two timestamp formats in the same
array. A recipient name that is sometimes null, sometimes missing
entirely. None of this is malicious. It is what a fifteen-year-old
vendor API returns after a decade of teams bolting fields on.

The question is not how to parse it. You can parse anything. The
question is where the mess is allowed to live. If a "0" string for a
boolean reaches a use case, it has already lost. Your domain now speaks
the vendor's dialect, and every service downstream inherits it.

The fix has a name: an anti-corruption layer. It sits at the adapter,
between the vendor's world and yours, and its one job is translation.

The domain type you actually want

Start from the shape your application wants to hold, not the shape the
vendor sends. A shipment your code can reason about:

<?php

declare(strict_types=1);

namespace App\Domain\Shipping;

use DateTimeImmutable;

final readonly class Shipment
{
    /** @param list<TrackingEvent> $events */
    public function __construct(
        public ShipmentId $id,
        public bool $delivered,
        public Weight $weight,
        public ?DateTimeImmutable $deliveredAt,
        public ?string $recipientName,
        public array $events,
    ) {}
}
Enter fullscreen mode Exit fullscreen mode

Real booleans. A Weight value object, not a stringy float. A nullable
DateTimeImmutable that is null when there is no delivery, not when the
vendor felt like sending "". This type carries no trace of the source.
Read it and you cannot tell whether it came from a REST call, a webhook,
or a fixture.

That is the target. Everything between the wire and this object is the
anti-corruption layer's problem, and nobody else's.

The port the use case sees

The application layer states its need as an interface in domain terms.
No Response, no array, no vendor SDK type in the signature.

<?php

declare(strict_types=1);

namespace App\Application\Port;

use App\Domain\Shipping\Shipment;
use App\Domain\Shipping\ShipmentId;

interface ShipmentTracker
{
    public function track(ShipmentId $id): Shipment;
}
Enter fullscreen mode Exit fullscreen mode

The use case depends on ShipmentTracker. It never learns the vendor's
name. Swap the vendor next year and the use case, its tests, and every
caller stay untouched. One file changes: the adapter behind this port.

Where mapping lives

The adapter implements the port. It talks HTTP, decodes JSON, and hands
the raw array to a dedicated mapper. Keep the two jobs separate: the
adapter owns transport, the mapper owns translation.

<?php

declare(strict_types=1);

namespace App\Infrastructure\Shipping\Acme;

use App\Application\Port\ShipmentTracker;
use App\Domain\Shipping\Shipment;
use App\Domain\Shipping\ShipmentId;
use GuzzleHttp\Psr7\Request;
use Psr\Http\Client\ClientInterface;

final readonly class AcmeShipmentTracker implements ShipmentTracker
{
    public function __construct(
        private ClientInterface $http,
        private AcmeShipmentMapper $mapper,
        private string $baseUri,
    ) {}

    public function track(ShipmentId $id): Shipment
    {
        $request = new Request(
            'GET',
            "{$this->baseUri}/shipments/{$id->value}",
        );
        $response = $this->http->sendRequest($request);

        $payload = json_decode(
            (string) $response->getBody(),
            associative: true,
            flags: JSON_THROW_ON_ERROR,
        );

        return $this->mapper->toDomain($payload);
    }
}
Enter fullscreen mode Exit fullscreen mode

The mapper is where the vendor's quirks get named and neutralized. It is
plain PHP, no framework, and it is the only file in the codebase that
knows "0" means false and "" means null.

<?php

declare(strict_types=1);

namespace App\Infrastructure\Shipping\Acme;

use App\Domain\Shipping\Shipment;
use App\Domain\Shipping\ShipmentId;
use App\Domain\Shipping\TrackingEvent;
use App\Domain\Shipping\Weight;

final readonly class AcmeShipmentMapper
{
    /** @param array<string, mixed> $raw */
    public function toDomain(array $raw): Shipment
    {
        $reader = new PayloadReader($raw, 'acme.shipment');

        return new Shipment(
            id: new ShipmentId($reader->string('shipment_id')),
            delivered: $reader->stringyBool('is_delivered'),
            weight: Weight::kilograms(
                $reader->stringyFloat('weight'),
            ),
            deliveredAt: $reader->optionalDate(
                'delivered_at',
                'Y/m/d H:i:s',
            ),
            recipientName: $reader->optionalString(
                'recipient.name',
            ),
            events: $this->mapEvents($reader->list('events')),
        );
    }

    /**
     * @param list<array<string, mixed>> $rows
     * @return list<TrackingEvent>
     */
    private function mapEvents(array $rows): array
    {
        $events = [];
        foreach ($rows as $i => $row) {
            $r = new PayloadReader($row, "acme.event[{$i}]");
            $events[] = new TrackingEvent(
                code: $r->string('code'),
                at: $r->flexibleTimestamp('ts'),
            );
        }
        return $events;
    }
}
Enter fullscreen mode Exit fullscreen mode

Every decision the vendor forced on you is now a named method call:
stringyBool, optionalDate, flexibleTimestamp. A reader six months
from now sees exactly which fields are unreliable, without reverse
engineering a chain of ?? and (bool) casts.

Validation is part of the translation

A mapper that trusts the payload is not an anti-corruption layer. It is
a rename. The reader validates as it reads and fails loudly with the
path that broke, so a malformed field points at itself instead of
surfacing three layers up as a TypeError with no context.

<?php

declare(strict_types=1);

namespace App\Infrastructure\Shipping\Acme;

use DateTimeImmutable;

final class PayloadReader
{
    /** @param array<string, mixed> $data */
    public function __construct(
        private readonly array $data,
        private readonly string $context,
    ) {}

    public function string(string $path): string
    {
        $value = $this->raw($path);
        if (!is_string($value) || $value === '') {
            throw new MalformedPayload(
                "{$this->context}.{$path} must be a "
                . 'non-empty string',
            );
        }
        return $value;
    }

    public function stringyBool(string $path): bool
    {
        return match ($this->raw($path)) {
            '1', 1, true, 'true' => true,
            '0', 0, false, 'false', '', null => false,
            default => throw new MalformedPayload(
                "{$this->context}.{$path} is not a "
                . 'recognizable boolean',
            ),
        };
    }

    public function optionalDate(
        string $path,
        string $format,
    ): ?DateTimeImmutable {
        $value = $this->raw($path);
        if ($value === null || $value === '') {
            return null;
        }
        $date = DateTimeImmutable::createFromFormat(
            $format,
            (string) $value,
        );
        if ($date === false) {
            throw new MalformedPayload(
                "{$this->context}.{$path} does not match "
                . $format,
            );
        }
        return $date;
    }

    private function raw(string $path): mixed
    {
        $cursor = $this->data;
        foreach (explode('.', $path) as $segment) {
            if (!is_array($cursor)
                || !array_key_exists($segment, $cursor)
            ) {
                return null;
            }
            $cursor = $cursor[$segment];
        }
        return $cursor;
    }
}
Enter fullscreen mode Exit fullscreen mode

MalformedPayload is your exception, thrown from your namespace. When
the vendor ships a breaking change on a Tuesday, the stack trace points
at acme.shipment.weight, not at some arithmetic deep in a use case
that was never wrong. The blast radius stops at the adapter.

The two-timestamp problem gets its own reader method, because the vendor
mixes a formatted string and a Unix epoch in the same array:

public function flexibleTimestamp(string $path): DateTimeImmutable
{
    $value = $this->raw($path);

    if (is_int($value)) {
        return (new DateTimeImmutable())
            ->setTimestamp($value);
    }
    if (is_string($value) && $value !== '') {
        $date = DateTimeImmutable::createFromFormat(
            'Y/m/d H:i:s',
            $value,
        );
        if ($date !== false) {
            return $date;
        }
    }
    throw new MalformedPayload(
        "{$this->context}.{$path} is neither epoch "
        . 'nor Y/m/d H:i:s',
    );
}
Enter fullscreen mode Exit fullscreen mode

Two vendor formats collapse into one domain type. The rest of the
application never learns that the timestamps ever disagreed.

What you get from the seam

The anti-corruption layer earns its place the first time the vendor
changes. Because translation lives in one mapper and one reader, a
schema change is a diff you can hold in your head. You update the reader,
re-run the mapper's tests, and nothing downstream moves.

Testing the mapper needs no HTTP at all. Feed it recorded payloads,
including the ugly real ones your logs captured, and assert on the domain
object:

public function test_string_zero_maps_to_false(): void
{
    $raw = json_decode(
        file_get_contents(__DIR__ . '/fixtures/delivered.json'),
        associative: true,
    );

    $shipment = (new AcmeShipmentMapper())->toDomain($raw);

    self::assertFalse($shipment->delivered);
    self::assertSame(1.4, $shipment->weight->kilograms);
    self::assertNull($shipment->deliveredAt);
}
Enter fullscreen mode Exit fullscreen mode

Save every malformed payload you meet as a fixture. Each one becomes a
test that keeps the vendor's next surprise from reaching production. The
mapper grows a memory of every quirk you have survived.

The pattern scales past shipping. A CRM that returns "N/A" for empty
fields, a payment gateway that nests the real error two objects deep, a
legacy SOAP bridge that hands you a stringified XML blob: each gets its
own adapter and its own mapper, and each keeps its dialect out of the
code that matters. The domain stays monolingual. It speaks only your
language, and the translators sit at the border.

One rule holds the whole thing together: no vendor-shaped array crosses
the port. If a raw array<string, mixed> from the wire ever appears in a
use case signature, the layer has leaked, and the cleanup starts there.


If this was useful

An anti-corruption layer is hexagonal architecture doing exactly what it
promises: the outside world is an adapter, and its mess stops at the
edge. Decoupled PHP spends its integration chapters on this seam — how
mappers, ports, and value objects keep a third-party API from dictating
the shape of your domain, and how to retrofit the pattern onto a codebase
that already swallowed a vendor's payload whole. The goal is an
application that outlives the API it depends on.

Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework

Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)