Krzysztof Hrynczenko's Dev Diary

My little place where I write about things that interest me.


SOLID #1: The single responsibility principle

Posted on December 07, 2020

SOLID

For a long time now, I have been following just a couple of rules, or rather intuitions, when I am coding. Keep code simple, don't repeat yourself, don't implement what you don't need (KISS, DRY, YAGNI). I was focusing on making my code easy to understand, and on making code adaptable by writing to interfaces, and using dependency injection. I still believe that simply following these few rules can get you very far. They alone, in my opinion, make a very competent programmer (as long as you are conciously, adhering to them).

Unfortunately, sometimes the actual means of conforming to these principles can be unclear. Not only that, different situations require a different approach. Because of that, even though the principles themself are simple, they are not as easy to conform to as it seems. It seems that some guidance, would be really helpful in times of need.

That is why I have decided to revisit the SOLID principles, which in my opinion focus mostly on making your code adaptable, and simple at the same time. In the following blog posts, I will revisit each principle and try to put emphasis on why and how it can be used to our advantage.

Let's start with the single responsibility!

The single responsibility principle

I think there are two groups of people when it comes to describing what actually is the SRP. One group would say that it is about your units of code doing only one thing. The other group would say that SRP states that code should have only one reason to change. I fall in the latter, but I will try to apply both ways of thinking about the SRP.

You would ask then, "but what is a unit of code, and what exactly is one thing?". That is why I like to follow the second approach because this is not often clear. A unit of code might be either a module, class, or function. As to the one thing, for me, it means that unit of code should do only that what its name says. For example, if the function name is deserialize_user, it shouldn't be doing decryption, and parsing since this is not its responsibility, even though it is needed in a deserialization process.

For the sake of example let's say that we actually want to deserialize user objects. This process involves decrypting and then parsing the serialized string. Decryption involves taking a string and changing each letter with the next character in the ASCII table. Parsing involves splitting a decrypted string into two parts user name, and user email, where a dollar mark is a separator between them.

@dataclass
class User:
    name: Text
    email: Text

def deserialize_user(serialized_user: Text) -> Optional[User]:
    # DECRYPTION
    decrypted_letters = map(lambda letter: chr(ord(letter) + 1),
                            serialized_user)
    decrypted_text = "".join(decrypted_letters)

    # PARSING
    parsed_values = decrypted_text.split("$")
    if len(parsed_values) != 2:
        return None
    user_name = parsed_values[0]
    user_email = parsed_values[1]
    return User(user_name, user_email)
print(deserialize_user("trdq#dl`hk?cnl`hm-bnl"))
>>> User(name='user', email='email@domain.com')

I think we can see clearly that this function does two things, it does decryption, and it does parsing. We can see that this function has also more than one reason to change, actually three:

  • it needs to change when decryption algorithm changes
  • it needs to change when parsing algorithm changes
  • it needs to change when deserialization algorithm changes, e.g., additional initial step of decoding has to be performed

Let's make the simplest change that we can think of, i.e., extract functions.

def decrypt_user(serialized_user: Text) -> Text:
    decrypted_letters = map(lambda letter: chr(ord(letter) + 1),
                            serialized_user)
    return "".join(decrypted_letters)


def parse_user(decrypted_user: Text) -> Optional[User]:
    parsed_values = decrypted_user.split("$")
    if len(parsed_values) != 2:
        return None
    user_name = parsed_values[0]
    user_email = parsed_values[1]
    return User(user_name, user_email)


def deserialize_user(serialized_user: Text) -> Optional[User]:
    return parse_user(decrypt_user(serialized_user))

You might be wondering how much has actually changed. At first glance, not a lot. It almost looks like we just moved parts of the code to other places. That is not the only effect this change has produced. Now even though it might not initially look like it, the deserialize_user function does only one thing. It does not perform any decryption or parsing. Instead, it delegates these to the functions responsible for that. So how many reasons to change exist for this function now? Only one:

  • it needs to change when deserialization algorithm changes, e.g., additional initial step of decoding has to be performed.

The other two, namely:

  • it needs to change when parsing algorithm changes,
  • it needs to change when deserialization algorithm changes, e.g., additional initial step of decoding has to be performed,

are now reasons for their new corresponding functions. To summarize each of our functions has only one reason to change now.

Is that all the story? Not really. Our refactoring definitely brought clarity, and it would be much easier to reason about this code, and apply necessary modifications, like bug fixes, but there is still room for improvement. Imagine that we would like to log when decryption and parsing, starts and finishes. Lets start with the obvious and slap logging into the deserialazing function.

def deserialize_user(serialized_user: Text) -> Optional[User]:
    logging.log(INFO, "Decryption process started...")
    decrypted = decrypt_user(serialized_user)
    logging.log(INFO, "Decryption process finished...")
    logging.log(INFO, "Parsing process started...")
    parsed = parse_user(decrypted)
    logging.log(INFO, "Parsing process finished...")
    return parsed

Again this looks okay at first, but we added one more responsibility to the deserialize_user function. It has one more reason to change now, i.e., when logging process needs to change. Now extracting a functions doesn't look like a feasible solution. There is another one.

We can make our code more adaptable, it is, resiliant in face of changing requirements (like requirement for logging). Since we can see that our function now delegates responsibilities for decryption and parsing (and soon logging), we could have them injected into the deserialize_user function as a dependency. But before doing that let's start using interfaces (or traits or abstract classes, or whatever your language provides), because I think for most people they are easier to understand than just using functions. Forget about logging for now, we will come back to it soon enough.

