Skip to content

Single Responsibility Principle (SRP)

samuelgfeller edited this page Apr 2, 2024 · 13 revisions

Introduction

The slim-example-project, the slim-api-starter and the slim-starter strive to follow the five SOLID principles, and the first one is the Single Responsibility Principle. 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.

Everything that goes into performing that action from the Application layer to the Infrastructure has to have its own classes responsible only for this task.

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, the required steps could be:

  • Validate the data
  • Check if user is allowed to perform the action
  • Log event for an audit trail
  • Call a repository to create a new resource

These 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 "side services" should work independently and be potentially reusable.

If we take the above example and say that the resource 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 and the event logger.

Usually there is one main service class for each 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 (hint CTRL + N in PHPStorm) than individual functions in big cluttered classes.

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 such 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.

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.

Everyone around me told me that it made sense, so I believed it for quite some time, but this view radically changed. 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 use-case as we don't have to worry about other use-cases at all.

Such a weight off the shoulders!

Clone this wiki locally