Django e Arquitetura Limpa: Como Organizar Seu Código Para Durar Anos
Django e Arquitetura Limpa: Como Organizar Seu Código Para Durar Anos
A arquitetura limpa é um conceito que surgiu do livro "Arquitetura Limpa" de Robert C. Martin, também conhecido como "Uncle Bob". Ela é uma abordagem de desenvolvimento de software que visa a criação de sistemas mais organizados, testáveis e mantíveis. Por que não trazer esses benefícios para o Django?
Em sua página inicial, temos o seguinte slogan: "O framework web para perfeccionistas com prazos." Com um slogan desses, seria até um pecado não seguir essa filosofia.
O problema que surge com o crescimento
O Django é um framework web que segue o padrão MVT (Model-View-Template), um padrão de arquitetura que separa a lógica de negócio, a lógica de apresentação e a lógica de dados. Essa separação é muito importante para a manutenibilidade do código.
Porém, à medida que a aplicação vai crescendo — e até mesmo com o aumento da equipe — temos um aumento exponencial de complexidade e dificuldade de manutenção. Cada desenvolvedor acaba adotando suas próprias convenções e padrões, o que pode gerar inconsistências e dificuldades de manutenção a longo prazo, com partes do código que são difíceis de entender e modificar.
Quando há um forte acoplamento entre as camadas do framework, qualquer mudança do que o framework recomenda é um risco que pode comprometer o sucesso do sistema, já que foge das convenções recomendadas e documentadas pelo próprio framework e pela comunidade. Isso impacta no tempo de entrega e manutenção, tornando o sistema às vezes caro de manter.
O mito da troca de framework
Muitas vezes, quando pensamos em implementar a arquitetura limpa em um projeto, estamos preocupados com a possibilidade de um dia mudarmos de framework e ter que reescrever todo o código.
Só que isso muitas vezes não acontece.
Ou quando acontece, não foi da forma que pensamos. A mudança de framework, muitas das vezes, vem junto de uma mudança de linguagem e até mesmo de banco de dados. Logo, o tempo investido em organizar e manter toda aquela estrutura acabou sendo um investimento que não compensou, já que com a mudança de linguagem, tudo precisou ser reescrito.
Não existe bala de prata
Na área de desenvolvimento de software, não existe uma bala de prata que resolva todos os problemas e que sirva para todos os casos. Cada projeto e equipe tem suas particularidades e desafios únicos, que devem ser analisados antes de aplicar qualquer arquitetura.
A forma que se desenvolve sistemas hoje não é a mesma de 20 anos atrás. Tivemos diversos avanços em diversas áreas correlatas, que possibilitaram resolvermos quase todos os problemas rotineiros que temos durante o processo de criação de um sistema de forma mais eficiente e escalável.
O objetivo deste artigo
Ao fim desse artigo, nosso objetivo é produzir um código que seja funcional em diferentes contextos, seja ele uma API, um Command ou até mesmo uma View Genérica que exporta os dados para uma página HTML.
O Django já entrega muito (e isso é bom)
O Django já cria uma abstração muito boa das camadas mais baixas. Ele, sendo um framework maduro que entrega e atua no desenvolvimento da solução de ponta a ponta, já nos entrega tudo para que, por exemplo, não precisemos fazer chamadas SQL diretamente — porque temos um ORM — ou até mesmo nos preocupar com as migrações de banco, porque ele já nos entrega uma forma de fazer isso de forma simples e eficiente.
Diferente de frameworks como o Express, onde podemos escolher qual ORM, qual banco de dados, ou até mesmo a melhor lib de API para expor nossos endpoints. Ou quando tentamos construir um framework do zero com PHP, que acaba sendo um grande desafio e que demanda muito tempo e conhecimento técnico.
Tentar passar por cima de toda essa base de conhecimento que foi construída com a experiência em tentativa e erro de muitos desenvolvedores pode ser um grande desafio que pode levar muitos à frustração, a médio e longo prazo. À medida que a aplicação vai crescendo, todas as camadas e decisões que tomamos no início se tornam mais difíceis de serem alteradas, e se não foram pensadas a longo prazo, acabam se tornando um fardo para a equipe.
Sobre adapters e a ilusão do desacoplamento
Na pratica mudanças de framework para a mesma linguagem são raras, e quando ocorrem, tem uma grande chance de não ser a mesma linguagem. Afinal, poucas coisas justificariam a mudança para algo que provavelmente tem desempenho e recursos semelhantes, onde muitas vezes os motivos que nos levaram a não usá-los no início se mantêm até o momento da ideia da mudança.
Um exemplo disso é a ideia de usar adapters para o ORM. O Django já possui um ORM que abstrai a necessidade de usar SQL direto. Fazer um adapter para ele seria apenas um overhead sem necessidade, já que, caso um dia eu mude para outro, a escrita seria provavelmente bem diferente da do Django.
O ORM mais famoso para Python atualmente é o SQLAlchemy, seguido de outros menos famosos como o Peewee e o Pony. Pessoalmente, não conheço ou nunca ouvi falar de nenhum caso onde, em um projeto de software, por decisão técnica, houve uma mudança plausível de ORM e que isso foi aplicado. Mas caso tenha tido, acredito que mesmo com o seguimento de todas as boas práticas, boa parte do projeto foi reescrito para se adequar à mudança — talvez até mesmo mudanças a nível de banco e migrations.
Use Case: Centralizar a lógica de negócio
Se não vamos trocar de framework, e se criar adapters é overhead, qual é o ganho real da arquitetura limpa no Django?
Centralize os cenários de negócio em um lugar só.
Segundo o Uncle Bob em "Arquitetura Limpa" (Martin, 2017):
Isso é um caso de uso. Um caso de uso é uma descrição da maneira como um sistema automatizado é usado. Ele especifica a entrada a ser fornecida pelo usuário, a saída a ser retornada para o usuário e os passos de processamento envolvidos na produção dessa saída. Um caso de uso descreve as regras de negócio específicas da aplicação, diferentes das Regras Cruciais de Negócios contidas nas Entidades.
Mais a frente no livro ele complementa com:
Os casos de uso não descrevem
como o sistema aparece para o usuário. Em vez disso,
descrevem regras específicas da aplicação que regem a
interação entre os usuários e as Entidades. O modo
como os dados entram e saem do sistema é irrelevante
para os casos de uso.
Um caso de uso é um objeto. Ele tem uma ou mais
funções que implementam as regras de negócio
específicas da aplicação. Também tem elementos de
dados que incluem os dados de entrada, os dados de
saída e as referências para as devidas Entidades com as
quais interage.
Isso resolve o problema que descrevi lá em cima: cada desenvolvedor colocando regra em um lugar diferente (view, serializer, form, admin, signal, command...).
A solução é simples: criar UseCases — classes que representam cenários do sistema.
Exemplo: criar um livro
class CriarLivroUseCase:
def set_params(self, params):
self.params = params
if not self.params.get("titulo"):
raise ValueError("Título é obrigatório")
if not self.params.get("autor_id"):
raise ValueError("Autor é obrigatório")
if not self.params.get("ano_publicacao"):
raise ValueError("Ano de publicação é obrigatório")
return self
def execute(self):
from books.models import Autor, Livro
autor = Autor.objects.get(id=self.params.get('autor_id'), is_active=True)
livro = Livro.objects.create(
titulo=self.params.get('titulo'),
ano_publicacao=self.params.get('ano_publicacao'),
autor=autor,
)
return livro.to_json()
DTO (Data Transfer Object) e Entidades: São os Forms e Serializers
Na nossa aplicação o DTO (Data Transfer Object) é representado pela camada de validação que recebe por exemplo os dados do formulário ou da requisição HTTP, e o django já possui recursos que fazem isso muito bem, como os Forms e Serializers, essa camada deve vir antes do UseCase, pois ela é responsável por validar os dados de entrada, que serão usados, na execução, para caso isso falhe, adicionamos uma clausula de guarda que é o set_params, apenas para garantir que nenhuma chamada ao banco ou lógica de negócio seja executada sem os dados minimos, isso vai de encontro com a programação defensiva e a filosofia de "fail fast" (falhar o mais rápido possível).
Algumas implementações usam o dataclasses do python e o pydantic como entidades para validação de dados, mas isso acaba sendo um adapter desnecessário no Django, pois o framework e suas principais bibliotecas já resolvem muito bem esse problema, o uso desses recursos adicionais só trazem complexidade para algo que já foi bem validado pela comunidade e é amplamente documentado e utilizado. Imagine uma situação onde você precisa adicionar uma nova coluna ao banco e você por exemplo usou como entidade uma classe com dataclass ou validação com pydantic, para fazer essa alteração ser funcional, você precisará fazer alterações:
- no modelo
- na entidade (pydantic)
- no serialize
- no usecase
- e no retorno da view
Isso é um problema arquitetural, pois seu commit abordará multiplas arquivos, para uma alteração simples no banco de dados, e por mexer em vários arquivos torna a validação mais complexa e propensa a erros, aumentando o custo do projeto.
Em todos os casos, a validação de entrada (Forms/Serializer/Schema) acontece primeiro, garantindo dados limpos. O set_params() serve como uma camada extra de defesa, validando regras de negócio mínimas antes de executar operações caras como queries ao banco.
O Model como Entidade (e o to_json() como contrato de saída)
Serialização de dados é a tradução dos dados para um formato que possa ser transmitido ou armazenado, com o propósito de ser lido por outros sistemas ou pessoas, o mais comum é o JSON, mas pode ser XML, CSV, etc.
No nosso caso, o Model do Django funciona como a Entity da arquitetura limpa. Segundo o Uncle Bob em "Arquitetura Limpa" (Martin, 2017):
ENTIDADES
Uma Entidade é um objeto contido em nosso sistema de computador. Ela incorpora um pequeno conjunto de regras cruciais de negócios que operam com base nos Dados Cruciais de Negócios. O objeto Entidade contém os Dados Cruciais de Negócios ou tem acesso muito fácil a esses dados. A interface da Entidade consiste em funções que implementam as Regras Cruciais de Negócios que operam com base nesses dados.
Ele representa o domínio (Autor, Livro) e sabe se serializar através do método to_json():
class Livro(models.Model):
titulo = models.CharField('Título', max_length=255)
ano_publicacao = models.IntegerField('Ano de Publicação')
autor = models.ForeignKey(Autor, on_delete=models.PROTECT, related_name='livros')
def to_json(self):
return {
'id': self.id,
'titulo': self.titulo,
'ano_publicacao': self.ano_publicacao,
'autor_id': self.autor_id,
'autor': self.autor.to_json() if self.autor else None,
}
Isso garante que a saída seja sempre a mesma, independente de onde o UseCase for chamado, e que o contrato seja sempre o mesmo independente da interface, centralizando regras de negócio.
Se por exemplo na nossa saída tivermos uma regra que o ano de publicação é exibido em vários lugares no formato DD/MM/YYYY, em outros no formato DD de ano, essa regra fica centralizada no método to_json().
def to_json(self):
return {
'id': self.id,
'titulo': self.titulo,
'ano_publicacao': self.ano_publicacao,
'ano_publicacao_formatado_dd_mm_yyyy': self.ano_publicacao.strftime('%d/%m/%Y') if self.ano_publicacao else None,
'ano_publicacao_formatado_dd_ano': f"{self.ano_publicacao.day} de {self.ano_publicacao.strftime('%B')}" if self.ano_publicacao else None,
'autor_id': self.autor_id,
'autor': self.autor.to_json() if self.autor else None,
}
O mesmo UseCase, várias interfaces
Esse é o ponto central do artigo: o mesmo código funciona em diferentes contextos.
Na View HTML (Django tradicional)
params = {
'titulo': request.POST.get('titulo'),
'autor_id': int(request.POST.get('autor_id')),
'ano_publicacao': int(request.POST.get('ano_publicacao')),
}
CriarLivroUseCase().set_params(params).execute()
No Django Ninja (API moderna e tipada)
@api.post("/livros")
def criar_livro(request, payload: LivroIn):
params = payload.dict()
return CriarLivroUseCase().set_params(params).execute()
No Django REST Framework
serializer = LivroSerializer(data=request.data)
if serializer.is_valid():
return Response(
CriarLivroUseCase().set_params(serializer.validated_data).execute(),
status=201,
)
Em um Management Command
CriarLivroUseCase().set_params({
'titulo': 'Clean Architecture',
'ano_publicacao': 2017,
'autor_id': autor_id,
}).execute()
Não importa a interface: a regra é uma só.
Repository
Se formos seguir a ideia de repository, precisariamos criar uma classe para cada modelo, e assim centralizar as operações de banco de dados, visando a portabilidade do código - que pode nunca acontecer -, criando assim uma camada a mais de complexidade e fugindo cada vez mais da filosofia dos criadores do Django.
Veja esse exemplo:
class LivroRepository:
def create(self, titulo: str, ano_publicacao: int, autor_id: int) -> Livro:
return Livro.objects.create(
titulo=titulo,
ano_publicacao=ano_publicacao,
autor_id=autor_id,
)
def get_by_id(self, id: int) -> Livro:
return Livro.objects.get(id=id)
def get_all(self) -> QuerySet[Livro]:
return Livro.objects.all()
def update(self, id: int, **kwargs) -> Livro:
livro = self.get_by_id(id)
for key, value in kwargs.items():
setattr(livro, key, value)
livro.save()
return livro
def delete(self, id: int) -> None:
self.get_by_id(id).delete()
class AutorRepository:
def create(self, nome: str) -> Autor:
return Autor.objects.create(nome=nome)
def get_by_id(self, id: int) -> Autor:
return Autor.objects.get(id=id)
def get_all(self) -> QuerySet[Autor]:
return Autor.objects.all()
def update(self, id: int, **kwargs) -> Autor:
autor = self.get_by_id(id)
for key, value in kwargs.items():
setattr(autor, key, value)
autor.save()
return autor
def delete(self, id: int) -> None:
self.get_by_id(id).delete()
Veja que para cada modelo, criamos um repository, e para cada repository, criamos os alguns métodos CRUDs básicos, e aparentmeente isso funcionou muito bem, porém depois de alguns meses a aplicação e a equipe cresceram e foi necessário, criar algumas consultas não tão convencionais, como por exemplo obter todos os livros de um autor, ou obter todos os livros de um ano de publicação, ou obter todos os livros de um autor e ano de publicação, ou obter todos os livros de um autor e ano de publicação e título, e assim por diante, será mesmo, que todos os membros da equipe pensarão nos mesmos nomes de métodos para essas operações? eu acredito que não, o mais provavel que ocorra, é averem operações repetidas, porém com nomes diferentes, e que provavelmente só serão usadas em poucos arquivos, porém geraram uma complexidade desnecessária, algumas consultas podem ser bem parecidas, porém com sutis mudanças no filtro, como por exemplo
# De
def get_all(self) -> QuerySet[Autor]:
return Autor.objects.filter(ativo=True)
# Para
def get_all(self) -> QuerySet[Autor]:
return Autor.objects.all()
Em uma equipe com muitos desenvolvedores, e em um projeto com muitos arquivos, dificilmente os membros terão a mesma criatividade de nomenclatura para os métodos, ou até mesmo gerar discussões desnecessárias e improdutivas sobre a nomeclatura, tornando assim o código mais caro de manter e entender, e criando uma complexidade para a equipe.
Por isso uma boa abordagem é centralizar a lógica de negócio nos Casos de Uso, onde qualquer desenvolvedor que precisar fazer uma alteração, em uma operação de um cenário especifico, poderá fazer com um commit menor e mais focado, sendo até mais fácil a revisão.
No mundo real, teremos alguns cenários onde precisaremos fazer diversas consultas, seguindo principios de refatoração e organização de código propostos por Martin Fowler, podemos criar métodos privados dentro dos casos de uso para organizar melhor a lógica de negócio, e assim manter o código mais limpo e organizado.
class CriarAutorUseCase:
def set_parameters(self, nome: str, email: str) -> None:
self.nome = nome
self.email = email
self.livros = []
def execute(self) -> Autor:
autor = self._criar_autor()
self._vincular_livros(autor)
return autor
def _criar_autor(self) -> Autor:
autor = Autor(nome=self.nome, email=self.email)
autor.save()
return autor
def _vincular_livros(self, autor: Autor) -> None:
# Lógica para vincular livros ao autor
pass
Caso a aplicação cresça e por exemplo o método _vincular_livros precise ser reutilizado em outros casos de uso, podemos separar essa lógica em um serviço específico, e assim manter o código mais limpo e organizado, por outro lado, a maioria das operações, não precisarão ser reutilizadas, e portanto, não precisamos nos preocupar com isso.
Conclusão
O Django já é um framework maduro que abstrai muito bem as camadas mais baixas. Tentar criar abstrações em cima dessas abstrações (como repositories em cima do ORM) geralmente é overhead sem retorno real.
Não existe bala de prata. Cada projeto e equipe tem suas particularidades. Mas se você quer um código que dure anos, que seja fácil de manter e que funcione em diferentes contextos — API, Command, View HTML — centralizar seus cenários em UseCases é um bom caminho.
Tendo o Django um slogan como "O framework web para perfeccionistas com prazos", seria até um pecado não seguir essa filosofia.
Referências
- Martin, R. C. (2017). Clean Architecture: A Craftsman's Guide to Software Structure and Design. Prentice Hall.
- Fowler, M., Beck, K., Brant, J., Opdyke, W., & Roberts, D. (1999). Refactoring: Improving the Design of Existing Code.
- https://www.djangoproject.com/
- https://speakerdeck.com/jairovadillo/django-and-clean-architecture?slide=29
- https://medium.com/21buttons-tech/clean-architecture-in-django-d326a4ab86a9
- https://breadcrumbscollector.tech/the-clean-architecture-in-python-how-to-write-testable-and-flexible-code/
- https://docs.peewee-orm.com/en/latest/
- https://ponyorm.org/
- https://pt.wikipedia.org/wiki/Serializa%C3%A7%C3%A3o
- https://dev.to/joaopolira/reduzindo-a-complexidade-e-facilitando-a-criacao-de-testes-com-fail-fast-e-early-return-4l28?utm_source=copilot.com
- https://books.google.com.br/books/about/Fail_Fast_Fail_Often.html?id=pYrZCwAAQBAJ&redir_esc=y