When to Consider Clean Architecture: A Practical Guide

How to design and implement clean architecture more easily has been described in detail in my previous articles. If you are interested in the details, you can refer to the following series of articles.

Instead of deeply diving into how to design and implement a clean architecture, this article will answer a question that we often encounter.

When should I consider clean architecture?

The main reason for asking this question is we often don’t design code with a clean architecture mindset from the start, but rather decide at some point to do a refactoring and introduce clean architecture.

This is a natural process because we don’t always have the ability to figure out how to encapsulate the domain in the first place, and we don’t always have the time to have a complete design at the beginning.

So, when should we refactor? And how? These two questions will be the core of this article.

Case Study

To answer these two questions we need a practical example, so let me describe my thought process using a chatbot.

Suppose I want to use a chatbot to implement an accounting program. The reason why I chose chatbot is to avoid complicated interface interactions, I just need to talk to my usual IM App to achieve the goal of accounting.

The overview of chatbot is as follows.

The user sends commands to the chatbot through a conversation, and the chatbot receives the message, encapsulates it and sends it to a web service via a webhook; the web service extracts the content with the corresponding SDK, processes the commands, and then sends the results back to the user. The process of processing the message involves accessing the database.

The code flow of the app is organized in the following five steps.

  • Extract: Use SDK to extract the received request.
  • Parse: Parses the message into a corresponding command.
  • Handle: Execute the command and generate the result. Interaction with the database also occurs at this stage.
  • Encapsulate: After handling the request, the result is packaged into the correct format by the SDK.
  • Send: Finally, the result is sent back to the user.

The whole core process will be the second and third steps. Without considering the clean architecture, the entire code would look like the following.

1
2
3
4
5
6
7
if msg == 'ooo':  
ret = user_guide()
elif msg.count('x') > 10:
ret = insert_items()
elif 'oxo' in msg:
ret = get_report()
# and so on

By parsing the msg, we know the command and take the corresponding action, in other words, this is the business logic of the whole program.

In the above example, we have three kinds of commands and three handlers, which correspond to the three common functions of a accounting program.

  1. ask how to use the program.
  2. write in accounts.
  3. generate reports.

I believe that adding a fourth command would be too painful for some people, and I would start thinking about refactoring it at about this point.

So to answer the first question: when should I consider refactoring with clean architecture?

The answer is simple: when the requirements keep iterating and the code becomes difficult to maintain.

But how to refactor? How to introduce clean architecture?

Refactoring

According to the above description, we have two main business logics.

  1. Parser
  2. Handler

Let’s review the onion architecture one more time.

Calling chain has to go from the outside to the inside, and the most inside is business logic.

So I will plan the refactored components as shown below.

Let me explain these components.

  • Controller: Handles the in/out of the webhook and gets the actual message and user id of the caller with the SDK.
  • Service:
  1. Call Factory to get concrete command.
  2. Execute Command directly and get the result, may be a string or an exception.
  • Factory: Handles the logic of generating the Command, i.e. the command parser.
  • Command: Handles the actual business logic, i.e. the handler, and is responsible for converting the result of the repo to a string.
  • Repo: handles database calls and wrapping the database format into a DTO, no logic involved.

Splitting the domain in this way has several advantages.

  1. Factory unit tests only need to have msg and match Command type, the implementation is quite simple.
  2. Command encapsulates the full behavior of individual commands and isolates the database implementation, so unit testing can easily replace the database through dependency injection and just verify business logic.
  3. Repo provides an interface to the database and wraps the format of the manipulated data into a DTO, providing a common specification.

Back to the onion architecture, Factory and Command are considered business logic, i.e., Entity, and Service is responsible for calling the Entity in order and doing the corresponding error handling, which belongs to the middle Use Case layer. The outer Controller handles the SDK and web framework, and calls the Service to get the result.

The only thing need to discuss is the outermost layer of DB, in the class diagram is actually Mongo Impl, through the implementation interface to make it out of the bottom layer of the calling chain, all the classes will only touch the Repo rather than the implementation of MongoDB. How to explain the source of the calling chain, i.e. the outer layer, is the DB?

The reason is that the outer layer of the code needs to prepare a concrete repo of the Mongo Impl and pass it to the Controller. In my current implementation, I’ve changed my approach a little bit by passing the class type instead of instance.

A similar implementation can be found in my previous Golang tutorial.

Finally, let’s look at the Factory and Command pieces.

First, the Factory decides what kind of Command to generate based on incoming msg.

1
2
3
4
5
6
7
8
9
10
11
12
class CommandFactory:  
@staticmethod
def generate(msg, uid):
if msg.startswith("/"):
if msg == '/sum':
return command.SummaryCommand(msg, uid)

return command.HelpCommand()
elif msg.count(' ') > 0:
return command.InsertManyCommand(msg, uid)

return command.HelpCommand()

Then the “Command”, where the actual business logic is executed, and the following examples will only pick one of them as a demonstration.

1
2
3
4
5
6
7
8
9
10
11
12
class InsertManyCommand(Command):  
def execute(self, repo_cls):
tokens = [x.strip() for x in self.msg.split(',')]

data = []
for token in tokens:
item, cost = [t(s) for t,s in zip((str,int), token.split())]
data.append(Item(item, cost))

repo = repo_cls(self.uid)
success_cnt = repo.insert_many(data)
return f'Successfully wrote {success_cnt} record(s)'

Conclusion

Let’s go back to the title, when should we start considering clean architecture? I feel the answer is obvious, when we need to change the code but we don’t know where to start, that’s a good time to do it.

Why do I let my business logic consist of Factory and Command? Because once I’ve clarified the behavioral patterns of the bot, I can clearly recognize that two things are necessary: parsing and handling, so these two corresponding entities are created. In addition, when these two things are separated, the difficulty of unit testing is significantly reduced.

I can fully test parsing messages, and of course I can test every self-contained command completely.

When we mention clean architecture, will always make people think of hexagonal architecture or domain-driven development and other formal rules, but in practice to make the architecture clean, as long as the basic essentials will be enough, that is, the concept of the onion architecture.

Some of the design patterns used in this article, such as the factory method or strategy pattern. Even though I didn’t follow the textbook, I was able to achieve a good result.

When I drew that class diagram, I didn’t actually have a specific design pattern in mind, but rather, it was based on what shape I wanted the code to be organized into. I first thought about how I would like to decouple and individually test a command if I needed to add one, and then I created two components (Factory and Command) with this idea in mind.

Although Factory has a relatively simple role, I indeed thought for a while about what context Command would be responsible for. Take InsertManyCommand for example, there are these options.

  • The Service is responsible for tokenization and conversion to DTO.
  • The Repo is responsible for tokenization and writing directly to the database.
  • The Service handles the presentation of the result string.

All of these were eventually put into Command, because how the strings are split and how the results are presented should be part of the business logic, and naturally should be handled by Command, which is the core of the business logic.

Once we have a concrete idea, we can fill in the details of the class diagram, and then we just need to follow the diagram to write the program exactly as it is designed.

Lastly, my thoughts on ORMs. In fact, I don’t use ORMs to implement database access, but rather use pymongo with a custom DTO.

Why don’t I just use an ORM? The reason is that ORM will make developers overlook the existence of database and make clean architecture difficult to achieve.

From the onion architecture, we know the database is on the outer layer and should not be called by any object, but with ORM, we can easily develop the ORM as an entity into the inner layer of the onion architecture, and it even contains a lot of business logic. This is difficult to test and difficult to maintain, and is the worst situation of all.

Originally published on Medium