The anti-corruption layer we should have written first

A logistics supplier renamed four fields in a minor release of their API, without notice and without a version bump. Accommodating it took twenty-two files, because their response shape had leaked all the way into the domain over three years of small conveniences.

The symptom

$ git show --stat 'HEAD~1'
 22 files changed, 88 insertions(+), 84 deletions(-)

 src/Domain/Shipping/Consignment.php        | 6 +-
 src/Domain/Shipping/DispatchPolicy.php     | 4 +-
 src/Http/Resources/ConsignmentResource.php | 8 +-
 src/Reporting/CarrierReport.php            | 12 +-
 resources/views/dispatch/note.blade.php    | 6 +-
 ... 17 more

$ grep -rl 'consignmentRef' src/ resources/ | wc -l
22

# a supplier's field name, in a Blade template.

A supplier’s spelling of a field name appearing in a view template is the diagnostic. It means the payload was passed through untranslated, and every layer it passed through now depends on a naming decision made by somebody else’s engineering team.

Why it happens

Decoding a response into an array and using it directly is the fastest way to get something working, and each subsequent use is one more small convenience. Nobody makes the decision to couple the domain to a supplier; it accumulates one array access at a time.

The fix

One translation point

namespace AppShippingSupplier;

final readonly class ConsignmentMapper
{
    public function fromPayload(array $p): Consignment
    {
        return new Consignment(
            reference: new ConsignmentReference($p['consignmentRef']),
            weight:    Weight::grams((int) $p['weightGrams']),
            status:    $this->status($p['statusCode']),
            carrier:   new Carrier($p['carrierCode'], $p['carrierName']),
            eta:       $this->eta($p['estimatedDelivery'] ?? null),
        );
    }
}

The domain type is constructed here and nowhere else, which is what makes this a boundary rather than a helper. Everything inside the application deals in Consignment, and the only file that knows the supplier’s vocabulary is this one.

A domain type on the inside

namespace AppDomainShipping;

final readonly class Consignment
{
    public function __construct(
        public ConsignmentReference $reference,
        public Weight $weight,
        public ConsignmentStatus $status,
        public Carrier $carrier,
        public ?DateTimeImmutable $eta,
    ) {}

    public function isDelayed(DateTimeImmutable $now): bool
    {
        return $this->eta !== null
            && $this->eta < $now
            && ! $this->status->isTerminal();
    }
}

The type carries behaviour, which the array could not, and isDelayed is the sort of question that had previously been asked with an inline comparison in three different places with three slightly different rules.

The mapping table, kept dull on purpose

private function status(string $code): ConsignmentStatus
{
    return match ($code) {
        'CRT', 'PND'        => ConsignmentStatus::Created,
        'CLD', 'AWC'        => ConsignmentStatus::AwaitingCollection,
        'ITR', 'OFD', 'HUB' => ConsignmentStatus::InTransit,
        'DLV'               => ConsignmentStatus::Delivered,
        'RTS', 'RTO'        => ConsignmentStatus::Returned,
        'EXC', 'FAI'        => ConsignmentStatus::Failed,
        default => throw new UnknownSupplierStatus($code),
    };
}

Eleven supplier codes collapsing to six domain states is the translation earning its keep — the domain does not care about the difference between “out for delivery” and “at hub”. Throwing on an unknown code rather than defaulting is the decision that makes the next silent addition loud.

Failing loudly, and logging the payload

try {
    $consignment = $this->mapper->fromPayload($payload);
} catch (UnknownSupplierStatus | TypeError | ErrorException $e) {
    $this->logger->error('supplier payload could not be mapped', [
        'supplier' => 'acme-logistics',
        'payload'  => $this->redact($payload),
        'error'    => $e->getMessage(),
    ]);

    throw new SupplierContractBroken(previous: $e);
}

Logging the payload is what turns “the supplier changed something” into a diagnosis in one look, and redacting is not optional — these payloads carry addresses. The exception type is distinct so that the retry policy can treat a contract break differently from a timeout: retrying a rename does not help.

Contract tests against a recorded response

public function testMapsTheCurrentSupplierShape(): void
{
    $payload = json_decode(
        file_get_contents(__DIR__ . '/fixtures/consignment-2023-05.json'),
        true, flags: JSON_THROW_ON_ERROR,
    );

    $c = (new ConsignmentMapper())->fromPayload($payload);

    self::assertSame('ACM-8814', $c->reference->value);
    self::assertSame(ConsignmentStatus::InTransit, $c->status);
    self::assertEquals(Weight::grams(2400), $c->weight);
}
and refreshing the recording, monthly:

  a job hits their sandbox, records one response per
  status code, and opens a pull request if any differs
  from the committed fixture.

  which is how we found the NEXT rename, in September,
  three weeks before it reached production.

The monthly refresh is the piece that converts this from damage limitation into early warning. It found the September change while it was still only in their sandbox, which meant the mapping was updated before any customer saw a failure.

Verifying it worked

# the September rename, applied to the fixture first
$ git show --stat
 2 files changed, 4 insertions(+), 4 deletions(-)
 src/Shipping/Supplier/ConsignmentMapper.php
 tests/fixtures/consignment-2023-09.json

# was 22 files. is 2.

$ grep -rl 'consignmentRef' src/ resources/ | wc -l
1

$ vendor/bin/deptrac
# Domain may not depend on ShippingSupplier: 0 violations

Two files instead of twenty-two, and the layer rule asserting that the domain cannot reach the supplier namespace at all. That rule is what stops the next small convenience, which is the failure this whole exercise exists to prevent recurring.

What this costs

A layer that reads as ceremony until the day it does not. There is a mapper, a set of domain types and a fixture per supplier response, which is real code standing between the application and a payload it could simply have used.

It also has to be maintained in step with a system nobody here controls. The monthly fixture refresh is a job that will break when their sandbox changes, and a fixture that has silently stopped refreshing is worse than no fixture — so the job’s own failure has to alert, which is one more thing to own.