On using PSR abstractions
source link: https://matthiasnoback.nl/2021/08/on-using-psr-abstractions/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
Several years ago, when the PHP-FIG (PHP Framework Interop Group) created its first PSRs (PHP Standard Recommendations) they started some big changes in the PHP ecosystem. The standard for class auto-loading was created to go hand-in-hand with the then new package manager Composer. PSRs for coding standards were defined, which I'm sure helped a lot of teams to leave coding standard discussions behind. The old tabs versus spaces debate was forever settled and the jokes about it now feel quite outdated.
Next up were the PSRs that aimed for the big goal: framework interoperability. It started with an easy one: PSR-3 for logging, but it took quite some time before the bigger ones were tackled, e.g. request/response interfaces, HTTP client and server middleware interfaces, service container interfaces, and several others. The idea, if I remember correctly, was that frameworks could provide implementation packages for the proposed interfaces. So you could eventually use the Symfony router, the Zend container, a Laravel security component, and so on.
I remember there were some troubles though. Some PSRs were abandoned, some may not have been developed to their full potential, and some may have been over-developed. I think it's really hard to find common ground between framework implementations for all these abstractions, and to define abstractions in such a way that users and implementers can both be happy (see for example an interesting discussion by Anthony Ferrara about the HTTP middleware proposal and an older discussion about caching).
One of the concerns I personally had about PSR abstractions is that once you have a good abstraction, you don't need multiple implementation packages. So why even bother creating a separate abstraction for others to use? Why not just create a single package that has both the implementation and the abstraction? It turns out, that doesn't work. Why? Because package maintainers sometimes just abandon a package. And if that happens, the abstraction becomes useless too because it is inside that abandoned package. So developers do like to have a separate abstraction package that isn't even tied to their favorite vendor.
(By the way, I think it's strange for frameworks to have their own Interfaces or Contracts package for their abstractions. I bet there are 0 cases where someone using Laravel or Symfony keeps using its abstractions, but not its implementations. Anyway... If you have a different experience, or want to share your story about these packages, please submit a comment below!)
Is it safe to depend on PSR abstraction packages?
Back in 2013, Igor Wiedler made a lasting impression with their article about dependency responsibility. By now we all know that by installing a vendor package you can import bugs and security issues into your project. Another common concern is the stability of the package: is it going to be maintained for a long time? Are the maintainers going to change it often?
Yes, these concerns should be addressed, and I think in general they are not considered well enough. But we need to distinguish between different kinds of packages. Packages have a certain level of stability which is in part related to its abstractness and the number of dependencies it has (if you're interested in this topic, check out my book "Principles of Package Design").
The abstractness of a package is based on the number of interfaces versus the number of classes. Since abstract things are supposed to change less often than concrete things, and in fewer ways, an abstract package will be a stable package and it will be more reliable than less abstract, i.e. concrete packages (I think this is why frameworks provide those Interface or Contract packages: as an indication of their intended stability).
Another reason for a package to become stable is when it is used by many people. This is more of a social principle: the maintainers won't change the package in drastic ways if that makes the users of the package angry. Of course, we have semantic versioning and backward compatibility promises for that, but abstract packages are less likely to change anyway, so it should be safe for many projects to rely on the abstractions and swap out the implementation packages if it's ever needed.
Applying these considerations to PSR abstraction packages: they are abstract packages, and they are likely going to have many users (this depends on package and framework adoption actually), so they are also not likely to change and become a maintenance liability.
Should a project have its own wrappers for PSR abstractions?
Since decoupling from vendor code has become a common strategy for developers, developers may now wonder: should we also decouple from PSR abstractions? E.g. create our own interfaces, wrapper classes, and so on? This is often inspired by some team rule that says you can never depend directly on vendor code. This may sometimes even be inspired by something I personally advocate: to decouple domain code from infrastructure code. However, keep in mind that:
- Not all code in vendor is infrastructure code, and
- Decoupling is only required in domain code
As an example, if you want to propagate a domain event to a remote web service, don't do it in the Domain layer. Do it in the Infrastructure layer, where in terms of dependency directions it's totally okay to use the PSR-18 HTTP client interface. No need to wrap that thing or create your own interface for it. It is a great abstraction already: it does what you need, nothing more, nothing less. After all, what you need is to send an HTTP request and do something with the returned HTTP response:
namespace Psr\Http\Client;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
interface ClientInterface
{
/**
* @throws \Psr\Http\Client\ClientExceptionInterface
*/
public function sendRequest(
RequestInterface $request
): ResponseInterface;
}
The only problem about this interface is maybe: how can you create a RequestInterface
instance? PSR-18 relies on PSR-7 for the request and response interfaces, but you need an implementation package to actually create these objects. Every time I need an HTTP client I struggle with this again: what packages to install, and how to get a hold of these objects? Once it's done, I may feel the need to never having to figure it out again and introduce my own interface for HTTP clients, e.g.
interface HttpClient
{
public function get(string $uri, array $headers, array $query): string;
public function post(string $uri, array $headers, string $body): string;
}
Then I should add an implementation of this interface that handles the PSR-18 and PSR-7 abstractions for me. Unfortunately, by creating my own abstraction I lose the benefits of using an established abstraction, being:
- You don't have to design a good abstraction yourself.
- You can use the interface and rely on an implementation package to provide a good implementation for it. If you find another package does a better job, it will be a very easy switch.
If you wrap PSR interfaces with your own classes you lose these benefits. You may end up creating an abstraction that just isn't right, or one that requires a heavy implementation that can't be easily replaced. In the example above, the interface inspires all kinds of questions for its user:
- What is the structure of the
$headers
array: header name as key, header value as value? All strings? - Same for
$query
; but does it support array-like query parameters? Shouldn't the query be part of the URI? - Should
$uri
contain the server hostname as well? - What if we want to use other request methods than get or post?
- What if we want to make a POST request without a body?
- What if we want to add query parameters to a POST request?
- How can we deal with failure? Do we always get a string? What kind of exceptions do these methods throw?
These questions have already been answered by PSR-18 and PSR-7, but by making our own abstraction we reintroduce the vagueness. Of course, we can remove the vagueness by improving the design, adding type hints, etc., but then we spend valuable time on something that was already done for us, and by more minds, with more knowledge about the HTTP protocol and more experience maintaining HTTP clients than us. So we should really think twice before wrapping these abstractions.
What about PSR abstractions that end up being outdated?
Without meaning to discredit the effort that went into it, nor anyone involved, there will always be standards that end up being outdated, like in my opinion PSR-11: Container interface. This PSR describes an interface that several container implementations support:
<?php
namespace Psr\Container;
/**
* Describes the interface of a container that exposes methods to read its entries.
*
*/
interface ContainerInterface
{
/**
* Finds an entry of the container by its identifier and returns it.
*
* @param string $id Identifier of the entry to look for.
*
* @throws NotFoundExceptionInterface No entry was found for **this** identifier.
* @throws ContainerExceptionInterface Error while retrieving the entry.
*
* @return mixed Entry.
*/
public function get($id);
/**
* Returns true if the container can return an entry for the given identifier.
* Returns false otherwise.
*
* `has($id)` returning true does not mean that `get($id)` will not throw an exception.
* It does however mean that `get($id)` will not throw a `NotFoundExceptionInterface`.
*
* @param string $id Identifier of the entry to look for.
*
* @return bool
*/
public function has($id);
}
PHP developers are relying more and more on types and this interface doesn't provide much help in that area. Even if you use class or interface names as "entry IDs", you still have to add type hints after using get()
, before the IDE and other static analysers can understand what's going on:
/** @var Router $router */
$router = $container->get(Router::class);
That's because the declared return type of get()
is mixed
.
Another issue with the PSR-11 container is that it supports a pattern called service locator, which is generally considered an anti-pattern, except near the composition root. This means that the primary use for a container like this is when the request path will be matched with a controller that needs to be invoked. So the "request dispatcher" will load the controller from the container, e.g.
$controllerId = $router->match($request);
/** @var Controller $controller */
$controller = $container->get($controllerId);
$response = $controller->handle($request);
Maybe $controllerId
is an undefined entry, in which case you get a NotFoundExceptionInterface
. If you want to deal with this error in your own way you could catch the exception, or call has()
first:
if ($container->has($controlledId)) {
// throw custom exception
}
However, why would a controller not be defined? This will always be a developer mistake. So has()
is completely unnecessary. The container should just throw an exception.
Considering the return type problem again, we'd be much better off if a container would return only services of a specific type. For controllers, we'd have a ControllerContainer
or ControllerFactory
(after all, a container is some kind of generic factory). You could only get Controller
s from it:
interface ControllerFactory
{
/**
* @throws CouldNotCreateController
*/
public function createController(string $controllerId): Controller;
}
The only other thing we'd need is a container for the application's entry point, but again, this doesn't need to be a generic container either:
final class ApplicationFactory
{
public function createApplication(): Application
{
// ...
}
}
// in the front controller (e.g. index.php):
(new ApplicationFactory())->createApplication()->run();
This is only a simplified example to show the problem and provide possible solutions. In practice you'll need a bit more; see also my article about Hand-written service containers. Still we should conclude that ContainerInterface
is somewhat outdated (mainly because the lack of type support), and that it comes with design issues or may design issues in your own project.
It still doesn't mean we should wrap ContainerInterface
or other PSR interfaces. It means we may skip it entirely, and just not use it in our own code. A PSR abstraction isn't good because it's an abstraction, or because it has been designed by smart people. And even if it's great today, it may not be good forever. So, as always, we should be prepared to modernize and upgrade our code base when needed. At the same time, we should also use PSR abstractions whenever it makes sense, since they will save us a lot of design work and will make our code less sensitive to changes in vendor packages.
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK