DEV Community

Aleksander Wons
Aleksander Wons

Posted on

Using interfaces the wrong way

This is a copy of an old post on my private blog from July 2023.

About interfaces

What is an interface? On the surface, it's an abstraction construct that allows us to define a contract using the following syntactic properties:

  • methods
  • arguments
  • return values

As we can see, we only talk about the syntax. Semantics are up to the implementor of that interface. But it may not be enough. Depending on the use case, some semantics might be required to define an interface. Let's look at the PSR-18 standard that defines an HTTP client. The interface itself is straightforward:

interface ClientInterface
{
    /**
     * @throws \Psr\Http\Client\ClientExceptionInterface If an error happens while processing the request.
     */
    public function sendRequest(RequestInterface $request): ResponseInterface;
}
Enter fullscreen mode Exit fullscreen mode

One would think we would have to implement the method however we like, as long as the method takes RequestInterface as an argument and returns ResponseInterface.

That's not true. If we open the PSR-18 specification, there are a lot of additional semantic requirements that the implementor needs to fulfill to meet the interface requirements. There are some requirements for the client itself, as well as error handling. Anything that does not meet those requirements cannot say it implements the interface. Even if the signature matches. Compatibility on the signature level is not enough.

Example

Let's devise an example depicting when using such generic interfaces in your custom production code can lead to issues or misunderstandings.

We will follow up with the PSR-18. We will build an HTTP client for making requests to a third-party API that requires OAuth authorization. To make a successful call, we must first authenticate the client and then use a token to make the final request. We will write PHP code here, but please remember there will be no real implementation—just the scaffolding with interfaces, classes, and method signatures. Anything that is not relevant to this post will be purposefully omitted.

We start with the authentication part. Because every request to the API needs a bearer token, we have to obtain it before we can continue. For that, we will build our class that will make an HTTP request to obtain the token.

class APIAuthentication
{
    public function __construct(
        private readonly ClientInterface $httpClient,
        private readonly string $clientID,
        private readonly string $clientSecret
    ){}

