SOLID Principles: Part 1, Single responsibility

Part 1 of SOLID Principles: Writing Maintainable Object-Oriented Code

#OOP #PHP #Symfony

Welcome to the first article in our series on SOLID development principles!
In this article, we'll be exploring the Single Responsibility Principle (SRP) and how it can help you write more maintainable code.
SRP is about keeping code focused on a single task, which makes it easier to understand, test, and modify.

image for SOLID Principles: Part 1, Single responsibility

Introduction to SRP

As we begin to explore the Single Responsibility Principle in this series about SOLID, it's important to note that these principles can be complex.
I will try to keep my examples as simple as possible, hopefully without oversimplifying the concepts.
Also, it's worth noting that while SOLID principles are great guidelines to follow, they might not always be the best way to write code for your specific application.

That being said, let's have a look at the first principle which is the one we are interested in for this article.

The Single Responsibility Principle says that:

"A class (or a function) should have only one reason to change"

So what does this mean? We can rephrase like that: "A class (or a function) should have only one responsibility".

It still remains a bit ambiguous as to what is a "responsibility" exactly.
A simple way I like to think about it is to group together the code that relates to a specific task and monitor the size of your classes and functions to avoid them becoming too large.

Project Setup

If you wish to follow along you can find the code for the example in this article and the ones that will follow in this Github repository.
We will start on the main branch.

This is a really simple blog like application that is built with Symfony.

To install the application, follow the steps in the README file.
The application provides a Docker container for a Mysql database, if you do not want to use Docker, feel free to replace it with the database of your choice and update the DATABASE_URL environment variable accordingly.
I use the Symfony CLI to serve the application for development.

In this application, we have only a few pages: a homepage, a list of articles, the detail for an article and an admin with the CRUD operations for the Article entity.

Here is a look at the homepage:

Screenshot of the homepage blog demo application

Breaking the SRP

In this series of article to illustrate the SOLID concepts we will first give an example of code that does not respect them and then try to "fix" it.

The creation of articles in our app is handled by the App\Service\ArticleCreator class.
Its "create" method receives a data transfer object, instantiates a new Article entity and set its properties accordingly and then calls the EntityManager to persist the new Article to the database.
We could already argue that creating the new Article and persisting it are two responsibilities but that is not the point in this example.

App\Service\ArticleCreator

namespace App\Service;

use App\DTO\ArticleDto;
use App\Entity\Article;
use Doctrine\ORM\EntityManagerInterface;

class ArticleCreator
{
    public function __construct(private readonly EntityManagerInterface $entityManager)
    {
    }

    public function create(ArticleDto $articleDto): Article
    {
        $article = new Article();
        $article->setTitle($articleDto->title);
        $article->setSlug($articleDto->slug);
        $article->setContent($articleDto->content);
        $article->setImage($articleDto->image);

        $this->entityManager->persist($article);
        $this->entityManager->flush();

        return $article;
    }
}

This is used in the App\Controller\Admin\ArticleController

#[Route(path: '/new', name: 'new', methods: ['GET', 'POST'])]
public function new(ArticleCreator $articleCreator, Request $request, EntityManagerInterface $entityManager): Response
{
    $articleDto = new ArticleDto();

    $form = $this->createForm(ArticleType::class, $articleDto);

    $form->handleRequest($request);
    if ($form->isSubmitted() && $form->isValid()) {
        $article = $articleCreator->create($articleDto);
        return $this->redirectToRoute('article_show', ['slug' => $article->getSlug()]);
    }

    return $this->render('admin/article/new.html.twig', [
        'form' => $form,
    ]);
}

Let's say that we now want to add a functionnality to send a notification email to an admin when an Article is created. We will start be requiring the Symfony Mailer.

composer require symfony/mailer

By the way, Symfony was kind enough to provide us with a docker-compose file for a mailcatcher, you will need to rebuild your docker containers to use it.

docker-compose.override.yml

version: '3'

services:
###> symfony/mailer ###
  mailer:
    image: schickling/mailcatcher
    ports: ["1025", "1080"]
###< symfony/mailer ###

