user photo

Arquitectura limpia con Python

Published on
Sergio Perea · 9 min read
python, clean code

En este artículo voy a tratar de explicarte desde el punto de vista del lenguaje Python como aplicar algunas de las cosas que Robert C. Martin nos ensenó en su libro Clean Architecture. Comenzaré, a modo de introducción, con esta cita del propio autor que resume perfectamente cual es el objetivo de la Arquitectura Limpia:

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.

El principal objetivo de una Arquitectura Limpia es su capacidad de separar el código en su diferentes responsabilidades. Para ello, el autor nos planteaba un modelo basado en tres capas:

  • Presentation Layer
  • Business Logic Layer
  • Data Layer

Esta arquitectura trata de resolver muchas cuestiones: la independencia en el desarrollo entre casos de uso, el desacoplamiento entre capas o la gestión de duplicidades serían las más importantes. Para ello os recomiendo leer el libro porque es importante entender las fronteras que delimitan las partes en las que dividir la arquitectura y cómo se cruzan desde el punto de vista técnico: comunicación de hilos, procesos o servicios. Todo ello compartiendo principios ya tratados en la arquitectura hexagonal de Alistair Cockburn: entidades, controladores, puertos, adaptadores, etc.

De este tipo de arquitecturas a las que Robert C. Martin llama "Limpias" aprenderemos, por ejemplo, que la base de datos como elemento externo a nuestra aplicación, no tiene porqué estar acoplada y por tanto no tiene porqué ser la base de nuestra aplicación. O lo que es lo mismo: no podemos empezar diseñándola en primer lugar tal como nos han enseñado siempre. Incluso el framework, que también es algo externo a nuestra aplicación, podría ser algo con lo que evitar acoplarse.

En definitiva, una Arquitectura Limpia es también una arquitectura en capas. Esto significa que los diversos elementos de uso de su sistema están categorizados y tienen un lugar específico donde estar, según la categoría que les hayamos asignado.

arquitectura-limpia

Las capas internas contendrán representaciones de conceptos relacionados con el negocio, mientras que las capas externas contendrán detalles específicos sobre la implementación en "la vida real".

Una capa interna no sabe nada sobre las externas, por lo que no puede entender las estructuras definidas allí. Sus elementos deben hablar hacia afuera usando interfaces, es decir, usando solo la API esperada por un componente, sin tener que conocer su implementación específica. Cuando se crea una capa externa, los elementos que vivan allí se conectarán a esas interfaces.

Principales capas de una Arquitectura Limpia con Python

Echemos un vistazo a las capas principales de una Arquitectura Limpia, teniendo en cuenta que su implementación puede requerir crear nuevas capas o dividir algunas de ellas en varias.

Entidades

Esta capa contiene una representación de los modelos de dominio, es decir, todo lo que tu sistema necesitará para interactuar y que además es lo suficientemente complejo como para necesitar una representación específica.

El libro de Robert C. Martin pone el ejemplo de las cadenas. En Python, están representadas por objetos muy complejos y funcionales. Con muchos métodos que puedes usar. De modo que no tiene sentido crear un modelo específico para ello. Sin embargo, una Póliza, un Recibo o un Siniestro en una aseguradora si podemos modelizarlos como entidades, y por tanto necesitarán modelos de dominio específicos.

Pero ojo, no confundas estos modelos de dominio con los Models que te imponen frameworks como Django. Esos modelos están fuertemente acoplados a una base de datos o un sistema de almacenamiento. Nuestros modelos de dominio deberían ser más ligeros.

Todas nuestras entidades convivirán en una capa concreta de nuestra aplicación, de modo que podremos permitir que interactúen de forma directa entre ellos. De lo que no tienen ninguna información es de las capas externas. Es importante entender esto.

