Relatório de spike · Plataforma Gryd.IO

Wolverine como sucessor do MediatR

Resposta ao roteiro do spike da especificação de Eventos e Outbox. A pergunta era: o Wolverine substitui o MediatR no Gryd.IO sem quebrar a tenancy e sem exigir gambiarra em nenhum dos três behaviors críticos? Desta vez houve execução — SDK, Docker e NuGet disponíveis, PostgreSQL de verdade em Testcontainers, as suítes de tenancy rodando contra o caminho novo. A resposta mudou, e o motivo está medido critério a critério.

branch spike/wolverine-sucessor-mediatr · a partir de 879dd954 · commit aef01e2f sem merge · nenhum arquivo de src/ alterado Wolverine 6.35.0 · MediatR 12.4.1 · net10.0 · EF Core 10.0.10 · SDK 10.0.301 · Docker 29.4.3 v2.1 · 09/09/2026 · resultados históricos do spike; limites na seção de evidência

Primeira tela

Veredito

Compatibilidade parcial — adoção adiada

Cinco dos seis critérios passaram com evidência executada; o sexto — o Hybrid — reprova na forma em que está escrito e só passa com um contorno que descaracteriza o tenant id do Wolverine. O pipeline aceita os três behaviors críticos, a suíte de tenancy fica verde sem editar um único teste, e o AggregateRoot é raspável sem tocá-lo. Mas a regra que faz tudo isso funcionar é invisível no código e quebra em silêncio: quem escreve no AsyncLocal tem de ser um Before síncrono.

O que passou, medido

Critérios 1 a 5. 157/157 testes verdes de tenancy, directory e group scope com 355 de 355 despachos indo pelo Wolverine e zero pelo MediatR — sem editar teste. O scraping de eventos de domínio entrega o handler 767,8 ms depois do commit. O Hangfire fica intocado, com 827 testes de GrydJobs/GrydReports verdes.

O que reprovou

Critério 6 · o Hybrid. Confirmado com compilador e com banco: ITenantedSource<string>.FindAsync(string tenantId) tem uma chave; ITenantConnectionStringResolver.ResolveAsync tem duas — tenant e módulo. Medido: o segundo banco do mesmo tenant ficou com 0 tabelas do Wolverine, isto é, sem nenhum agente de durabilidade olhando para o outbox dele.

Onde a aposta errou

Critérios 1 e 2. A especificação apostava que reprovariam. Passaram — mas o caminho até o verde custou seis contornos nomeados, e um deles é uma regra de segurança disfarçada de detalhe de estilo. A aposta acertou o alvo pelo motivo errado: o risco não é o pipeline não caber, é ele caber e mentir.

Placar · e como cada linha foi medida
#CritérioResultadoComo foi medido
1Os behaviors críticos cabem no pipelinepassa com 3 contornos8 testes em Criterion1_PipelineTests, ordem gravada passo a passo
2A tenancy sobrevivepassa 157/157, 0 testes editadossuíte de integração real, Testcontainers + PostgreSQL 16
3O AggregateRoot é raspávelpassa sem tocar a classeEF Core + PostgreSQL, evento entregue pós-commit
4O GrydJobs não é perdidopassa Hangfire convive827 testes verdes após o bump forçado do Cronos
5O impacto no consumidor é declarávelpassa em forma cabe em uma páginaa § Impacto no consumidor é a página
6O Wolverine acompanha o Hybridfalha só passa descaracterizando o tenant3 bancos PostgreSQL reais, contagem de tabelas por banco

A regra do roteiro — "talvez, com mais tempo" conta como não — foi aplicada literalmente. Nenhum teste de tenancy foi editado: a única alteração em arquivo de teste foi na fábrica de composição (TestWebApplicationFactory), atrás de uma variável de ambiente, para apontar o mediator ao Wolverine. Nenhuma asserção mudou. Nenhum arquivo de src/ foi tocado.

Honestidade de método

Método · o que rodou

Tudo abaixo foi compilado e executado na máquina do Roger, numa worktree própria em Gryd.IO/spike-wolverine, na branch spike/wolverine-sucessor-mediatr a partir de 879dd954. A pasta de trabalho principal continuou na feature/grydfiles-modulo-plataforma, sem alteração.

O ambiente, medido antes de começar
VerificaçãoResultado
SDK .NET10.0.301 — o global.json exige 10.0.102 com rollForward: disable; trocado só na worktree do spike
Docker29.4.3, contexto desktop-linux — Testcontainers sobe PostgreSQL 16 e Redis 7 de verdade
NuGetAlcançável no ambiente histórico; WolverineFx 6.35.0 era a versão selecionada no spike.
Baseline sobre código intocado157/157 verdes em 1 min 41 s — é contra este número que tudo é comparado
Duração do spike57 min de execução medida (20:12 → 21:09), fora o tempo de leitura

