Was this helpful? Support me via buymeacoffee.com and help me create lots more great content!

Return null or Throw Exception - Best Practice?

Recently I responded to a Twitter thread about whether you should return null or throw an exception when an error is encountered and it got me thinking, especially when the tweets started going back and forth. I thought it would be beneficial to gather my thoughts together into something more cohesive and here we are.

We've all had this conversation

Client: Your website doesn't work

Me: Can you provide some details about the issue?

Client: It doesn't work

Returning null

No Context

Returning null is effectively the programmatic equivalent of the above conversation. It "may" tell you that there is a problem but it provides no context about what happened or why, so debugging and resolving issues where a null value is returned become difficult.

Ambiguity

In a lot of circumstances, a null value is to be considered an acceptable outcome but if we return null when an error has happened then how do we tell if a null is an acceptable outcome or an error? If we consider something like $customer->getSubscription() which could return a Subscription object or null if they have not purchased a subscription, then in this case, null is an acceptable outcome.

Now let us consider what could be inside getSubscription(). Without delving deeper into the code, getSubscription() is effectively a black box. It could just be a couple of lines of code which return a Subscription object, or it could involve calls to an external system which manages the subscription details. i.e. if we have the following code

class Customer
{
    // SubscriptionAPI Client
    protected $apiClient;

    public function getSubscription()
    {
        try {
            $subscription = $this->apiClient->getSubscription();

            return $subscription;
        } catch (\Exception $e) {
            // Something went wrong;
            return null;
        }
    }
}

It's unclear whether a null response is a because the customer does not have a subscription or a server error connecting to the Subscription API.

Error Masking

Returning null can result in long-running problems that are masked because the application "appears" to be working correctly. Imagine the case where we are performing a calculation based on 2 values retrieved from a database record. We could return null if either value is empty but if we're not careful with the next bit of code, that null could be interpreted as a 0 which could result in completely incorrect values in further calculations.

The following is an overly simplistic example where I am attempting to get a LineItem total value - I've stripped out the logic that would be involved in determining the price and markup multiplier and I am just returning what would be the calculated value for simplicity and clarity.

class LineItem
{
    /**
     * Unit price of the line item
     * @return float
     */
    public function getPrice()
    {
        return 10.00;
    }

    /**
     * What the markup multiplier should be
     * @return float
     */
    public function applyMarkup()
    {
        return 1.2;
    }

    /**
     * How many are being purchased.
     * @return int
     */
    public function getQuantity()
    {
        return 2;
    }

    /**
     * Line Item Total
     * @return float|int
     */
    public function getTotal()
    {
        return $this->getQuantity() * ($this->getPrice() * $this->applyMarkup());
    }
}

In the above example running $lineItem->getTotal() would return 24, however, let's consider what would happen if the applyMarkup() method was to error and return null.

class LineItem
{
    /**
     * Unit price of the line item
     * @return float
     */
    public function getPrice()
    {
        return 10.00;
    }

    /**
     * What the markup multiplier should be
     * @return float
     */
    public function applyMarkup()
    {
        // Something went wrong so we're returning a null response
        return null;
    }

    /**
     * How many are being purchased.
     * @return int
     */
    public function getQuantity()
    {
        return 2;
    }

    /**
     * Line Item Total
     * @return float|int
     */
    public function getTotal()
    {
        return $this->getQuantity() * ($this->getPrice() * $this->applyMarkup());
    }
}

In this scenario, running $lineItem->getTotal() would return 0; The application would appear to be working as no errors are being reported by your application, but the result is that you could be giving away a lot of freebies.

Throw Exception

Context

When we throw exceptions we provide context about what is happening - as a minimum it allows you to describe the error that happened i.e.

throw new Exception('Product price is not set');

Throwing specific exceptions (or custom ones) can provide you with a greater level of detail and can be used within try-catch statements to allow you to do different things depending on which exception was thrown.

throw new ProductPriceNotSetException();

Graceful Execution

As returning null doesn't provide context, you can only handle it in a generic way i.e. display a "Sorry, an unexpected error occurred" but if we throw exceptions then we can provide context and with that, we can decide to handle things in a variety of different ways.

Let's consider Amazon's 1-click checkout - There could be a variety of things that go wrong as part of that process. If we returned null for an error we could have something that looks like

/** 
 * Apply logic to complete 1-click checkout
 * various exceptions are thrown depending on what has happened
 */
$success = $basket->performSingleClickCheckout();

if (null === $success) {
    // Cool! We've got a `null` back - but what the hell happened?

    // ... logic to apply a generic error and redirect the user to the basket
}

But if we use exceptions, we can change what we do based on the exception as we now have context

try {
    /** 
     * Apply logic to complete 1-click checkout
     * various exceptions are thrown depending on what has happened
     */
     $basket->performSingleClickCheckout();
} catch (ProductNotInStock $e) {
    /** 
     * The product's stock level has changed between them adding it
     * to their basket and their attempt to checkout. We could
     *
     * - redirect the customer to the basket page and show a warning
     * - send an email to stock control to get more stock ordered
     *
     * We can even use $e->getProduct() to get the product (provided we've set the event up for that).
     */

   // ... add logic here
} catch (ProductPriceMismatch $e) {
    /** 
     * The product's price has changed between them adding it
     * to their basket and their attempt to checkout. We could
     *
     * - redirect the customer to the basket page and show a warning
     */

   // ... add logic here
} catch (CustomerNoShippingAddress $e) {
    /** 
     * The customer has attempted to checkout without having 
     * supplied a shipping address
     *
     * - redirect the customer to the shipping page to capture a shipping address
     */

   // ... add logic here
}

Note: in the case above I've not caught the default Exception and this is because I want to handle the cases I except and I want the unexpected to error out so I can capture these issues and identify how to resolve them.

Now I can hear some of you shouting "What about the graceful degradation of your application? If you throw an exception then your users will get a bad experience!"

This is true, they will have a bad experience, however, graceful degradation should always be the aim but not to the point of potentially masking issues - this will cause you more pain and cause your customers more grief in the long run. Would you rather be told "We tried to take a payment and we can see an error with that attempt so we'll go and fix that" or "We tried to take a payment but we don't know why it failed?"

Conclusion

My recommendation here is

  • Integrate an error reporting platform like Sentry into your application.
  • Throw exceptions for errors in your application
  • Wrap any potentially expected exceptions in try/catch statements and handle them appropriately
  • Allow your application to throw exceptions for anything unexpected. These will be logged in your error reporting platform and you can be notified of anything critical in real-time - allowing a proactive approach to issues within your application.
  • Return null only when null is a valid return type.

This approach will provide you with the best of both worlds - graceful degradation for expected exceptions and real-time reporting of unexpected ones

Originally published at https://chrisshennan.com/blog/return-null-or-throw-exception-best-practice