Software Engineering Handbook (Part 3) - Single Responsibility Principle
It is arguably the most crucial principle in software design because most other principles and practices have their roots here.
Definition
Each entity (function, class, module, application, etc.) should have one responsibility, one job only, and one reason to change.
Deep dive
The hardest part about applying the Single Responsibility is defining what is a responsibility/a reason to change. I usually follow a pattern like this:
- Input/Output/Persistence handling: for each input your application has (HTTP, messaging, command line), there should be an entity responsible for, and for each output/persistence your application does (HTTP, database, messaging), there should be an entity responsible for it.
- Business/Application logic: your software does some processing, validation, apply rules and transformations over the data it accesses. Each of these operations should be implemented as a single entity. Since some entities group others (a module has many functions and classes), you may assemble related logic.
- Flow and composition: if you implement the above entities, they will be functional and clean. However, they will achieve nothing because they need to be connected, composed inside another entity representing some use case that flows data from input through the business logic, optionally a persistence, to the output.
You can often use these categories to find responsibilities inside your software and split them into distinct entities. However, you may need to break responsibilities even further in each one.
To better illustrate this, let’s use a Python script that generates a .gitignore
file using the Toptal API. This script accepts the output file name and a list of technologies that should be covered by the ignore rules.
import sys
import urllib.request
GITIGNORE_BASE_URL = "https://www.toptal.com/developers/gitignore/api/"
TECH_LIST_SEPARATOR = ","
##### COMPOSITION #####
def main(args):
selected_techs = selected_techs_from_args(args)
output_file = output_file_from_args(args)
try:
validate_selected_techs(selected_techs)
except ValueError as e:
print("Invalid argument", e)
return
except Exception as e:
print("Unknown error", e)
return
tech_list = build_tech_list(selected_techs)
gitignore = fetch_gitignore(tech_list)
try:
write_file(output_file, gitignore)
except Exception as e:
print("failed to write gitignore file", e)
return
print("Done!")
##### COMPOSITION #####
##### INPUT HANDLING #####
def output_file_from_args(args):
return args[1]
def selected_techs_from_args(args):
return args[2:]
##### INPUT HANDLING #####
##### BUSINESS LOGIC #####
def validate_selected_techs(selected_techs):
if len(selected_techs) == 0:
raise ValueError("no technology was provided")
##### BUSINESS LOGIC #####
##### OUTPUT HANDLING #####
def build_tech_list(selected_techs):
techs = map(lambda t: t.lower(), selected_techs)
return TECH_LIST_SEPARATOR.join(techs)
def fetch_gitignore(tech_list):
url = GITIGNORE_BASE_URL + tech_list
http_request = urllib.request.Request(
url, method="GET", headers={"user-agent": "python/example"}
)
try:
with urllib.request.urlopen(http_request) as http_response:
content = http_response.read().decode(
http_response.headers.get_content_charset("utf-8")
)
return content
except urllib.error.HTTPError as e:
raise Exception(
f"fetch gitignore failed with status code {e.code}, {e.reason}")
def write_file(filename, content):
with open(filename, 'w') as file:
file.write(content)
##### OUTPUT HANDLING #####
if __name__ == "__main__":
main(sys.argv)
The business logic section became the smallest because it’s such simple software. However, usually, it is the biggest and most important category of our implementation.
Also, the main
function is usually pretty small, letting all composition to a second function it calls. However, this is a case where context and judgment come in, the code is readable by itself, and we don’t expect it to change soon, so we can use a simpler approach. If we needed to change, it would be straightforward to separate the current use case into its own function and add support to others.
Each one of these categories could be extracted to its module to provide better separation. However, it is not because they are from the same category that they should be on the same module.
An excellent example of this is that we could do a toptal
module to house the functions build_tech_list
and fetch_gitignore
because they are related to how we interact with the Toptal API, and a module persistence
(if we had other types of persistence on our software, like databases, this would need to be more specific in the name, however in this case it is a good option because it avoids collision) to house write_file
and other code related to filesystem persistence. Hence, not all output handling should live in the same place.