class Decryption(ABC):
    @abc.abstractmethod
    def decrypt_user(self, serialized_user: Text) -> Text:
        pass


class PlusOneAsciiDecryption(Decryption):
    def decrypt_user(self, serialized_user: Text) -> Text:
        decrypted_letters = map(lambda letter: chr(ord(letter) + 1),
                                serialized_user)
        return "".join(decrypted_letters)


class Parsing(ABC):
    @abc.abstractmethod
    def parse_user(self, decrypted_user: Text) -> Optional[User]:
        pass


class DollarSplitParsing(ABC):
    def parse_user(self, decrypted_user: Text) -> Optional[User]:
        parsed_values = decrypted_user.split("$")
        if len(parsed_values) != 2:
            return None
        user_name = parsed_values[0]
        user_email = parsed_values[1]
        return User(user_name, user_email)


def deserialize_user(decryption: Decryption,
                     parsing: Parsing,
                     serialized_user: Text) -> Optional[User]:
    return parsing.parse_user(decryption.decrypt_user(serialized_user))

We extracted interfaces for our decryption and parsing algorithms and made them dependencies of our deserialize_user function. This gives some crucial benefits like:

  • ability to exchange these algorithms without modifying existing implementations
  • decide on what algorithm to use at runtime,
  • ability to extend the functionality of existing classes with decorators.

The third benefit is crucial for our logging functionality. Decorators are really handy when we want to extend functionality without introducing more responsibilites to some code. It is a common design pattern, an there are many explanations on how it works that you can find on the internet. We can now have another class (unit of code) be responsible for logging only. Let's see how this would play out in the code.

class LoggedDecryption(Decryption):
    def __init__(self, decryption: Decryption):
        self._decryption = decryption

    def decrypt_user(self, serialized_user: Text) -> Text:
        logging.log(INFO, "Decryption has started...")
        decrypted_user = self._decryption.decrypt_user(serialized_user)
        logging.log(INFO, "Decryption has finished...")
        return decrypted_user


class LoggedParsing(Parsing):
    def __init__(self, parsing: Parsing):
        self._parsing = parsing

    def parse_user(self, decrypted_user: Text) -> Optional[User]:
        logging.log(INFO, "Parsing has started...")
        parsed_user = self._parsing.parse_user(decrypted_user)
        logging.log(INFO, "Parsing has finished...")
        return parsed_user

As you can see LoggedDecryption, and LoggedParsing take responsibility for logging, and logging only. Lets look at it all at once.


@dataclass
class User:
    name: Text
    email: Text


class Decryption(ABC):
    @abc.abstractmethod
    def decrypt_user(self, serialized_user: Text) -> Text:
        pass


class PlusOneAsciiDecryption(Decryption):
    def decrypt_user(self, serialized_user: Text) -> Text:
        decrypted_letters = map(lambda letter: chr(ord(letter) + 1),
                                serialized_user)
        return "".join(decrypted_letters)


class Parsing(ABC):
    @abc.abstractmethod
    def parse_user(self, decrypted_user: Text) -> Optional[User]:
        pass


class DollarSplitParsing(ABC):
    def parse_user(self, decrypted_user: Text) -> Optional[User]:
        parsed_values = decrypted_user.split("$")
        if len(parsed_values) != 2:
            return None
        user_name = parsed_values[0]
        user_email = parsed_values[1]
        return User(user_name, user_email)


class LoggedDecryption(Decryption):
    def __init__(self, decryption: Decryption):
        self._decryption = decryption

    def decrypt_user(self, serialized_user: Text) -> Text:
        logging.log(INFO, "Decryption has started...")
        decrypted_user = self._decryption.decrypt_user(serialized_user)
        logging.log(INFO, "Decryption has finished...")
        return decrypted_user


class LoggedParsing(Parsing):
    def __init__(self, parsing: Parsing):
        self._parsing = parsing

    def parse_user(self, decrypted_user: Text) -> Optional[User]:
        logging.log(INFO, "Parsing has started...")
        parsed_user = self._parsing.parse_user(decrypted_user)
        logging.log(INFO, "Parsing has finished...")
        return parsed_user


def deserialize_user(decryption: Decryption,
                     parsing: Parsing,
                     serialized_user: Text) -> Optional[User]:
    return parsing.parse_user(decryption.decrypt_user(serialized_user))

if __name__ == "__main__":
    decryption = LoggedDecryption(PlusOneAsciiDecryption())
    parsing = LoggedParsing(DollarSplitParsing())
    print(deserialize_user(decryption, parsing, "trdq#dl`hk?cnl`hm-bnl"))

As you can see, now each of our units of code (classes and functions) has only one single responsibility. Although the number of lines of code has increased, we got much more in return. Each part of the code is now much more digestible. We can go to parsing and see how it works without even thinking on how decryption, or loggin works and vice versa. We can extend our functionality, without introducing new responsibilities by means of decorators. You can imagine that besides logging, we could chain many different decorators. We could not only log, but also measure performance, persist data in a database, or send it over the network. And each of these would be completley seperated, and could be used in different combinations with different implementations. And be decided on runtime. Is not that great?

To summarize, the single responsibility principle states that each unit of code like function or class should have only one reason to change. In other words, each unit of code should do one thing and one thing only. The simplest way of achieving that is by extracting functions (or classes). This often improves clarity, but in order to improve adaptability, i.e., resilience in face of changing requirements, we should adapt our code to use interfaces. Interfaces give us many benefits, such as loose coupling, runtime dependency injection, and improved extendibility with decorators.

I hope this shows how applying single responsibility principle can benefit our codebase. This was rather small example, but in real world scenarios this approach really shines.