Executando verificação de segurança...
3

Django e Arquitetura Limpa — A verdadeira batalha

Nesta publicação vamos experimentar uma abordagem de Arquitetura Limpa para uma simples aplicação construída em Django e falar sobre como fazer isso sem perder os benefícios que o Django nos traz.

Antes de mais nada, é recomendável estar familiarizado com Arquitetura Limpa e Django. Nós vamos discutir sobre como integrá-los, mas não vamos a fundo nesses tópicos. Talvez em outro momento possamos explorar esses conceitos e tecnologias. Dito isso, vamos falar brevemente sobre essas coisas.

Arquitetura Limpa

A Arquitetura Limpa foi criada por Robert C. Martin como uma filosofia de design de software e tenta nos guiar para um sistema independente. Ou seja: testável, independente de frameworks, independente de UI, independente de banco de dados e independente de qualquer agente externo.

Diagrama - Clean Architecture

A camada Framework & Drivers depende de Adaptadores de Interface, que por sua vez depende de Regras de Negócios da Aplicação que depende de Regras de Negócios da Empresa. Acompanhando? Mas lembre-se: O inverso nunca pode acontecer, em outras palavras: uma entidade nunca pode depender de um caso de uso, ou pior ainda — um framework… Os Deuses da Arquitetura Limpa podem assombrá-lo à noite se você fizer isso.

Nós vamos dar uma olhada em um microsserviço responsável por gerir as comidas favoritas do usuário. Para isso, vamos analisar um pouco sobre o agregado de Produto e nas camadas dessa aplicação.

A Camada de Entidade

Aqui nós construímos nosso arquivo entities.py:

from typing import Optional
from datetime import datetime
from dataclasses import dataclass, field

from domain.errors import EntityValidationException
from domain.validators import FoodValidatorFactory


@dataclass(frozen=True)
class Product:
    name: str
    quantity: Optional[int] = 1
    id: Optional[int] = None
    is_active: Optional[bool] = True
    created_at: Optional[datetime] = field(default_factory=datetime.now)

    def __post_init__(self):
        if not self.created_at:
            object.__setattr__(self, "created_at", datetime.now())
        self.validate()

    def increment_quantity(self, quantity: int) -> int:
        if quantity > 0:
            object.__setattr__(self, "quantity", self.quantity + quantity)
        return self.quantity

    def activate(self):
        object.__setattr__(self, "is_active", True)

    def deactivate(self):
        object.__setattr__(self, "is_active", False)

    def validate(self):
        validator = FoodValidatorFactory.create()
        is_valid = validator.validate(self.to_dict())

        if not is_valid:
            raise EntityValidationException(validator.errors)

    def to_dict(self):
        return {"id": self.id, "name": self.name, "quantity": self.quantity}

Em nosso código Python, usamos muito a biblioteca dataclass — sério, muito! O principal motivo é porque ela nos fornece benefícios como o construtor automático, o argumento frozen que se for Verdadeiro, os campos não podem ser atribuídos após a criação da instância do objeto. Isso é realmente útil quando você deseja garantir que seus atributos não serão alterados a não ser por seus métodos responsáveis por alterações em sua entidade. Por exemplo: se queremos aumentar a quantidade, não acessamos diretamente a propriedade quantidade, chamamos o método increment_quantity.

Agora é uma boa hora para criar testes. Vamos começar com uma validação básica:

import unittest
from unittest.mock import patch
from datetime import datetime

from domain.entities import Product

class TestProductUnit(unittest.TestCase):
    def test_constructor(self):
        with patch.object(Product, 'validate') as mock_validate_method:
            product = Product('Product1')
            mock_validate_method.assert_called_once()
            self.assertEqual(product.name, 'Product1')
            self.assertIsNone(product.id)
            self.assertEqual(product.quantity, 1)
            self.assertTrue(product.is_active)
            self.assertIsInstance(product.created_at, datetime)

Aqui nós usamos a biblioteca unittest para criarmos um caso de teste e mockar o método validate da entidade. O motivo principal de estarmos mockando o método validate é porque o ideal é termos um caso de teste específicio para o fluxo de validações e não misturar essa responsabilidade com o teste unitário da construção da entidade.

A partir da instanciação do objeto Product nós garantimos que o método validate é chamado e que cada um dos atributos corresponde como esperado, considerando validadores padrões — como quantity e is_active e gerados automaticamente pelo sistema — como o id, sempre considerando as regras de negócio e regras da nossa aplicação.

Casos de Uso

Agora vamos dar uma olhada em como as regras da aplicação devem funcionar em nossos casos de uso. Basicamente instanciamos um novo produto e ele sempre se valida e garante que sempre terá um objeto consistente e retornamos a resposta da camada de repositório — que por sua vez irá salvar a entidade Product em alguma fonte de dados. Vale ressaltar também que nossos produtos serão criados inativados caso forem adicionados na sexta, sábado ou domingo.

