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/)
EventBusType—enum { InMemory, External }.IDomainEventsBus—Task PublishAsync<T>(T, CancellationToken) where T : class, IDomainEvent.IDomainEventsBusFactory—IDomainEventsBus Create(EventBusType).INotificationsBus—Task 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/InMemoryNotificationsQueue—Channel<T>unbounded.InMemoryDomainEventsBus/InMemoryNotificationsBus— escrevem na fila.DomainEventsProcessor/NotificationEventsProcessor(BackgroundService) — drenam a fila e republicam via MediatorIPublisherem escopo novo. Correção vs WG: o domain processor do WG logavaGuid.NewGuid(); aqui logamos o event id real (GetEventId()).DomainEventsBusFactory— resolveGetKeyedService<IDomainEventsBus>(busType). Tolerante: seExternalfor pedido sem registro, lançaInvalidOperationExceptionclara (o publisher é do consumidor).
3. Harness de Service Bus (Messaging/)
ServiceBusEventReceiver<T>(where T : class, IDomainEvent) —ServiceBusProcessorcomAutoComplete=false,MaxConcurrentCalls=1,Prefetch=0. Deserializa (System.Text.Json), valida payload +TenantId(DeadLetter se inválido), troca oICurrentUserparaservicebus-workercom oTenantIddo evento, resolveIDomainEventMessageHandler<T>e aplica o settlement (máx. 5 entregas). Limpezas obrigatórias vs WG:- (a)
System.Text.Jsonem 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ávelFunc<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.
- (a)
RetryBackoffGate<T>—SemaphoreSlim+ pausa global para backoff transitório.ServiceBusReceiverHostedService<T>/ServiceBusReceiverQueue<T>—IHostedServiceque chamaStartAsync(queue)/StopAsync.
4. Registro DI (DevPackMessagingServiceCollectionExtensions)
AddDevPackInMemoryEventBus— filas (singleton),InMemoryDomainEventsBuskeyedInMemory+INotificationsBus, ambos processors viaAddHostedService, eIDomainEventsBusFactory → DomainEventsBusFactory.AddDevPackServiceBusClient—ServiceBusClientporConnectionStrings:ServiceBusouDefaultAzureCredential+ServiceBusOptions.Namespace(managed identity). PacoteAzure.Identityadicionado.AddDevPackServiceBusEventReceiver<T>(queueName)—RetryBackoffGate<T>+ServiceBusEventReceiver<T>(singletons) +ServiceBusReceiverHostedService<T>. O consumidor registra seuIDomainEventMessageHandler<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 (
DecideDispositionpuro); 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 (precedenteScopedDispatchDomainEventsInterceptor). O publisher fica fora do DevPack, então o grafo keyedExternalsó 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.