10 erros críticos em SaaS com Django que podem comprometer segurança, performance e estabilidade em produção

Published on: 2026-06-09
Post image
pt saas-com-django erros-em-django boas-praticas-django django-em-producao arquitetura-django seguranca-em-saas performance-em-django otimizacao-django desenvolvimento-saas aplicacoes-saas multi-tenant-django isolamento-de-tenant django

Equipes que desenvolvem aplicativos SaaS com Django tendem a repetir um conjunto de deslizes técnicos que parecem inofensivos no início e se tornam caros em produção. Esses equívocos raramente geram erros imediatos; eles se acumulam silenciosamente, afetam desempenho, vazam dados ou criam instabilidade sob carga real.

Ao mapear esses padrões, torna-se possível preveni-los com práticas simples, porém sistemáticas. As seções a seguir apresentam dez erros frequentes, por que acontecem e formas objetivas de corrigi-los. O foco recai em conceitos fundamentais de arquitetura, concorrência, isolamento entre clientes e integrações externas. Cada correção privilegia uma estrutura mais clara de responsabilidades e um caminho de execução previsível.

Erro 1: Regra de negócio dentro de views

Colocar a lógica de negócio diretamente na view mistura validação, acesso a dados, integração externa e orquestração de tarefas no mesmo lugar. Essa mistura dificulta testes, duplica regras e torna mudanças arriscadas. Um indício recorrente é a necessidade de montar todo o stack HTTP para validar uma única regra de negócio. O efeito prático é a propagação de bugs e acoplamento excessivo entre camadas.

A solução é concentrar a lógica em uma camada de serviço, deixando a view como mera orquestradora de entrada e saída. Esse serviço pode ser reutilizado por comandos de gerenciamento e tarefas assíncronas. Testes passam a validar o comportamento de forma direta, sem depender do ciclo completo de requisição. O fluxo de execução fica rastreável e previsível.

O exemplo a seguir mostra primeiro a lógica embutida na view e, em seguida, a delegação correta a um serviço.

# Antes — regra de negócio espalhada na view
class VisualizacaoAssinatura(APIView):
    def post(self, requisicao):
        plano = requisicao.data.get('plano')
        cliente = stripe.Customer.create(email=requisicao.user.email)
        assinatura = stripe.Subscription.create(customer=cliente.id, ...)
        requisicao.user.tenant.plan = plano
        requisicao.user.tenant.save()
        enviar_email_confirmacao(requisicao.user)
        return Response({'status': 'assinado'})

# Depois — view delega para um serviço testável
class VisualizacaoAssinatura(APIView):
    def post(self, requisicao):
        resultado = ServicoAssinatura.criar(
            inquilino=requisicao.user.tenant,
            plano=requisicao.data.get('plano'),
        )
        return Response(resultado)

class ServicoAssinatura:
    @staticmethod
    def criar(inquilino, plano):
        cliente = stripe.Customer.create(email=inquilino.usuario.email)
        stripe.Subscription.create(customer=cliente.id, ...)
        inquilino.plan = plano
        inquilino.save()
        enviar_email_confirmacao(inquilino.usuario)
        return {'status': 'assinado'}

Erro 2: Falta de escopo de inquilino (tenant) em QuerySets

Em um SaaS multi-inquilino, todo acesso a dados específicos deve ser filtrado pelo inquilino atual. A ausência desse escopo expõe dados entre clientes e cria brechas de segurança. Outro erro sutil é confiar no identificador de inquilino recebido do cliente, o que permite consultas cruzadas. O problema só aparece quando há dados reais e usuários curiosos.

O padrão seguro é centralizar o filtro do inquilino na camada de acesso, preferencialmente em uma classe base de viewset. Essa abordagem garante que cada consulta considere o inquilino autenticado, sem depender da entrada do cliente. A medida reduz risco de vazamento e simplifica auditoria. O comportamento torna-se consistente em toda a aplicação.

Os trechos a seguir mostram primeiro consultas sem escopo e depois a forma correta com filtro pelo inquilino autenticado.

