DEV Community

Niklas Lampén for Supermetrics

Posted on

Interfaces and exceptions

Using interfaces – or protocols in some languages – is something we as programmers definitely want to do. Interfaces should be used to define dependencies between classes as it comes with quite a few benefits such as following the Dependency Inversion Principle.

I'm using PHP in my examples, but this applies to other languages, too.

TL;DR

An interface is a contract, defining dependencies with interfaces makes it easy to replace an implementation, and you should only throw specified exceptions from them.

What is an interface?

An interface is a specification that the implementing class has to follow. This means that no matter what concrete class we operate with, we know what to expect.

A simple example

Let’s start with a simple example. In this example we want to store user data to a storage. Because we’re looking well ahead, we understand to not lock ourselves with our current storage solution, which is MySQL. As the first step we went through the requirements and realized that we need to be able to store users to the storage, and fetch users from the storage based on user’s ID.

After thinking about it for a while, we came up with a definition of how we want to communicate to the storage, a.k.a. interface, a.k.a. UserDriverInterface:

interface UserDriverInterface
{
    /**
     * Store User to a database
     *
     * @param User $user The user to store to the storage
     *
     * @throws UserException Thrown if storing of the User fails.
     * @return bool
     */
    public function store(User $user): bool;

    /**
     * Find a User from the database by the ID
     *
     * @param string $id ID of the user to find from the storage
     *
     * @return User|null Returns an instance of User if user is found from the storage, NULL otherwise.
     */
    public function findById(string $id): ?User;
}
Enter fullscreen mode Exit fullscreen mode

As said, our current storage solution is MySQL, so we wrote our first implementation, which connects to a MySQL database and uses that as the storage. As you can see, it implements UserDriverInterface:

class MysqlUserDriver implements UserDriverInterface
{
    protected ?PDO $connection;

    /**
     * @param string $username User name for the MySQL server
     * @param string $password Password for the MySQL server
     * @param string $server   MySQL Server URL
     * @param int    $port     MySQL Server port to use
     * @param string $database Name of the MySQL database/schema
     *
     * @throws UserException
     */
    public function __construct(string $username, string $password, string $server, int $port, string $database)
    {
        $connectionString = sprintf('mysql:host=%s;port=%d;dbname=%s', $server, $port, $database);

        try {
            $this->connection = new PDO($connectionString, $username, $password);
        }
        catch (\PDOException $e) {
            throw new UserException(
                'Connecting to MySQL server failed: ' . $server,
                0,
                $e
            );
        }
    }

    /**
     * @inheritDoc
     */
    public function store(User $user): bool
    {
        $stmt = $this->connection->prepare('INSERT INTO users (id, name) VALUES (:id, :name)');
        $stmt->bindValue(':id', $user->getId(), PDO::PARAM_STR);
        $stmt->bindValue(':name', $user->getName(), PDO::PARAM_STR);

        if ($stmt->execute() === false) {
            throw new UserException("Couldn't store user to MySQL database");
        }

        return true;
    }

    /**
     * @inheritDoc
     */
    public function findById(string $id): ?User
    {
        $stmt = $this->connection->prepare('SELECT id, name FROM users WHERE id=:id');
        $stmt->bindValue(':id', $id, PDO::PARAM_STR);
        $stmt->execute();
        $userData = $stmt->fetch(PDO::FETCH_OBJ);

        if ($userData === false) {
            return null;
        }

        /**
         * This should rather be done in separate converter
         */
        $user = new User();
        $user->setId($userData->id)
            ->setName($userData->name)
        ;

        return $user;
    }
}
Enter fullscreen mode Exit fullscreen mode

How do we use this?

When we're writing our code, the only thing we refer to is the interface. One way could be fetching this driver from a DI-container with something like $container->get(UserDriverInterface::class). Or maybe you want to abstract it more and add a UserService to the stack. In the latter case only the service would know about the driver interface, and the code using the service would just use the service and not know what happens inside it.

As we're not really referencing to the concrete classes anywhere, it’s easy for us to change the concrete implementation from MySQL to something else. Maybe it’s for testing, or maybe we just decided that using plain text files is the way to go.

Another implementation

Let’s assume that we want to use a in-memory storage for testing our main implementation. We could write something like this:

class MemoryUserDriver implements UserDriverInterface
{
    /**
     * @var array The data storage
     */
    protected array $data = [];

    /**
     * @inheritDoc
     */
    public function store(User $user): bool
    {
        if (empty($user->getId())) {
            throw new UserException('Can\'t store User without ID');
        }

        $this->data[$user->getId()] = $user;

        return true;
    }

    /**
     * @inheritDoc
     */
    public function findById(string $id): ?User
    {
        return $this->data[$id] ?? null;
    }
}
Enter fullscreen mode Exit fullscreen mode

All of the code you have written for MysqlUserDriver will work just as fine with MemoryUserDriver as both are implementing the UserDriverInterface.

If you wanted to use the memory storage in your development environment, and to use MySQL storage in production, you could have something like this in your factory:

class MyUserDriverFactory implements FactoryInterface {
    public function create(ContainerInterface $container): UserDriverInterface {
        $env = $container->get('environment');

        if ($env === System::ENV_PROD) {
            // Return MysqlUserDriver
        }

        // Return MemoryUserDriver
    }
}
Enter fullscreen mode Exit fullscreen mode

Interface and exceptions

As a reminder, here’s the definition of store() method of the UserDriverInterface:

    /**
     * Store User to a database
     *
     * @param User $user The user to store to the storage
     *
     * @throws UserException Thrown if storing of the User fails.
     * @return bool
     */
    public function store(User $user): bool;
Enter fullscreen mode Exit fullscreen mode

As you can see, the only exception it should throw is UserException. But what if our implementation is using a library that might throw an \AwesomeLibrary\SpaceTimeContinuumException?

What you should do is catch the \AwesomeLibrary\SpaceTimeContinuumException, and throw the UserException instead.

Why bother with catching and throwing?

Because, if we don’t, the code using the interface needs to know about the exact implementation of the interface – which pretty much defeats the purpose of an interface. The code using the interface shouldn’t know about the implementation.

When we are working on an interface that’s internal (used only inside the project), it’s easy to alter the interface each time you modify one of the implementations – but you shouldn’t do this. You should think about your interface as being defined somewhere else. Could you then add your own exceptions to it’s PHPDoc in that case? Well, no.

Or, what if there were 100 implementations of the interface? To list all of the possible exceptions, the interface should know about each implementation, and change the description of the interface for each of the implementations. Sounds sane? I didn’t think so.

And more importantly: what about the existing implementations? Let’s assume there’s this code:

try {
    $driver->store($user);
} catch (UserException $exception) {
    $logger->log('Storing user failed: ' . $exception->getMessage());

    return false;
}
Enter fullscreen mode Exit fullscreen mode

$driver->store() is expected to thrown a UserException but no other exceptions. If someone decided that now the store() method can also throw \AwesomeLibrary\SpaceTimeContinuumException, the code wouldn’t handle it.

Summary

When defining an interface you should think about things going wrong as well as the happy paths, and you should have a plan how these issues are communicated to the class/function using the interface. Your mindset should be that you don't know anything about the concrete classes implementing the interface, and the concrete classes don't know anything about where they are being used.


supermetrics.com/careers/engineering

Top comments (0)