Pular para o conteúdo principal
Versão: main (dev) 🚧

ADR-011 — Event bus abstractions + generic messaging harness in the DevPack

Data: 2026-06-29 Status: Aceito Contexto: DevPack 10.10.0 Pareado com: Fase 3 da iniciativa Bus/Messaging — concretos (publisher ServiceBus, escolha de transporte, evento real, handler de exemplo, wiring do Worker) no template ITLab.Template.Next. Base de referência: filas in-memory + processors + ServiceBusEventReceiver<T> + RetryBackoffGate<T> portados de ITLab.WorkGuardian (Integration/Bus/*, Integration/Messaging/*, worker-gateway). Ratifica: decisão D7 = Var-A do pre-plan (docs/initiatives/infra-services/00-pre-plan-report.md §4).

Contexto

A iniciativa pede publicação/consumo de eventos de domínio (e notification events) com dois transportes: InMemory (default, sem Azure) e External / Azure Service Bus. O WorkGuardian (WG) já tinha o padrão maduro — fila Channel<T> + BackgroundService processor + bus keyed por EventBusType, e um harness de Service Bus com settlement (MessageHandlingResult → Complete/Abandon/DeadLetter), RetryBackoffGate<T>, máx. 5 entregas e troca de ICurrentUser para um worker do sistema.

O DevPack já shippava o substrato de settlement (EMessageDisposition, MessageHandlingResult, MessagingErrors) e os tipos de domínio (IDomainEvent com Args/GetEventId()/TenantId, DomainEvent), além de ServiceBusOptions e o pacote Azure.Messaging.ServiceBus. Faltavam as interfaces de bus e o harness genérico.

A questão central (D7) era onde mora o harness: no DevPack (genérico, opt-in) ou no template (canon). O ADR-001 ("no framework wrappers") puxa para o template; o princípio G-01 do workspace ("regra universal → DevPack; duplicação garantida entre consumidores → extrair") puxa para o DevPack. Precedente histórico: ScopedDispatchDomainEventsInterceptor viveu no template até a 10.3 ratificar que a forma genérica pertence ao DevPack.

Decisão

As interfaces de bus + o harness genérico (filas in-memory, processors, receiver de Service Bus, gate de backoff, registro AddDevPack*) vivem no DevPack (Var-A). O publisher concreto de Service Bus (EventBusType.External) e a convenção de roteamento (Args[0]) vivem no consumidor (canon).

1. Interfaces (Messaging/)

  • EventBusTypeenum { InMemory, External }.
  • IDomainEventsBusTask PublishAsync<T>(T, CancellationToken) where T : class, IDomainEvent.
  • IDomainEventsBusFactoryIDomainEventsBus Create(EventBusType).
  • INotificationsBusTask PublishAsync<T>(T, CancellationToken) where T : class, INotificationEvent.
  • IDomainEventMessageHandler<in T>Task<MessageHandlingResult> HandleAsync(T, CancellationToken); reusa o substrato de settlement já existente.

2. Harness in-process (Messaging/)

  • InMemoryDomainEventsQueue / InMemoryNotificationsQueueChannel<T> unbounded.
  • InMemoryDomainEventsBus / InMemoryNotificationsBus — escrevem na fila.
  • DomainEventsProcessor / NotificationEventsProcessor (BackgroundService) — drenam a fila e republicam via Mediator IPublisher em escopo novo. Correção vs WG: o domain processor do WG logava Guid.NewGuid(); aqui logamos o event id real (GetEventId()).
  • DomainEventsBusFactory — resolve GetKeyedService<IDomainEventsBus>(busType). Tolerante: se External for pedido sem registro, lança InvalidOperationException clara (o publisher é do consumidor).

3. Harness de Service Bus (Messaging/)

  • ServiceBusEventReceiver<T> (where T : class, IDomainEvent) — ServiceBusProcessor com AutoComplete=false, MaxConcurrentCalls=1, Prefetch=0. Deserializa (System.Text.Json), valida payload + TenantId (DeadLetter se inválido), troca o ICurrentUser para servicebus-worker com o TenantId do evento, resolve IDomainEventMessageHandler<T> e aplica o settlement (máx. 5 entregas). Limpezas obrigatórias vs WG:
    • (a) System.Text.Json em todo o caminho (WG misturava Newtonsoft no publisher) — sem Newtonsoft.
    • (b) classificação de transitório desacoplada do WG: sem tipos OpenAi*; só ServiceBusException.IsTransient + TimeoutException, com hook opcional injetável Func<Exception, TimeSpan?>? (delay null = não-transitório) para o consumidor estender.
    • (c) lógica de decisão de disposition extraída no método puro estático DecideDisposition(MessageHandlingResult, int deliveryCount), testável sem mockar o SDK.
  • RetryBackoffGate<T>SemaphoreSlim + pausa global para backoff transitório.
  • ServiceBusReceiverHostedService<T> / ServiceBusReceiverQueue<T>IHostedService que chama StartAsync(queue)/StopAsync.

4. Registro DI (DevPackMessagingServiceCollectionExtensions)

  • AddDevPackInMemoryEventBus — filas (singleton), InMemoryDomainEventsBus keyed InMemory + INotificationsBus, ambos processors via AddHostedService, e IDomainEventsBusFactory → DomainEventsBusFactory.
  • AddDevPackServiceBusClientServiceBusClient por ConnectionStrings:ServiceBus ou DefaultAzureCredential + ServiceBusOptions.Namespace (managed identity). Pacote Azure.Identity adicionado.
  • AddDevPackServiceBusEventReceiver<T>(queueName)RetryBackoffGate<T> + ServiceBusEventReceiver<T> (singletons) + ServiceBusReceiverHostedService<T>. O consumidor registra seu IDomainEventMessageHandler<T>.

5. Fronteira: domain events in-process vs integration/bus events

Os domain events do canon continuam sendo despachados in-process pós-SaveChanges pelo Infrastructure.EfCore.DispatchDomainEventsInterceptor → Mediator (sem mudança). Esta fase adiciona um canal explícito de integration/bus events (in-memory desacoplado ou cross-process via Service Bus) sem remover o pipeline atual. As duas trilhas coexistem; o consumidor escolhe quando publicar via bus (ex.: cross-service) vs deixar o interceptor despachar in-process.

6. Publisher no consumidor (não no DevPack)

O publisher ServiceBusEventsBus : IDomainEventsBus (keyed External) fica no template, porque embute a convenção de roteamento (queue em domainEvent.Args[0], MessageId = GetEventId()) que é canon, não universal. O DevPack ship o bus in-memory (keyed InMemory) + o receiver harness + a factory tolerante.

Consequências

  • Positivas: zero duplicação do harness entre consumidores (alinhado a G-01); receiver testável sem Azure (DecideDisposition puro); transitório extensível sem fork; InMemory funciona end-to-end sem Azure (default dev).
  • Negativas / trade-offs: o ServiceBusEventReceiver<T> é uma casca fina sobre o SDK Azure — tensão admitida com o ADR-001 ("no framework wrappers"), resolvida a favor do G-01 por ser plumbing genérico opt-in (precedente ScopedDispatchDomainEventsInterceptor). O publisher fica fora do DevPack, então o grafo keyed External só fica completo no consumidor.
  • Follow-up explícito: Outbox transacional / exactly-once NÃO está no escopo desta fase. A publicação é best-effort (como no WG). Garantia transacional fica para um ADR futuro — registrado aqui para evitar a expectativa de exactly-once.

Alternativas consideradas

  • Var-B (harness no template): mais fiel ao ADR-001, mas custo = duplicação garantida em todo consumidor do DevPack que precise de bus. Rejeitada por G-01.
  • Publisher também no DevPack: possível (o SDK + options já estão lá), mas a convenção de roteamento Args[0] é canon — manter no consumidor.