from datetime import datetime

from domain.repositories import ProductRepository
from domain.entities import Product


class CreateProductUseCase:
    def __init__(self, product_repository: ProductRepository) -> None:
        self.product_repository = product_repository

    def execute(self, name: str, quantity: int) -> Product:
        product = Product(name, quantity)

        if datetime.today().weekday() in (4, 5, 6):
            product.deactivate()

        return self.product_repository.save(product)

Até agora tudo bem? Bom, vamos criar dois testes para termos certeza disso (ou mais perto de ter certeza).

import unittest
from freezegun import freeze_time

from application.use_cases import CreateProductUseCase
from domain.entities import Product


class MockRepository:
    called_times = 0

    def save(self, product: Product):
        self.called_times += 1
        return Product(
            product.name,
            product.quantity,
            123,
            product.is_active,
            product.created_at
        )


class TestCreateProductUseCase(unittest.TestCase):
    @freeze_time('2023-01-01')
    def test_should_execute_create_product_use_case_with_inactived_product(self):
        mock_repository = MockRepository()
        product = {"name": "Product Test", "quantity": 5}

        use_case = CreateProductUseCase(mock_repository)
        result = use_case.execute(product["name"], product["quantity"])

        assert result.id is not None
        assert result.name == product["name"]
        assert result.quantity == product["quantity"]
        assert result.is_active == False
        assert mock_repository.called_times == 1


    @freeze_time('2023-01-02')
    def test_should_execute_create_product_use_case_with_actived_product(self):
        mock_repository = MockRepository()
        product = {"name": "Product Test", "quantity": 5}

        use_case = CreateProductUseCase(mock_repository)
        result = use_case.execute(product["name"], product["quantity"])

        assert result.id is not None
        assert result.name == product["name"]
        assert result.quantity == product["quantity"]
        assert result.is_active == True
        assert mock_repository.called_times == 1

Quanto mais nos aprofundamos na solicitação, mais precisamos nos preocupar com as dependências e como mockar. Existem várias maneiras de fazer isso e algumas bibliotecas ajudam nisso, mas essa é uma maneira fácil e válida de fazer isso. Repare também que nós não nos preocupamos nesse momento na resposta real do repositório, seja qual for sua implementação. Nesse tipo de teste nós apenas validamos o fluxo da aplicação e o retorno que o caso de uso irá transportar para a próxima camada. Por fim, nós utilizamos o freezegun para fixarmos a data atual para que possamos ter o teste exato da ativação do produto apenas nos dias descritos na regra da nossa aplicação.

Agora chegou a hora de apresentarmos a camada de repositórios. Preparado? Voilà!

from abc import ABC, abstractmethod

from domain.entities import Product


class ProductRepository(ABC):
    @abstractmethod
    def save(self, product: Product) -> Product:
        """responsable for save product model"""
        pass

Exatamente, nós criamos abstrações para que nossa camada de casos de uso não fique dependente de implementações específicas do ORM, seja ele do Django ou qualquer que seja. Por isso, postergamos ao máximo a escolha e implementações específicas de tecnologias, como qual a maneira de persistência e consulta dos dados. No entanto, caso esteja curioso a respeito da nossa implementação atual, ficou basicamente assim:

from domain.entities import Product
from django_app.models import ProductModel


class DjangoProductRepository:
    def save(self, product: Product) -> Product:
        django_product = ProductModel.from_entity(product)
        django_product.save()
        return django_product.to_entity()

Note que nós sempre nos preocupamos em deixar cada camada cuidar do que lhe pertence e não sujar as outras camadas com o que não é relevante. Por exemplo, nós recebemos uma entidade Product e ela é convertida para um objeto específico do ORM que estamos utilizando, no caso, o do Django. Isso é muito importante, sobretudo porque a nossa entidade não tem necessidade nenhuma de seguir o mesmo contrato do nosso banco de dados — óbvio, a camada de regra de negócio não deve depender desse detalhe de implementação em nenhum sentido — e nesses casos é possível que seja feita a construção independente desse modelo a partir da entidade. Vamos a um exemplo:

Imagine que tens uma entidade Usuário e ela tem o CPF do mesmo. Uma possibilidade seria mais ou menos assim:

@dataclass(frozen=True)
class User:
    name: str
    base_number: str
    region: str
    verifying_digits: str

Na camada de regra de negócio isso até faz sentido, mas na camada de persistência, pouco importa, haverá apenas um campo chamado “cpf” e é disso o que o User.from_entity(entity) se encarregaria.

Voltando ao nosso exemplo, esse seria o nosso modelo do banco de dados:

from django.db import models

from domain.entities import Product


