NOTE

What developers miss about the Single Responsibility Principle

#software-engineering (40)#solid (1)#clean-code (4)

I reviewed a PR this week that added some functionality to Medium’s stats related to looking at a reader’s previous 90 days of activity.

This new function was proposed:

func (db *DB) GetViews90Days(ctx context.Context, userID string) ([]View, error) {
views, err := db.SelectViews(
userID,
time.Now().UTC(),
time.Now().AddDate(0, 0, -90).UTC(),
)
// ...
}

The function was clean, straightforward, and did a single thing.

I asked for a change to this logic before I approved the PR. The database layer shouldn’t need to know about the logical choice to look back at the past 90 days. That’s a concern of the engagement module that calls the database. GetViews90Days violated the Single Responsibility Principle.

The “S” in SOLID

The Single Responsibility Principle (SRP) states that a module should have one, and only one, reason to change (Robert Martin, Clean Architecture).

A “reason to change” could mean a host of different things.

  • Business requirements shift from 7-day to 90-day windows
  • Client APIs are migrating from REST to GraphQL
  • Internal APIs are migrating from REST to gRPC
  • Database tables need new indexes for faster lookups
  • Slow queries for users with lots of followers need a cache layer
  • Your org signed a vendor deal with GCP and now you need to migrate off of AWS

You could rewrite the SRP as: A module should be responsible to one, and only one, actor. “Actor” here refers to a stakeholder, user, or dependency. Different actors have different reasons driving them. Those reasons should not coexist in the same module.

GetViews90Days in the database module violates the SRP because the database shouldn’t know about the product decision to look back at the previous 90 days. A better function definition (and one that was ultimately accepted) would look like this:

func (db *DB) GetViews(ctx context.Context, userID string, from, to time.Time) ([]View, error) {
views, err := db.SelectViews(userID, from, to)
// ...
}

We can check this against a simple test: If we change the product functionality to look back at a different time window, say 180 days, does this module need to change? Nope! Just the module that defines the product functionality.

GetViews follows the Single Responsibility Principle because it only has one reason to change (in this case, the database).

SRP rhymes with “cohesion”

“Cohesion” is a term closely related to the SRP. A module is cohesive when all the code related to a single “reason to change” lives together.

The code that changes together stays together.

The goal of the SRP is to maximize cohesion and reduce unwanted coupling.

A note on coupling and cohesion: I often hear “coupling” and “cohesion” mentioned together, usually in a phrase like “High cohesion, low coupling.” Both are worth defining clearly.

  • High cohesion within modules is good. You want code that changes together to live together.
  • Low coupling between modules is good. You don’t want changes in one module to cause changes in other modules.

You want functionality grouped in a way that you can make changes in as few places as possible. And the fundamental concept that drives this? The Single Responsibility Principle.

If you’re interested in learning more, I highly recommend reading Clean Architecture. It explains the concept in a much more eloquent and approachable way.