Skip to main content

Single Responsibility Principle

Introduction

Responsibility Icon

The Single Responsibility Principle is the first of the SOLID principles. Robert Martin originally state the single responsibility principle as:

A class should have only one reason to change

This definition is a bit ambiguous, which tends to result in it being misunderstood. The biggest issue is multiple interpretations of the work 'reason'. Martin rephrased is as:

A module should be responsible to one, and only one, actor.

This definition is even worse. What is an actor in this context? My preferred definition is:

A class should do one thing and therefore it should have only a single reason to change.

The core idea behind this principle is that only one type of change in the software specification will cause a change in the specification of the class. If a change the database logic and a change in the UI interface both force us to rewrite a class, then that class is probably in violation of the single responsibility principle. Another example would be if a class is a data container then it should only change when the data model changes.

Types of Responsibilities

There are many types of responsibilities that a class can have. Here are a few examples:

  • Business Logic: Knowing how to do some business function. For example: identifying a transaction as fraud, converting XML to json, or constructing some API call.
  • Data Model: The class could be a data container, and only responsible for holding data. This is common in DTOs (Data Transfer Objects) or POJOs (Plain Old Java Objects).
  • External Integration: This could be integrations between modules in a project, or integrations with external systems like databases or APIs.
  • Control Flow: This is typically connections between components with well defined responsibilities in the project. For example a function that checks for a fraudulent transaction, logs the result, and sends an alert through some service. Each of those would be implemented by separate classes, but something needs to control how data flows through the system.

SRP Advantages

One of the biggest advantages of following the single responsibility principle is the effect it has on team collaboration. If a class has only one reason to change, then it is less likely that developers will introduce incompatible changes, and there will be fewer merge conflicts because it will be obvious when user stories will need to result in a change in the class.

It also makes it easier to read the commit history, because every change to a file is related to a single type of specification change. Every commit for that file, will be directly related to the class purpose.

Finally, if classes have a single responsibility then they tend to be shorter, easier to read, and easier to test.

Recognizing SRP Violations

The common way of checking if a class is violating SRP, is to describe what the class does in a single sentence. If that sentence contains the word and then you might be violating the principle.

What makes implementing this principle difficult in practice is that every developer will have their own idea of what the class is responsible for, making it subjective whether or not a change is in scope.

Another way of approach this is to ask yourself if splitting a class would result in two classes that are tightly coupled and would usually be used together in practice, then it’s probably best to leave them alone.

Examples

Example 1

What is this class responsible for?

class User(private val name: String, private val email: String) {
fun save() {
// Save the user to the database
}

fun sendEmail() {
// Send an email to the user
}
}

It is responsible for the data associated with a user, saving the user to the database, and sending an email to the user. Clearly this class is violating the single responsibility principle.

It would be better to split this class in a data class that holds the data model for the user, and two separate classes that handle saving the user to the database and sending an email to the user.

data class User(private val name: String, private val email: String)

class UserDatabase {
fun save(user: User) {
// Save the user to the database
}
}

class EmailService {
fun sendEmail(user: User) {
// Send an email to the user
}
}

Doing so ensures that the class has only one reason to change. If the data model changes, then only the User class needs to be updated. If the database logic changes or we switch providers, then only the UserDatabase class needs to be updated. If the email service changes, then only the EmailService class needs to be updated.

Example 2

What is this class responsible for?

class Transaction(private val emailService: EmailService) {
fun processTransaction() {
if (isFraud()) {
logTransaction()
sendAlert()
}
}

private fun isFraud(): Boolean {
// Check if the transaction is fraud
}

private fun logTransaction() {
// Log the transaction
}

private fun sendAlert() {
// Send an alert through the email service
}
}

This class is doing a lot. It is processing a transaction, checking if a transaction is fraudulent, logging a message, and sending an alert through an external service. Very little of this makes sense. The only action inside the transaction class that actually belongs there is the isFraud function. It makes sense for a transaction to check itself.

class Transaction() {
fun isFraud(): Boolean {
// Check if the transaction is fraud
}
}

The other functions should be moved to separate classes. The logTransaction function should be moved to a TransactionLogger class, and the sendAlert function should be moved to an AlertService class.

class TransactionLogger {
fun logTransaction(transaction: Transaction) {
// Log the transaction
}
}

class AlertService(private val emailService: EmailService) {
fun sendAlert(transaction: Transaction) {
// Send an alert through the email service
}
}

Finally a TransactionProcessor class should be created to handle the business logic of processing a transaction.

class TransactionProcessor(
private val transactionLogger: TransactionLogger,
private val alertService: AlertService
) {
fun processTransaction(transaction: Transaction) {
if (transaction.isFraud()) {
transactionLogger.logTransaction(transaction)
alertService.sendAlert(transaction)
}
}
}

These changes involve a lot more typing, but the long term benefits are worth it. We have ensured that our code is easier to maintain and extend, and much easier to test. If we stuck with the original implementation, then every time we changed any part of processing a transaction, we would need to completely retest the entire class.

Conclusion

Keeping the single responsibility principle in mind when designing classes can make a huge difference in the maintainability of a codebase. It can make it easier to collaborate with other developers, easier to read the commit history, and easier to test. It can also make it easier to reason about the code, and make it easier to extend and maintain.

Image Credits

Sustainability icons created by Freepik - Flaticon