While our web applications grow, the developers concentrate on new features and business logic, the topic of error handling is often neglected.
Quite often this results in wild changes to method return values or - even worst - passing additional parameters by reference to bring uprising errors to the presentation layer.
Let’s take a typical example: Changing a customer’s email address with the following subtasks:
Customer
object by a customer ID from storageCustomer
objectCustomer
object to the storageThe author of the following code chose “wisely” an ErrorList
object instead of a php array for collecting error messages.
<?php
class CustomerRepository
{
/**
* @param string $customerId
*
* @return Customer|ErrorList
*/
public function findOneWithId( string $customerId )
{
// Code that loads the customer object from storage
if ( $customer !== null )
{
return $customer;
}
return new ErrorList( "Customer with ID {$customerId} not found." );
}
}
This code already comes up with a bunch of problems:
But let’s go on for now …
<?php
class Customer
{
private $email;
/**
* @param string $newEmail
*
* @return ErrorList
*/
public function changeEmail( string $newEmail ) : ErrorList
{
$errorList = new ErrorList();
if ( empty($newEmail) )
{
$errorList->addMessage( 'Email address is empty.' );
}
elseif ( !filter_var( $newEmail, FILTER_VALIDATE_EMAIL ) )
{
$errorList->addMessage( 'Email address is invalid.' );
}
elseif ( $newEmail == $this->email )
{
$errorList->addMessage( 'Email address is same as current address.' );
}
$this->email = $newEmail;
return $errorList;
}
}
<?php
class CustomerRepository
{
/**
* @param Customer $customer
*
* @return ErrorList
*/
public function update( Customer $customer ) : ErrorList
{
$errorList = new ErrorList();
// Code that unloads the customer object to the storage
if ( !$updateResult )
{
$errorList->addMessage( "Updating customer object failed." );
}
return $errorList;
}
}
<?php
class ChangeCustomerEmailHandler
{
/**
* @param Request $request
*/
public function handle( Request $request )
{
$errorList = new ErrorList();
$customerRepository = new CustomerRepository();
# 1. Load the customer object from storage
$customer = $customerRepository->findOneWithId( $request->getCustomerId() );
if ( $customer instanceof Customer )
{
# 2. Change the customer object's email address
$error = $customer->changeEmail( $request->getNewEmail() );
if ( !$error->isEmpty() )
{
$errorList->append( $error );
}
else
{
# 3. Unload changed customer object to storage
$error = $customerRepository->update( $customer );
if ( !$error->isEmpty() )
{
$errorList->append( $error );
}
}
}
elseif ( $customer instance of ErrorList )
{
$errorList->append($customer);
}
if ( $errorList->isEmpty() )
{
echo "Email address was changed successfully.";
}
else
{
echo "Email address could not be changed: ";
echo join( '<br>', $errorList->getMessages() );
}
}
}
Code like this is
$customer
has two completely different value types assigned (Customer
object or ErrorList
object),By the way you can ask yourself, if you really want to present a message to the user, if your system was unable to persist a customer.
Another problem with these specific, distributed error messages for users is, you can’t change them for other use cases or languages without turning the whole application upside-down.
In the first step of our refactoring we’ll replace the ErrorList
returns with Exceptions
.
<?php
class CustomerRepository
{
/**
* @param string $customerId
*
* @throws Exception
* @return Customer
*/
public function findOneWithId( string $customerId ) : Customer
{
// Code that loads the customer object from storage
if ( $customer !== null )
{
return $customer;
}
throw new Exception( "Customer with ID {$customerId} not found." );
}
/**
* @param Customer $customer
*
* @throws Exception
*/
public function update( Customer $customer )
{
// Code that unloads the customer object to storage
if ( !$updateResult )
{
throw new Exception( "Updating customer object failed." );
}
}
}
class Customer
{
private $email;
/**
* @param string $email
*
* @throws Exception
*/
public function changeEmail( string $newEmail )
{
if ( empty($newEmail) )
{
throw new Exception( 'Email address is empty.' );
}
elseif ( !filter_var( $newEmail, FILTER_VALIDATE_EMAIL ) )
{
throw new Exception( 'Email address is invalid.' );
}
elseif ( $newEmail == $this->email )
{
throw new Exception( 'Email address is same as current address.' );
}
$this->email = $newEmail;
}
}
class ChangeCustomerEmailHandler
{
/**
* @param Request $request
*/
public function handle( Request $request )
{
$customerRepository = new CustomerRepository();
try
{
# 1. Loading the customer object from storage
$customer = $customerRepository->findOneWithId( $request->getCustomerId() );
# 2. Changing the customer object's email address
$customer->changeEmail( $request->getNewEmail() );
# 3. Unloading the customer object to storage
$customerRepository->update( $customer );
echo "Email address was changed successfully.";
}
catch ( Exception $e )
{
echo "Email address could not be changed: " . $e->getMessage();
}
}
}
By this first change we can see at a glance:
ChangeCustomerEmailHandler
,CustomerRepository::findOneWithId
a unique return type andBut the following problems remain unresolved:
Exception
’s messages still contain error messages for users.To get aware of the context the error occured in we’ll replace the generic Exception
class with our own Context Exceptions.
To do so, we need to identify the contexts in the first place:
CustomerRepository
class to load and unload Customer
objects from/to the storage. Let’s name this context CustomerStorage
.Customer
class to change its state by changing its data. Let’s name this context CustomerData
.Now we can deduce the names of our exceptions from these contexts:
CustomerStorageException
CustomerDataException
Depending on your application or structure of components you can define contexts more widely.
The macro context here would be Customer
and its Context Exception CustomerException
.
But let’s move on with our two contexts.
After the replacement the code looks as follows:
<?php
class CustomerStorageException extends Exception {}
class CustomerDataException extends Exception {}
class CustomerRepository
{
/**
* @param string $customerId
*
* @throws Exception
* @return Customer
*/
public function findOneWithId( string $customerId ) : Customer
{
// Code that loads the customer object from storage
if ( $customer !== null )
{
return $customer;
}
throw new CustomerStorageException( "Customer with ID {$customerId} not found." );
}
/**
* @param Customer $customer
*
* @throws Exception
*/
public function update( Customer $customer )
{
// Code that unloads the customer object to storage
if ( !$updateResult )
{
throw new CustomerStorageException( "Updating customer object failed." );
}
}
}
class Customer
{
private $email;
/**
* @param string $email
*
* @throws Exception
*/
public function changeEmail( string $newEmail )
{
if ( empty($newEmail) )
{
throw new CustomerDataException( 'Email address is empty.' );
}
elseif ( !filter_var( $newEmail, FILTER_VALIDATE_EMAIL ) )
{
throw new CustomerDataException( 'Email address is invalid.' );
}
elseif ( $newEmail == $this->email )
{
throw new CustomerDataException( 'Email address is same as current address.' );
}
$this->email = $newEmail;
}
}
class ChangeCustomerEmailHandler
{
/**
* @param Request $request
*/
public function handle( Request $request )
{
$customerRepository = new CustomerRepository();
try
{
# 1. Loading the customer object from storage
$customer = $customerRepository->findOneWithId( $request->getCustomerId() );
# 2. Changing the customer object's email address
$customer->changeEmail( $request->getNewEmail() );
# 3. Unloading the customer object to storage
$customerRepository->update( $customer );
echo "Email address was changed successfully.";
}
catch ( CustomerDataException $e )
{
echo "Emailaddress could not be changed: " . $e->getMessage();
}
catch ( CustomerStorageException $e )
{
echo "Storage error.";
}
}
}
By adding a second catch
branch we now can differentiate between both error contexts.
This allows us to handle these errors in a more compliant way.
CustomerData
context a message shall be presented to the user.CustomerStorage
context the admin shall be informed and the user shall get an “Internal Server Error” response.This is quite easy now:
<php
class ChangeCustomerEmailHandler
{
/**
* @param Request $request
*/
public function handle( Request $request )
{
$customerRepository = new CustomerRepository();
try
{
# 1. Loading the customer object from storage
$customer = $customerRepository->findOneWithId( $request->getCustomerId() );
# 2. Changing the customer object's email address
$customer->changeEmail( $request->getNewEmail() );
# 3. Unloading the customer object to storage
$customerRepository->update( $customer );
echo "Email address was changed successfully.";
}
catch ( CustomerDataException $e )
{
# Message to the user
echo "Email address could not be changed: " . $e->getMessage();
}
catch ( CustomerStorageException $e )
{
# Inform the admin
mail( 'admin@example.com', 'Customer storage error', $e->getMessage() );
# Internal Server Error response to user
header( 'Content-Type: text/plain', true, 500 );
echo "Internal Server Error";
}
}
}
In most cases this context distiction is too rough to present proper messages to the user or even offer adequate actions.
So it makes sense to bring more precision into our introduced contexts by extending the exceptions to more specialized ones.
By the way we still need to solve the problem of distributed user messages in several application layers.
There are 2 concrete errors in the CustomerStorage
context:
There are 3 concrete errors in the CustomerData
context:
We can merge the first two errors together, because an empty email address is an invalid email address.
For a better read of our code we choose expressive names for our new exceptions:
<?php
# CustomerStorage context
class CustomerNotFound extends CustomerStorageException {}
class UpdatingCustomerFailed extends CustomerStorageException {}
# CustomerData context
class InvalidEmailAddress extends CustomerDataException {}
class EmailAddressAlreadySet extends CustomerDataException {}
… and embed them into our code:
<?php
class CustomerRepository
{
/**
* @param string $customerId
*
* @throws CustomerNotFound
* @return Customer
*/
public function findOneWithId( string $customerId ) : Customer
{
// Code that loads the customer object from storage
$this->guardCustomerWasFound( $customer );
return $customer;
}
/**
* @param Customer|null $customer
*
* @throws CustomerNotFound
*/
private function guardCustomerWasFound( $customer )
{
if ( !($customer instanceof Customer) )
{
throw new CustomerNotFound();
}
}
/**
* @param Customer $customer
*
* @throws UpdatingCustomerFailed
*/
public function update( Customer $customer )
{
// Code that unloads the customer object to storage
$this->guardCustomerWasUpdated( $updateResult );
}
/**
* @param bool $updateResult
*
* @throws UpdatingCustomerFailed
*/
private function guardCustomerWasUpdated( bool $updateResult )
{
if ( $updateResult === false )
{
throw new UpdatingCustomerFailed();
}
}
}
class Customer
{
private $email;
/**
* @param string $email
*
* @throws Exception
*/
public function changeEmail( string $newEmail )
{
$this->guardEmailAddressIsValid( $newEmail );
$this->guardEmailAddressDiffers( $newEmail );
$this->email = $newEmail;
}
/**
* @param string $email
*
* @throws InvalidEmailAddress
*/
private function guardEmailAddressIsValid( string $email )
{
if ( empty($newEmail) )
{
throw new InvalidEmailAddress();
}
if ( !filter_var( $newEmail, FILTER_VALIDATE_EMAIL ) )
{
throw new InvalidEmailAddress();
}
}
/**
* @param string
*
* @throws EmailAddressAlreadySet
*/
private function guardEmailAddressDiffers( string $email )
{
if ( $newEmail == $this->email )
{
throw new EmailAddressAlreadySet();
}
}
}
class ChangeCustomerEmailHandler
{
/**
* @param Request $request
*/
public function handle( Request $request )
{
$customerRepository = new CustomerRepository();
try
{
# 1. Loading customer object from storage
$customer = $customerRepository->findOneWithId( $request->getCustomerId() );
# 2. Changing the customer object's email address
$customer->changeEmail( $request->getNewEmail() );
# 3. Unloading the customer object to storage
$customerRepository->update( $customer );
echo "Email address was changed successfully.";
}
catch ( InvalidEmailAddress $e )
{
# Message to the user
echo "The given email address is invalid. Please check your input.";
}
catch ( EmailAddressAlreadySet $e )
{
# Message to the user
echo "Please choose another email address.";
}
catch ( CustomerStorageException $e )
{
# Inform admin
mail( 'admin@example.com', 'Customer storage error', get_class( $e ) . ' - ' . $e->getMessage() );
# Internal Server Error response to the user
header( 'Content-Type: text/plain', true, 500 );
echo "Internal Server Error";
}
}
}
For better separation of concerns checks were extracted to guard...()
methods.
This also reduces the complexity of our business methods.
By adding another catch
branch we now can differentiate between both concrete errors from the CustomerData
context.
Errors from CustomerStorage
context remain grouped by a single catch
, but we now tell the admin what error occurred (get_class( $e )
).
This means we’re free to decide whether to handle occurred errors precisely or in less detail.
Furthermore we do not have user messages in the CustomerRepository
and Customer
class anymore.
These user messages are genereted in the ChangeCustomerEmailHandler
according to requirements.
So now we have a closed context where we can produce user or language specific error messages.
And this is where they belong, the place nearest to or even part of the presentation layer.
In addition our code is reusable now, because another handler can react on our Context Exceptions in a different way, without us changing the code the errors come from.
Let’s summarize:
In the beginning our code included the information which customer ID we searched for, when the error occured that it could not be found.
return new ErrorList( "Customer with ID {$customerId} not found." );
This information vanished by our replacement with Exceptions.
throw new CustomerNotFound();
Poor admin, who now gets the email with this error message. He won’t be able to search any backup for a distinct customer ID.
In order to get additional information from the specific context to our error description, we simply can extend our
specialized Context Exceptions. A best practice is to use so called with...()
methods, because they ensure a good read of the code.
On the example of our CustomerNotFound
exception this looks like this:
<?php
class CustomerNotFound extends CustomerStorageException
{
private $customerId = '';
public function withCustomerId( string $customerId ) : self
{
$this->customerId = $customerId;
return $this;
}
public function getCustomerId() : string
{
return $this->customerId;
}
}
So its call will change as follows:
<?php
class CustomerRepository
{
/**
* @param string $customerId
*
* @throws CustomerNotFound
* @return Customer
*/
public function findOneWithId( string $customerId ) : Customer
{
// Code that loads the customer object from storage
$this->guardCustomerWasFound( $customerId, $customer );
return $customer;
}
/**
* @param string $customerId
* @param Customer|null $customer
*
* @throws CustomerNotFound
*/
private function guardCustomerWasFound( string $customerId, $customer )
{
if ( !($customer instanceof Customer) )
{
throw (new CustomerNotFound)->withCustomerId( $customerId );
}
}
}
And handling this error could possibly look like this:
<?php
class ChangeCustomerEmailHandler
{
/**
* @param Request $request
*/
public function handle( Request $request )
{
$customerRepository = new CustomerRepository();
try
{
# 1. Loading the customer object from storage
$customer = $customerRepository->findOneWithId( $request->getCustomerId() );
# ...
echo "Email address was changed successfully.";
}
/*
...
*/
catch ( CustomerNotFound $e )
{
# Inform admin
$subject = 'Customer with ID ' . $e->getCustomerId() . ' not found in storage.';
mail( 'admin@example.com', $subject, get_class( $e ) . ' - ' . $e->getMessage() );
# Internal Server Error response to customer
header( 'Content-Type: text/plain', true, 500 );
echo "Internal Server Error";
}
}
}
As we handle the precise CustomerNotFound
exception we make sure there is a getter for a customer ID providing
information we can embed in the error message.
Of course this use case is trivial, because we could have taken the customer ID from the Request
object.
More interesting than the customer ID would be e.g. the current used storage.
With this aproach we now are able to “carry” usually hidden information from the context to the error handling part of our application.
- HaPHPy throwing!