Design Principles: Single Responsibility Principle

Laser-Focused Coding

The Single Responsibility Principle is fairly self-explanatory: don’t try to do more than one thing at a time. The goal is to improve cohesiveness of software by decoupling ill-suited components. Improving cohesion by reducing coupling may seem a bit counter-intuitive until you look at the meanings of those words from the perspective of computer science.

This post is part of a series on software design principles. As with all design principles, this is a general guideline, not a hard and fast rule. Following principles such as this will improve code quality, legibility, testability, and maintainability.

The Single Responsibility Principle (SRP) is the S in the SOLID design principles acronym. It follows an old Unix coding philosophy: write programs that do one thing and do it well. The idea was to write Unix CLI commands that serve a single purpose, fulfill that purpose well, and are designed to interact well with other such-single purpose CLI tools. The concept holds up today, and is the primary impetus for the industry-wide migration from burdensome and unmaintainable monolith applications to microservices architecture.

As with the old Unix philosophy, the SRP is about cohesion and decoupling. Somewhat counter-intuitively, these two concepts have an inverse relationship: decoupled code is cohesive code. Cohesion is about the conceptual relatedness of the components within a module. Coupling is about intrinsically tying two implementations together in a way that makes it difficult to separate them later.

It’s common to conflate cohesion and coupling. Putting all of an application’s code in a single type so that all methods and attributes are available wherever required makes for strong, easily maintainable code, right?

Wrong.

Consider a web application for a publisher that has a RESTful web API microservice controller which queries a database and returns data about books. An ASP.NET Core implementation in C# for such a type might have the following members:

