ISP: Refactoring & Takeaways

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $8.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

We've just finished adding the ability to add a bonus to the score if the score is less than 50 and there are 3 photos or more on a sighting. And... management is already requesting another change: we need to make sure that - no matter what - a score never receives more than a 100 points.

No problem! We can create another scoring factor class to check for this. In the Scoring/ directory, add a class called, how about, MaxScoreAdjuster. I'm giving this a slightly different name, even though it's a scoring factor, because it's real job is going to be to adjust the score. Make it implement ScoringFactorInterface.

Now go to Code -> Generate - or Command + N on a Mac - and just generate, adjustScore() to start. For the logic, return the minimum of $finalScore or 100. So if the $finalScore is over a hundred, this will return 100.

... lines 1 - 4
use App\Entity\BigFootSighting;
class MaxScoreAdjuster implements ScoringFactorInterface
{
public function adjustScore(int $finalScore, BigFootSighting $sighting): int
{
return min($finalScore, 100);
}
}

Now, setting the priority of the scoring factors so that this is the final one would be especially important. But since that doesn't relate to ISP, we won't worry about it.

Of course, in this new class, we also need to implement the other method: score(). We can just return 0 since we don't care about that.

... lines 1 - 6
class MaxScoreAdjuster implements ScoringFactorInterface
{
... lines 9 - 13
public function score(BigFootSighting $sighting): int
{
return 0;
}
}

Okay, we've got this working! But we've violated ISP! A lot of the classes that implement ScoringFactorInterface - like MaxScoreAdjuster and CoordinatesFactor - have a dummy method... which we added just to satisfy the needs of the interface.

The Signs that You're Violating ISP

When you see something like this, it's a signal that your interface is polluted... or has gotten fat. But again, even though we're using an interface in our example, this also applies to classes in general. If you have a class with multiple public methods... and other parts of your code only use one or some of its methods... that's also a violation of ISP. In fact, that's the main purpose of ISP. You're requiring clients of your class to depend on interfaces - in other words, methods - that they do not need.

What's the solution? Categorize the methods based on their purpose and how they're used... and split them into multiple classes.

For example, if you have a class with 3 methods and 2 of those methods are always called together, then the class should be split into only two pieces: one class with those 2 methods and another class with only the third method.

Splitting our Interface

In our example, it's pretty obvious that splitting the interface into two pieces would make the classes that implement them simpler. So in this Scoring/ directory, create a new class - or really an interface - and call it ScoreAdjusterInterface. What we'll do is go into ScoringFactorInterface, steal the adjustScore() method and move it into the new interface. Hit okay to import that use statement.

... lines 1 - 4
use App\Entity\BigFootSighting;
interface ScoreAdjusterInterface
{
public function adjustScore(int $finalScore, BigFootSighting $sighting): int;
}

Thanks to this, we can now go into CoordinatesFactor and remove the dummy adjustScore()... and then do the same thing in TitleFactor... and also in DescriptionFactor, which feels pretty good! In MaxScoreAdjuster, change this to implement ScoreAdjusterInterface... and then we no longer need the dummy score() method.

... lines 1 - 6
class MaxScoreAdjuster implements ScoreAdjusterInterface
{
public function adjustScore(int $finalScore, BigFootSighting $sighting): int
{
return min($finalScore, 100);
}
}

Injecting the Collection of Scoring Adjusters

Finally, the PhotoFactor class is interesting: it needs to implement both interfaces, which is totally allowed. Add ScoreAdjusterInterface.

... lines 1 - 6
class PhotoFactor implements ScoringFactorInterface, ScoreAdjusterInterface
... lines 8 - 33

The last thing to do is make our SightingScorer support using both interfaces by repeating the trick of injecting a collection of services for ScoreAdjusterInterface. In other words, we're now going to inject an iterable of scoring factors and a second iterable of scoring adjusters.

Start in: src/Kernel.php. Copy the registerForAutoConfiguration()... and we're going to repeat the same thing, but this time for ScoreAdjusterInterface and call the tag scoring.adjuster.

53 lines src/Kernel.php
... lines 1 - 12
class Kernel extends BaseKernel
{
... lines 15 - 41
protected function build(ContainerBuilder $container)
{
... lines 44 - 48
$container->registerForAutoconfiguration(ScoreAdjusterInterface::class)
->addTag('scoring.adjuster');
}
}

Next, over in services.yaml, down on our service, copy the $scoringFactors argument, paste, rename to $scoringAdjusters and use the new tag name: scoring.adjuster.

... lines 1 - 7
services:
... lines 9 - 34
App\Service\SightingScorer:
class: App\Service\DebuggableSightingScorer
arguments:
... line 38
$scoreAdjusters: !tagged_iterator scoring.adjuster

Copy that argument name and head into SightingScorer. Add this as a second iterable argument. Then hit Alt + Enter and go to Initialize Properties to create that property and set it. I'll steal the PHPDoc from above the old property to help my editor know that this will hold an iterable of ScoreAdjusterInterface objects.

