Understanding the Single Responsibility Principle (SRP) using C#

Understanding the Single Responsibility Principle (SRP) using C#

This is the second post in our S.O.L.I.D Principles Using C# series. In the previous post, we discussed an introduction to S.O.L.I.D principles. In this post, we will dive deeper into the S in S.O.L.I.D, the Single Responsibility Principle (SRP), using examples in C#.

Single Responsibility Principle (SRP)

The Single Responsibility Principle (SRP) is one of the five SOLID principles of object-oriented programming (OOP). It was first introduced by Robert C. Martin, also known as Uncle Bob. SRP states that a class should have only one reason to change, meaning that it should have only one responsibility or job to do.

In other words, a class should do one thing and do it well. This makes the class easier to understand, test, maintain and reuse. If a class has multiple responsibilities, it becomes more difficult to modify without introducing bugs or unexpected behavior.

Let's delve deeper into understanding SRP with a practical example in C#.

Example Scenario

Suppose we are building a library management system where users can borrow books, renew their loans, pay fines, etc. We might start by creating a Library class that handles all these operations:

public class Library 
{
    private List<Book> _books;
    private List<User> _users;

    public Library(Database db)
    {
        // Code for simulating fetching data from a database
        _books = Database.GetBooks();
        _users = Database.GetUsers();
    }

    public void BorrowBook(string userId, string bookId) 
    {
        // Check if user exists
        var user = _users.FirstOrDefault(u => u.Id == userId);
        if (user == null) throw new Exception("Invalid user");

        // Check if book exists
        var book = _books.FirstOrDefault(b => b.Id == bookId);
        if (book == null) throw new Exception("Invalid book");

        // Check if book is available
        if (!book.IsAvailable) throw new Exception("Book not available");

        // Update book status
        book.UpdateStatus(false);

        // Add loan record
        user.AddLoanRecord(new LoanRecord(book));
    }

    public void RenewLoan(string userId, string bookId) 
    {
        // Check if user exists
        var user = _users.FirstOrDefault(u => u.Id == userId);
        if (user == null) throw new Exception("Invalid user");

        // Check if book exists
        var book = _books.FirstOrDefault(b => b.Id == bookId);
        if (book == null) throw new Exception("Invalid book");

        // Check if book is checked out by this user
        var currentLoan = user.GetCurrentLoanByBookId(bookId);
        if (currentLoan == null) throw new Exception("No active loan for this book");

        // Update loan due date
        currentLoan.Renew();
    }

    public void PayFine(string userId, decimal amount) 
    {
        // Check if user exists
        var user = _users.FirstOrDefault(u => u.Id == userId);
        if (user == null) throw new Exception("Invalid user");

        // Calculate fine balance
        var fineBalance = user.CalculateFineBalance();

        // Validate payment
        if (amount <= 0 || amount > fineBalance) throw new Exception("Invalid payment amount");

        // Deduct payment from balance
        user.DeductPayment(amount);
    }
}

This implementation seems simple enough, but let's analyze its violations of SRP:

  • The BorrowBook method checks if a user exists, if a book exists, if a book is available, updates the book status, adds a loan record, and throws exceptions on failure. That's at least four different reasons to change, such as adding validation rules, updating the database schema, changing error messages, etc.

  • The RenewLoan method checks if a user exists, if a book exists, if a book is checked out by this user, updates the loan due date, and throws exceptions on failure. Again, that's several different reasons to change, such as changing the loan policy, updating the database schema, changing error messages, etc.

  • The PayFine method checks if a user exists, calculates the fine balance, validates payment amounts, deducts payments, and throws exceptions on failure. Yet another set of reasons to change, such as changing the fine calculation formula, updating the currency format, changing error messages, etc.

Each method has multiple responsibilities that could change independently, making it harder to isolate and manage changes. Moreover, each method contains duplicate code for checking if a user exists, which increases complexity and maintenance costs.

Applying SRP

To fix these issues, we need to refactor our code according to SRP. Here's an improved version:

public class UserRepository 
{
    private List<User> users;

    public UserRepository(Database db)
    {
        // Code for simulating fetching data from a database
        users = db.GetUsers();
    }

    public bool Exists(string id) 
    {
        return users.Any(u => u.Id == id);
    }

    public User GetById(string id)
    {
        return users.FirstOrDefault(u => u.Id == id);
    }
}

public class BookRepository 
{
    private List<Book> books;

    public BookRepository(Database db)
    {
        // Code for simulating fetching data from a database
        books = db.GetBooks();
    }

    public bool Exists(string id) 
    {
        return books.Any(b => b.Id == id);
    }

    public Book GetById(string id) 
    {
        return books.FirstOrDefault(b => b.Id == id);
    }
}

// Has only one responsibility - managing loans
public class LoanManager 
{
    private readonly UserRepository _users;
    private readonly BookRepository _books;

    public LoanManager(UserRepository users, BookRepository books) 
    {
        _users = users;
        _books = books;
    }

    public void BorrowBook(string userId, string bookId) 
    {
        if (!_users.Exists(userId)) throw new ArgumentException("Invalid user", nameof(userId));
        if (!_books.Exists(bookId)) throw new ArgumentException("Invalid book", nameof(bookId));

        var user = _users.GetById(userId);
        var book = _books.GetById(bookId);

        if (!book.IsAvailable) throw new InvalidOperationException($"Book '{bookId}' is not available");

        book.UpdateStatus(false);
        user.AddLoanRecord(new LoanRecord(book));
    }

    public void RenewLoan(string userId, string bookId) 
    {
        if (!_users.Exists(userId)) throw new ArgumentException("Invalid user", nameof(userId));
        if (!_books.Exists(bookId)) throw new ArgumentException("Invalid book", nameof(bookId));

        var user = _users.GetById(userId);
        var book = _books.GetById(bookId);

        if (!user.HasActiveLoanForBook(book))
            throw new InvalidOperationException($"No active loan for book '{bookId}' found under user '{userId}'.");

        user.GetActiveLoanForBook(book).Renew();
    }
}

// Has only one responsibility - handling money transactions
public class PaymentProcessor 
{
    private const string CurrencyFormat = "C2";
    private readonly UserRepository _users;

    public PaymentProcessor(UserRepository users)
    {
        _users = users;
    }

    public void PayFine(string userId, decimal amount) 
    {
        var user = _users.GetById(userId);
        var fine = user.CalculateFineBalance();

        if (amount <= 0 || amount > fineBalance)
            throw new ArgumentOutOfRangeException(nameof(amount), $"Payment must be greater than zero and less than or equal to ${fine.Amount}");

        // Deduct payment from balance
        user.DeductPayment(amount);
        fineBalance -= amount;
        Console.WriteLine($"Successfully paid ${amount} towards fine for user {userId}. Remaining fine: ${fineBalance}");
    }
}

Here's what changed:

  • We separated concerns into smaller classes, each having only one responsibility, following SRP. For instance, LoanManager manages loans, while PaymentProcessor handles financial transactions. Each class depends on abstractions rather than concrete implementations, adhering to Dependency Injection Principle (We will cover DIP in a later post).

  • Instead of duplicating code for existence checks, we created two repository classes that handle data access logic for users and books. These repositories expose methods like Exists(), GetById(), etc., which simplify usage within higher-level classes like LoanManager.

  • Within LoanManager, we removed redundant exception throwing in favor of simpler argument checking. Also, we moved some business logic related to loan expiration into separate methods like HasActiveLoanForBook, GetActiveLoanForBook into the User class (not shown here), further improving readability and modularity.

By applying SRP and separating concerns into smaller classes, we made our codebase more robust, extensible, and maintainable, reducing potential side effects when making modifications.

Summary

The Single Responsibility Principle is a fundamental concept in software development that promotes better design, maintainability, and scalability of code. By adhering to SRP, you can create classes that are focused, reusable, and easier to maintain, leading to more robust and flexible applications.