Arquitetura de eventos (event streaming)
TLDR: em vez de chamar serviços externos de forma síncrona no meio do request, publicamos eventos com Wisper e processamos tudo em jobs do GoodJob — sem broker externo.
Contexto
Quando o Zeus precisa acionar um serviço externo, é preciso fazer uma requisição HTTP. Isso soma tempo ao ciclo request/response enquanto uma pessoa espera do outro lado, e devolve erro quando qualquer um desses sistemas está temporariamente fora do ar.
Decisão
Adotamos um sistema de notificação event-driven, sem broker de eventos complexo. Nada de Kafka ou Amazon SNS neste momento. Usamos o que já existe na stack:
| Peça | Ferramenta |
|---|---|
| Enfileiramento | ActiveJob |
| Execução de jobs | GoodJob |
| Gerência de eventos | wisper |
A arquitetura tem três papéis:
mermaid
graph LR
P["Publisher<br/>qualquer ponto do código<br/>que emite um evento"] -->|broadcast| L["Listener<br/>app/listeners/<br/>registrado no initializer"]
L -->|enqueue| J["Job<br/>GoodJob"]
style P fill:#1f2937,color:#fff
style L fill:#374151,color:#fff
style J fill:#374151,color:#fff
Publisher
Para publicar um evento, basta chamar broadcast, definido no módulo Wisper::Publisher:
```ruby class DemoClass include Wisper::Publisher
def my_method # faz alguma coisa broadcast(:event_name, param1: :param1, param2: :param2) end end ```
Usar named params é essencial para a implementação dos listeners. Como a chamada de broadcast é síncrona, é permitido enviar qualquer tipo de parâmetro, de tipos simples a objetos inteiros.
Listener
Um listener é uma classe em app/listeners/, registrada em config/initializers/listeners.rb. Todo listener precisa de pelo menos um parâmetro **args, e pode declarar quantos outros precisar:
```ruby class MyListener1 def event_name(param1:, **args) # esta classe só precisa de param1, e ignora os demais parâmetros enviados end end
class MyListener2 def event_name(**args) # esta classe não precisa de nenhum parâmetro end end ```
O initializer correspondente:
```ruby Rails.application.reloader.to_prepare do Wisper.clear if Rails.env.development?
Wisper.subscribe(MyListener1.new) Wisper.subscribe(MyListener2.new) end ```
Job
Todo evento deve enfileirar pelo menos um job no GoodJob, para que a ação seja processada em outro processo sem bloquear a thread principal. Um único método de evento pode enfileirar vários jobs, mas isso não é recomendado — se for preciso enfileirar vários jobs no mesmo evento, crie vários listeners.
Consequências
Convenção de nomes de evento
Todo evento segue uma convenção simples, que também vale para arquiteturas event-driven mais complexas:
- Quem está envolvido? — substantivo, ou substantivos quando há mais construtos.
- O que aconteceu? — verbo no passado.
Bons exemplos: payment_processed, payment_failed. A menos que o evento reporte uma operação CRUD, evite ao máximo verbos CRUD como em payment_created ou payment_deleted.
Como o nome do evento vira nome de método, todo evento é escrito em snake_case.
Leitura complementar sobre convenções de nome: What’s in an event name.
Imutabilidade e atomicidade
Todo evento deve ser imutável e atômico. Isso é fundamental para o sistema permanecer confiável e consistente.
Testes
Os testes usam a gem wisper-rspec, que adiciona matchers para verificar se eventos foram (ou não) publicados, além de permitir stub do tratamento de eventos.
Referências
app/listeners/payment_listener.rb,app/listeners/transfer_listener.rb— listeners em usoconfig/initializers/listeners.rb— registro dos listeners- ../reference/payments/payment_flow.md — os dois broadcasts que o fluxo de pagamento dispara
- ../specs/20250211221228_payment_notifications.md — a spec de notificações que depende desta arquitetura