... lines 1 - 9
class SightingScorer
{
... lines 12 - 16
/**
* @var ScoreAdjusterInterface[]
*/
private $scoreAdjusters;
public function __construct(iterable $scoringFactors, iterable $scoreAdjusters)
{
... line 24
$this->scoreAdjusters = $scoreAdjusters;
}
... lines 27 - 40
}

Now loop over these instead. You can already see that PhpStorm is not happy because there is no adjustScore() method on the scoring factors. Change this to $scoringAdjusters... and I'll rename the variable to $scoringAdjuster here and here.

... lines 1 - 27
public function score(BigFootSighting $sighting): BigFootSightingScore
{
... lines 30 - 34
foreach ($this->scoreAdjusters as $scoreAdjuster) {
$score = $scoreAdjuster->adjustScore($score, $sighting);
}
... lines 38 - 39
}
... lines 41 - 42

Done! We made our interface smaller, which allowed us to remove all of the dummy methods.

Why Should We Care about ISP?

So, other than being forced to create dummy methods just to make an interface happy, why should we care about ISP? I can think of three reasons.

The first is naming. Whether you have a class that's too big or an interface like in our example, splitting it into smaller pieces allows you to give each a more descriptive name that fits its purposes. We can see this in SightingScorer. We're now working with scoring adjusters, which better describes the purpose of those services than just a "scoring factor"... which does multiple things.

The second is that ISP is a good signal that you might be violating the single responsibility principle. If you notice that you often only call one or two methods from a class... but not it's other public methods, that is a violation of ISP. This forces you to think about the responsibilities of that class, which may result in organizing into smaller classes based on those responsibilities.

The third reason we should care about ISP is that it keeps your dependencies lighter. We didn't see that in this specific example, but we did see it earlier when we talked about SRP. In that case... let me actually close all of my classes... we split a UserManager class into two pieces: UserManager and ConfirmationEmailSender. The send() method simply sends the confirmation email, and we use it both after registration and when requesting a re-send of that email.

If we had kept these two public function inside of UserManager - then resending the confirmation would have be a violation of the interface segregation principle. That would have been a situation where we only needed to call one of the two public methods on the class.

And, in order to resend the email, Symfony would need to instantiate a class which depends on, for example, the password encoder service. Why is that a problem? Well, it's minor, but this would force Symfony to instantiate the password encoder so that it could instantiate the UserManager... so that we could send a confirmation email... but we would never actually use the password encoder. That's a waste of resources!

Anyways, the tl;dr on the interface segregation principle is this: when you have an interface with a method that not all of its classes need... or if you have a class where you routinely use only some of its public methods... it may be time to split it into smaller pieces. Or, more simply, you can remember to not build giant classes. But, like everything, it's not an absolute rule. If I had, for example, a GitHubApiClient that helped me talked to GitHub's API... I might be ok putting 5 methods in this service, even though I routinely only use one or two of them at a time. After all, the name of the class is still pretty clear... and having more methods probably doesn't increase the number of dependencies that I need to inject into that service.

Next: we're on to principle number five! And this one really made my head spin at first. It's: the dependency inversion principle!

Leave a comment!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.4 || ^8.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "composer/package-versions-deprecated": "^1.11", // 1.11.99.1
        "doctrine/annotations": "^1.0", // 1.12.1
        "doctrine/doctrine-bundle": "^2", // 2.3.1
        "doctrine/doctrine-migrations-bundle": "^3", // 3.1.1
        "doctrine/orm": "^2", // 2.8.4
        "knplabs/knp-time-bundle": "^1.15", // v1.16.0
        "phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
        "sensio/framework-extra-bundle": "^6.0", // v6.1.2
        "symfony/console": "5.2.*", // v5.2.6
        "symfony/dotenv": "5.2.*", // v5.2.4
        "symfony/flex": "^1.9", // v1.12.2
        "symfony/form": "5.2.*", // v5.2.6
        "symfony/framework-bundle": "5.2.*", // v5.2.6
        "symfony/http-client": "5.2.*", // v5.2.6
        "symfony/mailer": "5.2.*", // v5.2.6
        "symfony/property-access": "5.2.*", // v5.2.4
        "symfony/property-info": "5.2.*", // v5.2.4
        "symfony/security-bundle": "5.2.*", // v5.2.6
        "symfony/serializer": "5.2.*", // v5.2.4
        "symfony/twig-bundle": "5.2.*", // v5.2.4
        "symfony/validator": "5.2.*", // v5.2.6
        "symfony/webpack-encore-bundle": "^1.6", // v1.11.1
        "symfony/yaml": "5.2.*", // v5.2.5
        "twig/cssinliner-extra": "^3.3", // v3.3.0
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.0
        "twig/twig": "^2.12|^3.0" // v3.3.0
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.2", // 3.4.0
        "fakerphp/faker": "^1.13", // v1.14.1
        "symfony/debug-bundle": "^5.2", // v5.2.4
        "symfony/maker-bundle": "^1.13", // v1.30.2
        "symfony/monolog-bundle": "^3.0", // v3.7.0
        "symfony/stopwatch": "^5.2", // v5.2.4
        "symfony/var-dumper": "^5.2", // v5.2.6
        "symfony/web-profiler-bundle": "^5.2" // v5.2.6
    }
}