The Ultimate explanation of the Single Responsibility Principle

The Ultimate explanation of the Single Responsibility Principle

The Single Responsibility Principle (SRP) is one of the most important concepts in software design. It states that every class or module should have one and only one reason to change. In other words, each class or module should have a single responsibility or a single purpose within the system.

The benefits of applying the SRP are:

  • It makes the code more cohesive, meaning that the elements of a class or module are closely related and work together towards a common goal.
  • It makes the code more decoupled, meaning that the classes or modules have minimal dependencies on each other and can be easily reused or modified without affecting other parts of the system.
  • It makes the code more readable, meaning that the classes or modules have clear and meaningful names and functions that reflect their responsibility.
  • It makes the code more testable, meaning that the classes or modules can be easily isolated and verified with unit tests.

To illustrate the SRP, let’s look at some examples of bad and good design in Kotlin code. The same should apply to any other language, eg Java.

Example 1

Bad Example

Suppose we have a class called User that represents a user of an online shopping system. The class has the following properties and functions:

class User(
    val id: Int,
    val name: String,
    val email: String,
    val address: String,
    val cart: MutableList<Product>
) {
    fun addToCart(product: Product) {
        cart.add(product)
    }

    fun removeFromCart(product: Product) {
        cart.remove(product)
    }

    fun checkout() {
        // calculate the total price of the cart
        var total = 0.0
        for (product in cart) {
            total += product.price
        }
        // apply a discount if applicable
        if (cart.size >= 10) {
            total *= 0.9
        }
        // send an email confirmation to the user
        val message = "Dear $name, \n" +
                "Thank you for your purchase. \n" +
                "Your order total is $$total. \n" +
                "Your order will be shipped to $address. \n" +
                "Please reply to this email if you have any questions or concerns."
        sendEmail(email, message)
    }

    fun sendEmail(to: String, message: String) {
        // create an email object
        val email = Email(to, "Order Confirmation", message)
        // connect to an SMTP server
        val smtp = SmtpClient("smtp.example.com", 25)
        // send the email
        smtp.send(email)
    }
}

This class violates the SRP because it has more than one reason to change. It has multiple responsibilities, such as:

  • Managing the user’s personal information (id, name, email, address)
  • Managing the user’s shopping cart (cart, addToCart, removeFromCart)
  • Performing the checkout logic (checkout, sendEmail)

If any of these responsibilities change, the class will have to be modified, which increases the risk of introducing bugs and breaking other functionalities. For example, if the business rules for the checkout logic change, such as adding a new discount policy or a new payment method, the User class will have to be updated. This could affect the other functions of the class, such as managing the user’s information or shopping cart.

Good Example

To apply the SRP, we should split the User class into smaller classes that have a single responsibility each. For example, we could have the following classes:

// A class that represents a user's personal information
class User(
    val id: Int,
    val name: String,
    val email: String,
    val address: String
)

// A class that represents a user's shopping cart
class Cart(
    val user: User,
    val items: MutableList<Product>
) {
    fun addToCart(product: Product) {
        items.add(product)
    }

    fun removeFromCart(product: Product) {
        items.remove(product)
    }
}

// A class that performs the checkout logic
class CheckoutService {
    fun checkout(cart: Cart) {
        // calculate the total price of the cart
        var total = 0.0
        for (product in cart.items) {
            total += product.price
        }
        // apply a discount if applicable
        if (cart.items.size >= 10) {
            total *= 0.9
        }
        // send an email confirmation to the user
        val message = "Dear ${cart.user.name}, \n" +
                "Thank you for your purchase. \n" +
                "Your order total is $$total. \n" +
                "Your order will be shipped to ${cart.user.address}. \n" +
                "Please reply to this email if you have any questions or concerns."
        EmailService.sendEmail(cart.user.email, message)
    }
}

// A class that handles the email communication
class EmailService {
    companion object {
        fun sendEmail(to: String, message: String) {
            // create an email object
            val email = Email(to, "Order Confirmation", message)
            // connect to an SMTP server
            val smtp = SmtpClient("smtp.example.com", 25)
            // send the email
            smtp.send(email)
        }
    }
}

This design follows the SRP because each class has a single responsibility and a single reason to change. The User class only manages the user’s personal information, the Cart class only manages the user’s shopping cart, the CheckoutService class only performs the checkout logic, and the EmailService class only handles the email communication. If any of these responsibilities change, only the corresponding class will have to be modified, which reduces the coupling and increases the cohesion of the code.

