Single Responsibility Principle (SRP)
Table of contents
Loading...Introduction
The slim-example-project, the slim-api-starter and the slim-starter aim to follow the five SOLID principles, and the first one is the Single Responsibility Principle (SRP). It states that a class should have only one reason to change and only a single responsibility.
This is a radically different approach than what I was being thought in school and how we
coded in the company I worked for.
It took some time to fully understand and embrace it, but now I'm beyond convinced that
it's the way to go for small and large projects.
"Use case" based approach
I think of it as use cases. One use case is one reason to change something.
At the root, a use case is a single action that a user can perform. This could be "creating a specific resource", "submitting a login request", "displaying a page", etc.
Within one of those use cases, there are often multiple different business logic steps to fulfill
the action.
For instance, after the user submits a form to create a new resource, the required steps could be:
- Process request, extract data, call service function, return result (Action)
- Validate the data
- Check if user is allowed to perform the action (Authorization)
- Log event for an audit trail
- Call a repository to create a new resource
These steps are different aspects of a problem and really separate responsibilities, and should, therefore, be in separate classes. These are like "accessory" use cases inside a use case.
Each of those accessory services should work independently and be potentially reusable.
If we take the above example and say that the resource the user wants to create is a Client,
the validation would be done
in the ClientValidator
service which may be available and used in other use cases of the Client module
such as when updating a client.
The same goes for the authorization checker, the event logger and repository, which have their own classes and functions.
And I usually create one main service class
(e.g. ClientCreator
) called by the action,
that coordinates all the other services and calls the repositories.
Why do it this way?
Separating each use-case into its own class helps in ensuring that each part of the codebase
has a clear, focused purpose, making the code more maintainable, understandable and adaptable
to change.
Additionally, having a single responsibility, a class or module becomes less likely to be affected
by modifications in other parts of the system and encourages a better separation of concerns.
This will inevitably lead to more classes, but that's a good thing. They will be smaller, tailored to exactly one use case and easy to understand and change.
I even find such specific classes easier to find than
individual functions in big cluttered classes (hint CTRL + N
in PHPStorm or Ctrl + P
in VSCode).
At first, I was sceptical about implementing this full-on.
For instance, once when two use cases mostly needed the same data structure,
I returned the same "result" data object from the
repository thinking it'd save me time.
Documentation on why this was a bad idea can be found
in this practical SRP example with DTOs.
Instead of being scared to have too many classes or some duplications when use cases share similarities, the fact that it's so much more simple to understand and maintain (because the responsibilities are decoupled) easily outweigh those concerns for me.
The "classic" way
My experience with big frameworks is limited, but I imagine a reason why we created
ORM Entities and big "manager"
classes that are used in many different use cases, is to centralize the data structure and make it
"more comfortable" to work with those components as we have access to a lot of "pre-made"
functions or "preloaded" attributes.
Also, if the data structure changes, there are only a few places that have to be modified
thanks to the additional abstraction Entities offer.
And that does make sense to some extent, but I realized that it brings a strong rigidity with it. Like a spiderweb, things are closely interconnected and dependent on one another, which makes it a lot harder to understand and have a feel for the whole system. As the application grows and different developers code in their own ways, with their understanding of the codebase, it becomes more and more complex and refactoring a hell work.
Duplications that such systems try to avoid for the case something must be changed are often simple
and solved by search and replace (Ctrl + Shift + R
in PHPStorm).
And functions that are reused eventually grow as they have to be changed in different ways to englobe more and more deviations from use case to use case.
All that is to say, in my opinion, it's easily worth having to change something trivial at more places and having
more (agile) functions and classes even if their job is not very different from one another.
It makes it easy and fun to change the "logic" of a single use-case when we don't have to worry about
other use-cases at all.
And when refactoring, we have a clear overview and path to follow, as we can change one use-case at a time.
Such a weight off the shoulders!