SOLID Principles: Part 4, Interface Segregation
Part 4 of SOLID Principles: Writing Maintainable Object-Oriented Code
#OOP #PHP #Symfony
In the fourth article of the SOLID series, we'll explore the Interface Segregation Principle (ISP).
This principle emphasizes creating interfaces that cater to the specific requirements of clients, rather than a universal approach.
Introduction to ISP
Interface Segregation Principle tells us that:
"no code should be forced to depend on methods it does not use."
This principle is particularly relevant in object-oriented programming, where interfaces define contracts between objects and the outside world.
By applying ISP, we can avoid unnecessary dependencies, reduce coupling, and improve the cohesion of our code.
One way I like to think about this one is: first do not put too many methods in the same interface (kind of like SRP but from the perspective of the client) and second watch out for "dummy" methods in your concrete implementations, i.e. methods that you have to implement to "please" your interface but that you do not really need in this particular implementation.
Project Setup
If you read the previous articles in this SOLID series, you are probably now accustomed with the simple blog demo application built with Symfony that we use in our examples. If not, then I suggest that you read the other articles first as each of them is starting where the previous one finished.
You can find the code for the example in this article in this Github repository. We will start on the "liskov_right" branch which is where we left off in the previous article about Liskov Substitution Principle.
To install the application, follow the steps in the README file. For more details on this application please have a look at the first article of this series.
Adding an Import Articles Command
Let's get started by adding a new functionality to our application. We want to be able to import new articles from a yaml data file.
We will first create this new file, to keep things simple I will use the same keys as the properties of our Article entity.
data/articles.yaml
- title: 'Article 1 From Yaml File'
slug: 'article_1_from_yaml_file'
image: "https://picsum.photos/id/21/1000/600"
content:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut imperdiet neque velit, eget consectetur ante.'
- title: 'Article 2 From Yaml File'
slug: 'article_2_from_yaml_file'
image: "https://picsum.photos/id/22/1000/600"
content:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut imperdiet neque velit, eget consectetur ante.'
- title: 'Article 3 From Yaml File'
slug: 'article_3_from_yaml_file'
image: "https://picsum.photos/id/23/1000/600"
content:
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut imperdiet neque velit, eget consectetur ante.'
Then we will create an interface that our importer service will have to implement, it defines an "importArticles" method signature, this method will be responsible for the import itself.
App\Contract\ImporterInterface
namespace App\Contract;
interface ImporterInterface
{
public function importArticles(): void;
}
The importer service itself will use the Yaml component to parse the file and our ArticleCreator service to create the new articles.
App\Service\Import\LocalFileImporter
namespace App\Service\Import;
use App\Contract\ImporterInterface;
use App\DTO\ArticleDto;
use App\Service\ArticleCreator;
use Symfony\Component\Filesystem\Exception\FileNotFoundException;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Yaml\Yaml;
class LocalFileImporter implements ImporterInterface
{
public function __construct(
private readonly FileSystem $fileSystem,
private readonly ArticleCreator $articleCreator,
private readonly string $filePath,
)
{
}
public function importArticles(): void
{
if (!$this->fileSystem->exists($this->filePath)) {
throw new FileNotFoundException(sprintf('File %s not found', $this->filePath));
}
$articles = Yaml::parseFile($this->filePath);
foreach ($articles as $article) {
$this->articleCreator->create(
new ArticleDto(
$article['title'],
$article['slug'],
$article['content'],
$article['image'],
)
);
}
}
}
We need to configure the "$filePath" argument in our ou services.yaml file.
config/services.yaml
services:
...
App\Service\Import\LocalFileImporter:
arguments:
$filePath: data/articles.yaml
To call this importer service we create a new console command.
It takes an ImporterInterface as a dependency, for now there is only one class that implements this interface so we do not need to do more configuration.
App\Command\ImportArticlesCommand
namespace App\Command;
use App\Contract\ImporterInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(
name: 'app:import-articles',
description: 'Imports articles.',
hidden: false,
)]
class ImportArticlesCommand extends Command
{
public function __construct(private readonly ImporterInterface $importer)
{
parent::__construct();
}
protected function configure(): void
{
$this
->setHelp('This command allows you to import articles...')
;
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$output->writeln('Importing articles');
$this->importer->importArticles();
$output->writeln('Import done!');
return Command::SUCCESS;
}
}
We can run the command like that:
php bin/console app:import-article
If we go to the list of our articles on our application, we can see that 3 new articles have been added.
Breaking the ISP
Everything works fine and we did not break the ISP yet.
Now we receive a new requirement to make our import of articles command work with data from an API source.
We will use dummyJSON API for this example.
As we will need to make some HTTP request to the API we will start by installing the HTTP Client component from Symfony.
composer require symfony/http-client
For the sake of our example let's say that our source API requires some kind of authentication. We add a "getAuthToken" method to our ImporterInterface interface. It will be responsible for getting the authentication token that will be added to the API requests headers.
App\Contract\ImporterInterface
namespace App\Contract;
interface ImporterInterface
{
public function importArticles(): void;
public function getAuthToken(): string;
}
Then we create our new API importer service and make it implement our ImporterInterface interface.
It uses the HttpClient to make requests, our ArticleCreator service to create new articles and some config variables for the authentication and articles urls.
The "importArticles" method first calls the "getAuthToken" method to get the authentication token, then it sends a GET request to the API articles url, decode it and loop over the data to create new articles.
App\Service\Import\ApiImporter
namespace App\Service\Import;
use App\Contract\ImporterInterface;
use App\DTO\ArticleDto;
use App\Service\ArticleCreator;
use Symfony\Component\String\Slugger\SluggerInterface;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class ApiImporter implements ImporterInterface
{
public function __construct(
private readonly HttpClientInterface $client,
private readonly ArticleCreator $articleCreator,
private readonly SluggerInterface $slugger,
private readonly string $authUrl,
private readonly string $articlesUrl,
)
{
}
/**
* @throws RedirectionExceptionInterface
* @throws ClientExceptionInterface
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface
*/
public function importArticles(): void
{
$token = $this->getAuthToken();
$response = $this->client->request('GET', $this->articlesUrl, [
'headers' => [
'Authorization' => 'Bearer '.$token
]
]);
$articles = json_decode($response->getContent());
foreach ($articles->posts as $article) {
$this->articleCreator->create(
new ArticleDto(
$article->title,
$this->slugger->slug($article->title),
$article->body,
"https://picsum.photos/id/" . $article->id + 40 . "/1000/600",
)
);
}
}
/**
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface
* @throws RedirectionExceptionInterface
* @throws ClientExceptionInterface
*/
public function getAuthToken(): string
{
$response = $this->client->request('POST', $this->authUrl, [
'body' => [
'username' => 'kminchelle',
'password' => '0lelplR',
]
]);
return json_decode($response->getContent())->token;
}
}
We need to update some configuration in our services.yaml file for the $authUrl and $articlesUrl arguments. And we just need to tag the ImporterInterface with our new ApiImporter service to use it in our ImportArticlesCommand instead of the LocalFileImporter.
config/services.yaml
services:
...
App\Service\Import\ApiImporter:
arguments:
$authUrl: https://dummyjson.com/auth/login
$articlesUrl: https://dummyjson.com/posts?limit=5
App\Contract\ImporterInterface: '@App\Service\Import\ApiImporter'
Finally as we modified ImporterInterface we must update LocalFileImporter accordingly by adding an implementation for "getAuthToken" method or else PHP will yell at us with en error:
"Error: Class App\Service\Import\LocalFileImporter contains 1 abstract method and must therefore be declared abstract or implement the remaining methods (App\Contract\ImporterInterface::getAuthToken)"
App\Service\Import\LocalFileImporter
class LocalFileImporter implements ImporterInterface
{
...
public function getAuthToken(): string
{
return 'whatever...';
}
}
And this is where we are breaking the Interface Segregation Principle.
We have to implement a method that is completely useless in this LocalFileImporter class.
You can find all the code for this in the interface_segregation_wrong branch.
By the way, when we run our command with the new API importer we can see that it works fine:
Refactoring to Follow the ISP
To fix this, we must begin by removing the "getAuthToken" from the ImporterInterface interface.
App\Contract\ImporterInterface
namespace App\Contract;
interface ImporterInterface
{
public function importArticles(): void;
}
We will also remove it from the LocalFileImporter class where it is not needed.
App\Contract\ImporterInterface
namespace App\Service\Import;
use App\Contract\ImporterInterface;
use App\DTO\ArticleDto;
use App\Service\ArticleCreator;
use Symfony\Component\Filesystem\Exception\FileNotFoundException;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Yaml\Yaml;
class LocalFileImporter implements ImporterInterface
{
public function __construct(
private readonly FileSystem $fileSystem,
private readonly ArticleCreator $articleCreator,
private readonly string $filePath,
)
{
}
public function importArticles(): void
{
if (!$this->fileSystem->exists($this->filePath)) {
throw new FileNotFoundException(sprintf('File %s not found', $this->filePath));
}
$articles = Yaml::parseFile($this->filePath);
foreach ($articles as $article) {
$this->articleCreator->create(
new ArticleDto(
$article['title'],
$article['slug'],
$article['content'],
$article['image'],
)
);
}
}
}
Instead we will create a new HasAuthTokenInterface interface.
App\Contract\HasAuthTokenInterface
namespace App\Contract;
interface HasAuthTokenInterface
{
public function getAuthToken(): string;
}
And we will make the ApiImporter class implement both ImporterInterface and HasAuthTokenInterface.
App\Service\Import\ApiImporter
namespace App\Service\Import;
use App\Contract\HasAuthTokenInterface;
use App\Contract\ImporterInterface;
use App\DTO\ArticleDto;
use App\Service\ArticleCreator;
use Symfony\Component\String\Slugger\SluggerInterface;
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class ApiImporter implements ImporterInterface, HasAuthTokenInterface
{
public function __construct(
private readonly HttpClientInterface $client,
private readonly ArticleCreator $articleCreator,
private readonly SluggerInterface $slugger,
private readonly string $authUrl,
private readonly string $articlesUrl,
)
{
}
/**
* @throws RedirectionExceptionInterface
* @throws ClientExceptionInterface
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface
*/
public function importArticles(): void
{
$token = $this->getAuthToken();
$response = $this->client->request('GET', $this->articlesUrl, [
'headers' => [
'Authorization' => 'Bearer '.$token
]
]);
$articles = json_decode($response->getContent());
foreach ($articles->posts as $article) {
$this->articleCreator->create(
new ArticleDto(
$article->title,
$this->slugger->slug($article->title),
$article->body,
"https://picsum.photos/id/" . $article->id + 40 . "/1000/600",
)
);
}
}
/**
* @throws TransportExceptionInterface
* @throws ServerExceptionInterface
* @throws RedirectionExceptionInterface
* @throws ClientExceptionInterface
*/
public function getAuthToken(): string
{
$response = $this->client->request('POST', $this->authUrl, [
'body' => [
'username' => 'kminchelle',
'password' => '0lelplR',
]
]);
return json_decode($response->getContent())->token;
}
}
To test our command, we can either delete the last five articles that where created before or, in services.yaml, we can update the $articlesUrl to "https://dummyjson.com/posts?skip=5&limit=5", addind a "skip=5" query parameter will allow us to fetch the next articles after the five first.
You can find all the code for this version in the interface_segregation_right branch.
And that's it the ISP is fixed.
Of course here the HasAuthTokenInterface is not really useful and was added for the sake of this demonstration.
I hope this article was helpful in introducing the Interface Segregation Principle and how it can be applied in object-oriented programming to reduce coupling and improve the cohesion of our code.
I also hope the example provided in this article helped demonstrate how ISP can be implemented.
Thank you for reading and see you in the next, and last, article in this SOLID series which will be about Dependency Inversion Principle.
Articles in this series
Last updated 1 year ago