saylornotes

The Blog of Chris Saylor

Search Results

    Interop in PHP Should Not Be Exceptional

    December 22, 2020 engineering Chris Saylor

    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.

    Related Posts

    Enforce code standards with composer, git hooks, and phpcs April 14, 2014

    Maintaining code quality on projects where there are many developers contributing is a tough assignment. How many times have you tried to contribute …

    Design for Success June 7, 2018

    This year, I gave a talk at Syntaxcon in Charleston, SC. Being my first talk on design, I was out of my comfort zone, however I would be remiss if I …

    Managing Polylingual Side Projects July 19, 2020

    Like many engineers, I have a life-long passion for learning. I satiate this need by creating side projects that explore new concepts, languages, and …

    Ruminate More June 30, 2020

    Do you remember back to your school days of writing a paper, giving it a once over, and turning it in only to be surprised on return of bad editing …