O trabalho foi feito em duas camadas, de propósito:

1 · Sondas isoladas_spike/Spike.Wolverine, 1.131 linhas. Um host Wolverine mínimo com os três behaviors portados e duas requisições-sonda, para medir ordem e estado ambiente sem o ruído de uma API inteira. É onde o defeito do AsyncLocal apareceu primeiro, com o mecanismo isolado num teste que não menciona o Wolverine.

2 · A suíte realGrydAuth.IntegrationTests, por HTTP, com o host completo. A fábrica ganhou um caminho opcional atrás de SPIKE_WOLVERINE=1; com SPIKE_WOLVERINE_ALL=1 todo IMediator.Send vai para o Wolverine. As asserções são as que já existiam.

Controle negativo: SPIKE_WOLVERINE_NAIVE=1 troca o porte correto pelo porte ingênuo. É o que prova que o caminho está mesmo vivo — e que o verde não é um falso positivo. As 15 sondas ficam verdes, e uma delas fixa o defeito como asserção: the_tenant_scope_written_by_an_async_Before_is_LOST_before_the_handler.

O controle negativo é a parte que vale. Um spike que só mostra o verde não prova nada: o caminho novo pode nem ter sido exercitado. Por isso cada execução gravou contadores de quantas vezes cada behavior de fato fez trabalho, e o mesmo host foi rodado com o porte ingênuo para ver a suíte ficar vermelha.

As três execuções, lado a lado — mesmo filtro, mesmas 157 asserções
MedidaMediatR (baseline)Wolverine · porte corretoWolverine · porte ingênuo
Resultado157 verdes157 verdes4 falhas, 153 verdes
Despachos pelo Wolverine0355355
Despachos pelo MediatR35500
Gate de permissão executado19×19×
Filter cross-tenant desligado18×18×
Escopo de grupo resolvido25×25×
Escopo de grupo aplicado25× ← o defeito
Testes que caemnenhumnenhumos 4 que leem em scope=group

Os quatro que caem no porte ingênuo têm nome: SystemAdmin_InAGroupTenant_ShouldSeeTheGroup_NotThePlatform, GroupSummary_ShouldReportTheScopeAndLabelEveryCompany e as duas variantes de TheAnnouncedGroupReach_IsExactlyTheGroupCallOutcome. São exatamente as leituras de grupo — e nenhuma outra.

Correção ao roteiro, conferida em 879dd954: o repositório tem 12 implementações de IPipelineBehavior<,>, não 13. MissingDatabaseBehavior é um enum de política de risco em GrydAuth.Application/Common/Security/, não um behavior de pipeline. O nome enganou a tabela.

O corpo do relatório

Os seis critérios

Um a um, com a evidência bruta. Onde a evidência é um erro de compilador ou uma saída de teste, ela está aqui literalmente — não a paráfrase dela.

1 · Os behaviors críticos cabem no pipeline passa

Passa a ordem; falha a letra "sem ordem implícita". Os três behaviors foram portados e rodam na ordem declarada. A ordem foi gravada passo a passo pelo próprio pipeline:

order (probe A · permissão + escopo de grupo):
  permission > tenantscope:resolve > tenantscope:apply > handler > tenantscope:finally

order (probe B · permissão + cross-tenant):
  permission > crosstenant > handler > crosstenant:finally

Duas coisas boas aparecem aqui e não estavam previstas. A primeira: o Wolverine não gera o middleware que não se aplica ao tipo da mensagem — a sonda A não tem crosstenant na cadeia, enquanto o MediatR chama o behavior e volta na primeira linha. É mais barato, e a ordem fica declarada no registro, não implícita na composição.

A segunda: o Before de um middleware pode devolver um valor que o Before de outro recebe por tipo. É isso que torna o contorno do AsyncLocal possível sem estado estático novo — o critério proíbe exatamente isso, e o contorno não precisou dele.

Correção ao roteiro, encontrada pelo compilador do próprio domínio. O passo 2 pede "um caso de uso só, que atravesse os três behaviors críticos". Ele não existe e não pode existir: o TenantScopeBehavior lança quando a requisição é ao mesmo tempo IGroupScopableQuery e ICrossTenantRequest"Group scope narrows the read to a tenant set; cross-tenant access disables the tenant filter entirely. Pick one." A sonda com os três marcadores morreu nessa exceção. O spike passou a usar duas sondas, que é o que toda requisição real do repositório faz.

O que não passa na letra do critério é "nenhum precisa de ordem implícita para funcionar". O behavior de escopo de grupo precisa ser partido em duas metades, e a segunda tem de ser a última antes do handler. Isso é uma ordem implícita: nada no código diz, nenhum compilador acusa, e o teste que a violaria fica verde na maioria das suítes. Está detalhado em § O que aconteceu com a tenancy.