Example 2

Bad Example

Suppose we have a class called Book that represents a book with some properties and functions:

class Book(
    val title: String,
    val author: String,
    val content: String
) {
    fun print() {
        // print the book content to a printer
    }

    fun format() {
        // format the book content according to some rules
    }

    fun publish() {
        // upload the book content to a publishing platform
    }
}

This class violates the SRP because it has more than one reason to change. It has multiple responsibilities, such as:

  • Managing the book information (title, author, content)
  • Printing the book content (print)
  • Formatting the book content (format)
  • Publishing the book content (publish)

If any of these responsibilities change, the class will have to be modified, which increases the risk of introducing bugs and breaking other functionalities. For example, if the printing or publishing platform changes, the Book class will have to be updated. This could affect the other functions of the class, such as managing the book information or formatting the book content.

Good Example

To apply the SRP, we should split the Book class into smaller classes that have a single responsibility each. For example, we could have the following classes:

// A class that represents a book's information
class Book(
    val title: String,
    val author: String,
    val content: String
)

// A class that prints the book content
class BookPrinter {
    fun print(book: Book) {
        // print the book content to a printer
    }
}

// A class that formats the book content
class BookFormatter {
    fun format(book: Book) {
        // format the book content according to some rules
    }
}

// A class that publishes the book content
class BookPublisher {
    fun publish(book: Book) {
        // upload the book content to a publishing platform
    }
}

This design follows the SRP because each class has a single responsibility and a single reason to change. The Book class only manages the book information, the BookPrinter class only prints the book content, the BookFormatter class only formats the book content, and the BookPublisher class only publishes the book content. If any of these responsibilities change, only the corresponding class will have to be modified, which reduces the coupling and increases the cohesion of the code.

Example 3

Bad Example

Suppose we have a class called Calculator that represents a calculator application with some properties and functions:

class Calculator(
    var input: String,
    var output: String
) {
    fun parseInput() {
        // parse the input string into numbers and operators
    }

    fun performOperation() {
        // perform the arithmetic operation based on the input
    }

    fun displayOutput() {
        // display the output string on the screen
    }

    fun saveOutput() {
        // save the output string to a file
    }
}

This class violates the SRP because it has more than one reason to change. It has multiple responsibilities, such as:

  • Managing the input and output strings (input, output)
  • Parsing the input string into numbers and operators (parseInput)
  • Performing the arithmetic operation based on the input (performOperation)
  • Displaying the output string on the screen (displayOutput)
  • Saving the output string to a file (saveOutput)

If any of these responsibilities change, the class will have to be modified, which increases the risk of introducing bugs and breaking other functionalities. For example, if the user interface or the file format changes, the Calculator class will have to be updated. This could affect the other functions of the class, such as parsing the input or performing the operation.

Good Example

To apply the SRP, we should split the Calculator class into smaller classes that have a single responsibility each. For example, we could have the following classes:

// A class that represents the input and output strings
class CalculatorData(
    var input: String,
    var output: String
)

// A class that parses the input string into numbers and operators
class CalculatorParser {
    fun parseInput(data: CalculatorData) {
        // parse the input string into numbers and operators
    }
}

// A class that performs the arithmetic operation based on the input
class CalculatorOperation {
    fun performOperation(data: CalculatorData) {
        // perform the arithmetic operation based on the input
    }
}

// A class that displays the output string on the screen
class CalculatorDisplay {
    fun displayOutput(data: CalculatorData) {
        // display the output string on the screen
    }
}

// A class that saves the output string to a file
class CalculatorStorage {
    fun saveOutput(data: CalculatorData) {
        // save the output string to a file
    }
}

This design follows the SRP because each class has a single responsibility and a single reason to change. The CalculatorData class only manages the input and output strings, the CalculatorParser class only parses the input string into numbers and operators, the CalculatorOperation class only performs the arithmetic operation based on the input, the CalculatorDisplay class only displays the output string on the screen, and the CalculatorStorage class only saves the output string to a file. If any of these responsibilities change, only the corresponding class will have to be modified, which reduces the coupling and increases the cohesion of the code.

Conclusion

The Single Responsibility Principle is a key principle of software design that helps to create more maintainable, readable, and testable code. By following the SRP, we can avoid the problems of having classes or modules that are too large, complex, or dependent on each other, and instead have smaller, simpler, and more independent units of code that are easier to work with.

Read more