- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You open the exception tracker on a Monday. The top error is SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry. It was thrown from inside RegisterUser, your use case. The stack trace climbs through PDO, through Doctrine, through the repository, and lands in a try/catch that someone wrote in the application layer to turn that SQLSTATE into a friendly message.
Read that again. Your business logic knows the MySQL error code for a duplicate key. It knows that 23000 means the email is already taken. The day you move to Postgres, that number changes to 23505, and your use case is wrong everywhere it guessed.
This is the leak. SQL error codes, HTTP status numbers, and vendor exception classes have crawled up out of the adapters and into the layer that is supposed to speak only domain language. The fix is a boundary: catch the infrastructure exception where it is thrown, translate it to a domain error, and let nothing vendor-shaped cross into the use case.
What a leak looks like
Here is the shape that grows by accident. The use case wants to register a user. It catches the database failure because someone had to, and the only place it got caught was here.
<?php
declare(strict_types=1);
namespace App\Application\User;
use App\Domain\User\User;
use Doctrine\DBAL\Exception\
UniqueConstraintViolationException;
final readonly class RegisterUser
{
public function __construct(
private UserRepository $users,
) {}
public function execute(RegisterUserInput $in): void
{
$user = User::register($in->email, $in->name);
try {
$this->users->save($user);
} catch (UniqueConstraintViolationException $e) {
throw new EmailAlreadyTaken($in->email);
}
}
}
It looks harmless. It even produces a clean domain exception. The problem is the use statement. Your application layer now depends on Doctrine\DBAL\Exception. Swap the persistence layer and this file breaks. Write a unit test with an in-memory fake and you can never reach the catch, because the fake does not throw Doctrine exceptions. The boundary is in the wrong place.
Move the catch into the adapter
The repository is the only code that knows it talks to Doctrine. So the catch belongs there. The adapter throws the domain exception; the use case never sees a vendor type.
<?php
declare(strict_types=1);
namespace App\Infrastructure\Persistence\Doctrine;
use App\Application\User\UserRepository;
use App\Domain\User\EmailAlreadyTaken;
use App\Domain\User\User;
use Doctrine\DBAL\Exception\
UniqueConstraintViolationException;
use Doctrine\ORM\EntityManagerInterface;
final readonly class DoctrineUserRepository
implements UserRepository
{
public function __construct(
private EntityManagerInterface $em,
private UserRecordMapper $mapper,
) {}
public function save(User $user): void
{
$record = $this->mapper->toRecord($user);
try {
$this->em->persist($record);
$this->em->flush();
} catch (UniqueConstraintViolationException $e) {
throw new EmailAlreadyTaken(
$user->email(),
previous: $e,
);
}
}
}
The use case shrinks back to one line that means what it says:
public function execute(RegisterUserInput $in): void
{
$user = User::register($in->email, $in->name);
$this->users->save($user);
}
EmailAlreadyTaken lives in Domain/. It carries the email and the original exception as previous, so the stack trace stays intact for your logs. The use case throws domain errors and catches none. The translation happened at the only seam that has any business knowing what Doctrine is.
Keep the original for your logs
Translation does not mean throwing away the cause. PHP exceptions chain through the $previous argument, and you want that chain. The domain error is what the use case and the controller reason about. The original PDOException or GuzzleException is what the on-call engineer reads at 3am.
final class EmailAlreadyTaken extends \DomainException
{
public function __construct(
public readonly string $email,
?\Throwable $previous = null,
) {
parent::__construct(
"Email {$email} is already registered.",
previous: $previous,
);
}
}
Log the whole chain. Most loggers walk getPrevious() for you. The domain message tells you what rule was hit; the previous tells you which constraint, which connection, which SQLSTATE. You lose nothing by translating, as long as you pass previous.
The same boundary for HTTP
Persistence is not the only place vendor exceptions escape. An outbound HTTP adapter that talks to a payment provider, an email service, or a search index throws GuzzleException subtypes. Those have no place in a use case either.
<?php
declare(strict_types=1);
namespace App\Infrastructure\Payment;
use App\Application\Port\PaymentGateway;
use App\Domain\Payment\PaymentDeclined;
use App\Domain\Payment\PaymentGatewayUnavailable;
use App\Domain\Shared\Money;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException;
final readonly class HttpPaymentGateway
implements PaymentGateway
{
public function __construct(
private ClientInterface $http,
private string $endpoint,
) {}
public function charge(Money $amount, string $key): void
{
try {
$this->http->request('POST', $this->endpoint, [
'headers' => ['Idempotency-Key' => $key],
'json' => [
'amount_minor' => $amount->minorUnits,
'currency' => $amount->currency,
],
'timeout' => 5,
]);
} catch (ConnectException $e) {
throw new PaymentGatewayUnavailable(previous: $e);
} catch (RequestException $e) {
$status = $e->getResponse()?->getStatusCode();
if ($status === 402) {
throw new PaymentDeclined($amount, previous: $e);
}
throw new PaymentGatewayUnavailable(previous: $e);
}
}
}
A timeout becomes PaymentGatewayUnavailable. A 402 becomes PaymentDeclined. The use case branches on those two domain errors and never learns that HTTP status 402 means "payment required" or that a ConnectException is a network problem. The mapping from transport detail to business meaning is the adapter's whole job.
Two failure modes, not one
A point worth slowing down on: an adapter usually translates into two kinds of domain error, and they are different on purpose.
The first is an expected business outcome. A duplicate email, a declined card, a record that is already gone. These are part of the domain. The use case wants to catch them and respond: show a message, pick another path, return a 409. They get specific exception types like EmailAlreadyTaken or PaymentDeclined.
The second is an infrastructure failure. The database is down, the gateway timed out, DNS broke. These are not business outcomes; the use case cannot do anything sensible about them except let them bubble. They get a single coarse type like PaymentGatewayUnavailable or a shared StorageUnavailable. The controller turns those into a 503 and the alerting picks them up.
If you collapse both into one exception, the use case loses the ability to tell "the customer's card was declined" from "the payment provider is on fire." Those need different responses. Keep them as separate domain types and the branching at the call site stays honest.
What the use case is allowed to know
The rule that keeps this clean: the only exception types an application or domain file may name in a catch or an import are types defined under Domain/ or Application/. No Doctrine\, no GuzzleHttp\, no PDOException, no RedisException.
You can enforce it without trusting discipline. A grep in CI is enough for a first pass:
grep -rE \
'use (Doctrine|GuzzleHttp|PDO|Redis)' \
src/Domain src/Application \
&& echo "vendor leak" && exit 1 || exit 0
deptrac or phparkitect do the same with real rules and better messages, but the grep ships today and fails the build the moment a vendor exception sneaks across the boundary. The test that proves the translation works lives next to the adapter, because that is the only layer that can produce the vendor exception in the first place.
Why it is worth the extra class
The translation costs you one catch and one domain exception per failure you care about. What you get back is a use case you can unit-test with fakes that never throw vendor exceptions, a domain that survives a database swap without edits, and an exception tracker where the top error reads EmailAlreadyTaken instead of a SQLSTATE you have to look up.
The boundary is the point of the whole architecture. Infrastructure details (error codes, status numbers, vendor classes) stay on the infrastructure side of the line. The domain speaks in its own words. When the next migration comes, and it will, the rewrite is a handful of adapter files, not a search across every use case for the MySQL code for a duplicate key.
If this was useful
This boundary — translate at the adapter, never in the domain — is one of the seams the book walks through layer by layer, from the first port to the migration playbook for framework-coupled apps. Decoupled PHP spends real pages on the failure-mode taxonomy: which errors are business outcomes, which are infrastructure noise, and how to keep the two from blurring as a system grows.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)