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.
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.
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.
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.
| # | Critério | Resultado | Como foi medido |
|---|---|---|---|
| 1 | Os behaviors críticos cabem no pipeline | passa com 3 contornos | 8 testes em Criterion1_PipelineTests, ordem gravada passo a passo |
| 2 | A tenancy sobrevive | passa 157/157, 0 testes editados | suíte de integração real, Testcontainers + PostgreSQL 16 |
| 3 | O AggregateRoot é raspável | passa sem tocar a classe | EF Core + PostgreSQL, evento entregue pós-commit |
| 4 | O GrydJobs não é perdido | passa Hangfire convive | 827 testes verdes após o bump forçado do Cronos |
| 5 | O impacto no consumidor é declarável | passa em forma cabe em uma página | a § Impacto no consumidor é a página |
| 6 | O Wolverine acompanha o Hybrid | falha só passa descaracterizando o tenant | 3 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.
| Verificação | Resultado |
|---|---|
| SDK .NET | 10.0.301 — o global.json exige 10.0.102 com rollForward: disable; trocado só na worktree do spike |
| Docker | 29.4.3, contexto desktop-linux — Testcontainers sobe PostgreSQL 16 e Redis 7 de verdade |
| NuGet | Alcançável no ambiente histórico; WolverineFx 6.35.0 era a versão selecionada no spike. |
| Baseline sobre código intocado | 157/157 verdes em 1 min 41 s — é contra este número que tudo é comparado |
| Duração do spike | 57 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 real — GrydAuth.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.
| Medida | MediatR (baseline) | Wolverine · porte correto | Wolverine · porte ingênuo |
|---|---|---|---|
| Resultado | 157 verdes | 157 verdes | 4 falhas, 153 verdes |
| Despachos pelo Wolverine | 0 | 355 | 355 |
| Despachos pelo MediatR | 355 | 0 | 0 |
| Gate de permissão executado | — | 19× | 19× |
| Filter cross-tenant desligado | — | 18× | 18× |
| Escopo de grupo resolvido | — | 25× | 25× |
| Escopo de grupo aplicado | — | 25× | 0× ← o defeito |
| Testes que caem | nenhum | nenhum | os 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 ReportGeneration — nã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 tipado — Result<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.
| Assunto | Hoje (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 Handle | sim os 136 handlers do GrydAuth já casam com a convenção, sem uma linha alterada |
| Escrever um behavior | IPipelineBehavior + await next() | Before / Finally, sem next() | não todo using em volta do handler vira duas metades |
| Curto-circuitar com um valor | devolver Result<T>.Failure | lançar exceção | não muda o corpo da resposta de validação |
| Evento de domínio | despachado dentro da transação, com laço de re-save | despachado depois do commit | não é a mudança semântica maior — veja abaixo |
| Handler que altera outro agregado | persistido na mesma transação | precisa da sua própria transação | não exige revisar caso a caso |
| Agendamento | Hangfire | Hangfire, intocado | sim |
| Build | dotnet build | codegen write antes, ou Roslyn em runtime | parcial etapa nova no CI |
| Registro de DI | qualquer forma | lambda factories caem em service location | parcial 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:
| Medida | src/ | Repositório inteiro |
|---|---|---|
Arquivos com using MediatR | 196 | 240 |
| Arquivos com qualquer menção a MediatR | 213 | 263 |
Chamadas a AddDomainEvent | 73 | 74 ✓ confere com o roteiro |
Pontos de AddMediatR | 8 ✓ | 10 (mais templates/ e tools/) |
Implementações de IPipelineBehavior | 12 | 12 (o roteiro diz 13) |
| Handlers no GrydAuth.Application | 136 | — todos casam com a convenção do Wolverine |
O que eu de fato toquei
| Item | Quantidade | Tempo |
|---|---|---|
| Arquivos rastreados alterados | 4 · +84 linhas | 57 min de execução medida (20:12 → 21:09), incluindo 4 execuções completas da suíte de integração |
| Código de spike escrito | 1.527 linhas em 8 arquivos novos | |
| Do qual: pipeline portado (viraria produção) | 396 linhas · 6 behaviors + 1 novo | |
Arquivos de src/ alterados | 0 |
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.
| Frente | Superfície | Natureza | Estimativa |
|---|---|---|---|
Trocar using e o tipo do request | ~240 arquivos | mecânico, script + revisão | 3 a 5 dias |
| Portar os 12 behaviors | 12 arquivos | um a um, com julgamento: cada using vira duas metades e cada escrita ambiente precisa ser síncrona | 2 a 3 semanas |
Auditar os 74 AddDomainEvent | 74 pontos | semântico: separar "avisar" de "completar a escrita na mesma transação" | 2 a 3 semanas |
Desmontar o laço de eventos do DbContext | 2 DbContext + testes | arquitetural, com regressão em toda a auditoria | 1 a 2 semanas |
| Registro de DI vs. service location | grafo inteiro | opcional se ficar em AllowedButWarn; grande se não | 0 ou 3+ semanas |
codegen write no CI | pipeline | infra | 2 a 4 dias |
Resolver o Hybrid | — | nã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:
| O que | Arquivo |
|---|---|
| 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 portado | tests/Integration/GrydAuth.IntegrationTests/Spike/WolverinePipeline.cs |
A composição, atrás de SPIKE_WOLVERINE | tests/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:
- Wolverine · Middleware — a forma gerada
Before/try/After/finally/Finally, o retorno doBeforereaproveitado nos métodos posteriores,HandlerContinuation.Stop, injeção de construtor em middleware não estático. - Wolverine · EF Core integration —
UseEntityFrameworkCoreTransactions(),AddDbContextWithWolverineIntegration<T>(), o outbox sobre a conexão e a transação doDbContext. - Wolverine · Publishing Domain Events —
PublishDomainEventsFromEntityFrameworkCore<T>(x => x.Events)e o exemplo com coleção mutável. - Wolverine · Multi-Tenancy with EF Core —
RegisterStaticTenants(),IDynamicTenantSource<string>,AddDbContextWithWolverineManagedMultiTenancy, conjoined tenancy eWolverineFx.RuntimeCompilation. - Wolverine · PostgreSQL Integration —
PersistMessagesWithPostgresql,UseMasterTableTenancy(), um connection string por tenant id e a exigência do banco "main". - JasperFX · One Application, Many Brokers —
AddTenant(...)nos dez transportes, carimbo e propagação do tenant id,TenantIdBehavior. - JasperFx/wolverine · issue #2585 —
DomainEventScrapere "Collection was modified" comDbContextcompartilhado, em 5.32.0 com .NET 10 e EF Core 10; corrigido pelo #2594. - JasperFx/wolverine · LICENSE — MIT.
- NuGet · WolverineFx — versão
6.35.0, publicada em 08/09/2026, alvosnet9.0enet10.0.
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.