[ApiController]
[Route("api/[controller]")
public class BooksController : ControllerBase
{
    // API

    [HttpGet("bookId")}
    public Task<ActionResult<BookModel>> GetBookAsync(
        int bookId)
    {
        /**/
    }
    [HttpGet]
    public Task<ActionResult<PageModel<BookModel>>> GetBooksAsync(
        [FromQuery] int pageNumber,
        [FromQuery] int pageSize)
    {
        /**/
    }
    [HttpPost]
    public Task<ActionResult<BookModel>> CreateBookAsync(
        [FromBody] CreateBookRequest createBookRequest)
    {
        /**/
    }
    [HttpPut("bookId")]
    public Task<ActionResult<BookModel>> UpdateBookAsync(
        int bookId,
        [FromBody] UpdateBookRequest updateBookRequest)
    {
        /**/
    }
    [HttpDelete("bookId")]
    public Task<IActionResult> DeleteBookAsync(
        int bookId)
    {
        /**/
    }

    // Helpers

    private BookModel ConvertToModel(Book book) { /**/ }
    private PageModel<BookModel> ConvertToPage(
        int pageNumber,
        int pageSize,
        IEnumerable<Book> books)
    {
        /**/
    }

    // Business Logic

    private Task<bool> VerifyUniqueBookNameAsync(
        SqlConnection sqlConnection,
        string bookName)
    {
        /**/
    }

    // Database

    private Task<SqlConnection> ConnectToSqlDatabaseAsync() { /**/ }

    private Task<Book>  GetBookFromDatabaseAsync(
        SqlConnection sqlConnection,
        int bookId)
    {
        /**/
    }
    private Task<IEnumerable<Book>> QueryBooksFromDatabaseAsync(
        SqlConnection sqlConnection,
        BookQuery bookQuery)
    {
        /**/
    }
    private Task<Book> InsertBookIntoDatabaseAsync(
        SqlConnection sqlConnection,
        Book book)
    {
        /**/
    }
    private Task<Book> SaveBookToDatabaseAsync(
        SqlConnection sqlConnection,
        Book book)
    {
        /**/
    }
    private Task DeleteBookFromDatabaseAsync(
        SqlConnection sqlConnection,
        int bookId)
    {
        /**/
    }
}

While it may be true that adding functionality becomes relatively trivial for developers already familiar with this class, it’s not true that this class is easy to understand. There are 4 distinct pieces of functionality contained in this class:

  • connecting to, querying, and modifying the database
  • business logic to determine uniqueness of a given title
  • mapping of DB entities to MVC view models
  • wrapping of response data (presumably including errors) in an appropriate ActionResult to encode for consumption by an HTTP client

This class has low cohesion because it mixes functionality from across multiple levels in a way that is not explicitly required. In order to understand the entire controller, a developer must understand each of the 4 phases as one whole. However, this doesn’t include other common Web API phases such as user authentication; authorization to access, modify, or delete a particular book; model validation; or post-processing inclusions like Hypertext Application Language (HAL) markup. While understanding the functionality of all of these phases is important to understanding the end-to-end flow of a Web API service, it shouldn’t be required to understand any individual phase.

It can certainly be argued that each method in this controller should be easy to understand, which makes the class overall relatively easy to understand. This particular implementation also puts a header above each “section” that defines a phase, making the file easy to browse. However, there’s no guarantee that a controller implemented this way would be organized similarly. A developer may place helper functions immediately before or immediately after the first method to require it. That would jumble everything up, making it potentially harder for some developers to parse the file at a glance. A developer might also choose not to break out the database access functionality into helper methods, instead leaving that inline within the action methods. That would increase the size of the action methods, making them inherently harder to understand.

There’s also testing to consider. As designed, this controller would be very difficult to test. Since the database connection logic is hard-coded within the controller logic, that would mandate that any tests would need access to a real DB in order to run. This precludes unit tests, requiring all tests against the class to be integration tests. Integration tests are harder to maintain due to extra configuration management, and are slower to run due to communication overhead. In order to test this controller, a developer must also test database access, business logic rules, and result mapping. That’s a lot of tests to write against a single class.

Finally, there’s coupling. This class is tightly coupled to a particular database technology, in this case some flavor of SQL. If the development team were to ever decide to migrate to a different SQL flavor, or even to a document DB such as Couchbase or MongoDB, then significant parts of the controller would have to be rewritten. The controller would also have to be refactored all at once: one couldn’t just implement querying a different DB while leaving the writes going to the old DB. If that version of the code were to ever be deployed, then data would potentially be lost without some external mechanism to migrate it over to the new DB and keep it in sync.

As designed, this sample controller class is highly coupled with low cohesion. Consider instead a different approach:

[ApiController]
[Route("api/[controller]")
public class BooksController : ControllerBase
{
    public BooksController(IBooksRepository booksRepository)
    {
        /**/
    }

    [HttpGet("bookId")}
    public Task<ActionResult<BookModel>> GetBookAsync(
        int bookId)
    {
        /**/
    }

    [HttpGet]
    public Task<ActionResult<PageModel<BookModel>>> GetBooksAsync(
        [FromQuery] int pageNumber,
        [FromQuery] int pageSize)
    {
        /**/
    }

    [HttpPost]
    public Task<ActionResult<BookModel>> CreateBookAsync(
        [FromBody] CreateBookRequest createBookRequest,
        [FromServices] IBooksService booksService)
    {
        /**/
    }

    [HttpPut("bookId")]
    public Task<ActionResult<BookModel>> UpdateBookAsync(
        int bookId,
        [FromBody] UpdateBookRequest updateBookRequest)
    {
        /**/
    }

    [HttpDelete("bookId")]
    public Task<IActionResult> DeleteBookAsync(
        int bookId)
    {
        /**/
    }
}

public interface IBooksService
{
    Task<BookModel> CreateBookAsync(
        CreateBookRequest createBookRequest);
}

public interface IBooksRepository
{
    Task<BookModel> GetBookAsync(int bookId);

    Task<PageModel<BookModel>> GetBooksAsync(
        BookQuery bookQuery);

    Task<BookModel> SaveBookAsync(BookModel bookModel);

    Task DeleteBookAsync(int bookId);
}

public class BooksService : IBooksService
{
    public BooksService(IBooksRepository booksRepository)
    {
        /**/
    }

    /* IBooksService Interface implementation */

    private Task<bool> VerifyUniqueBookNameAsync(
        string bookName)
    {
        /**/
    }
}

public class SqlBooksRepository : IBooksRepository
{
    public SqlBooksRepository(
        IDbConnection dbConnection,
        IMapper mapper) // AutoMapper: map DB entities to BookModels
    {
        /**/
    }

    /* IBooksRepository Interface Implementation */
}

Each type has a clear, individual purpose:

  • BooksController now focuses on mapping data from the lower-level service and repository into appropriate ActionResults
  • BooksService has the sole purpose of verifying the uniqueness of a title before saving a book to the underlying repository
  • SqlBooksRepository communicates with the DB and returns BooksModels for use in higher-level business logic

Some developers may balk at the extra type definitions required by this. However, the benefits far outweigh the few lines of extra code and files. In particular, each type is now considerably smaller than the original, making them all easier to understand. Testing of each layer also becomes considerably easier: instead of relying on a real database connection, unit tests can pass mocks of the interfaces to each of the concrete types. This implementation also makes use of the Dependency Inversion Principle by decoupling the controller and service classes from the underlying database technology, and instead using a repository abstraction to manage storage. This makes it possible to unit test the logic without requiring any external dependencies. It also makes it considerably easier to pivot to a new database provider without having to switch the repository used by the controller until the new implementation is ready.

This implementation has high cohesion because all of the types are focused on a single responsibility within the Web API stack. It also has very low coupling by way of abstraction layers.

The ease with which any software entity – be it a method definition, a type definition, a file containing multiple types, or an entire library module – can be understood is inversely proportional to its size. Because we can only hold so much information in our heads at once, it’s simply easier for us as human beings to fully comprehend small amounts of information at a time. In terms of cognitive ergonomics, it’s also easier to understand information that is highly related since this triggers associative memory recall. Therefor, small modules made up of short type definitions with even shorter methods, all of which focus on a single set of related tasks, are the easiest for developers to fully understand and retain.

The Single Responsibility Principle helps to enforce this. By implementing single tasks at a time, and separating tasks that are not directly related into individual types and files, modules become smaller and easier to work with. It does take time and experience to learn how best to draw those functional boundaries. But when the principle does start to click, it will make a huge difference in code quality.