Clean architecture in Python: a step-by-step demo. Part 2

Original author: Leonardo Giordani
  • Transfer
  • Tutorial


Domain Models


Git tag: Step02

Let's start with a simple model definition StorageRoom. As mentioned earlier, models in pure architecture are very lightweight, at least lighter than their ORM counterparts in frameworks.

Since we follow the TDD methodology, the first thing we write is tests. Create a file tests/domain/test_storageroom.pyand put this code inside it:

import uuid
from rentomatic.domain.storageroom import StorageRoom
def test_storageroom_model_init():
    code = uuid.uuid4()
    storageroom = StorageRoom(code, size=200, price=10,
                         longitude='-0.09998975',
                         latitude='51.75436293')
    assert storageroom.code == code
    assert storageroom.size == 200
    assert storageroom.price == 10
    assert storageroom.longitude == -0.09998975
    assert storageroom.latitude == 51.75436293
def test_storageroom_model_from_dict():
    code = uuid.uuid4()
    storageroom = StorageRoom.from_dict(
        {
            'code': code,
            'size': 200,
            'price': 10,
            'longitude': '-0.09998975',
            'latitude': '51.75436293'
        }
    )
    assert storageroom.code == code
    assert storageroom.size == 200
    assert storageroom.price == 10
    assert storageroom.longitude == -0.09998975
    assert storageroom.latitude == 51.75436293

These two tests ensure that our model can be initialized with the correct values ​​passed to it or with the help of a dictionary. In the first case, you need to specify all the model parameters. Later it will be possible to make some of them optional, after writing the necessary tests.

In the meantime, let's write a class StorageRoomby placing it in a file rentomatic/domain/storageroom.py. Do not forget to create a file __init__.pyin each subdirectory of the project, which Python should perceive as modules.

from rentomatic.shared.domain_model import DomainModel
class StorageRoom(object):
   def __init__(self, code, size, price, latitude, longitude):
       self.code = code
       self.size = size
       self.price = price
       self.latitude = float(latitude)
       self.longitude = float(longitude)
   @classmethod
   def from_dict(cls, adict):
       room = StorageRoom(
           code=adict['code'],
           size=adict['size'],
           price=adict['price'],
           latitude=adict['latitude'],
           longitude=adict['longitude'],
       )
       return room
DomainModel.register(StorageRoom)

The model is very simple and requires no explanation. One of the benefits of pure architecture is that each layer contains small pieces of code that, when isolated, should perform simple tasks. In our case, the model provides an API for initializing and storing information inside the class.

The method from_dictis useful when creating a model from data coming from another layer (such as a database layer or from a query string in a REST layer).

It may be tempting to try to simplify a function from_dictby abstracting and exposing it as a method of the Model class . And given that a certain level of abstraction and generalization is possible and necessary, and the initialization of models can interact with various other scenarios, it is better to implement it directly in the class itself.

An abstract base classDomainModel is an easy way to classify a model for future scenarios, such as checking for a class to belong to a model in the system. For more information on using Abstract Base Classes in Python, I advise you to read this post .

Serializers


Git tag: Step03

If we want to return our model as a result of an API call, then it will need to be serialized. A typical serialization format is JSON, as it is a widespread standard used for web APIs. The serializer is not part of the model. This is an external special class that receives an instance of a model and translates its structure and values ​​into some representation.

To test our class JSON serialization, StorageRoomput the tests/serializers/test_storageroom_serializer.pyfollowing code in a file

import datetime
import pytest
import json
from rentomatic.serializers import storageroom_serializer as srs
from rentomatic.domain.storageroom import StorageRoom
def test_serialize_domain_storageroom():
    room = StorageRoom('f853578c-fc0f-4e65-81b8-566c5dffa35a',
                       size=200,
                       price=10,
                       longitude='-0.09998975',
                       latitude='51.75436293')
    expected_json = """
        {
            "code": "f853578c-fc0f-4e65-81b8-566c5dffa35a",
            "size": 200,
            "price": 10,
            "longitude": -0.09998975,
            "latitude": 51.75436293
        }
    """
    assert json.loads(json.dumps(room, cls=srs.StorageRoomEncoder)) == json.loads(expected_json)
def test_serialize_domain_storageruum_wrong_type():
    with pytest.raises(TypeError):
        json.dumps(datetime.datetime.now(), cls=srs.StorageRoomEncoder)

Put the rentomatic/serializers/storageroom_serializer.pycode that passes the test into the file :

import json
class StorageRoomEncoder(json.JSONEncoder):
    def default(self, o):
        try:
            to_serialize = {
                'code': o.code,
                'size': o.size,
                'price': o.price,
                "latitude": o.latitude,
                "longitude": o.longitude,
            }
            return to_serialize
        except AttributeError:
            return super().default(o)

Providing a class inherited from JSON.JSONEncoder, we use json.dumps(room, cls = StorageRoomEncoder)to serialize the model.

We may notice some repetition in the code. This is a minus of clean architecture, which is annoying. Since we want to isolate layers as much as possible and create lightweight classes, we ultimately repeat some of the steps. For example, the serialization code that assigns attributes from StorageRoomto JSON attributes is similar to what we use to create an object from a dictionary. Not the same thing, but there are similarities between the two functions.

