In many ways, PHP has come a long way to becoming a competent, typed language.
With the newly minted PHP 8, strong types have eliminated a whole host of problems when dealing with class and function parameter input.
However, it isn’t all just a bed of roses.
Thrown exceptions (or Throwables
these days) are notoriously absent from any sort of concrete specification within interfaces, classes, and functions.
This is particularly troubling if one of our goals is for interchangeable implementations for a business process.
A common interop activity is swapping out a backend vendor for a specific business process. If your business is constantly re-evaluating its expenditures and contracts with vendors (as they should), the need to swap out software where your business application needs to communicate with the vendor may be a prudent design consideration.
Let’s look at a typical, albeit simplified, example for a fictional email service provider that needs to interop with the application. First, the entry point in the application.
EmailClientInterface.php
interface EmailClientInterface
{
public function sendEmail(
string $emailTemplate,
EmailAddress $address,
array $parameterMap
) : void
}
The interface accepts an email template to use, email address, and an array of parameters to map to content. It is a fire and forget function that doesn’t specify anything about errors. In the application, an email is sent as a consequence for placing an order.
Order.php
final class Order
{
private $emailClient;
public function __constructor(EmailClientInterface $emailClient)
{
$this->emailClient = $emailClient;
}
public function placeOrder(CustomerOrder $order) : void
{
// charge and persist an order
$this->emailClient->sendEmail(
'order.placed',
$order->emailAddress(),
[
'items' => $order->items()
]
);
}
}
What happens if the vendor sending the emails is down? Perhaps it throws a \RuntimeException
? How would our application react to that?
It would crash, likely resulting in our web server returning a 500
error response or rolling back the order transaction.
Certainly not user friendly.
At first blush, the simplest solution would be to surround with a try
/catch
statement.
Order.php
final class Order
{
private $emailClient;
public function __constructor(EmailClientInterface $emailClient)
{
$this->emailClient = $emailClient;
}
public function placeOrder(CustomerOrder $order) : void
{
// charge and persist an order
try {
$this->emailClient->sendEmail(
'order.placed',
$order->emailAddress(),
[
'items' => $order->items()
]
);
} catch (\Throwable $e) {
// log an error and perhaps send a message to the client
}
}
}
The 500
is no longer occurring, but what about recovering from something as simple as a request rate limit? Maybe the client sends a RateLimitExceeded
exception.
The app could catch that and handle it differently, but now there is a new problem: the client is dictating behavior and is no longer interoperable with other clients.
The interface needs to improve to allow for the application to handle error scenarios in an agnostic way from the client.
In other words, the client needs to conform to the needs of the application, not the other-way-around.
However, in PHP, the interface can’t specify thrown exceptions.
Comments (@throws
) don’t count.
Instead, a return value should define this behavior.
Let’s start by thinking about what our response needs.
EmailSentResult.php
final class EmailSentResult
{
const REASON_INVALID_EMAIL = 'invalid_email';
const REASON_RATE_LIMIT_EXCEEDED = 'rate_limit_exceeded';
private $error;
private $errorReason;
private $errorReasons = [
self::REASON_INVALID_EMAIL,
self::REASON_RATE_LIMIT_EXCEEDED,
];
public static function fromSuccess() : EmailSentResult
{
return new static();
}
public static function fromError(
\Throwable $error,
string $reason
) : EmailSentResult
{
if (!in_array($this->errorReasons, $reason)) {
throw new \UnexpectedValueException();
}
$this->error = $error;
$this->errorReason = $reason;
}
public function isSuccessful() : bool
{
return empty($this->error);
}
public function error() : ?\Throwable
{
return $this->error;
}
public function errorReason() : string
{
return $this->errorReason ?? '';
}
}
The EmailSentResult
class can indicate if the request was successful, and if it wasn’t, the reason.
In particular, the reasons are finite and known, thus can be handled specifically by the application.
It also includes the original error to be able to log stack traces in the cases were it is unexpected.
The interface can be improved to return this result.
EmailClientInterface.php
interface EmailClientInterface
{
public function sendEmail(
string $emailTemplate,
EmailAddress $address,
array $parameterMap
) : EmailSentResult
}
The application is now able to handle known reasons of failure.
Order.php
final class Order
{
private $emailClient;
public function __constructor(EmailClientInterface $emailClient)
{
$this->emailClient = $emailClient;
}
public function placeOrder(CustomerOrder $order) : void
{
// charge and persist an order
$result = $this->emailClient->sendEmail(
'order.placed',
$order->emailAddress(),
[
'items' => $order->items()
]
);
if ($result->isSuccessful()) {
return;
}
switch ($result->errorReason()) {
case EmailSentResult::REASON_RATE_LIMIT_EXCEEDED:
// implement a retry or queue for later send
break;
default:
// Log as an unrecoverable error
// using $result->error() for the stack trace
}
}
}
Any email client we wish to interop with the application can be swapped easily as long as it returns the EmailSentResult
properly.
The client is free to use all the exceptions it wants, as long as it converts those errors into a result.
Notice that I didn’t include a concrete client in this article?
That is intentional: the details of the client are irrelevant.
If the properties of the email client can’t be defined in the interface, then it is not interoperable with our application, or at the very least, a potential hazard.
Unexpected errors happen all the time in production environments. Be mindful of them, but don’t let them dictate the control flow of your application.