Blog

Why Throwing Exceptions is the Best Choice?

Yuriy Melnikov

A brief analysis with simple and clear examples.

Let's examine a few examples where using exceptions is objectively appropriate.

I'll provide an example of updating a place's image in the Tip2Go project.

Let's start writing the PlaceService class with the updatePlaceImage method.

class PlaceService {
  public function updatePlaceImage(int $place_id, string $image_filename): string
  {
    $place = $this->getPlaceById($place_id);
    ...
  }
}

We encounter the possibility of an error when retrieving a place by its ID, as a non-existent ID might be passed, returning null instead of an existing place.

If null is returned, we shouldn't continue executing the code and should return without updating the image. To do this, we need to change the return types of the updatePlaceImage method and add the possibility of returning null in case of an error.

class PlaceService {
  public function updatePlaceImage(int $place_id, string $image_filename): ?string
  {
    $place = $this->getPlaceById($place_id);

    if (!$place instanceof Place) {
      return null;
    }

    if (!file_exists($image_filename)) {
      return null;
    }

    $newImage = ImageHelper::resize($image_filename);

    if (!file_exists(self::IMAGES_DIR . '/' . $place->getId())) {
      $mkdir = mkdir(self::IMAGES_DIR . '/' . $place->getId());
    }

    if (!$mkdir) {
      return null;
    }
  }
}

Now we've added a check for the existence of the original file and the directory for storing images, and if it's not there, we create it using the place ID.

In this case, we might encounter two errors: the absence of the original file and the inability to create a new directory. In case of errors, we also return null and stop setting the new image.

Great, our method can already be interrupted in three places due to errors. We can add more error checks, for example, when moving the temporary file to a new directory, resulting in four points where the method execution can be interrupted.

And how do we understand what exactly happened? The first and most obvious option is to add the ability to return an int in the updatePlaceImage method to receive an error code.

class PlaceService {
  public function updatePlaceImage(int $place_id, string $image_filename): string|int
  {
    $place = $this->getPlaceById($place_id);

    if (!$place instanceof Place) {
      return -1;
    }

    if (!file_exists($image_filename)) {
      return -2;
    }

    $newImage = ImageHelper::resize($image_filename);

    if (!file_exists(self::IMAGES_DIR . '/' . $place->getId())) {
      $mkdir = mkdir(self::IMAGES_DIR . '/' . $place->getId());
    }

    if (!$mkdir) {
      return -3;
    }
  }
}

In this case, in the calling method, we need to check all possible return values.

$placeService = new PlaceService();
$result = $placeService->updatePlaceImage(1, 'filename.jpg');

if (is_string($result)) {
  return 'Картинка обновлена';
} else {
  switch($result) {
    case -1:
      return 'Место с таким ID не найдено';
      break;
    case -2:
      return 'Исходная картинка не найдена';
      break;
    case -3:
      return 'Невозможно создать директорию с ID места';
      break;
  }
}

Now imagine how the switch block will grow as the number of checks increases? And if the method itself must return an int, then we have to come up with something for the return type of errors? Overall, such code quickly turns into unreadable spaghetti.

Now let's try to do the same checks but using exceptions.

class PlaceService {
  public function updatePlaceImage(int $place_id, string $image_filename): string
  {
    $place = $this->getPlaceById($place_id);

    if (!$place instanceof Place) {
      throw new Exception('Место с таким ID не найдено');
    }

    if (!file_exists($image_filename)) {
      throw new Exception('Исходная картинка не найдена');
    }

    $newImage = ImageHelper::resize($image_filename);

    if (!file_exists(self::IMAGES_DIR . '/' . $place->getId())) {
      $mkdir = mkdir(self::IMAGES_DIR . '/' . $place->getId());
    }

    if (!$mkdir) {
      throw new Exception('Невозможно создать директорию с ID места');
    }
  }
}

In this case, when calling the method, we will only make one check for an exception.

$placeService = new PlaceService();

try {
  $placeService->updatePlaceImage(1, 'filename.jpg');
} catch (Exception $exception) {
  return $exception->getMessage();
}

return 'Картинка обновлена';

Since we have already specified the error text when throwing the exception, we don't need to write it again; it returns when calling $exception->getMessage().

This way, we easily made the code more readable and protected ourselves from possible errors when checking the return value.

Of course, exceptions have their downsides. For example, when an exception event occurs, a corresponding object is created and filled with the necessary data.

Yes, in modern realities, this takes negligible time and can be ignored, but in sensitive systems, this needs to be considered.

Exceptions should be used, but wisely!