Scenarios (part 1)


Git tag: Step04

It's time to implement the real business logic of our application, which will be available outside. Scenarios are the place where we implement classes that request storage, apply business rules, logic, transform data as our heart desires, and return the result.

Given these requirements, let's begin to build a scenario sequentially . The simplest scenario that we can create is one that retrieves all the storage facilities from the warehouse and returns them. Please note that we have not yet implemented the storage layer, so in our tests we will wet it (replace it with a fiction).

Here is the basis for a simple script testwhich displays a list of all storage facilities. Put this code in a filetests/use_cases/test_storageroom_list_use_case.py

import pytest
from unittest import mock
from rentomatic.domain.storageroom import StorageRoom
from rentomatic.use_cases import storageroom_use_cases as uc
@pytest.fixture
def domain_storagerooms():
    storageroom_1 = StorageRoom(
        code='f853578c-fc0f-4e65-81b8-566c5dffa35a',
        size=215,
        price=39,
        longitude='-0.09998975',
        latitude='51.75436293',
    )
    storageroom_2 = StorageRoom(
        code='fe2c3195-aeff-487a-a08f-e0bdc0ec6e9a',
        size=405,
        price=66,
        longitude='0.18228006',
        latitude='51.74640997',
    )
    storageroom_3 = StorageRoom(
        code='913694c6-435a-4366-ba0d-da5334a611b2',
        size=56,
        price=60,
        longitude='0.27891577',
        latitude='51.45994069',
    )
    storageroom_4 = StorageRoom(
        code='eed76e77-55c1-41ce-985d-ca49bf6c0585',
        size=93,
        price=48,
        longitude='0.33894476',
        latitude='51.39916678',
    )
    return [storageroom_1, storageroom_2, storageroom_3, storageroom_4]
def test_storageroom_list_without_parameters(domain_storagerooms):
    repo = mock.Mock()
    repo.list.return_value = domain_storagerooms
    storageroom_list_use_case = uc.StorageRoomListUseCase(repo)
    result = storageroom_list_use_case.execute()
    repo.list.assert_called_with()
    assert result == domain_storagerooms

The test is simple. First, we replaced the repository so that it provides a method list()that returns a list of previously created models. Then we initialize the script with the repository and execute it, remembering the result. The first thing we check is that the storage method was called without any parameter, and the second is the correctness of the result.

And here is the implementation of the script that passes the test. Put the code in a filerentomatic/use_cases/storageroom_use_case.py

class StorageRoomListUseCase(object):
    def __init__(self, repo):
        self.repo = repo
    def execute(self):
        return self.repo.list()

However, with such a scenario, we will soon encounter a problem. Firstly, we don’t have a standard way to transfer call parameters, which means that we don’t have a standard way to check their correctness. The next problem is that we are missing the standard way to return the results of a call, and therefore we cannot find out if the call was successful or not, and if not, for what reason. The same problem is with the incorrect parameters discussed in the previous paragraph.

Thus, we want some structures to be introduced to wrap the input and output of our scripts . These structures are called request and response objects .

Requests and Answers


Git tag: Step05

Requests and answers are an important part of clean architecture. They move call parameters, input data and call results between the script layer and the external environment.

Requests are created based on incoming API calls, so they will encounter such things as incorrect values, missing parameters, incorrect format, etc. Answers , on the other hand, should contain the results of API calls, including should present errors and give detailed information about what happened.

You may use any implementation of requests and responsespure architecture says nothing about it. The decision on how to pack and present the data lies entirely with you.

In the meantime, we just need StorageRoomListRequestObjectone that can be initialized without parameters, so, let's create a file tests/use_cases/test_storageroom_list_request_objects.pyand put the test for this object in it.

from rentomatic.use_cases import request_objects as ro
def test_build_storageroom_list_request_object_without_parameters():
    req = ro.StorageRoomListRequestObject()
    assert bool(req) is True
def test_build_file_list_request_object_from_empty_dict():
    req = ro.StorageRoomListRequestObject.from_dict({})
    assert bool(req) is True

At the moment, the request object is empty, but it will be useful to us as soon as we have the parameters for the script that lists the objects. The code for the class StorageRoomListRequestObjectis in the file rentomatic/use_cases/request_objects.pyand looks like this:

class StorageRoomListRequestObject(object):
    @classmethod
    def from_dict(cls, adict):
        return StorageRoomListRequestObject()
    def __nonzero__(self):
        return True

The request is also quite simple, because at the moment we need only a successful response. Unlike the request , the response is not related to any specific scenario , so the test file can be calledtests/shared/test_response_object.py

from rentomatic.shared import response_object as ro
def test_response_success_is_true():
    assert bool(ro.ResponseSuccess()) is True

and the actual response object is in the filerentomatic/shared/response_object.py

class ResponseSuccess(object):
    def __init__(self, value=None):
        self.value = value
    def __nonzero__(self):
        return True
    __bool__ = __nonzero__

To be continued in Part 3 .


Also popular now: