So far, all projects I’ve seen have broken the Single Responsibility Principle (SRP). All of them! At some point, it becomes unavoidable, but I’m not talking about those cases. Instead, I’m referring to the ones where people have a choice and pick the simplest one without even giving it a thought.
If you don’t know, the SRP is the S of SOLID. You hear a lot about it in engineering. Simply put, SOLID is a set of principles that help you write better code, avoid many pitfalls and ease your life.
SRP says that a class should have a single purpose. Having a single purpose also implies that there will be only a single reason to change it.
Let’s consider the case of placing an order and sending a confirmation notification for that order (plus automated tests).
By doing a rough estimation, I would say that more than half of the engineers in this world will implement it like this:
class OrderService ( private val orderDao: OrderDao, private val notificationService: NotificationService, ) { fun placeOrder(order: Order): Order { // other business logic val savedOrder = orderDao.save(order) notificationService.sendOrderCreatedNotification(savedOrder) return savedOrder } }
The code is simple, fast to write and works as expected. Why is it still problematic?! Well… It’s not. Only as long as you don’t need to make changes. But changes will definitely appear.
Let’s now suppose that a bizarre requirement appears and it says that the notification should also contain the bonus points the customer has acquired so far. The code will definitely become like this:
class OrderService ( private val orderDao: OrderDao, private val notificationService: NotificationService, private val bonusPointsService: BonusPointsService, ) { fun placeOrder(order: Order): Order { // other business logic val savedOrder = orderDao.save(order) val bonusPoints = bonusPointsService.calculateBonusPoints(order.customer.id) notificationService.sendOrderCreatedNotification(savedOrder, bonusPoints) return savedOrder } }
Still looking pretty. How about the consequences?! Coupling the classes, complex logic in a single place, dependencies between domains and many more.
This example might not seem much, but, believe me, it’s not easy to get it right without the proper tools. If it seems too easy, think about extracting the notification service into a separate module or another service. Having those classes highly coupled will make things significantly more difficult.
Hints for spotting potential SRP breakings
- Touching multiple classes for a single change instead of one. In our example, two classes were touched to introduce the points in the notification:
OrderService
(calculating the points) andNotificationService
(passing an additional parameter). In addition to that, the tests will also need adjustments. - Passing the wrong parameters to a function. Looking at
sendOrderCreatedNotification()
, why would aNotificationService
know about anOrder
? The only responsibility ofNotificationService
is to notify someone of something. Think of it as a generic service for sending any notifications. - Passing unnecessary class dependencies. In our case, constructor parameters. The question to be asked is: can we manipulate
Order
s withoutNotification
s? There’s no other answer other than YES. And because of that, we don’t want theNotificationService
inOrderService
class. The same goes for theBonusPointsService
. - Thinking about function names and identifying what can be taken out. The
placeOrder()
function is only responsible for placing the order. But here, it also sends a notification. Can we create an order without a notification? Yes! And so, the notification mechanism should be moved out. - Reading the class top-down and trying to describe the functionality. If an “AND” appears in the thought process, you have more responsibilities inside a single class.
How to avoid breaking SRP
There are multiple ways of avoiding breaking the SRP, but I will talk only about the ones I had impressive results with.
Segregate a class into multiple smaller classes
The simplest one yet one of the most efficient. It is supposed to split a single class with multiple responsibilities into classes with a single responsibility. This won’t apply to our example so let’s consider the following example:
class Car { fun cleanInterior() { ... } fun cleanExterior() { ... } fun startEngine() { ... } }
This can be broken into:
class CarInteriorCleaner { fun cleanInterior(car: Car) { ... } } class CarExteriorCleaner { fun cleanExterior(car: Car) { ... } } class Car { fun startEngine() { ... } }
Delegation
One of the most powerful tools available to developers, yet so few use it. It’s considered a very good alternative to inheritance. I’ve seen many people having a hard time understanding and working with it even after 10 years spent in the engineering domain. Delegation works by using a common API in two classes, each with its own responsibility. One class receives the instance of another and calls the methods from the other. This might seem difficult to grasp, but try to figure it out from the example. The initial sample code can be rewritten as:
interface OrderService { fun placeOrder(order: Order): Order } class OrderServiceImpl (private val orderDao: OrderDao) : OrderService { override fun placeOrder(order: Order): Order { val savedOrder = orderDao.save(order) return orderDao.save(order) } } class OrderServiceWithNotification ( private val orderService: OrderService, private val notificationService: NotificationService, private val bonusPointsService: BonusPointsService, ) : OrderService by orderService { override fun placeOrder(order: Order): Order { val savedOrder = orderService.placeOrder(order) val bonusPoints = bonusPointsService.calculateBonusPoints(order.customer.id) notificationService.sendOrderCreatedNotification(savedOrder, bonusPoints) return savedOrder } } // usage val orderService = OrderServiceWithNotification( orderService = OrderServiceImpl(OrderDao()), bonusPointsService = BonusPointsService(), notificationService = NotificationService(), )
It completely separates the order creation functionality OrderServiceImpl
from the notification functionality OrderServiceWithNotification
. Note the private val orderService: OrderService
passed to OrderServiceWithNotification
. This is how we can link together the implementations.
This example uses Kotlin’s delegation feature, significantly reducing the boilerplate code. We can’t see that in our case because we have a single function in the interface. If we had another function, it wouldn’t have been necessary to implement it in OrderServiceWithNotification
.
This delegation is so powerful that it even got implemented in Kotlin. Shame on those senior engineers who can’t figure out how this works.
Implementing this technique will establish a base for the upcoming changes in your project. For example, we could publish a Kafka message to a topic just by providing another implementation of OrderService
and wired it up with the previous order service functionality.
It might seem a lot more work, and, indeed, it is. But the benefits will be visible in time.
Thinking about testing, by adding the bonus calculation to the notification, the only affected tests will be the ones related to the notification.
Using an event system / bus
Even though the same results can be achieved using Kafka or RabbitMQ, this is not about them. An event bus uses events architecture and concepts to decouple the components. Unlike Kafka and RabbitMQ, those systems don’t involve network communication. Such event mechanisms are already integrated into frameworks such as Spring or Vert.x since they rely heavily on them. The event systems might come with multiple communication implementations, one of which is Publisher/Subscriber. In Pub/Sub, when an event is published, all subscribers of that event type will be notified, synchronously or asynchronously, in the same transaction or separated.
Here’s an example of the synchronous approach:
@Service class OrderService( private val applicationEventPublisher: ApplicationEventPublisher, private val orderDao: OrderDao, ) { fun placeOrder(order: Order) { val savedOrder = orderDao.save(order) applicationEventPublisher.publishEvent(OrderPlaced(this, savedOrder)) } } @Component class OrderPlacedListener( private val notificationService: NotificationService, private val bonusPointsService: BonusPointsService, ) : ApplicationListener<OrderPlaced> { override fun onApplicationEvent(event: OrderPlaced) { val bonusPoints = bonusPointsService.calculateBonusPoints(event.order.customer.id) notificationService.sendOrderCreatedNotification(event.order, bonusPoints) } }
This involves significantly less work than delegation but upon an entire system. If you have it, then you might be good to go. If not, you might have a hard time finding ways to convince your colleagues that this is something you might need.
Conclusion
These are only some ways of achieving classes with a single responsibility. Choose the one that best suits your needs. Try to avoid being one of the lazy developers described at the beginning of the article.
I use those techniques almost every day with great success. Even though you might need additional effort to implement them, the long-term benefits will show up when your projects evolve.