2 · A tenancy sobrevive passa

157 de 157, sem editar um teste. As suítes Tests.Authorization (121), Tests.Directory e Tests.GroupScope (36) rodaram com o GrydAuth inteiro despachado pelo Wolverine:

Aprovado!  – Com falha: 0, Aprovado: 157, Total: 157, Duração: 1 m 37 s

wolverine sends ........: 355
mediatr sends ..........: 0
permission gate ran ....: 19
cross-tenant filter off : 18
group scope resolved ...: 25
group scope applied ....: 25

O verde só vale porque os contadores mostram que os três behaviors críticos fizeram trabalho: 19 avaliações de permissão, 18 desligamentos do filtro entre tenants, 25 escopos de grupo resolvidos e aplicados. Uma execução anterior — antes de estender o roteamento a todas as requisições — também ficou 121/121 verde com o gate de permissão executando zero vezes. Aquele verde não dizia nada, e é por isso que os contadores existem.

Ressalva que não pode sair da mesma tela que o verde. Para chegar a 157/157 foram portados 6 dos 12 behaviors: TenantInjection, TenantValidation, PermissionAuthorization, CrossTenantDataFilter, TenantScope (em duas metades) e Validation, mais um middleware de aquecimento que não existe no MediatR. Os seis restantes — os três de observabilidade, TenantAudit, JobExecution e ReportGenerationnão rodaram. Nenhum teste desta suíte depende deles; isso não é o mesmo que dizer que portam sem susto.

3 · O AggregateRoot é raspável passa

Sem tocar a classe, e sem tocar as 74 chamadas. A API do Wolverine aceita a coleção que o IAggregateRoot já expõe:

opts.PublishDomainEventsFromEntityFrameworkCore<IAggregateRoot, DomainEvent>(a => a.DomainEvents);

IAggregateRoot.DomainEvents já é public IReadOnlyCollection<DomainEvent>. O critério permite "no máximo expor a coleção"; nem isso foi preciso. Um agregado de teste que herda o AggregateRoot real, com um AddDomainEvent na forma exata dos 74 pontos, teve o evento raspado, gravado no outbox e entregue ao handler contra um PostgreSQL real.

4 · O GrydJobs não é perdido passa

O Hangfire convive; ninguém precisa assumir nada. O GrydJobs tem sua própria camada de provider (HangfireJobScheduler, HangfireJobMonitor), e o Wolverine entra ao lado sem disputar dashboard, DLQ ou política de retry. O que o Wolverine cobra é colateral de pacote, e é obrigatório:

error NU1109: Downgrade de pacote detectado: Cronos de 0.11.0 para 0.8.4 definido centralmente.
  Spike.Wolverine -> WolverineFx 6.35.0 -> Cronos (>= 0.11.0)
  Spike.Wolverine -> Cronos (>= 0.8.4)

O Cronos é usado pelo GrydReports.Infrastructure e pelo GrydJobs.Infrastructure. Subi para 0.11.0 no Directory.Packages.props e medi a consequência: a solução inteira compila com 0 warnings e 827 testes de GrydJobs, GrydReports e Notifications ficam verdes. Passa — mas note que adotar o Wolverine obriga um bump transitivo num pacote de agendamento que não tem nada a ver com mensageria.

5 · O impacto no consumidor é declarável passa em forma

Cabe em uma página, e a página é a § Impacto no consumidor. Passa em forma; o conteúdo dela é que decide se é vendável, e há pelo menos um item — o despacho pós-commit — que muda semântica visível de quem já usa o framework.

6 · O Wolverine acompanha o Hybrid falha

A chave de resolução tem tamanho errado, e isso é verificável em duas camadas. Primeiro no compilador:

Wolverine  : FindAsync(String tenantId)
Gryd.IO    : ResolveAsync(Nullable`1 tenantId, String moduleName, CancellationToken cancellationToken)

Depois no banco. Montei três bancos PostgreSQL — shared, dedicated_auth e dedicated_files — e um catálogo na forma que o TenantConnectionStringResolver devolve no modo Hybrid: o tenant tenant-hybrid com GrydAuth num banco dedicado e GrydFiles em outro banco dedicado. Registrei os tenants no Wolverine por uma fonte fixada num módulo e publiquei uma mensagem durável para o tenant:

envelope tables · dedicated_auth=3  dedicated_files=0  shared=3

O banco do outro módulo do mesmo tenant ficou com zero tabelas do Wolverine. Não há inbox, não há outbox, e — o que importa — não há agente de durabilidade varrendo aquele banco. Um envelope gravado ali pelo DbContext do GrydFiles, na mesma transação do dado de negócio, nunca sairia.

Existe contorno, e ele funciona — mas o preço é a identidade. Registrando os tenants por uma chave composta tenant::módulo, os dois bancos passam a ser cobertos:

composite key · dedicated_auth=3  dedicated_files=3

Sem message store próprio e sem fork da persistência — a letra do critério, atendida. Mas a partir daí DeliveryOptions.TenantId, InvokeForTenantAsync, o carimbo do envelope e a multi-tenancy gerida do DbContext deixam de falar de tenant e passam a falar de (tenant, módulo). Publicar do GrydAuth para o GrydFiles do mesmo cliente vira uma mensagem entre dois "tenants" diferentes. Isso não é uma configuração: é uma reinterpretação do conceito central do produto, feita para fora do que a documentação dele descreve. Chamo isso de reprovação.

Some-se um detalhe menor mas real: PersistMessagesWithPostgresql exige um connection string "main" antes de qualquer tenant existir. O Gryd.IO não tem um banco global — tem ModuleDefaults por módulo e um DefaultConnectionString de fallback. Dá para apontar um; é mais um conceito importado do que uma peça que já existia.

A seção que costuma sumir

Onde doeu

Seis contornos. Nenhum é fatal isolado; juntos são o custo real da migração, e é isso que uma estimativa por contagem de arquivos não captura.

1 · O global.json não deixa o repositório compilar. Ele fixa o SDK 10.0.102 com rollForward: disable; a máquina tem 10.0.301. Nada roda sem trocar.

Contorno: global.json permissivo só dentro da worktree do spike. Não é um problema do Wolverine — é um pedágio que qualquer trabalho local paga, e vale registrar porque custou a primeira tentativa inteira.

2 · O Wolverine 6 não traz mais o compilador de runtime. O host sobe e morre na primeira mensagem:

InvalidOperationException: Wolverine is running in TypeLoadMode.Dynamic, which compiles
handler/middleware code at runtime, but no IAssemblyGenerator (Roslyn) is registered.
Core WolverineFx no longer ships the runtime compiler. (GH-2876)

Contorno: referenciar WolverineFx.RuntimeCompilation. Em produção a saída correta é a outra — dotnet run -- codegen write no build e TypeLoadMode.Static —, o que significa uma etapa nova de geração de código no pipeline de CI. Esse item não apareceu na estimativa de ninguém.

3 · Middleware não recebe a mensagem por interface marcadora. Os três behaviors são genéricos em TRequest e testam request is not IX. Escrever o equivalente no Wolverine não compila:

JasperFx.CodeGeneration.UnResolvableVariableException: JasperFx was unable to resolve a
variable of type GrydAuth.Application.Common.Interfaces.IRequirePermission as part of the
method ProbeRequestHandler118808494.HandleAsync(...)

Contorno: o Before recebe Envelope e faz o cast à mão — if (envelope.Message is not IRequirePermission gated) return;. Funciona, e custa o type safety que o MediatR dava de graça. Multiplicado por 12 behaviors, é 12 lugares onde um marcador renomeado deixa de ser erro de compilação e passa a ser um if que nunca entra.

4 · Dois middlewares que devolvem o mesmo tipo colidem no código gerado. Os dois behaviors que envolvem o handler num using devolviam IDisposable?. O arquivo gerado não compila:

CS0136: Um local ou um parâmetro denominado "disposable" não pode ser declarado neste
escopo porque esse nome é usado em um escopo delimitador de local

Contorno: um tipo descartável distinto por middleware (CrossTenantFilterScope, GroupScopeHandle). O Wolverine nomeia a variável gerada a partir do tipo de retorno, então tipos iguais em escopos aninhados colidem. É uma restrição de codegen que vaza para o desenho das classes de domínio da infraestrutura.

5 · ServiceLocationPolicy.NotAllowed é o padrão do 6.x, e o Gryd.IO não passa nele. Rodar a suíte pela primeira vez deu 14 falhas, todas com a mesma causa:

Wolverine.Configuration.InvalidServiceLocationException: Found service locations while
generating code for Message Handler for GrydAuth.Application.Features.MultiTenancy.Queries
.GetTenantsAdminQuery, but ServiceLocationPolicy.NotAllowed is in effect

Contorno: opts.ServiceLocationPolicy = ServiceLocationPolicy.AllowedButWarn. A causa é estrutural: boa parte do grafo do Gryd.IO é registrada com lambda factories — o próprio DbContextOptions<GrydAuthDbContext> é uma delas —, e o codegen do Wolverine não consegue embutir isso. Ficar em AllowedButWarn significa abrir mão da vantagem de performance que é o argumento de venda do codegen; sair dela significa reescrever registro de DI em escala.

6 · O ValidationBehavior não porta. Ponto. Ele curto-circuita devolvendo um valor tipadoResult<T>.Failure(...). O middleware do Wolverine não é genérico no retorno do handler e não tem como produzir aquele Result<T>.

Contorno: lançar FluentValidation.ValidationException, que o CoreExceptionHandler já mapeia para 400 com VALIDATION_ERROR. Os testes voltam ao verde porque asseguram o status code. O payload não é idêntico ao do caminho Result, e a versão portada precisa de reflection para alcançar IValidator<TConcreto> — coisa que o critério 1 proíbe explicitamente, e que aqui aparece num dos outros nove behaviors, não nos três críticos.

O que os seis têm em comum. Nenhum é um bug do Wolverine. Todos são consequência da mesma escolha de projeto: middleware como código gerado em vez de delegate encadeado. O MediatR envolve o handler numa chamada (await next()); o Wolverine costura Before/try/finally num método único que ele escreve. Isso é mais rápido e mais inspecionável — e é por isso que interface marcadora, retorno genérico, nome de variável e lambda factory passam a importar.

O achado que decide a recomendação

O que aconteceu com a tenancy

Sob o Wolverine, uma escrita no AsyncLocal feita dentro de um Before async não chega ao handler. Não dá erro. Não dá warning. O filtro global do EF Core simplesmente lê outra coisa.

O mecanismo, isolado do Wolverine

Antes de acusar o produto, o mecanismo foi medido sozinho, num teste que não menciona o Wolverine:

static async Task WriteItAsync()
{
    await Task.Yield();
    TenantContextAccessor.ScopeTenantIds = new[] { Guid.NewGuid() };
}

WriteItAsync().GetAwaiter().GetResult();
TenantContextAccessor.ScopeTenantIds   // →  NULL

É comportamento documentado do .NET: o AsyncMethodBuilder restaura o ExecutionContext do chamador quando o método async termina, então mutação de AsyncLocal feita lá dentro não sobe. No MediatR isso nunca apareceu porque o using (_applier.Apply(scope)) { return await next(); } está no mesmo método async que chama o handler — a escrita desce, não sobe.

O mesmo mecanismo, dentro do Wolverine

O TenantScopeBehavior precisa das duas coisas ao mesmo tempo: await resolver.ResolveAsync(...) e applier.Apply(scope). Portado como um único Before async, o handler vê:

order: permission > tenantscope > handler > tenantscope:finally
scope seen by handler ...: NULL          ← duas empresas resolvidas, nenhuma aplicada

Repare que a ordem está certa e o finally roda. O middleware executou, resolveu o grupo, auditou a leitura e devolveu um descartável. Só o efeito colateral que importa se perdeu.

O contorno, e a regra que ele cria

Partir o behavior em duas metades: um Before async que só resolve, devolvendo o escopo; e um Before síncrono que recebe esse escopo por tipo e faz a escrita ambiente.

order (contorno): permission > tenantscope:resolve > tenantscope:apply > handler > tenantscope:finally
scope seen (contorno) ..: 22222222-…-2222, 33333333-…-3333    ← as duas empresas

Uma pergunta de acompanhamento, respondida em vez de assumida: um passo async entre a aplicação e o handler destrói a escrita? Não. Um middleware async registrado depois do apply deixou o escopo intacto. A regra é estreita e precisa: quem escreve tem de ser síncrono; quem só passa pelo caminho não importa.

Onde isso mordeu de verdade — e o que quase passou batido

O CrossTenantDataFilterBehavior passou de primeira, porque a sua resolução é síncrona. O TenantScopeBehavior foi corrigido pelo contorno. E aí a suíte ainda ficou vermelha em dois testes, com uma mensagem que é quase uma acusação:

InvalidOperationException: No tenant scope is in effect and there is no current tenant to
fall back to. A scope-capable read reached this point without tenant context, which means
the request bypassed tenant resolution.        → HTTP 409

A causa é o mesmo mecanismo num terceiro lugar, que ninguém teria procurado. O GrydAuthTenantContextAdapter.TenantId sincroniza o TenantContextAccessor como efeito colateral do getter:

public Guid? TenantId
{
    get
    {
        var httpContext = _httpContextAccessor.HttpContext;
        if (httpContext?.Items.TryGetValue(GrydAuthConstants.TenantIdKey, out var tenantIdObj) == true)
        {
            if (tenantIdObj is Guid tenantGuid)
            {
                EnsureTenantContextAccessorSynced(tenantGuid);   // ← a escrita ambiente
                return tenantGuid;
            }
            …

Sob o MediatR, a primeira leitura acontece dentro da cadeia de behaviors, num fluxo async contínuo até o handler, e a escrita fica. Sob o Wolverine, a primeira leitura caiu dentro de um Before async — e o handler rodou sem tenant ambiente nenhum. O que devolveu o verde foi um middleware síncrono de três linhas cujo único trabalho é ler a propriedade antes de todo mundo.

E o query filter global?

Aqui está a razão de isto ser uma regra de segurança e não de estilo. O predicado que o EF Core compila para toda entidade com tenant obrigatório é:

e => !context.IsTenantFilterEnabled ||
     (context.ScopeTenantIds != null
        ? context.ScopeTenantIds.Contains(e.TenantId)
        : context.CurrentTenantId == null
            ? !context.RequireTenantOnRead      // ← sem tenant E sem escopo
            : e.TenantId == context.CurrentTenantId);

RequireTenantOnRead é um bool sem inicializador: vale false por padrão. Com o AsyncLocal vazio nas duas pontas, o ramo escolhido é !false — literalmente predicado sempre verdadeiro, nenhum filtro. Nos casos que este spike observou, o código falhou fechado (409 e 422) antes de chegar à consulta, porque o ICurrentTenantScope se recusa a operar sem tenant. Mas essa proteção é de uma camada acima; o predicado abaixo dela está pronto para devolver o banco inteiro se alguma leitura chegar lá com as duas pontas nulas.

A conclusão em uma frase. O Wolverine não quebra a tenancy do Gryd.IO — mas remove a única coisa que a mantinha correta por acidente, que era o MediatR executar tudo num único fluxo async. Depois da migração, "esta escrita ambiente é síncrona?" vira uma pergunta de code review em todo behavior novo, com um teste de grupo como única rede.

Critério 5, na íntegra

Impacto no consumidor

O que muda para quem já constrói sobre o Gryd.IO — um módulo, um serviço, o Nexio. Uma página, que é o critério.

O que muda, e se dá para não notar
AssuntoHoje (MediatR 12.4.1)Depois (Wolverine 6.35)Dá para não notar?
Enviar um comando_mediator.Send(cmd)bus.InvokeAsync<T>(cmd)sim um adaptador de IMediator segura a fachada
Escrever um handler: IRequestHandler<C,R>classe *Handler com Handlesim os 136 handlers do GrydAuth já casam com a convenção, sem uma linha alterada
Escrever um behaviorIPipelineBehavior + await next()Before / Finally, sem next()não todo using em volta do handler vira duas metades
Curto-circuitar com um valordevolver Result<T>.Failurelançar exceçãonão muda o corpo da resposta de validação
Evento de domíniodespachado dentro da transação, com laço de re-savedespachado depois do commitnão é a mudança semântica maior — veja abaixo
Handler que altera outro agregadopersistido na mesma transaçãoprecisa da sua própria transaçãonão exige revisar caso a caso
AgendamentoHangfireHangfire, intocadosim
Builddotnet buildcodegen write antes, ou Roslyn em runtimeparcial etapa nova no CI
Registro de DIqualquer formalambda factories caem em service locationparcial funciona com AllowedButWarn, ao custo do ganho de codegen

A linha que exige atenção. Hoje o GrydAuthDbContext.SaveChangesAsync roda um laço: salva, despacha os eventos de domínio, e se algum handler gerou novas mudanças, salva de novo — até 10 iterações, tudo dentro de uma transação. É por isso que um handler que incrementa TokenVersion hoje "simplesmente funciona".

O Wolverine entrega o evento depois do commit — medido em 767,8 ms na sonda deste spike. Isso é melhor (é justamente o que o roteiro do épico quer: nada é anunciado antes de o banco confirmar), e é incompatível com qualquer handler que hoje dependa de participar da mesma transação. São 74 pontos de AddDomainEvent a auditar, um a um, para separar "quero avisar" de "quero completar a escrita".

Com o método, não só o número

Custo estimado

O 199 do roteiro não se reproduz em 879dd954. As medidas vizinhas, feitas com grep no commit conferido, são estas:

A pegada do MediatR, medida
Medidasrc/Repositório inteiro
Arquivos com using MediatR196240
Arquivos com qualquer menção a MediatR213263
Chamadas a AddDomainEvent7374 ✓ confere com o roteiro
Pontos de AddMediatR8 ✓10 (mais templates/ e tools/)
Implementações de IPipelineBehavior1212 (o roteiro diz 13)
Handlers no GrydAuth.Application136— todos casam com a convenção do Wolverine

O que eu de fato toquei

O denominador da extrapolação
ItemQuantidadeTempo
Arquivos rastreados alterados4 · +84 linhas57 min de execução medida
(20:12 → 21:09), incluindo
4 execuções completas da
suíte de integração
Código de spike escrito1.527 linhas em 8 arquivos novos
Do qual: pipeline portado (viraria produção)396 linhas · 6 behaviors + 1 novo
Arquivos de src/ alterados0

Por que a extrapolação por arquivo é inválida aqui — e é o achado mais útil da seção. Dos ~240 arquivos com using MediatR, a esmagadora maioria são handlers e requests. Os 136 handlers do GrydAuth já casam com a convenção do Wolverine: ele os descobriu todos e montou 153 cadeias sem nenhuma alteração de código. O trabalho não está nos 240 arquivos. Está em 12.

A estimativa, por natureza do trabalho
FrenteSuperfícieNaturezaEstimativa
Trocar using e o tipo do request~240 arquivosmecânico, script + revisão3 a 5 dias
Portar os 12 behaviors12 arquivosum a um, com julgamento: cada using vira duas metades e cada escrita ambiente precisa ser síncrona2 a 3 semanas
Auditar os 74 AddDomainEvent74 pontossemântico: separar "avisar" de "completar a escrita na mesma transação"2 a 3 semanas
Desmontar o laço de eventos do DbContext2 DbContext + testesarquitetural, com regressão em toda a auditoria1 a 2 semanas
Registro de DI vs. service locationgrafo inteiroopcional se ficar em AllowedButWarn; grande se não0 ou 3+ semanas
codegen write no CIpipelineinfra2 a 4 dias
Resolver o Hybridnão estimado: não há solução conhecida que preserve o significado de tenant id

Total, sem o Hybrid: 6 a 10 semanas de uma pessoa, com a faixa dominada pelas duas frentes semânticas — os behaviors e os eventos de domínio —, não pela contagem de arquivos. Com o Hybrid na conta, não há número: a questão deixa de ser prazo e vira decisão de produto.

A extrapolação é válida na direção "mecânico" e frágil na direção "semântico". Portei 6 behaviors em uma sessão, mas os 6 que sobraram incluem os três de observabilidade, que envolvem o handler em spans e cronômetros — exatamente a forma using que mais dói. O número acima assume que eles doem como o TenantScope doeu, e essa é a suposição mais frágil do quadro.

A resposta que o roteiro pediu

Recomendação

Adotar depois — com gatilho explícito

Não adotar agora. Não descartar. O Wolverine passou em cinco dos seis critérios com execução real, o que é mais do que a especificação apostava. O que impede o "agora" não é maturidade do produto: é que o único critério reprovado é o que toca o modelo de banco do Gryd.IO, e o contorno existente cobra o significado de tenant.

O gatilho, escrito de forma que dê para verificar sem discussão:

Gatilho A — o produto resolve. O Wolverine passar a aceitar uma chave de resolução composta, ou um message store por DbContext em vez de por tenant. Concretamente: ITenantedSource aceitar mais de um discriminador, ou a integração de EF Core registrar o store a partir do DbContext resolvido em runtime. Verificável abrindo uma issue no repositório deles e acompanhando.

Gatilho B — o Gryd.IO resolve. O modo Hybrid deixar de existir, ou passar a garantir que todos os módulos de um tenant moram no mesmo banco. Aí a chave do Wolverine passa a caber, e o critério 6 vira verde sem contorno. Verificável no TenantConnectionStringResolver.

Gatilho C — o custo muda de lado. O MediatR 12.x congelado começar a doer de verdade — uma CVE sem patch, ou incompatibilidade com um .NET futuro. Aí a conta passa a ser "migrar com o contorno de chave composta" contra "manter um fork", e a primeira pode ganhar.

O que fazer enquanto isso, e que não é esperar. As features F3 a F6 do épico devem ser construídas como especificadas — a versão fina do outbox no Core. Duas razões, e a segunda é a que importa:

1 · O critério 6 reprova hoje, e F3–F6 não podem esperar por um gatilho sem data.

2 · Construir a versão fina encurta a migração futura em vez de duplicá-la. O contrato de despacho pós-commit — que é a mudança semântica cara — passa a existir antes, e a auditoria dos 74 AddDomainEvent acontece de qualquer jeito, com ou sem Wolverine. Quando o gatilho vier, o Gryd.IO já terá pago a parte semântica e restará a mecânica.

Uma coisa para fazer esta semana, independente da decisão. A regra "escrita ambiente tem de ser síncrona" já vale hoje, no MediatR, para qualquer behavior novo que não envolva o handler num using contínuo. Vale escrever um teste de tenancy que rode scope=group e afirme o conteúdo de TenantContextAccessor.ScopeTenantIds dentro do handler. Custa uma hora e é a rede que faltou aqui: sem ele, o porte ingênuo passa em 153 dos 157 testes e parece pronto.

Lacuna declarada vale mais que lacuna escondida

O que o spike não respondeu

1 · Seis dos doze behaviors não foram portados. Os três de observabilidade (Logging, Telemetry, Performance), o TenantAudit, o JobExecution e o ReportGeneration. Os três de observabilidade são os que mais preocupam: envolvem o handler em span e cronômetro, que é a forma using que exigiu contorno nos dois casos em que apareceu.

2 · Nenhum outro módulo foi testado. Só o GrydAuth foi despachado pelo Wolverine. GrydFiles, GrydReports, GrydCrud e GrydAudit continuaram no MediatR o tempo todo. O escopo negativo do roteiro pedia isso, mas a consequência é que a descoberta de handlers só foi provada em uma assembly.

3 · O IMediator.Publish nunca saiu do MediatR. O laço de eventos de domínio do DbContext continuou usando o MediatR em todas as execuções, de propósito — movê-lo confundiria a medição da tenancy. Portanto: o despacho pós-commit está provado numa sonda dedicada, e não contra o GrydAuthDbContext real com o seu laço de re-save. Essa é a lacuna mais cara da lista.

4 · Performance não foi medida. Nem throughput, nem latência, nem o ganho que o codegen promete — e a configuração usada (ServiceLocationPolicy.AllowedButWarn) é justamente a que abre mão dele. Se o argumento de adoção incluir performance, ele ainda não tem número.

5 · O contorno de chave composta não foi levado até o fim. Ficou provado que os dois bancos passam a ser cobertos. Não foi testado o que acontece quando uma mensagem atravessa módulos do mesmo cliente, nem como a multi-tenancy gerida do DbContext se comporta com a chave sintética. O raciocínio de que "descaracteriza o tenant" é dedução do contrato, não medição.

6 · TypeLoadMode.Static não foi exercitado. Todas as execuções usaram compilação em runtime via WolverineFx.RuntimeCompilation. O caminho de produção — codegen write no build — não foi montado, então o custo real dessa etapa de CI é estimativa, não medição.

Rastreabilidade

Fontes

Toda afirmação sobre o Gryd.IO vem de git show 879dd954:<arquivo> no repositório, com o caminho citado no próprio texto. Toda afirmação sobre o Wolverine vem daqui:

Toda afirmação executada nesta versão vem de um artefato no repositório, na branch spike/wolverine-sucessor-mediatr, commit aef01e2f:

Onde está cada evidência
O queArquivo
Baseline sobre código intocado_spike/logs/11-baseline-tenancy.txt · C1-final-baseline.txt
Ordem e AsyncLocal (critério 1)_spike/logs/30-criterion1.txt · Spike.Wolverine/Criterion1_PipelineTests.cs
Descoberta de handlers_spike/logs/70-criterion2-discovery.txt
Hybrid, três bancos (critério 6)_spike/logs/40-criterion6.txt · Spike.Wolverine/Criterion6_HybridTests.cs
Scraping e pós-commit (critérios 3 e 7)_spike/logs/50-criterion3and7.txt
Cronos e os 827 testes (critério 4)_spike/logs/60-cronos-bump-build.txt · D1-jobs-reports.txt
As três execuções comparadas (critério 2)_spike/logs/C1, C2, C3 · C2-counters.txt · C3-counters.txt
O pipeline portadotests/Integration/GrydAuth.IntegrationTests/Spike/WolverinePipeline.cs
A composição, atrás de SPIKE_WOLVERINEtests/Integration/GrydAuth.IntegrationTests/Infrastructure/TestWebApplicationFactory.cs

A superfície de API do Wolverine citada aqui foi lida por reflection sobre os assemblies 6.35.0 de fato restaurados, não só pela documentação — o despejo está em _spike/logs/20-api-surface.txt. Onde documentação e assembly discordaram, vale o assembly. A documentação continua sendo a fonte para intenção e contexto:

Contrato vigente · revisão consolidada

Revisão, evidência e limites do spike

Os resultados desta página são o relato histórico do spike, preservados: 157 testes de tenancy/diretório/escopo, 355 despachos, 827 testes de jobs/reports e cinco de seis critérios atendidos. Base reportada 879dd954, trabalho em spike/wolverine-sucessor-mediatr; referência de resultado aef01e2f. Essa referência de resultado não estava disponível no checkout local para reprodução desta revisão.

Não reapresentar essas contagens como testes executados neste PR nem como garantia para versões atuais. O resultado pós-commit medido não cobre automaticamente duas transações/DbContexts na mesma requisição, savepoints, leases interrompidos ou restauração. O cenário Hybrid foi reprovado no relato.

Decisão vigente: manter MediatR e adiar Wolverine. Implementar o mecanismo de plataforma conforme Eventos e outbox, com tabela e migração no contexto produtor. Reavaliar adoção somente com gatilho documentado e reprodução das provas nas versões efetivamente consumidas.