# Incorreto — sem escopo de inquilino
def get_queryset(self):
    return Projeto.objects.all()

# Incorreto — confia no dado do cliente
def get_queryset(self):
    inquilino_id = self.request.data.get('inquilino_id')
    return Projeto.objects.filter(inquilino_id=inquilino_id)

# Correto — usa o inquilino autenticado
def get_queryset(self):
    return Projeto.objects.filter(inquilino=self.request.user.tenant)

Erro 3: Consultas N+1 em serializers

O problema de N+1 consultas ocorre quando cada item serializado aciona novas idas ao banco. Em Django REST Framework, campos calculados em serializers mascaram o custo até que o volume de dados cresça. O resultado são centenas de consultas extras para uma única página. O desempenho degrada de forma não linear.

O remédio é preparar o queryset com joins e pré-buscas, evitando que o serializer toque o banco novamente. Métodos como select_related e prefetch_related carregam relacionamentos de forma eficiente. A serialização então opera sobre dados em memória, com uso previsível. Essa abordagem estabiliza a latência sob carga.

O exemplo abaixo mostra a origem da N+1 e a correção com pré-carregamento no queryset.

# Incorreto — acessa relações por item
class PedidoSerializer(serializers.ModelSerializer):
    nome_cliente = serializers.SerializerMethodField()
    itens = serializers.SerializerMethodField()

    def get_nome_cliente(self, obj):
        return obj.cliente.nome  # consulta por pedido

    def get_itens(self, obj):
        return [p.nome for p in obj.produtos.all()]  # consulta por pedido

# Correto — pré-carrega no queryset da view
class PedidoViewSet(viewsets.ModelViewSet):
    queryset = Pedido.objects.all()

    def get_queryset(self):
        return (
            Pedido.objects
            .select_related('cliente')
            .prefetch_related('produtos')
        )

Erro 4: Tarefas Celery que recebem instâncias de modelos

Enviar uma instância de modelo para uma fila Celery parece prático, mas introduz dados obsoletos. A serialização ocorre no momento do enqueue e a desserialização no worker, que pode rodar minutos depois. Nesse intervalo, o registro pode ter mudado ou sido removido. O efeito é processamento com informações antigas sem alertas claros.

A prática segura é enviar apenas o identificador e buscar o registro mais recente dentro da tarefa. Essa estratégia mantém a tarefa alinhada ao estado atual do banco. A abordagem também reduz o volume de dados trafegando na fila. O comportamento fica determinístico e fácil de depurar.

O trecho a seguir ilustra o antipadrão e, em seguida, a forma correta com busca interna.

# Incorreto — envia instância inteira
processar_pedido.delay(pedido)

# Correto — envia apenas o ID e busca dentro da tarefa
processar_pedido.delay(pedido.id)

@shared_task
def processar_pedido(pedido_id):
    pedido = Pedido.objects.get(id=pedido_id)
    # processa com dados atualizados

Erro 5: Sinais post_save com regra de negócio

Usar sinais para acoplar ações a eventos de modelo parece elegante, porém espalha regras por caminhos implícitos. Um post_save pode disparar em testes, no admin, em migrações e em comandos, exigindo inúmeras guardas. O rastreamento do que acontece em cada salvamento torna-se difícil. Mudanças em fluxo de cadastro passam a exigir conhecimento de vários arquivos de sinal.

Concentrar a regra em um serviço explícito reduz surpresas e facilita teste. A criação de entidades relacionadas, integração com provedores externos e envio de e-mails ficam em um único método. O controle de quando executar a lógica é intencional, sem disparos laterais. A manutenção torna-se mais previsível.

O exemplo abaixo mostra a remoção da regra do sinal e sua migração para um serviço de usuários.

# Incorreto — muita regra em um sinal
@receiver(post_save, sender=Usuario)
def ao_criar_usuario(sender, instance, created, **kwargs):
    if created:
        stripe.Customer.create(email=instance.email)
        enviar_email_boas_vindas(instance)
        EstadoOnboarding.objects.create(usuario=instance)
        criar_espaco_padrao(instance)