So let's get started with our wrong implementation for our email notification.
Well the requirements said "send an email to the admin when an article is created", so in our ArticleCreator class, we inject the MailerInterface in our constructor and then we use it to build and send the email after the Article is created.

App\Service\ArticleCreator

namespace App\Service;

use App\DTO\ArticleDto;
use App\Entity\Article;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;

class ArticleCreator
{
    public function __construct(
        private readonly EntityManagerInterface $entityManager,
        private readonly MailerInterface $mailer,
    )
    {
    }

    /**
     * @throws TransportExceptionInterface
     */
    public function create(ArticleDto $articleDto): Article
    {
        $article = new Article();
        $article->setTitle($articleDto->title);
        $article->setSlug($articleDto->slug);
        $article->setContent($articleDto->content);
        $article->setImage($articleDto->image);

        $this->entityManager->persist($article);
        $this->entityManager->flush();

        $email = (new Email())
            ->from('my_blog@example.com')
            ->to('admin@example.com')
            ->subject('New article created')
            ->text('A new article has been created: ' . $article->getTitle())
        ;

        $this->mailer->send($email);

        return $article;
    }
}

You can find the whole code in the SRP_wrong branch.

If we create a new Article and have a look in our mailcatcher it worked, yeah!

Screenshot of a mailcatcher

Refactoring to Follow the SRP

So we are very happy that our email notification is working... but hold on a minute, what was the responsibility of the ArticleCreator class?
As its name suggests it is to create articles, so why is it now sending notification email as well?
It seems we broke SRP.
So let's try to fix our mistake.

We can start by putting back the ArticleCreator as it was before by removing all the logic linked to the email notification.

App\Service\ArticleCreator

namespace App\Service;

use App\DTO\ArticleDto;
use App\Entity\Article;
use Doctrine\ORM\EntityManagerInterface;

class ArticleCreator
{
    public function __construct(private readonly EntityManagerInterface $entityManager)
    {
    }

    public function create(ArticleDto $articleDto): Article
    {
        $article = new Article();
        $article->setTitle($articleDto->title);
        $article->setSlug($articleDto->slug);
        $article->setContent($articleDto->content);
        $article->setImage($articleDto->image);

        $this->entityManager->persist($article);
        $this->entityManager->flush();

        return $article;
    }
}

And then create a new AdminNotifier service which will be responsible for sending the notification email.

App/Service/AdminNotifier.php

namespace App\Service;

use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Email;

class AdminNotifier
{
    public function __construct(
        private readonly MailerInterface $mailer,
    )
    {
    }

    public function notifyNewArticle(string $title): void
    {
        $email = (new Email())
            ->from('my_blog@example.com')
            ->to('admin@example.com')
            ->subject('New article created')
            ->text('A new article has been created: ' . $title)
        ;

        $this->mailer->send($email);
    }
}

Then in our ArticleController, we can inject this new service and call it after the article is created

App\Controller\Admin\ArticleController

#[Route(path: '/new', name: 'new', methods: ['GET', 'POST'])]
public function new(
    ArticleCreator $articleCreator, 
    AdminNotifier $adminNotifier, 
    Request $request
): Response
{
    $articleDto = new ArticleDto();

    $form = $this->createForm(ArticleType::class, $articleDto);

    $form->handleRequest($request);
    if ($form->isSubmitted() && $form->isValid()) {
        $article = $articleCreator->create($articleDto);
        $adminNotifier->notifyNewArticle($article->getTitle());
        return $this->redirectToRoute('article_show', ['slug' => $article->getSlug()]);
    }

    return $this->render('admin/article/new.html.twig', [
        'form' => $form,
    ]);
}

This way classes ArticleCreator and AdminNotifier both have only their single responsibility.
We could also have used an Event Listener instead of calling our email notifier directly in the controller, and maybe put this in a queue but this will do for the purpose of this article.

You can find the whole code in the SRP_right branch.

This example was quite simple but I hope it can help better understand SRP.
For me this one is not the most difficult principle to wrap my head around, I just try to think "do not put too much different logic in the same class".

See you in the next article which will be about the Open-closed Principle.


Last updated 7 months ago