Interfaces modernas costumam falhar quando ações importantes acontecem “por trás” do sistema, mas nada visível aparece na tela. Uma compra pode disparar e-mail, notificação interna e webhooks, enquanto a interface permanece estática, criando incerteza e repetição de cliques.
Uma abordagem eficiente para resolver esse descompasso é construir uma UI orientada a eventos, em que eventos do backend refletem rapidamente no frontend. A combinação de HTMX com Django Signals viabiliza feedback quase instantâneo usando HTML dinâmico e eventos do servidor, sem exigir WebSockets nem um ecossistema pesado de JavaScript.
O que é uma UI orientada a eventos e por que ela melhora a experiência
Uma UI orientada a eventos é uma interface em que mudanças relevantes do sistema são comunicadas e refletidas visualmente assim que acontecem. O “evento” é uma ocorrência interna, como a criação de um pedido, o envio de um e-mail ou a confirmação de um webhook. Em aplicações tradicionais, a tela só muda após um redirecionamento ou atualização manual. Ao sincronizar eventos internos com atualizações parciais da página, o estado do sistema fica mais claro e coerente.
Esse modelo reduz ações duplicadas porque a interface demonstra progresso e confirmações de forma contínua. Também diminui custos de suporte, pois dúvidas como “funcionou?” aparecem menos quando o sistema mostra etapas concluídas. Outro ganho é a rastreabilidade: quando cada etapa marca um estado, fica mais fácil diagnosticar falhas. Por fim, torna o fluxo mais previsível, pois o que ocorre no backend passa a ter representação explícita na UI.
Papel do HTMX: HTML como motor de interatividade
HTMX é uma biblioteca que permite disparar requisições HTTP a partir de atributos HTML e trocar partes do DOM com a resposta do servidor. Em vez de escrever muito JavaScript, a interatividade surge de atributos como hx-get, hx-post, hx-target e hx-swap. A resposta do servidor geralmente é um fragmento HTML pronto para substituir ou inserir em um trecho da página. Assim, o servidor permanece responsável pela renderização, mantendo a lógica de apresentação centralizada.
Esse estilo facilita UIs “incrementais”, em que apenas um container muda após um evento. Também simplifica validações e mensagens de erro, pois o backend pode retornar o mesmo template com erros do formulário. O HTMX inclui recursos úteis como indicadores de carregamento (classe htmx-indicator) e gatilhos periódicos (polling) por hx-trigger. Quando combinado com rastreamento de estado no banco, o polling pode refletir progresso sem complexidade.
Papel do Django Signals: efeitos colaterais desacoplados
Django Signals são um mecanismo de publicação/assinatura dentro do Django que permite reagir a eventos do framework e do domínio. Um exemplo clássico é o post_save, disparado após salvar um model. O objetivo é executar efeitos colaterais de forma desacoplada, como enviar e-mail, criar notificações ou acionar webhooks. Em vez de entupir a view com muitas responsabilidades, os handlers de signal encapsulam reações a eventos do sistema.
Além dos signals “prontos” do Django, é possível criar signals customizados para representar etapas de negócio, como “e-mail enviado” ou “webhook confirmado”. Esses sinais ajudam a separar o “disparar ação” do “marcar progresso” e do “informar UI”. Quando o domínio cresce, o desacoplamento reduz impacto de mudanças e facilita testes unitários. Ainda assim, exige cuidado para não esconder regras críticas em handlers difíceis de rastrear.
Arquitetura proposta: eventos no backend e feedback incremental no frontend
A arquitetura combina três peças: uma ação principal (ex.: criar pedido), etapas assíncronas ou secundárias (e-mail, notificação, webhook) e uma UI que exibe progresso consultando o estado. Em termos de fluxo, a view cria o pedido e retorna uma tela de “pedido criado” com um componente que consulta periodicamente o progresso. Esse componente chama uma rota que renderiza um fragmento HTML com checkmarks e spinners. Quando tudo termina, a UI para de consultar e mostra o estado final.
O ponto central é que a UI não precisa saber “como” o e-mail foi enviado, apenas se foi enviado. Isso é resolvido com campos de rastreamento no model, como email_sent_at e webhook_triggered_at. Os signals atualizam esses campos ao concluir cada etapa. Com isso, o frontend se limita a renderizar um estado consistente e o backend mantém o histórico.
Modelagem do exemplo: pedidos e notificações com rastreamento de etapas
Um exemplo didático envolve um model Order (pedido) e um model Notification (notificação interna). O pedido guarda produto, quantidade e timestamps. Para mostrar progresso, o pedido também registra quando o e-mail foi enviado e quando o webhook foi confirmado. Já a notificação fica associada ao pedido e ao usuário, com flag de leitura.
O trecho abaixo apresenta uma modelagem completa e funcional, incluindo related_name para facilitar consultas e campos de rastreamento. Também há um método simples de string para identificação. Essa estrutura permite que a view de progresso responda com base no banco, sem variáveis globais nem estado em memória. Em cenários com múltiplos servidores, esse detalhe é essencial.
from django.conf import settings
from django.db import models
class Order(models.Model):
usuario = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="pedidos",
)
produto = models.CharField(max_length=200)
quantidade = models.PositiveIntegerField()
criado_em = models.DateTimeField(auto_now_add=True)
# Rastreio de etapas orientadas a eventos
email_enviado_em = models.DateTimeField(null=True, blank=True)
webhook_confirmado_em = models.DateTimeField(null=True, blank=True)
def __str__(self):
return f"Pedido #{self.id} - {self.usuario}"
class Notification(models.Model):
usuario = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="notificacoes",
)
pedido = models.ForeignKey(
Order,
on_delete=models.CASCADE,
related_name="notificacoes_do_pedido",
)
mensagem = models.TextField()
criado_em = models.DateTimeField(auto_now_add=True)
lida = models.BooleanField(default=False)
def __str__(self):
return f"Notificação #{self.id} - Pedido #{self.pedido_id}"
Signals: criação do pedido e emissão de eventos de progresso
O uso de post_save permite detectar quando um pedido foi criado e disparar efeitos colaterais. Para manter o progresso observável, signals customizados podem representar marcos, como “e-mail enviado” e “webhook disparado”. Cada handler faz duas coisas: executa a ação (ou delega) e registra o resultado. O registro no banco serve como fonte de verdade para a UI.
O exemplo abaixo inclui signals customizados e handlers separados para marcar o progresso. Isso cria uma separação clara: um handler coordena as ações e outros handlers apenas atualizam o estado do pedido. Também existe um signal de erro para registrar falhas sem quebrar a experiência por completo. Em ambientes reais, falhas de webhook e e-mail são comuns e precisam de rastreio.
import requests
from django.core.mail import send_mail
from django.db import transaction
from django.db.models.signals import post_save
from django.dispatch import Signal, receiver
from django.utils import timezone
from .models import Notification, Order
# Signals customizados para acompanhamento de progresso
email_enviado = Signal()
notificacao_criada = Signal()
webhook_disparado = Signal()
processamento_com_erro = Signal()
@receiver(post_save, sender=Order)
def ao_criar_pedido(sender, instance: Order, created: bool, **kwargs):
"""
Coordena efeitos colaterais após criação do pedido.
Em caso de falha, emite um signal de erro.
"""
if not created:
return
try:
# 1) E-mail de confirmação
send_mail(
subject=f"Confirmação do Pedido #{instance.id}",
message="Pedido recebido e em processamento.",
from_email="noreply@sistema.local",
recipient_list=[instance.usuario.email],
fail_silently=False,
)
email_enviado.send(sender=Order, pedido=instance)
# 2) Notificação interna
Notification.objects.create(
usuario=instance.usuario,
pedido=instance,
mensagem=f"Pedido #{instance.id} confirmado e em processamento.",
)
notificacao_criada.send(sender=Order, pedido=instance)
# 3) Webhook para serviços externos
try:
resposta = requests.post(
"https://api.exemplo.local/webhooks/pedidos",
json={"pedido_id": instance.id, "status": "criado"},
timeout=5,
)
sucesso = 200 <= resposta.status_code < 300
except Exception:
sucesso = False
webhook_disparado.send(sender=Order, pedido=instance, sucesso=sucesso)
except Exception as exc:
processamento_com_erro.send(
sender=Order,
pedido=instance,
erro=str(exc),
)
@receiver(email_enviado)
def marcar_email_enviado(sender, pedido: Order, **kwargs):
# Atualização direta e simples para refletir progresso no banco
Order.objects.filter(id=pedido.id).update(email_enviado_em=timezone.now())
@receiver(webhook_disparado)
def marcar_webhook(sender, pedido: Order, sucesso: bool, **kwargs):
if sucesso:
Order.objects.filter(id=pedido.id).update(webhook_confirmado_em=timezone.now())
@receiver(processamento_com_erro)
def lidar_com_erro(sender, pedido: Order, erro: str, **kwargs):
# Exemplo: registrar uma notificação de erro visível na UI
Notification.objects.create(
usuario=pedido.usuario,
pedido=pedido,
mensagem=f"Falha no processamento do pedido #{pedido.id}: {erro}",
)
Views: criação do pedido e endpoint de progresso para o HTMX
Para uma UI incremental, a view de criação retorna HTML parcial no mesmo container, em vez de redirecionar. Quando o formulário é válido, o pedido é salvo e os signals são disparados automaticamente. Em seguida, o servidor devolve um template “pedido criado” contendo um bloco que consulta progresso. Para isso, uma segunda view retorna um fragmento HTML com o estado atual das etapas.
A view de progresso verifica campos do pedido e a existência de notificações ligadas ao pedido. Isso fornece um retrato fiel do processamento, mesmo que o servidor reinicie. Caso as etapas ainda estejam em andamento, o HTMX continua consultando. Quando estiver completo, o fragmento renderiza um estado final e o polling é interrompido pelo próprio template.
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, render
from django.views.decorators.http import require_http_methods
from .forms import OrderForm
from .models import Order
@login_required
@require_http_methods(["GET", "POST"])
def criar_pedido(request):
if request.method == "GET":
form = OrderForm()
return render(request, "orders/criar_pedido.html", {"form": form})
form = OrderForm(request.POST)
if not form.is_valid():
return render(
request,
"orders/_form_pedido.html",
{"form": form},
status=400,
)
pedido = form.save(commit=False)
pedido.usuario = request.user
pedido.save()
return render(
request,
"orders/pedido_criado.html",
{"pedido": pedido},
status=200,
)
@login_required
@require_http_methods(["GET"])
def progresso_pedido(request, pedido_id: int):
pedido = get_object_or_404(Order, id=pedido_id, usuario=request.user)
email_ok = pedido.email_enviado_em is not None
notificacao_ok = pedido.notificacoes_do_pedido.exists()
webhook_ok = pedido.webhook_confirmado_em is not None
completo = all([email_ok, notificacao_ok, webhook_ok])
return render(
request,
"orders/_progresso.html",
{
"pedido": pedido,
"email_ok": email_ok,
"notificacao_ok": notificacao_ok,
"webhook_ok": webhook_ok,
"completo": completo,
},
status=200,
)
Formulário e templates: submissão HTMX e troca de fragmentos
A camada de templates organiza a página em fragmentos reutilizáveis. O formulário é renderizado em um container principal e enviado com hx-post. O retorno do servidor substitui o conteúdo desse container usando hx-target e hx-swap. Assim, o mesmo espaço na tela troca do formulário para o “pedido criado” sem recarregar a página.
Para manter consistência, um fragmento separado renderiza o formulário com erros. Isso evita duplicação e facilita retornar status 400 com HTML pronto. O HTMX trata o retorno normalmente e atualiza a área-alvo. Esse padrão também mantém o backend no controle do HTML e das mensagens.
<div id="pedido-container">
<div>
<h4>Novo pedido</h4>
<p>O envio do formulário inicia o processamento e a tela passa a exibir o andamento das etapas internas.</p>
</div>
<div
hx-get="/pedidos/form/"
hx-trigger="load"
hx-target="#pedido-container"
hx-swap="innerHTML">
</div>
</div>
<!-- orders/_form_pedido.html -->
<form
hx-post="/pedidos/criar/"
hx-target="#pedido-container"
hx-swap="innerHTML">
{% csrf_token %}
<div>
<label>Produto</label>
{{ form.produto }}
{% if form.produto.errors %}<p>{{ form.produto.errors|striptags }}</p>{% endif %}
</div>
<div>
<label>Quantidade</label>
{{ form.quantidade }}
{% if form.quantidade.errors %}<p>{{ form.quantidade.errors|striptags }}</p>{% endif %}
</div>
<button type="submit">Confirmar pedido</button>
<div id="indicador" class="htmx-indicator">
<p>Processando envio do pedido...</p>
</div>
</form>
<!-- orders/pedido_criado.html -->
<div>
<h4>Pedido criado</h4>
<p>O pedido foi registrado e as ações secundárias estão sendo concluídas, com atualização automática do status.</p>
<div
id="progresso"
hx-get="/pedidos/{{ pedido.id }}/progresso/"
hx-trigger="load, every 1s"
hx-swap="innerHTML">
<p>Carregando andamento...</p>
</div>
</div>
<!-- orders/_progresso.html -->
<div>
<h4>Andamento do processamento</h4>
<p>O status abaixo reflete o estado persistido no servidor, indicando quais etapas já foram concluídas.</p>
<div>
<p>{% if email_ok %}✓{% else %}⏳{% endif %} E-mail de confirmação</p>
<p>{% if notificacao_ok %}✓{% else %}⏳{% endif %} Notificação interna</p>
<p>{% if webhook_ok %}✓{% else %}⏳{% endif %} Webhook externo</p>
</div>
{% if completo %}
<p><strong>Processamento concluído</strong>. O pedido segue o fluxo normal de atendimento.</p>
<div hx-trigger="none"></div>
{% endif %}
</div>
Atualização otimista e indicadores de carregamento
Uma atualização otimista ocorre quando a interface mostra uma resposta imediata antes mesmo do processamento terminar. No HTMX, isso pode ser feito com um indicador visual, reduzindo a sensação de “travamento”. O atributo hx-indicator associa um elemento a ser exibido durante requisições. Isso não confirma sucesso, mas comunica atividade do sistema.
O indicador também evita reenvio repetido por ansiedade de clique. Em formulários, pode ser combinado com desabilitar botão via CSS, mas o essencial é sinalizar que a requisição está em curso. Quando o HTML de resposta chega, o HTMX troca o container, removendo a necessidade de scripts. A UI continua leve e previsível.
<button
type="submit"
hx-indicator="#indicador">
Confirmar pedido
</button>
<div id="indicador" class="htmx-indicator">
<p>Enviando e iniciando processamento...</p>
</div>
Tratamento de erros: falhas em e-mail, notificação e webhook
Falhas em etapas secundárias acontecem por motivos comuns, como indisponibilidade de SMTP, tempo excedido em HTTP e instabilidade de rede. Em uma UI orientada a eventos, o objetivo é manter o pedido criado, mas marcar claramente que uma etapa falhou. Signals customizados podem registrar o erro criando uma notificação interna específica. Assim, o progresso não fica eternamente “girando” sem explicação.
Uma estratégia simples é persistir uma mensagem de erro relacionada ao pedido, permitindo que a view de progresso mostre esse estado. Outra estratégia é adicionar campos de erro no model, como webhook_erro ou email_erro, quando for necessário detalhar por etapa. A separação por etapa ajuda a diferenciar “não executou ainda” de “executou e falhou”. Essa clareza sustenta um fim bem definido para o fluxo.
Notificações em tempo quase real: badge de não lidas com polling moderado
Notificações internas podem aparecer como um contador (badge) que consulta periodicamente o servidor. Essa técnica usa polling e, apesar de não ser instantânea no sentido estrito, entrega um resultado prático com baixo custo de implementação. O endpoint retorna um fragmento HTML mínimo, como um número dentro de um span. O HTMX atualiza apenas o badge, sem recarregar a página.
Para evitar sobrecarga, o intervalo deve ser moderado, como 10 segundos, e pode variar conforme criticidade. Também é importante filtrar por usuário autenticado e considerar cache quando aplicável. O fragmento retornado deve ser pequeno para reduzir latência e banda. Esse padrão cobre bem cenários de “atualização contínua” sem complexidade.
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from .models import Notification
@login_required
def contagem_notificacoes(request):
qtd = Notification.objects.filter(usuario=request.user, lida=False).count()
return HttpResponse(f"<span>{qtd}</span>")
<div
id="badge-notificacoes"
hx-get="/notificacoes/contagem/"
hx-trigger="load, every 10s"
hx-swap="innerHTML">
<span>0</span>
</div>
Performance e escalabilidade: polling com atraso, interrupção e tarefas longas
O polling é simples, porém pode gerar carga se for agressivo. O HTMX permite usar modificadores como delay para reduzir picos e espaçar chamadas. Outro ponto crítico é interromper o polling quando o processamento termina. Ao renderizar um fragmento final sem hx-trigger recorrente, o componente deixa de fazer requisições.
Etapas longas, como envio de e-mail em volume ou integrações externas lentas, costumam ser delegadas a uma fila. Uma solução comum é usar um sistema de tarefas como Celery, que executa trabalho em background. Nesse modelo, o pedido é criado rapidamente e a UI acompanha o progresso enquanto tarefas concluem etapas e atualizam o banco. O resultado preserva o mesmo padrão: a UI consulta estado persistido, não estado transitório.
<div
hx-get="/pedidos/{{ pedido.id }}/progresso/"
hx-trigger="every 2s delay:1s"
hx-swap="innerHTML">
</div>
from celery import shared_task
from django.core.mail import send_mail
from .models import Order
from .signals import email_enviado
@shared_task
def enviar_email_pedido(pedido_id: int):
pedido = Order.objects.get(id=pedido_id)
send_mail(
subject=f"Confirmação do Pedido #{pedido.id}",
message="Pedido recebido e em processamento.",
from_email="noreply@sistema.local",
recipient_list=[pedido.usuario.email],
fail_silently=False,
)
email_enviado.send(sender=Order, pedido=pedido)
Testes: validação dos signals e do rastreamento de progresso
Testar uma arquitetura orientada a eventos exige verificar efeitos colaterais e marcações de estado. O foco é garantir que, ao criar um pedido, o envio de e-mail seja chamado e o timestamp seja gravado. Para não enviar e-mails reais, utiliza-se mock, que substitui a função por uma simulação. Também é importante validar que o handler de marcação foi acionado.
Ao manter a verdade no banco, os testes ficam mais determinísticos. Um teste pode criar um pedido e depois recarregar do banco para conferir campos atualizados. Caso tarefas assíncronas sejam usadas, testes podem rodar em modo “eager” no Celery, mas esse ajuste depende do ambiente de testes. O essencial é assegurar que cada etapa produza uma evidência persistida.
from django.contrib.auth import get_user_model
from django.test import TestCase
from unittest.mock import patch
from orders.models import Order
class TestSignalsPedido(TestCase):
def setUp(self):
self.usuario = get_user_model().objects.create_user(
username="ana",
email="ana@sistema.local",
password="senha-forte-123",
)
@patch("orders.signals.send_mail")
def test_criacao_pedido_dispara_email_e_marca_timestamp(self, mock_send_mail):
pedido = Order.objects.create(
usuario=self.usuario,
produto="Produto de Teste",
quantidade=1,
)
self.assertTrue(mock_send_mail.called)
pedido.refresh_from_db()
self.assertIsNotNone(pedido.email_enviado_em)
Fechamento: como o fluxo “era” e como passa a ser
No fluxo tradicional, o backend executa ações secundárias sem refletir nada na interface, gerando incerteza e repetição de submissões. A tela confirma apenas a criação do registro principal, mas não comunica etapas que também importam para confiança e previsibilidade. A depuração também se torna mais difícil, pois não existe um rastro claro por etapa no banco. O resultado é uma experiência fragmentada e pouco transparente.
No fluxo orientado a eventos com Django Signals e HTMX, o pedido é criado e a UI passa a renderizar progresso com base em estado persistido. Cada etapa conclui e grava um marcador, permitindo que a interface mostre avanços sem depender de JavaScript pesado. O polling é controlado e interrompido quando o processo termina, mantendo o sistema eficiente. O fim do fluxo fica explícito, com um estado final coerente e verificável, encerrando o ciclo de feedback com clareza.