DEV Community

Morad Ankri
Morad Ankri

Posted on

Single responsibility principle - from a service level to the class level

Many times we tend to give more emphasis to higher level design principles and we might forget to apply similar principles to the “low level” code. In this post I want to review some of the design principles of the high level architecture and then try to connect the same principles to the code that we write every day.


Microservices architecture teaches us, among other things, that a service should have a responsibility for a single business related process (or at least for a limited set of related processes). This requirement helps us make the service small, independently deployable, managed by a single team and in general contributes to the other benefits that microservices architecture promises.

There are different approaches to the question of how to divide a system to services with different tradeoffs but there is one approach that we need to avoid, or at least minimize - the entity microservice (this is also called the entity trap).

An entity microservice design is when the services are divided based on the data model, for example:

  • Product service
  • Shopping cart service
  • Orders service
  • Payment service
  • Shipment service

If we look at the history of such a system, before it evolved to be a microservices based system, it’s easy to imagine that in the past it had a single DB and each one of these services was basically a large table or a set of tables.
As mentioned this type of services design is undesirable as we’ll see below.

Another approach for services design is the actor/actions approach (as described in the book “Fundamentals of Software Architecture: An Engineering Approach” by Mark Richards and Neal Ford). In this post I’ll use the terms role and use case instead.
In this approach we identify the roles (actors) in the system and the relevant use cases (actions) for each role.

For example in the same system above we might have the following roles:

  • Seller
  • Buyer
  • Shipping coordinator
  • (And probably few more)

Note that a role in this case is not a person or a specific user, it’s more about the business process - the same person can have different roles at different times within the same system (for example the Shipping coordinator employee can also be a Buyer when they buy something from the same system during their lunch break).

Each role will have a different set of use cases that they will perform in the system. For example:
A seller wants to post products for sale, but maybe before they post a product for sale they want to edit the product page, upload images, etc. A seller will also want to view some reports about their sales.
As expected a Buyer wants to search and view products, add products to a shopping cart and eventually choose shipment and payment details. A buyer will also want to view their order history and there are probably many more use cases that we’ll ignore for now.
The interesting detail here are additional roles like “Shipping coordinator” (I don’t know if this is a real thing, it’s just for the example), maybe in this case a Shipping coordinator wants to be able to post and edit shipping options (like possible routes, prices, shipping companies, etc).

The actor/actions approach says that we need to try to create services based on these roles and use cases that we identified. For example:
A service for a seller to edit their products (before they are published to the store), maybe another service to collect and view relevant reports, etc.
For the buyer we’ll want a different set of services that support their use cases, maybe a service that handles the search and view of products and another service for shopping cart management and maybe another service for managing a buyer shipping and payment processes.
For the shipping coordinator we probably need another set of services (maybe even only one) to manage the available shipment options in the system (maybe even test and optimize them before publishing them).

In reality, this will be the first iteration and an actual services design will require more iterations and changes based on different tradeoffs (like reducing network calls, etc), but the actual services design of this system is not the topic of this post.


One of the reasons that we try to design our services based on roles and uses cases is the fact that we expect to have different numbers of users for each role (not absolute numbers, this mostly talks about orders of magnitudes), hopefully we have a significant number of buyers, few orders of magnitude less of sellers, and probably a very low number of shipping coordinators (and other roles of course). In addition each role will probably have a different pattern of usage. For example buyers will mostly search and view items, and compared to that very rarely add an item to the cart and maybe even less - make an actual order. Again the absolute numbers are hopefully very high in such a system but there are orders of magnitude difference between them.

If we divide the services based on the role and the use cases and if possible we put together in the same service the related use cases that have the same usage pattern (for a specific role) - we can use that to optimize the deployment of the services (run more instances of the more used use cases, etc).

Again, there are many benefits for microservices architecture (at least when it is a relevant option) and the deployment and scaling consideration that was described above is only one of them, but as we can see roles and use cases are a big part of the design process.


If we look carefully, what we described here is basically the Single-responsibility principle but at the architecture level (or at least a very similar principle), and of course the Single-responsibility principle can be applied to lower levels - modules and classes (and it was actually defined first for a class level).

The Single-responsibility principle says that “A class should have only one reason to change”.
In this post I'm mostly talking about classes that control and manage business processes and for these types of classes the part “reason to change” refers to a use case - in other words what this principle is trying to say is that a class should be limited to handle a single use case (or a part of a single use case, because we’ll most likely have more than one class for a single use case).

If we go back to the entity microservice design that we mentioned above (and remember that this is an undesirable design) and zoom in to the Product service we might see a ‘ProductManager’ class with the following public methods:

  • SearchProducts
  • GetProductById
  • AddProduct
  • EditProduct
  • RemoveProduct

It’s very possible that we’ll also see many many more private methods to help with the code of these public methods.

As we can see this class clearly handles different use cases for different roles.

Like with many other decisions, we have different options to break this class into smaller classes. One of the options on the other side of the spectrum is to move each use case to its own class. For example:

  • SearchManager class with a ‘Search’ method.
  • ProductViewer class with a ‘GetById’ method.
  • etc

(Ideally we’ll find better names to avoid the extra ‘Manager/Viewer’ suffix, but that’s a different discussion).

This class structure will also allow us to move the relevant private methods to the relevant classes and reduce a lot of complexity (yes, in some cases it might violate DRY, that’s another different discussion), it can help with testing, but more importantly for this discussion - we can now have an easier way to move the classes of different roles and use cases to different modules - maybe even in the same way that we want to structure our services (and even before we actually have a microservices design).

The point is that even if your current system architecture is not strictly by the book microservices architecture, if we structure our code according to the Single-responsibility principle, not only we get the benefits of SRP, we’ll also take steps in the direction that might make it easier for us to restructure our modules and eventually even our entire architecture.


Conclusion
We all learn and spend a lot of time thinking about microservice design, this is even a popular topic in interviews, but the fact is that we rarely do that in our everyday work.
On the other hand - we write code every day - we add and modify methods in existing classes and modules, we create new classes and modules, etc, and this is where we can actually apply some of the rules that we learn from high level architecture and even microservice design - in the “low level" code.
Maybe it will even help you in the future, when you, as the system architect, will try to move the system to a new architecture.

Top comments (0)