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