    public function getBaererToken(): string
    {
        // Prepare the request...
        $response = $this->httpClient->sendRequest($request);
        // Parse response...

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

We can use this class in another one that will make the final HTTP requests. To make it generic, we can use the PSR-18 interface.

class APIHttpClient implements ClientInterface
{
    public function __construct(
        private readonly APIAuthentication $apiAuth,
        private readonly ClientInterface $httpClient
    ){}

    public function sendRequest(RequestInterface $request): ResponseInterface
    {
        $token = $this->apiAuth->getBaererToken();
        // Enrich the request with the token
        // ...
        return $this->httpClient->sendRequest($request);
    }
}
Enter fullscreen mode Exit fullscreen mode

When using generic interfaces is not what you want.

At this point, we have an HTTP client that implements the standard PSR-18 interface. Apart from delegating the request to the underlying generic implementation, this client will ensure the request has the required authorization token. All that is left is to build the API client.

class APIClient
{
    public function __construct(
        private readonly ClientInterface $httpClient
    ){}

    public function getSomething()
    {
        // Prepare the request
        ...
        $response = $this->httpClient->sendRequest();
        // Process response
        // ...
        return $result;
    }
}
Enter fullscreen mode Exit fullscreen mode

This class would then be instantiated like this:

$apiAuth = new ApiAuthentication(/** ... */);
$apiHttpClient = new APIHttpClient($apiAuth, /** ... */);
$apiClient = new ApiClient($apiHttpClient, /** ... */);
Enter fullscreen mode Exit fullscreen mode

This code would work just fine. It has, IMO, a problem that might not be obvious at first. The problem is the signature of the APICleint's constructor. The signature says we can inject any object if it implements the ClientInterface. The problem - this is a lie. Our API client needs authorization. But the PSR-18 standard says nothing about that. It's just a request. In theory, we could try to inject any generic PSR-18 client implementation, and it would always fail to obtain results. Why? Because there is no authorization happening. And there shouldn't be because the interface does not mandate it.

As mentioned before, an interface is not only a set of syntax requirements. Each interface has a very specific semantic meaning that needs to be implemented in all cases. Nothing more, nothing less. We can rely on generic interfaces if we can adhere to those requirements. But in the above example, we really can't.

We will see how we can improve the design later.

Generic interfaces and frameworks

I can already hear people saying there is nothing wrong with that approach because the "put your favorite framework here" framework does it like that.

Well, that's because they usually got it right.

Let's have a look at Symfony's HttpCleintInterface. It's an interface for an HTTP client — something similar to PSR-18 but in Symfony's version.

If we browse through the code, we will find a lot of classes implementing that interface: CurlHttpClient, NoPrivateNetworkHttpClient, ScopingHttpClient, UriTemplateHttpClient, RetryableHttpClient, EventSourceHttpClient. They are usually interchangeable. As always, with a strong emphasis on "usually." Using some of the implementations may be awkward or not really make more sense, but it will mostly work.

Let's have a look at the class EventSourceHttpClient and try to use it in Symfony\Component\Webhook\Server\Transport. It's an entirely hypothetical example to illustrate the point.

For that class to work, we need to call the connect() method before we can start sending requests. It's not authentication but something that needs to happen before. Otherwise, our requests will all fail. The caller needs to connect to the source to use this class in the transport class, and then the generic transport implementation can do its thing. We can always replace the client with, for example, RetryableHttpClient, and the transport still works as expected. The difference is that the caller does not need to call the connect() method.

I can already see you saying, "And how does this differ from your example above? It's the caller's responsibility to make the authentication". Here is where I disagree. In case of EventSourceHttpClient, it is the implementation of EventSourceHttpClient that requires calling connect(). In our previous example, it is the API client that requires authentication. Nothing to do with the HTTP client. We use an HTTP client to obtain the token, but this is needed for the API client and not for the HTTP Client implementation.

When Symfony uses those classes, it assumes the implementation follows the interface to the letter. And if not, then the details are irrelevant from the user's perspective. Let's take a look at the Symfony\Component\Webhook\Server\Transport class. It is very straightforward. Take a subscriber and an event, and send a request based on those two values. This is as generic as it gets. It does not care about what transport we talk about. It does not care about the concrete HTTP client implementation. It makes something generic. Therefore it is OK to have the HTTP client argument as an interface. If there is anything specific on the HTTP level, it needs to happen outside of that class.

If we compare it with our previous API example, things are entirely different. We are no longer talking about something generic. We cannot say, "Just use anything that implements the interface," because we have particular requirements not fulfilled by the interface specification. And therefore we need a different solution.

Let's go back to Symfony and find other places HttpCleintInterface is used. One other example is Symfony\Component\Notifier\Bridge\Twilio\TwilioTransport. Twilio requires authentication. Exactly as our API. And how is it implemented? The authentication is configured within the transport class. Not in the client directly. Why? We already know the reason. To use a generic client, we need to guarantee that as long as the user provides a correct interface implementation, our functionality works as expected. The provider of the client needs to know nothing about the authentication.

And this is how we could solve our API problem.

class APIClient
{
    public function __construct(
        private readonly ClientInterface $httpClient,
        private readonly AuthProvider $authProvider
    ){}

    public function getSomething()
    {
        $token = $this->authProvider->getToken();
        // Prepare the request with auth
        ...
        $response = $this->httpClient->sendRequest();
        // Process response
        // ...
        return $result;
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, our API client can depend on a generic interface. True, it is not 100% foolproof, but as long as the provider follows the interface's semantics, we are good to go.

Summary

As we can see, defining and using interfaces is not as simple as one may think. A definition of an interface consists of syntactic and semantic requirements. Sometimes, it is enough to define the signatures, and everybody knows what needs to be done. Sometimes, we have to add more context and semantic requirements that need to be fulfilled by the implementor.

It's essential to distinguish between the requirements of the specific implementation of an interface and the requirements of the user of an interface. As long as we keep them apart, we should be fine :)

Top comments (0)