# Correto — serviço explícito chamado pelo fluxo de cadastro
class ServicoUsuario:
    @staticmethod
    def criar(dados):
        usuario = Usuario.objects.create(**dados)
        stripe.Customer.create(email=usuario.email)
        enviar_email_boas_vindas(usuario)
        EstadoOnboarding.objects.create(usuario=usuario)
        criar_espaco_padrao(usuario)
        return usuario

Erro 6: Ausência de select_for_update em escritas concorrentes

Condições de corrida aparecem quando duas requisições validam um limite ao mesmo tempo e ambas passam. A leitura de contagem seguida da criação sem bloqueio resulta em ultrapassagem de cotas. Em ambientes paralelos, isso ocorre esporadicamente e é difícil de reproduzir. O impacto surge como dados incoerentes e regras violadas.

O uso de transação com select_for_update bloqueia a linha relevante até o commit. A segunda requisição aguarda, lê o estado atualizado e respeita o limite. Essa técnica é essencial ao aplicar regras de cota, estoque e saldo. O custo do bloqueio é menor que o custo de corrigir dados corrompidos.

O código a seguir demonstra o cenário de corrida e a correção com bloqueio de linha.

# Incorreto — sujeito a condição de corrida
def adicionar_membro(inquilino, usuario):
    total = MembroEquipe.objects.filter(inquilino=inquilino).count()
    if total >= inquilino.plano.limite_membros:
        raise LimiteExcedido()
    MembroEquipe.objects.create(inquilino=inquilino, usuario=usuario)

# Correto — transação e bloqueio da linha do inquilino
from django.db import transaction

def adicionar_membro(inquilino, usuario):
    with transaction.atomic():
        inquilino_bloqueado = (
            Inquilino.objects.select_for_update().get(id=inquilino.id)
        )
        total = MembroEquipe.objects.filter(inquilino=inquilino_bloqueado).count()
        if total >= inquilino_bloqueado.plano.limite_membros:
            raise LimiteExcedido()
        MembroEquipe.objects.create(inquilino=inquilino_bloqueado, usuario=usuario)

Erro 7: Enfileirar tarefas Celery antes do commit

Agendar uma tarefa dentro de uma transação ativa pode dispará-la antes do registro existir no banco. O worker tenta buscar o objeto e recebe inexistência intermitente. Esse defeito depende da velocidade do worker e raramente aparece em ambientes locais. Em produção, sob carga, torna-se fonte de erros difíceis de rastrear.

O gancho transaction.on_commit garante que o agendamento ocorra somente após o commit bem-sucedido. Com isso, a tarefa sempre encontra os dados persistidos. O comportamento deixa de ser temporalmente frágil. A operação fica correta mesmo com workers rápidos.

O exemplo a seguir contrasta o agendamento precoce com o uso do gancho pós-commit.

# Incorreto — agenda antes do commit
from django.db import transaction

def criar_pedido(dados, inquilino):
    with transaction.atomic():
        pedido = Pedido.objects.create(inquilino=inquilino, **dados)
        processar_pedido.delay(pedido.id)  # pode rodar cedo demais

# Correto — agenda após o commit
def criar_pedido(dados, inquilino):
    with transaction.atomic():
        pedido = Pedido.objects.create(inquilino=inquilino, **dados)
        transaction.on_commit(lambda: processar_pedido.delay(pedido.id))

Erro 8: Regras de plano codificadas no código

Verificações como “plano pró ou empresarial” espalhadas pelo código envelhecem mal. A adição de um novo plano ou mudança de benefícios exige buscas e edições manuais. Pontos esquecidos viram falhas de autorização em funcionalidades pagas. O resultado é suporte reativo e bugs no dia do lançamento.

Uma abordagem robusta é modelar recursos do plano no banco e consultá-los por chave. A lógica passa a perguntar “tem o recurso X?” em vez de “é o plano Y?”. A criação ou ajuste de planos vira operação de dados, sem alteração de código. A consistência cresce e o risco de regressão cai.

