- Published on
Clean Architecture in Python
- Authors
- Name
- Sergio Perea
- @spereadev
In this article, I will try to explain from the perspective of the Python language how to apply some of the concepts presented by Robert C. Martin in his book, Clean Architecture. I will begin with a quote from the author that perfectly summarizes the goal of Clean Architecture:
Good architecture makes the system easy to understand, easy to develop, easy to maintain, and easy to deploy. The ultimate goal is to minimize the lifetime cost of the system and to maximize programmer productivity.
The main objective of Clean Architecture is to separate the code into different responsibilities. To achieve this, the author proposed a model based on three layers:
- Presentation Layer
- Business Logic Layer
- Data Layer
- This architecture aims to address several issues, including independence in the development of use cases, decoupling between layers, and managing duplications. I recommend reading the book to understand the boundaries that define the divisions in the architecture and how they interact from a technical perspective, such as inter-thread communication, processes, or services. It shares principles already discussed in Alistair Cockburn's hexagonal architecture, such as entities, controllers, ports, adapters, etc.
From these types of architectures, which Robert C. Martin refers to as "clean" architectures, we learn, for example, that the database, as an external element to our application, does not need to be tightly coupled and does not need to be the foundation of our application. In other words, we cannot start by designing the database as we have always been taught. Even the framework, which is also an external element to our application, could be something to avoid coupling with.
In essence, Clean Architecture is also an architecture in layers. This means that the various elements of your system are categorized and have specific places to reside based on the assigned category.
The internal layers contain representations of business-related concepts, while the external layers contain implementation-specific details for "real-life" scenarios.
An internal layer does not know anything about the external layers, so it cannot understand the structures defined there. Its elements should communicate outward using interfaces, meaning they should use only the API expected by a component without needing to know its specific implementation. When an external layer is created, the elements residing in that layer will connect to those interfaces.
Key Layers of Clean Architecture with Python
Let's take a look at the main layers of Clean Architecture, keeping in mind that its implementation may require creating new layers or dividing existing ones further.
Entities
This layer contains representations of domain models, which encompass everything your system needs to interact with and are complex enough to require specific representations.
Robert C. Martin's book uses the example of strings. In Python, strings are represented by highly functional and complex objects with many methods. Therefore, it doesn't make sense to create a specific model for strings. However, policies, receipts, or claims in an insurance company can be modeled as entities, and thus require specific domain models.
But be careful not to confuse these domain models with the Models imposed by frameworks like Django. Those models are tightly coupled to a database or storage system. Our domain models should be lighter.
All our entities coexist in a specific layer of our application, allowing them to interact directly with each other. However, they have no knowledge of the external layers. It is important to understand this.
Let's see how an entity model for an application that displays a list of insurance policies in an insurance company would look like. First, we'll write a test to create a "Policy" object with a premium of 1000 euros and a deductible of 150. Obviously, the concept of a policy has been simplified for the sake of a simpler example:
import uuid
from insurance.domain import policy as p
def test_policy_model_init():
code = uuid.uuid4()
policy = p.Policy(code, price=1000.0, franchise=150.0)
assert policy.code == code
assert policy.price == 1000.0
assert policy.franchise == 150.0
But we won't settle for just this. Let's make our Policy class capable of managing policy data as a dictionary. We can specify this with the following two simple tests:
def test_policy_model_from_dict():
code = uuid.uuid4()
policy = r.Policy.from_dict(
{
'code': code,
'price': 1000.0,
'franchise': 150.0
}
)
assert policy.code == code
assert policy.price == 1000.0
assert policy.franchise == 150.0
def test_policy_model_to_dict():
policy_dict = {
'code': code,
'price': 1000.0,
'franchise': 150.0
}
policy = r.Policy.from_dict(policy_dict)
assert policy.to_dict() == policy_dict
Why do we want our Policy domain model to work as a Python dictionary? Because it will be much easier to perform specific operations on these objects. For example, let's enforce the ability to compare two policies in addition to the previous functionality using dictionaries (we will achieve this through an eq function).
Once again, let's develop this using TDD. First, the test:
def test_policy_model_compare():
policy_dict = {
'code': code,
'price': 1000.0,
'franchise': 150.0
}
policy_1 = p.Policy.from_dict(policy_dict)
policy_2 = p.Policy.from_dict(policy_dict)
assert policy_1 == policy_2
Now, let's try to write a domain model class that allows all the previous tests to pass:
class Policy:
def __init__(self, code, price, franchise):
self.code = code
self.price = price
self.franchise = franchise
@classmethod
def from_dict(cls, adict):
return cls(
code=adict['code'],
price=adict['price'],
franchise=adict['franchise'],
)
def to_dict(self):
return {
'code': self.code,
'price': self.price,
'franchise': self.franchise
}
def __eq__(self, other):
return self.to_dict() == other.to_dict()
Note that this is not enough. If our intention is to display policy information in a REST API, it makes sense to be able to serialize this class. So, let's create a "Serializer" class, anticipating the usage by other layers. Here's a simple test that could verify
import json
import uuid
from insurance.serializers import policy_json_serializer as serializer
from insurance.domain import policy as p
def test_serialize_domain_Serializer():
code = uuid.uuid4()
policy = p.Serializer(
code= code,
price= 1000.0,
franchise= 150.0
)
expected_json = """
{{
"code": "{}",
"price": 2000.0,
"franchise": 150.0
}}
""".format(code)
json_policy = json.dumps(policy, cls=ser.PolicyJsonEncoder)
assert json.loads(json_policy) == json.loads(expected_json)
The PolicyJsonEncoder class for serializing the Policy is quite simple:
import json
class PolicyJsonEncoder(json.JSONEncoder):
def default(self, o):
try:
to_serialize = {
'code': str(o.code),
'price': o.size,
'franchise': o.price
}
return to_serialize
except AttributeError:
return super().default(o)
Use Cases
A use case is a description of an action or activity. A use case diagram is a description of the activities that someone or something must perform to complete a process. The characters or entities that participate in a use case diagram are called actors. In the context of software engineering, a use case diagram represents a system or subsystem as a set of interactions that occur between use cases and between use cases and their actors in response to an initiating event by a primary actor. Use case diagrams are used to specify the communication and behavior of a system through its interaction with users and/or other systems.
Understanding use cases as processes that occur within our application, we model them in a layer above our entities. Use cases can utilize domain models to work with real data. Therefore, use cases have knowledge of entities and can instantiate and use them.
By isolating these processes into use cases, we achieve an architecture that is easier to test and maintain.
Of course, by placing use cases in the same layer of our architecture, they can communicate with each other, just as the domain models did.
A use case based on the domain model from the previous example, which involves displaying a list of policies, should pass the following (extremely simple, for now) test:
import pytest
import uuid
from unittest import mock
@pytest.fixture
def domain_policies():
policy_1 = r.Policy(
code=uuid.uuid4(),
price=1000.0,
franchise=150.0,
)
policy_2 = r.Policy(
code=uuid.uuid4(),
price=800.0,
franchise=250.0,
)
policy_3 = r.Policy(
code=uuid.uuid4(),
price=500.0,
franchise=300.0,
)
return [policy_1, policy_2, policy_3]
def test_policy_list_without_parameters(domain_policies):
repository = mock.Mock()
repository.list.return_value = domain_policies
policy_list_use_case = uc.PolicyListUseCase(repository)
result = policy_list_use_case.execute(request)
repository.list.assert_called_with()
assert result == domain_policies
Essentially, this test mocks a repository based on a list of three domain models created earlier. Our use case can be instantiated with this repository, and when executed, it should return the expected result. It does nothing more, but we will make it more complex in the future.
class PoliciesListUseCase:
def __init__(self, repo):
self.repo = repo
def execute(self):
return self.repo.list()
Many controllers for communication with external systems
This part of the architecture is composed of external systems that implement the interfaces defined in the previous layer. Examples of these systems can be a specific framework that exposes a REST API or a specific database management system.
The outermost layer includes the user interface, infrastructure, and testing system. The outer layer is reserved for things that can change more frequently. Therefore, it makes sense to isolate all of this from the core of the application.
In future articles, we will expand on this by creating external systems that allow information to be stored through a database or an API that handles the execution of the example use case.