class ProductModel(models.Model):
    name: str = models.CharField(max_length=100)
    quantity: int = models.IntegerField()

    def to_entity(self) -> Product:
        return Product(self.name, int(self.quantity), self.pk)

    @staticmethod
    def from_entity(product: Product) -> "ProductModel":
        return ProductModel(name=product.name, quantity=product.quantity)

    def __str__(self) -> str:
        return f"{self.pk} - {self.name}"

    class Meta:
        db_table = "products"
        verbose_name = "Product"
        verbose_name_plural = "Products"

Novamente, veja que, quanto mais fundo nós vamos na requisição, mais nós temos que nos preocupar com o framework, mas não antes do necessário. Percebeu que no nosso código a primeira aparição do nome “Django” foi só na implementação do repositório? Antes disso, nós não precisávamos nem saber que estamos trabalhando com Django, poderíamos simplesmente plugar outro framework — como FastAPI ❤, por exemplo — e o restante da nossa aplicação ficaria intacta com regras de negócios e de aplicação intactas.

Agora vamos voltar ao início da vida da requisição. Por onde ela chega? Bom, no nosso caso, numa View (Ou Controller). E é nesse ponto que as coisas começam a ficar um pouco mais complicadas quando nós trabalhamos com Django e Arquitetura Limpa. Não é raro vermos regras de negócio, de aplicação, persistência no banco e tudo o que for possível, dentro de uma View do nosso querido Django. Afinal, o conceito “baterias inclusas” faz com que as coisas funcionem dessa forma, pelo menos a curto prazo. Uma excelente alternativa que encontramos é criar uma View que encapsule toda a requisição e encaminhe-a aos nossos casos de uso — que posteriormente irão retornar nossos DTOs. Ficaria mais ou menos assim:

from typing import Dict, Any

from application.serializers import ProductSerializer
from application.use_cases import CreateProductUseCase
from domain.errors import EntityAlreadyExist


class ProductView:
    def __init__(self, create_product_use_case: CreateProductUseCase):
        self.create_product_use_case = create_product_use_case

    def post(self, request: Dict[str, Any]):
        name = request.get("name")
        quantity = request.get("quantity")

        try:
            product = self.create_product_use_case.execute(name, quantity)
        except EntityAlreadyExist:
            body = {"error": "Product already exists!"}
            status = 412
        else:
            body = ProductSerializer.serialize(product)
            status = 201
        return body, status

Repare que estamos abstraindo o conceito de View/Controller do Django, portanto, não há menção alguma do framework neste módulo.

Tudo lindo e maravilhoso, mas e como que fica a implementação disso? Como o Django vai entender que isso é uma View e que deve encaminhar as requisições para ela? Simples, criemos uma espécie de Wrapper para isso:

from typing import Optional
from rest_framework.views import APIView
from rest_framework.request import Request
from rest_framework.response import Response
from django_app.factories import ProductViewFactory


class ViewWrapper(APIView):
    view_factory: Optional[ProductViewFactory] = None

    def post(self, request: Request, *args, **kwargs):
        body, status = self.view_factory.create().post(request.POST)

        return Response(body, status=status)

E só o que precisamos fazer é dar um “alô” para o nosso arquivo urls.py e vai estar tudo certo.

from django.urls import path
from django.contrib import admin

from django_app.views import ViewWrapper
from django_app.factories import ProductViewFactory

urlpatterns = [
    path("admin/", admin.site.urls),
    path("product/", ViewWrapper.as_view(view_factory=ProductViewFactory)),
]

Basicamente é isso! O ponto principal que nós devemos nos atentar quando trabalhamos com Python é como fazer o Django “enxergar”, há muitos outros conceitos que seria muito interessante abordarmos, como Presenters, Builders, Composition, comunicação entre serviços e microsserviços, como isso funcionaria numa arquitetura orientada a microsserviços? E em um monolito? Injeção de dependência em Python também é um tópico bem interessante (eu particularmente uso bastante o kink) e se é realmente necessário. Há um palco para muitas discussões e em breve discutiremos sobre.

Dúvidas, sugestões, críticas, reclamações, anseios e aflições, entrem em contato comigo e serão sempre muito bem-vindas.

Não deixe de dar uma olhada no repositório do projeto e fique a vontade para contribuir de qualquer forma.

Referências

Uncle Bob Blog: https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
Clean Architecture is not only about business logic: http://www.fatecrp.edu.br/WorkTec/edicoes/2020-1/trabalhos/I-Worktec-Pablo_Aguilar.pdf
Conceitos de micro serviços: https://microservices.io/

Show me the code

Código fonte que nós construímos. https://github.com/gustavovalle23/POC-schema-registry/tree/master/food-app

Carregando publicação patrocinada...
2
1
1

Excelente artigo, direto ao ponto, com um exemplo prático. Melhor e mais completo que a grande maioria de tutoriais que já vi sobre Clean Architecture na Internet.

Inclusive, nunca tinha pensado em abstrair o conceito de "view", achei muito interessante essa abordagem! Geralmente eu assumo que a "view" está muito acoplada à framework mesmo, mas vou testar a sua estratégia :)

Obrigado!

0