The Bigger the Interface, The Weaker the Abstraction

The Bigger the Interface, The Weaker the Abstraction

The Coder's Proverbs #4

Featured on Hashnode

The Coder's Proverbs is a series where I summarize some lessons and principles I've learned over my career by using a memorable and simple saying of wisdom.


In previous articles, we have been talking a bit about abstractions: specifically, how to design good ones. The last article was about keeping an eye on leaking implementation details from the public API of an abstraction (the interface definition). Today, we'll talk about keeping the size of our abstractions small, so they can be used effectively.

Before I continue with this article, I must give credit where credit is due. I heard this proverb actually from Rob Pike in his Go Proverbs talk. I don't know if he heard it from someone else or not, but I heard it from him. His explanation is rather short and he claims it to be "a very go specific idea" but I think this principle is quite applicable to other languages as well.

When I'm explaining this to other developers I always point to the StreamInterface in the PSR-7 standards. It's a massive interface that breaks this idea from top to bottom. Here is a list of what this interface does:

  1. It writes (write)

  2. It checks it can write (isWritable)

  3. It reads (read, getContents, __toString)

  4. It checks it can read (isReadable)

  5. It closes (close)

  6. It seeks (eof, rewind, tell, seek)

  7. It checks it can seek (isSeekable)

  8. It exposes implementation details

    1. You can get the underlying PHP resource (detach)

    2. You can get the resource metadata (getMetadata)

There are several problems with this interface. First, it does too much. Sometimes we just need a thing where to write data or a thing from where to read. But now any I/O operation with this interface requires that we implement all 14 methods, even if they are not relevant to our use case. This makes composition harder because the API surface to decorate is bigger.

Also, the interface "lies" in a certain way. It says it reads (it has a method for it) but first you need to check if the interface supports reading by invoking isReadable. Same for writing and seeking. This capability checking by using methods is an anti-pattern. Types like interfaces are made to express the idea of capabilities using the type system.

And, because this interface leaks implementation details (it's pretty obvious that it is using a PHP resource) it can only be implemented in that way. We have covered this in the previous article by the way.

Breaking Down the Monster

This interface could be broken down into multiple interfaces:

<?php

interface Reader
{
    /**
      * Reads some bytes from a source.
      *
      * @throws IOError if there is an error while reading
      * @throws EOFError if the end of file has been reached
      */
    public function read(int $bytes): string;
}

interface Writer
{
    /**
      * Writes some bytes to a target.
      *
      * @return int The number of bytes written
      *
      * @throws IOError if there is an error while writing
      */
    public function write(string $contents): int;
}

interface Closer
{
    /**
      * Closes the underlying source
      *
      * @return void
      *
      * @throws IOError if there is an error while closing
      */
    public function close(): void;
}

interface Seeker
{
    /**
      * Seeks to the specified position.
      *
      * @return int The new position of the pointer
      *
      * @throws IOError if there is an error while seeking
      */
    public function seek(int $offset, int $pos = SEEK_CURRENT): int;
}

This is only more powerful now thanks to PHP's 8.1 Intersection Types:

<?php

class Request
{
    public string $method;
    public Uri $uri;
    // Notice how we compose the two types becasue the body of a request can only be read and be closed.
    public Reader&Closer $body;
    public Headers $headers;
}

Type Checking Instead of Method Calling

Instead of calling a method like isSeekable, now we can ensure the type hints for the required behaviour. But also, we could optionally be lenient and take a Reader and on runtime check if we need to seek to a particular point just to be safe:

<?php

function readAll(Reader $reader): string
{
    // Note how this is more robust and clear
    // because it uses the type system
    if ($reader instanceof Seeker) {
        $reader->seek(0, SEEK_START);
    }

    $contents = '';
    while (true) {
        try {
            $contents .= $reader->read(2046); 
        catch (EOFError $e) {
            break;
        }     
    }

    return $contents;
}

Granted: the conditional logic here would be no different than in the StreamInterface by using isSeekable instead of instanceof. But still, it's more powerful because of the type-system: you can enforce the Seeker by type hints and ensure your program will behave correctly.

Think About Capabilities, Not Mere Wrappers

Sometimes, when designing abstractions that may have many methods, it's more useful to think about the abstraction as a collection of capabilities rather than just a mere wrapper around an implementation.

I had this problem when I created a custom abstraction over certain payment gateways in a project I was working on. In the beginning, I started with a big interface called PaymentGateway. It seemed logical at the time:

<?php

interface PaymentGateway
{
    public function authorize(Payment $payment): Authorization;

    public function capture(Money $amount, Authorization $auth): Capture;

    public function refund(Money $amount, Authorization $auth): Refund;
}

But then not all the Payment Gateways I was implementing supported deferred capture of funds, or refunds, so I ended up adding the following methods:

<?php

interface PaymentGateway
{
    public function authorize(Payment $payment): Authorization;

    public function capture(Money $amount, Authorization $auth): Capture;

    public function refund(Money $amount, Authorization $auth): Refund;

    public function supportsCapture(): bool;

    public function supportsRefunds(): bool;
}

That was a bad idea. A Payment Gateway basic mission is to authorize the transfer of funds on behalf of a customer. Capturing and refunding are secondary capabilities that not all gateways possess and support. It would have been better to separate those capabilities into their own interfaces and model them as separate types.

<?php

interface PaymentGateway
{
    public function authorize(Payment $payment): Authorization;
}

interface Capturer
{
    public function capture(Money $amount, Authorization $auth): Capture;
}

interface Refunder
{
    public function refund(Money $amount, Authorization $auth): Refund;
}

This way, a WorldpayPaymentGateway could implement all three of these methods, but others won't.

Conclusion

Big abstractions are weaker. They are harder to implement and often lie about the methods they support, and if you are not careful, they might even end up exposing implementation details.

What I've explained here is really the Interface Segregation Principle. This principle states that a client should not rely on methods they don't use. The original reason is that in certain languages this undesired dependency might trigger a chain of recompilation of other modules. But also from a maintenance perspective, big interfaces make implementation and composition much harder to do than they should be.

Remember then, the bigger the interface, the weaker the abstraction.

Did you find this article valuable?

Support Matías Navarro-Carter by becoming a sponsor. Any amount is appreciated!