Vamos a ver cómo sería un modelo de entidad para una aplicación que muestra un listado de pólizas de seguros en una compañía aseguradora. Lo primero es escribir un test con el que crearemos un elemento "Póliza" que tiene una prima de 1000 euros y 150 de franquicia. Obviamente, el concepto de póliza se ha simplificado a un extremo absurdo de cara a poneros un ejemplo más sencillo:

    
    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
    

Pero no nos vamos a conformar con esto. Vamos a imponer que nuestra clase Póliza sea capaz de gestionar los datos de la póliza como un diccionario. Lo posdemos especificar con estos dos sencillos test:

    
    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

¿por qué quiero que el modelo de dominio de la Póliza trabaje como un diccionario de Python? Pues porque será mucho más sencillo crear operaciones específicas sobre esos objetos. Por ejemplo, vamos a tratar de imponer, además de lo anterior, que dos pólizas puedan compararse. Fijaos lo fácil que será si utilizamos diccionarios (lo resolveremos a través de una función llamada eq).

Nuevamente, vamos a desarrollar esto mediante TDD. Primero el 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
    

Y ahora vamos a tratar de escribir una clase de modelo de dominio que permita que todos los test anteriores se pongan en verde:

    
    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()
    
    

Ojo, esto no es suficiente. Si nuestra idea es mostrar la información de las pólizas en una API REST; tiene sentido que seamos capaces de serializar esta clase. Así que podemos crear una clase "Serializer" adelantándonos al uso que le darán otras capas. Veamos un sencillo test que podría probar esto:

    
    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)
    

La clase para serializar Póliza es bastante 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)
    

Casos de uso

Un caso de uso es la descripción de una acción o actividad. Un diagrama de caso de uso es una descripción de las actividades que deberá realizar alguien o algo para llevar a cabo algún proceso. Los personajes o entidades que participarán en un diagrama de caso de uso se denominan actores. En el contexto de ingeniería del software, un diagrama de caso de uso representa a un sistema o subsistema como un conjunto de interacciones que se desarrollarán entre casos de uso y entre estos y sus actores en respuesta a un evento que inicia un actor principal. Los diagramas de casos de uso sirven para especificar la comunicación y el comportamiento de un sistema mediante su interacción con los usuarios y/u otros sistemas.

Entendiendo pues los casos de uso como procesos que suceden en nuestra aplicación, los modelizaremos en una capa por encima de nuestras Entidades. Los casos de uso pueden usar los modelos de su dominio para trabajar con datos reales. Por tanto los casos de uso conocen las entidades, y pueden instanciarlas y utilizarlas.

Aislando estos procesos en casos de uso, tendremos una arquitectura mucho más fácil de probar y mantener.

Por supuesto, al poner los casos de uso en la misma capa de nuestra arquitectura, éstos pueden comunicarse entre sí, como sucedía con los modelos de dominio.

Un caso de uso basado en el modelo de dominio del ejemplo anterior, que consistiera en mostrar una lista de pólizas, debería superar el siguiente test (extremadamente simple, de momento):

    
    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
    

Básicamente lo que hace este test es mockear un repositorio a partir de una lista 3 modelos de dominio creados antes. Nuestro caso de uso se podrá reiniciar con dicho repositorio, así que lo ejecutaremos obteniendo el resultado esperado. No hace nada más. Aunque lo iremos complicando próximamente.

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

Un montón de controladores para comunicarnos con sistemas externos

Esta parte de la arquitectura está compuesta por sistemas externos que implementan las interfaces definidas en la capa anterior. Ejemplos de estos sistemas pueden ser un marco específico que expone una API REST o un SGBD específico.

En la parte más externa se encuentra la interfaz de usuario, la infraestructura y el sistema de pruebas. La capa exterior está reservada para las cosas que pueden cambiar más a menudo. Por tanto tiene mucho sentido que todo esto esté aislado del núcleo de la aplicación.

En próximos artículos iré complicando esto creando sistemas externos que permitan almacenar la información a través de una BD, o una API Rest que atienda a la ejecución del caso de uso de ejemplo.