O trecho a seguir apresenta a verificação frágil por slug e a alternativa baseada em recurso.

# Incorreto — checagem por nome do plano
def exportar(requisicao):
    if requisicao.user.tenant.plan not in ('pro', 'enterprise'):
        return Response({'erro': 'Upgrade necessário'}, status=403)
    # lógica de exportação

# Correto — checagem por recurso de plano
class ServicoAssinatura:
    @staticmethod
    def tem_recurso(inquilino, chave_recurso):
        return RecursoPlano.objects.filter(
            plano=inquilino.plan, chave=chave_recurso, ativo=True
        ).exists()

def exportar(requisicao):
    if not ServicoAssinatura.tem_recurso(requisicao.user.tenant, 'exportacao_csv'):
        return Response({'erro': 'Upgrade necessário'}, status=403)
    # lógica de exportação

Erro 9: Configurações de DEBUG vazando em produção

O problema não se limita a manter DEBUG ativo; configurações condicionais a DEBUG podem mascarar comportamentos críticos. Um backend de e-mail de console sob a bandeira de DEBUG interrompe entregas reais se um ambiente for configurado incorretamente. O incidente passa despercebido até que usuários relatem falhas. O risco é alto para notificações de segurança e cobrança.

A prática segura é separar arquivos de configuração por ambiente sem condicionais. Produção define explicitamente o backend real e dependências necessárias. Desenvolvimento usa ferramentas e atalhos locais sem chance de escapar para produção. O caminho de execução das configurações torna-se inequívoco.

Os trechos abaixo ilustram a armadilha condicional e a organização por ambientes.

# Incorreto — comportamento muda com DEBUG
if DEBUG:
    INSTALLED_APPS += ['debug_toolbar']
    EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'

# Correto — arquivos de settings por ambiente
# settings/producao.py
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'

# settings/desenvolvimento.py
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'

Erro 10: Ausência de timeout em requisições HTTP externas

Chamadas externas sem timeout podem bloquear workers indefinidamente quando o provedor degrada. Em cenários de incidente, o bloqueio se multiplica até esgotar processos do servidor de aplicação. O efeito cascata derruba o serviço principal por dependências lentas. A recuperação exige reinícios e redução de carga.

Definir tempos de conexão e leitura limita o impacto de degradações. Timeouts explícitos viram requisito de revisão de código para toda chamada externa. Valores razoáveis protegem o sistema sem gerar falsos positivos. O comportamento degradado passa a ser controlado, não catastrófico.

O exemplo abaixo mostra uma chamada sem proteção e a alternativa com tempos separados de conexão e leitura.

# Incorreto — sem prazos definidos
resposta = requests.post(
    'https://api.exemplo.com/v1/acao',
    data=payload,
    headers=cabecalhos,
)

# Correto — tempo de conexão e de leitura explícitos
resposta = requests.post(
    'https://api.exemplo.com/v1/acao',
    data=payload,
    headers=cabecalhos,
    timeout=(5, 30),  # 5s para conectar, 30s para ler
)

O padrão por trás dos dez erros

Todos os problemas compartilham uma mesma raiz: algo que funciona em desenvolvimento e falha no mundo real. Vazamento entre inquilinos aparece quando existe dado sensível; N+1 explode quando há volume; tarefas assíncronas tropeçam no tempo de transação; integrações externas travam quando parceiros estão lentos. A produção expõe caminhos implícitos, acoplamentos e suposições otimistas.

Isolar regras em serviços, aplicar escopo de inquilino por padrão, usar pré-carregamento de relações, respeitar transações, bloquear linhas críticas, projetar planos por recursos e impor timeouts são práticas que tornam o comportamento previsível. Comportamentos previsíveis facilitam testes e observabilidade. O resultado é um SaaS mais estável, seguro e econômico. A prevenção custa menos do que corrigir sob pressão.