sperea.es
Published on

Clean Architecture in Python

Authors

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.

arquitectura-limpia

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.