Repository: JasperFx/wolverine (2026-05-27T19:09:40Z)

Issue:
Source-generate gRPC service discovery to replace runtime type scanning (Wolverine.Grpc counterpart to #2906)
Follow-up to #2906 — apply the same generated-manifest approach to **Wolverine.Grpc** service discovery.

## Current behavior (the scan to eliminate)

`GrpcGraph.DiscoverServices` builds the gRPC service chains by scanning application assemblies:

- `src/Wolverine.Grpc/CodeFirstGrpcServiceChain.cs:108` — `assembly.GetExportedTypes()` to find code-first gRPC service contract implementations.
- (Hand-written services follow the same pattern via `HandWrittenGrpcServiceChain`.)

`CodeFirstGrpcServiceChain` (`CodeFirstGrpcServiceChain.cs:28`) and `HandWrittenGrpcServiceChain` (`HandWrittenGrpcServiceChain.cs:25`) are already `Chain<>` + `ICodeFile`, mirroring `HandlerChain`/`HttpChain`.

## Proposed approach (mirror #2906)

As settled in #2906 — **not** a Roslyn source generator:

1. A runtime-codegen **`ICodeFile`** reads the **already-built `GrpcGraph`** (built by Wolverine's normal discovery during `dotnet run -- codegen write`) and emits a single manifest into the **application assembly**, capturing the discovered gRPC services/methods. No additional scanning — it reads what discovery already produced.
2. Under **`TypeLoadMode.Static`**, `GrpcGraph.DiscoverServices` consumes the manifest to **skip the `GetExportedTypes` scan** (reflective fallback retained when absent).
3. Custom discovery rules and per-chain configuration are captured for free, since the manifest reflects the *result* of the built graph — same rationale as #2906.

## Acceptance criteria

- [ ] gRPC service discovery resolvable from a compile-time-emitted manifest with no `GetExportedTypes` scan when present; reflective fallback retained.
- [ ] Single manifest in the application assembly, produced at `codegen write` time from the built `GrpcGraph`.
- [ ] Parity with reflective discovery across the Wolverine.Grpc test suite.

## Related
- #2906 (handler/message discovery — the parent design)



Discussion:

Patch:
diff --git a/src/Wolverine.Grpc/GrpcGraph.cs b/src/Wolverine.Grpc/GrpcGraph.cs
index a7b1f0bc9..0cfc6a98e 100644
--- a/src/Wolverine.Grpc/GrpcGraph.cs
+++ b/src/Wolverine.Grpc/GrpcGraph.cs
@@ -47,7 +47,18 @@ public GrpcGraph(WolverineOptions options, IServiceContainer container)
     /// <summary>Hand-written service chains (concrete service class → generated delegation wrapper).</summary>
     public IReadOnlyList<HandWrittenGrpcServiceChain> HandWrittenChains => _handWrittenChains;
 
-    public IReadOnlyList<ICodeFile> BuildFiles() => [.._chains, .._codeFirstChains, .._handWrittenChains];
+    public IReadOnlyList<ICodeFile> BuildFiles()
+    {
+        // GH-2926: capture the discovered service types (across all three discovery flavors) so startup
+        // can skip the GetExportedTypes scans under TypeLoadMode.Static. The types come from the
+        // already-built chains, so no scan is needed to produce the manifest.
+        var registry = new GrpcServiceRegistryCodeFile(
+            _chains.Select(x => x.StubType),
+            _codeFirstChains.Select(x => x.ServiceContractType),
+            _handWrittenChains.Select(x => x.ServiceClassType));
+
+        return [.._chains, .._codeFirstChains, .._handWrittenChains, registry];
+    }
 
     /// <summary>
     ///     Scans the assemblies already registered with Wolverine and builds chains for every
@@ -59,46 +70,79 @@ public void DiscoverServices(WolverineGrpcOptions grpcOptions)
     {
         var logger = Container.GetInstance<ILogger<GrpcGraph>>();
 
-        AssertNoConcreteProtoStubs(_options.Assemblies);
+        // Cold-start fast path (GH-2926): in TypeLoadMode.Static, rebuild the chains from the
+        // pre-generated GrpcServiceRegistry instead of scanning assemblies (and skip the codegen-time
+        // validation scans). Never applies during `codegen write` itself — that must run a fresh scan
+        // to regenerate the registry accurately.
+        if (!DynamicCodeBuilder.WithinCodegenCommand && Rules.TypeLoadMode == TypeLoadMode.Static &&
+            GrpcServiceRegistry.TryLoad(_options.ApplicationAssembly, out var registry))
+        {
+            logger.LogInformation(
+                "Using pre-generated Wolverine gRPC service registry; skipping assembly scan");
 
-        var stubs = FindProtoFirstStubs(_options.Assemblies).ToArray();
-        logger.LogInformation(
-            "Found {Count} proto-first Wolverine gRPC services in assemblies {Assemblies}",
-            stubs.Length,
-            _options.Assemblies.Select(x => x.GetName().Name!).Join(", "));
+            foreach (var stub in registry!.ProtoFirstStubTypes())
+            {
+                _chains.Add(new GrpcServiceChain(stub, this));
+            }
 
-        foreach (var stub in stubs)
-        {
-            _chains.Add(new GrpcServiceChain(stub, this));
+            DisambiguateCollidingTypeNames(_chains);
+
+            foreach (var contract in registry.CodeFirstContractTypes())
+            {
+                _codeFirstChains.Add(new CodeFirstGrpcServiceChain(contract)
+                {
+                    ApplicationAssemblies = _options.Assemblies
+                });
+            }
+
+            foreach (var serviceClass in registry.HandWrittenServiceTypes())
+            {
+                _handWrittenChains.Add(new HandWrittenGrpcServiceChain(serviceClass));
+            }
         }
+        else
+        {
+            AssertNoConcreteProtoStubs(_options.Assemblies);
+
+            var stubs = FindProtoFirstStubs(_options.Assemblies).ToArray();
+            logger.LogInformation(
+                "Found {Count} proto-first Wolverine gRPC services in assemblies {Assemblies}",
+                stubs.Length,
+                _options.Assemblies.Select(x => x.GetName().Name!).Join(", "));
+
+            foreach (var stub in stubs)
+            {
+                _chains.Add(new GrpcServiceChain(stub, this));
+            }
 
-        DisambiguateCollidingTypeNames(_chains);
+            DisambiguateCollidingTypeNames(_chains);
 
-        var contracts = FindCodeFirstServiceContracts(_options.Assemblies).ToArray();
-        logger.LogInformation(
-            "Found {Count} code-first Wolverine gRPC service contracts in assemblies {Assemblies}",
-            contracts.Length,
-            _options.Assemblies.Select(x => x.GetName().Name!).Join(", "));
+            var contracts = FindCodeFirstServiceContracts(_options.Assemblies).ToArray();
+            logger.LogInformation(
+                "Found {Count} code-first Wolverine gRPC service contracts in assemblies {Assemblies}",
+                contracts.Length,
+                _options.Assemblies.Select(x => x.GetName().Name!).Join(", "));
 
-        foreach (var contract in contracts)
-        {
-            CodeFirstGrpcServiceChain.AssertNoConcreteImplementationConflicts(contract, _options.Assemblies);
-            var codeFirstChain = new CodeFirstGrpcServiceChain(contract)
+            foreach (var contract in contracts)
             {
-                ApplicationAssemblies = _options.Assemblies
-            };
-            _codeFirstChains.Add(codeFirstChain);
-        }
+                CodeFirstGrpcServiceChain.AssertNoConcreteImplementationConflicts(contract, _options.Assemblies);
+                var codeFirstChain = new CodeFirstGrpcServiceChain(contract)
+                {
+                    ApplicationAssemblies = _options.Assemblies
+                };
+                _codeFirstChains.Add(codeFirstChain);
+            }
 
-        var handWritten = FindHandWrittenServiceClasses(_options.Assemblies).ToArray();
-        logger.LogInformation(
-            "Found {Count} hand-written Wolverine gRPC service classes in assemblies {Assemblies}",
-            handWritten.Length,
-            _options.Assemblies.Select(x => x.GetName().Name!).Join(", "));
+            var handWritten = FindHandWrittenServiceClasses(_options.Assemblies).ToArray();
+            logger.LogInformation(
+                "Found {Count} hand-written Wolverine gRPC service classes in assemblies {Assemblies}",
+                handWritten.Length,
+                _options.Assemblies.Select(x => x.GetName().Name!).Join(", "));
 
-        foreach (var serviceClass in handWritten)
-        {
-            _handWrittenChains.Add(new HandWrittenGrpcServiceChain(serviceClass));
+            foreach (var serviceClass in handWritten)
+            {
+                _handWrittenChains.Add(new HandWrittenGrpcServiceChain(serviceClass));
+            }
         }
 
         // Apply policy-registered middleware and IChainPolicy implementations.
diff --git a/src/Wolverine.Grpc/GrpcServiceRegistry.cs b/src/Wolverine.Grpc/GrpcServiceRegistry.cs
new file mode 100644
index 000000000..1598e6266
--- /dev/null
+++ b/src/Wolverine.Grpc/GrpcServiceRegistry.cs
@@ -0,0 +1,162 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Reflection;
+using JasperFx.CodeGeneration;
+using JasperFx.CodeGeneration.Frames;
+using JasperFx.CodeGeneration.Model;
+using JasperFx.Core.Reflection;
+
+namespace Wolverine.Grpc;
+
+/// <summary>
+///     Base class for the pre-generated gRPC service registry emitted by <c>codegen write</c>
+///     (GH-2926, the Wolverine.Grpc counterpart to the handler manifest in GH-2906). In
+///     <see cref="TypeLoadMode.Static" />, Wolverine.Grpc consumes the generated subclass to skip the
+///     <c>GetExportedTypes</c> assembly scans that <see cref="GrpcGraph.DiscoverServices" /> would
+///     otherwise perform across the three discovery flavors (proto-first, code-first, hand-written).
+/// </summary>
+public abstract class GrpcServiceRegistry
+{
+    /// <summary>
+    ///     The C# identifier of the generated subclass. Lives under
+    ///     <c>Internal.Generated.WolverineHandlers</c> after <c>codegen write</c>.
+    /// </summary>
+    public const string GeneratedTypeName = "GeneratedGrpcServiceRegistry";
+
+    /// <summary>Proto-first stub types discovered at <c>codegen write</c> time.</summary>
+    public abstract Type[] ProtoFirstStubTypes();
+
+    /// <summary>Code-first <c>[ServiceContract]</c> interface types discovered at <c>codegen write</c> time.</summary>
+    public abstract Type[] CodeFirstContractTypes();
+
+    /// <summary>Hand-written service class types discovered at <c>codegen write</c> time.</summary>
+    public abstract Type[] HandWrittenServiceTypes();
+
+    // Locates the pre-generated GrpcServiceRegistry subclass in the application assembly. The
+    // single-assembly ExportedTypes walk is far cheaper than service discovery's multi-assembly scans.
+    [UnconditionalSuppressMessage("Trimming", "IL2026",
+        Justification =
+            "ExportedTypes walk over the application assembly to find the codegen-emitted GrpcServiceRegistry; the type is rooted by construction. See AOT guide.")]
+    [UnconditionalSuppressMessage("Trimming", "IL2072",
+        Justification =
+            "Activator.CreateInstance over the generated GrpcServiceRegistry subclass; the type carries its codegen-emitted public parameterless constructor. See AOT guide.")]
+    internal static bool TryLoad(Assembly? applicationAssembly, out GrpcServiceRegistry? registry)
+    {
+        registry = null;
+
+        if (applicationAssembly == null)
+        {
+            return false;
+        }
+
+        var registryType = applicationAssembly.ExportedTypes.FirstOrDefault(t =>
+            t is { IsClass: true, IsAbstract: false } && typeof(GrpcServiceRegistry).IsAssignableFrom(t));
+
+        if (registryType == null)
+        {
+            return false;
+        }
+
+        registry = (GrpcServiceRegistry)Activator.CreateInstance(registryType)!;
+        return true;
+    }
+}
+
+/// <summary>
+///     <see cref="ICodeFile" /> that emits the <see cref="GrpcServiceRegistry.GeneratedTypeName" />
+///     subclass during <c>codegen write</c>, capturing the discovered service types (across all three
+///     discovery flavors) as compile-time <c>typeof(...)</c> arrays so no reflection-based assembly scan
+///     is needed in <see cref="TypeLoadMode.Static" />.
+/// </summary>
+internal class GrpcServiceRegistryCodeFile : ICodeFile
+{
+    private readonly Type[] _protoFirstStubTypes;
+    private readonly Type[] _codeFirstContractTypes;
+    private readonly Type[] _handWrittenServiceTypes;
+    private GeneratedType? _generatedType;
+
+    public GrpcServiceRegistryCodeFile(IEnumerable<Type> protoFirstStubTypes,
+        IEnumerable<Type> codeFirstContractTypes, IEnumerable<Type> handWrittenServiceTypes)
+    {
+        _protoFirstStubTypes = onlyPublic(protoFirstStubTypes);
+        _codeFirstContractTypes = onlyPublic(codeFirstContractTypes);
+        _handWrittenServiceTypes = onlyPublic(handWrittenServiceTypes);
+    }
+
+    private static Type[] onlyPublic(IEnumerable<Type> types)
+    {
+        return types
+            .Where(x => x is { IsPublic: true } or { IsNestedPublic: true })
+            .Distinct()
+            .OrderBy(x => x.FullName, StringComparer.Ordinal)
+            .ToArray();
+    }
+
+    public Type? RegistryType { get; private set; }
+
+    string ICodeFile.FileName => GrpcServiceRegistry.GeneratedTypeName;
+
+    void ICodeFile.AssembleTypes(GeneratedAssembly assembly)
+    {
+        _generatedType = assembly.AddType(GrpcServiceRegistry.GeneratedTypeName, typeof(GrpcServiceRegistry));
+
+        foreach (var type in _protoFirstStubTypes.Concat(_codeFirstContractTypes).Concat(_handWrittenServiceTypes))
+        {
+            assembly.ReferenceAssembly(type.Assembly);
+        }
+
+        _generatedType.MethodFor(nameof(GrpcServiceRegistry.ProtoFirstStubTypes))
+            .Frames.Add(new WriteServiceTypesFrame(_protoFirstStubTypes));
+
+        _generatedType.MethodFor(nameof(GrpcServiceRegistry.CodeFirstContractTypes))
+            .Frames.Add(new WriteServiceTypesFrame(_codeFirstContractTypes));
+
+        _generatedType.MethodFor(nameof(GrpcServiceRegistry.HandWrittenServiceTypes))
+            .Frames.Add(new WriteServiceTypesFrame(_handWrittenServiceTypes));
+    }
+
+    Task<bool> ICodeFile.AttachTypes(GenerationRules rules, Assembly assembly, IServiceProvider? services,
+        string containingNamespace)
+    {
+        var found = this.As<ICodeFile>().AttachTypesSynchronously(rules, assembly, services, containingNamespace);
+        return Task.FromResult(found);
+    }
+
+    [UnconditionalSuppressMessage("Trimming", "IL2026",
+        Justification =
+            "ExportedTypes walk over the generated assembly to attach the generated registry type; the type is known by construction at codegen time. See AOT guide.")]
+    bool ICodeFile.AttachTypesSynchronously(GenerationRules rules, Assembly assembly, IServiceProvider? services,
+        string containingNamespace)
+    {
+        RegistryType = assembly.ExportedTypes.FirstOrDefault(x => x.Name == GrpcServiceRegistry.GeneratedTypeName);
+        return RegistryType != null;
+    }
+}
+
+/// <summary>
+///     Writes the body of a <see cref="GrpcServiceRegistry" /> type-array accessor as a single
+///     <c>typeof(...)</c> array literal — fully resolved at codegen time, no reflection at runtime.
+/// </summary>
+internal class WriteServiceTypesFrame : SyncFrame
+{
+    private readonly Type[] _types;
+
+    public WriteServiceTypesFrame(Type[] types)
+    {
+        _types = types;
+    }
+
+    public override void GenerateCode(GeneratedMethod method, ISourceWriter writer)
+    {
+        if (_types.Length == 0)
+        {
+            writer.Write("return System.Array.Empty<System.Type>();");
+        }
+        else
+        {
+            var literals = string.Join(", ", _types.Select(t => $"typeof({t.FullNameInCode()})"));
+            writer.Write($"return new System.Type[] {{ {literals} }};");
+        }
+
+        Next?.GenerateCode(method, writer);
+    }
+}


Test patch:
diff --git a/src/Wolverine.Grpc.Tests/grpc_service_manifest.cs b/src/Wolverine.Grpc.Tests/grpc_service_manifest.cs
new file mode 100644
index 000000000..c1ae8c0f2
--- /dev/null
+++ b/src/Wolverine.Grpc.Tests/grpc_service_manifest.cs
@@ -0,0 +1,62 @@
+using GreeterProtoFirstGrpc.Server;
+using JasperFx;
+using JasperFx.CodeGeneration;
+using JasperFx.CodeGeneration.Model;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Shouldly;
+using Xunit;
+
+namespace Wolverine.Grpc.Tests;
+
+// GH-2926: the generated GrpcServiceRegistry manifest captures the discovered service types (across
+// proto-first, code-first, and hand-written discovery) at `codegen write` time so that, under
+// TypeLoadMode.Static, GrpcGraph.DiscoverServices can skip the GetExportedTypes scans (the
+// Wolverine.Grpc counterpart to GH-2906).
+[Collection(GrpcSerialTestsCollection.Name)]
+public class grpc_service_manifest
+{
+    [Fact]
+    public async Task generated_registry_captures_discovered_service_types()
+    {
+        DynamicCodeBuilder.WithinCodegenCommand = true;
+        try
+        {
+            using var host = await Host.CreateDefaultBuilder()
+                .UseWolverine(opts =>
+                {
+                    // The GreeterProtoFirstGrpc server assembly carries the abstract
+                    // [WolverineGrpcService] proto-first stub.
+                    opts.ApplicationAssembly = typeof(GreeterGrpcService).Assembly;
+                })
+                .ConfigureServices(services => services.AddWolverineGrpc())
+                .StartAsync();
+
+            var graph = host.Services.GetRequiredService<GrpcGraph>();
+            var grpcOptions = host.Services.GetRequiredService<WolverineGrpcOptions>();
+            graph.DiscoverServices(grpcOptions);
+            graph.Chains.ShouldNotBeEmpty("the Greeter proto-first stub should be discovered");
+
+            var registryFile = graph.BuildFiles()
+                .FirstOrDefault(f => f.FileName == GrpcServiceRegistry.GeneratedTypeName);
+            registryFile.ShouldNotBeNull("BuildFiles should include the generated gRPC service registry");
+
+            var serviceVariableSource = host.Services.GetService<IServiceVariableSource>();
+            var generatedAssembly = ((ICodeFileCollection)graph).StartAssembly(graph.Rules);
+            registryFile.AssembleTypes(generatedAssembly);
+            var code = generatedAssembly.GenerateCode(serviceVariableSource);
+
+            // All three discovery flavors get their own accessor...
+            code.ShouldContain("ProtoFirstStubTypes()");
+            code.ShouldContain("CodeFirstContractTypes()");
+            code.ShouldContain("HandWrittenServiceTypes()");
+
+            // ...and the discovered proto-first stub type is captured.
+            code.ShouldContain(nameof(GreeterGrpcService));
+        }
+        finally
+        {
+            DynamicCodeBuilder.WithinCodegenCommand = false;
+        }
+    }
+}