If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeWe've made it to the fifth and final SOLID principle: the dependency inversion principle, or DIP. This puppy has a two part definition. Ready? One:
High level modules should not depend on low level modules, both should depend on abstractions - for example, interfaces.
And part two says:
Abstractions should not depend on details. Details - meaning concrete implementations - should depend on abstractions.
Uhh... if that makes sense to you, you are awesome! And... I am jealous of you!
How would I rephrase this? Um, yikes. How about this. One:
Classes should depend on interfaces instead of concrete classes.
And two:
Those interfaces should be designed by the class that uses them, not by the classes that will implement them.
That's probably still fuzzy... but don't sweat it. This requires a real example.
Here's our new problem. We've been getting so popular - no surprise - that some of our sightings are getting a lot of spam comments... like comments that say that Bigfoot is not real. Those are definitely bots!
So we need a way to determine whether or not a comment is spam based on some business logic that we've created. If you downloaded the course code from this page, then you should have a tutorial/
directory with a CommentSpamManager
class inside. Copy that, then go create a new directory in src/
called Comment/
... and paste the class there.
... lines 1 - 2 | |
namespace App\Comment; | |
... lines 4 - 6 | |
class CommentSpamManager | |
{ | |
public function validate(Comment $comment): void | |
{ | |
$content = $comment->getContent(); | |
$badWordsOnComment = []; | |
$regex = implode('|', $this->spamWords()); | |
preg_match_all("/$regex/i", $content, $badWordsOnComment); | |
if (count($badWordsOnComment[0]) >= 2) { | |
// We could throw a custom exception if needed | |
throw new \RuntimeException('Message detected as spam'); | |
} | |
} | |
... lines 23 - 33 | |
} |
This class basically determines if a comment should be flagged as spam by running a regular expression on the content using a list of predefined spam words. If the content contains two or more of those words, then we consider the comment as spam and throw an exception.
If you think about the single responsibility principle, you could argue that this class already has two responsibilities: the low-level regular expression logic that looks for the spam words and a higher level business logic that decides that two spam words is the limit.
Let's pretend that we do think that these are two different responsibilities. And so, we decide to split this class into two pieces. In the Service/
directory, create a new class called RegexSpamWordHelper
. Let's see: move the private spamWords()
method to the new class... and then create a new public function called getMatchedSpamWords()
where we pass it the string $content
and return an array of the matched spam words.
... lines 1 - 4 | |
class RegexSpamWordHelper | |
{ | |
public function getMatchedSpamWords(string $content): array | |
{ | |
} | |
private function spamWords(): array | |
{ | |
return [ | |
'follow me', | |
'twitter', | |
'facebook', | |
'earn money', | |
'SymfonyCats', | |
]; | |
} | |
} |
Next, move the regex logic itself into the class. Copy the entire contents of the existing method.... but leave it... then paste. Let's see... we don't need $comment->getContent()
anymore.... it's just called $content
... and the 0 index of $badWordsOnComment
will contain the matches, so we can return that.
... lines 1 - 6 | |
public function getMatchedSpamWords(string $content): array | |
{ | |
$badWordsOnComment = []; | |
$regex = implode('|', $this->spamWords()); | |
preg_match_all("/$regex/i", $content, $badWordsOnComment); | |
return $badWordsOnComment[0]; | |
} | |
... lines 17 - 29 |
Beautiful! Now that this class is ready, let's inject it into CommentSpamManager
. Add public function __construct()
with RegexSpamWordHelper
$spamWordHelper
. I'll press Alt + Enter and select "Initialize properties" to create that property and set it.
... lines 1 - 5 | |
use App\Service\RegexSpamWordHelper; | |
class CommentSpamManager | |
{ | |
private RegexSpamWordHelper $spamWordHelper; | |
public function __construct(RegexSpamWordHelper $spamWordHelper) | |
{ | |
$this->spamWordHelper = $spamWordHelper; | |
} | |
... lines 16 - 41 | |
} |
Below, now we can say $badWordsOnComment = $this->spamWordHelper->getMatchedSpamWords()
and pass that $content
from above. We don't need any of the logic in the middle anymore. Finally, $badWordsOnComment
will contain the array of matches, so we don't need to use the 0 index anymore: just count that entire variable.
... lines 1 - 16 | |
public function validate(Comment $comment): void | |
{ | |
$content = $comment->getContent(); | |
$badWordsOnComment = $this->spamWordHelper->getMatchedSpamWords($content); | |
if (count($badWordsOnComment) >= 2) { | |
throw new \Exception('Message detected as spam'); | |
} | |
} | |
... lines 26 - 27 |
Done!
At this point, we've separated the high-level business logic - deciding how many spam words should cause a comment to be marked as spam - from the low level details: matching and finding the spam words. The dependency inversion principle doesn't necessarily tell us whether or not we should split the original logic into two classes like we just did. That's probably more the concern of the single responsibility principle.
But DIP does teach us to think about our code in terms of "high-level" modules (or classes) like CommentSpamManager
- that depend on "low level" modules (or classes) like RegexSpamWordHelper
. And it gives us concrete rules about how this relationship should be handled.
Next, let's refactor the relationship between these two classes to be dependency inversion principle compliant. We'll see, in real terms, exactly what changes each of the two parts of this principle want us to make.
Hey Fabien,
You can't learn without doing mistakes of course :) But I agree, it would be good to mention in the course. It's always a balance. Sometimes, it's hard to think in advance you write a rough first version, then look at the whole picture again, and refactor it to make it even better. And that's a totally ok too, developing is a constant refactoring, in practice, you can just add new features without refactoring existent.
> Oh sorry, they use interfaces - when they need to write a mock client for some external HTTP service so they can turn on the real client only in production.
This is already a good sing, probably better than adding a boolean field that will choose what implementation in the concrete class to run :) Though, sometimes such boolean flags may help too, so mostly it depends on a concrete use case.
Thank you for sharing your thoughts on it!
Cheers!
"And they even say that it's fine because they want "this" class" - before this sentence I forgot to write that the other classes (i.e. other than Symfony services) they inject are the concrete, custom classes.
Hey Fabien,
Done! I edited your first comment and added that sentence :)
P.S. You can easily edit your comments, watch for "Edit" link below your comments.
Cheers!
// composer.json
{
"require": {
"php": ">=8.1",
"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.18.7
"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
}
}
I still meet developers where the only interfaces they inject are the interfaces from Symfony components. The other classes (i.e. other than Symfony services) they inject are the concrete, custom classes. And they even say that it's fine because they want "this" class. But what's even more interesting to me is why they have never asked themselves why the heck Symfony is using interfaces everywhere and maybe it's something they should reconsider.
Oh sorry, they use interfaces - when they need to write a mock client for some external HTTP service so they can turn on the real client only in production.