Repository: JasperFx/wolverine (2026-05-27T21:00:44Z)

Issue:
Source-generate gRPC service discovery to replace conventional runtime type scanning
Part of the effort to remove runtime assembly/type scanning from Wolverine bootstrap. Targets **gRPC service discovery**. The design should be **as similar as possible to the handler discovery work** (#2906) — same per-assembly manifest + runtime-aggregation pattern, gated to eligible assemblies.

## Current behavior
`GrpcGraph.DiscoverServices()` (invoked from `MapWolverineGrpcServices()` at endpoint-map time, over the known `_options.Assemblies` list) reflectively scans for four kinds of gRPC service types:
- `src/.../GrpcGraph.cs:207` — `FindProtoFirstStubs()`: abstract stub classes with `[WolverineGrpcService]` + a proto-generated base.
- `src/.../GrpcGraph.cs:229` — `FindCodeFirstServiceContracts()`: `[ServiceContract]` interfaces with `[WolverineGrpcService]`.
- `src/.../GrpcGraph.cs:253` — `FindHandWrittenServiceClasses()`: concrete classes ending in `GrpcService` (or attributed) implementing a `[ServiceContract]` interface.
- `src/.../WolverineGrpcExtensions.cs:235` — `FindDirectMappedServiceTypes()`: the hand-written scan, used to map services directly without a generated wrapper.

No filesystem probe, but `GetExportedTypes()`-style reflection is trim/AOT-hostile. It runs lazily at map-time (not cold start), so this is lower urgency than the cold-start sites — but it should follow the same model so the whole stack converges.

## Goal (mirror #2906)
Emit a compile-time manifest of gRPC service types per assembly (e.g. `JasperFx.Generated.DiscoveredGrpcServices`, or an extension of the shared manifest), gated to `[JasperFxAssembly]`-derived / executable assemblies, aggregated at runtime. Skip the reflective scan when the manifest is present; retain the reflective fallback otherwise.

## Open item — satellite assembly discovery (may spin out)
gRPC services frequently live in **satellite assemblies marked `[JasperFxAssembly]`**, not just the entry assembly. We need to *guarantee* those are discovered correctly:
- Each `[JasperFxAssembly]` satellite assembly must emit its own gRPC-service manifest (the SG already gates on `[JasperFxAssembly]`-derived attributes), and Wolverine must aggregate them across loaded assemblies — same as the handler-module concern in #2905.
- Verify parity with today's behavior, where the scan ran over `_options.Assemblies` (which includes registered satellite assemblies).

**This may warrant its own follow-up issue** to track the satellite-assembly guarantee specifically.

## Runtime-surface caution (cf. #2906)
As with the `HandlerChain` concern on #2906, before committing we should confirm what each discovery mode contributes beyond the type itself — the generated-wrapper attachment in `GrpcServiceChain` / `CodeFirstGrpcServiceChain` uses targeted runtime type-name lookups (tracked separately); make sure the manifest path reproduces the full chain build, not just the discovered type.

## Acceptance criteria (initial)
- [ ] All four gRPC discovery modes resolvable from a compile-time manifest with no `GetExportedTypes()` scan when present.
- [ ] Parity with reflective discovery across proto-first / code-first / hand-written / direct-mapped, on the gRPC test suite.
- [ ] gRPC services in `[JasperFxAssembly]` satellite assemblies are discovered identically to today.
- [ ] Reflective fallback for non-generator apps.

## Related
- #2906 (handler/message source-gen discovery — the model to mirror), #2905 (handler-module satellite-assembly scan), #2902 (ExtensionLoader), JasperFx/jasperfx#371 (manifest pattern).


Discussion:

Patch:
diff --git a/src/Wolverine.Grpc/GrpcGraph.cs b/src/Wolverine.Grpc/GrpcGraph.cs
index 0cfc6a98e..d570d3543 100644
--- a/src/Wolverine.Grpc/GrpcGraph.cs
+++ b/src/Wolverine.Grpc/GrpcGraph.cs
@@ -49,13 +49,21 @@ public GrpcGraph(WolverineOptions options, IServiceContainer container)
 
     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.
+        // GH-2926/GH-2907: capture the discovered service types (across all four discovery flavors) so
+        // startup can skip the GetExportedTypes scans under TypeLoadMode.Static. The first three come
+        // from the already-built chains (no scan). Direct-mapped services receive no chain, so — like
+        // the conventional message types in the handler manifest — they require a scan, performed only
+        // while actually generating code (BuildFiles is also enumerated during a Static-mode attach,
+        // where a scan would defeat the purpose).
+        var directMapped = DynamicCodeBuilder.WithinCodegenCommand
+            ? WolverineGrpcExtensions.FindGrpcServiceTypes(_options.Assemblies, this)
+            : [];
+
         var registry = new GrpcServiceRegistryCodeFile(
             _chains.Select(x => x.StubType),
             _codeFirstChains.Select(x => x.ServiceContractType),
-            _handWrittenChains.Select(x => x.ServiceClassType));
+            _handWrittenChains.Select(x => x.ServiceClassType),
+            directMapped);
 
         return [.._chains, .._codeFirstChains, .._handWrittenChains, registry];
     }
diff --git a/src/Wolverine.Grpc/GrpcServiceRegistry.cs b/src/Wolverine.Grpc/GrpcServiceRegistry.cs
index 1598e6266..ae224006d 100644
--- a/src/Wolverine.Grpc/GrpcServiceRegistry.cs
+++ b/src/Wolverine.Grpc/GrpcServiceRegistry.cs
@@ -31,6 +31,15 @@ public abstract class GrpcServiceRegistry
     /// <summary>Hand-written service class types discovered at <c>codegen write</c> time.</summary>
     public abstract Type[] HandWrittenServiceTypes();
 
+    /// <summary>
+    ///     Direct-mapped hand-written service class types discovered at <c>codegen write</c> time — the
+    ///     concrete <c>*GrpcService</c>/<c>[WolverineGrpcService]</c> classes that receive no generated
+    ///     wrapper and are mapped directly by <c>MapWolverineGrpcServices()</c> (see
+    ///     <c>WolverineGrpcExtensions.FindGrpcServiceTypes</c>). Captured so that map-time discovery can
+    ///     skip its assembly scan too.
+    /// </summary>
+    public abstract Type[] DirectMappedServiceTypes();
+
     // 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",
@@ -72,14 +81,17 @@ internal class GrpcServiceRegistryCodeFile : ICodeFile
     private readonly Type[] _protoFirstStubTypes;
     private readonly Type[] _codeFirstContractTypes;
     private readonly Type[] _handWrittenServiceTypes;
+    private readonly Type[] _directMappedServiceTypes;
     private GeneratedType? _generatedType;
 
     public GrpcServiceRegistryCodeFile(IEnumerable<Type> protoFirstStubTypes,
-        IEnumerable<Type> codeFirstContractTypes, IEnumerable<Type> handWrittenServiceTypes)
+        IEnumerable<Type> codeFirstContractTypes, IEnumerable<Type> handWrittenServiceTypes,
+        IEnumerable<Type> directMappedServiceTypes)
     {
         _protoFirstStubTypes = onlyPublic(protoFirstStubTypes);
         _codeFirstContractTypes = onlyPublic(codeFirstContractTypes);
         _handWrittenServiceTypes = onlyPublic(handWrittenServiceTypes);
+        _directMappedServiceTypes = onlyPublic(directMappedServiceTypes);
     }
 
     private static Type[] onlyPublic(IEnumerable<Type> types)
@@ -99,7 +111,8 @@ void ICodeFile.AssembleTypes(GeneratedAssembly assembly)
     {
         _generatedType = assembly.AddType(GrpcServiceRegistry.GeneratedTypeName, typeof(GrpcServiceRegistry));
 
-        foreach (var type in _protoFirstStubTypes.Concat(_codeFirstContractTypes).Concat(_handWrittenServiceTypes))
+        foreach (var type in _protoFirstStubTypes.Concat(_codeFirstContractTypes)
+                     .Concat(_handWrittenServiceTypes).Concat(_directMappedServiceTypes))
         {
             assembly.ReferenceAssembly(type.Assembly);
         }
@@ -112,6 +125,9 @@ void ICodeFile.AssembleTypes(GeneratedAssembly assembly)
 
         _generatedType.MethodFor(nameof(GrpcServiceRegistry.HandWrittenServiceTypes))
             .Frames.Add(new WriteServiceTypesFrame(_handWrittenServiceTypes));
+
+        _generatedType.MethodFor(nameof(GrpcServiceRegistry.DirectMappedServiceTypes))
+            .Frames.Add(new WriteServiceTypesFrame(_directMappedServiceTypes));
     }
 
     Task<bool> ICodeFile.AttachTypes(GenerationRules rules, Assembly assembly, IServiceProvider? services,
diff --git a/src/Wolverine.Grpc/WolverineGrpcExtensions.cs b/src/Wolverine.Grpc/WolverineGrpcExtensions.cs
index ac78206bf..97338fa32 100644
--- a/src/Wolverine.Grpc/WolverineGrpcExtensions.cs
+++ b/src/Wolverine.Grpc/WolverineGrpcExtensions.cs
@@ -133,8 +133,9 @@ public static IEndpointRouteBuilder MapWolverineGrpcServices(this IEndpointRoute
         }
 
         // Map hand-written classes that have NO generated wrapper directly (i.e. those not
-        // claimed by a HandWrittenGrpcServiceChain in the graph).
-        foreach (var type in FindDirectMappedServiceTypes(assemblies, graph))
+        // claimed by a HandWrittenGrpcServiceChain in the graph). Under TypeLoadMode.Static this reads
+        // the pre-generated manifest instead of scanning assemblies (GH-2907).
+        foreach (var type in ResolveDirectMappedServiceTypes(runtime, assemblies, graph))
         {
             MapGrpcServiceMethod.MakeGenericMethod(type).Invoke(null, [endpoints]);
         }
@@ -228,6 +229,23 @@ public static IEnumerable<Type> FindGrpcServiceTypes(IEnumerable<Assembly> assem
         return FindDirectMappedServiceTypes(assemblies, graph);
     }
 
+    // Cold-start fast path (GH-2907): in TypeLoadMode.Static, read the direct-mapped service types from
+    // the pre-generated GrpcServiceRegistry instead of scanning assemblies. The manifest already excludes
+    // wrapper-claimed types (it was captured via FindGrpcServiceTypes at codegen write time). Never
+    // applies during `codegen write` itself — that must run a fresh scan to regenerate the registry.
+    private static IEnumerable<Type> ResolveDirectMappedServiceTypes(WolverineRuntime runtime,
+        IEnumerable<Assembly> assemblies, GrpcGraph? graph)
+    {
+        if (!DynamicCodeBuilder.WithinCodegenCommand &&
+            runtime.Options.CodeGeneration.TypeLoadMode == TypeLoadMode.Static &&
+            GrpcServiceRegistry.TryLoad(runtime.Options.ApplicationAssembly, out var registry))
+        {
+            return registry!.DirectMappedServiceTypes();
+        }
+
+        return FindDirectMappedServiceTypes(assemblies, graph);
+    }
+
     private static IEnumerable<Type> FindDirectMappedServiceTypes(IEnumerable<Assembly> assemblies,
         GrpcGraph? graph)
     {


Test patch:
diff --git a/src/Wolverine.Grpc.Tests/grpc_direct_mapped_manifest.cs b/src/Wolverine.Grpc.Tests/grpc_direct_mapped_manifest.cs
new file mode 100644
index 000000000..0268932ac
--- /dev/null
+++ b/src/Wolverine.Grpc.Tests/grpc_direct_mapped_manifest.cs
@@ -0,0 +1,52 @@
+using JasperFx;
+using JasperFx.CodeGeneration;
+using JasperFx.CodeGeneration.Model;
+using JasperFx.CodeGeneration.Services;
+using JasperFx.Core.Reflection;
+using Microsoft.Extensions.DependencyInjection;
+using Shouldly;
+using Xunit;
+
+namespace Wolverine.Grpc.Tests;
+
+// GH-2907: the fourth gRPC discovery mode — direct-mapped hand-written services (mapped without a
+// generated wrapper by MapWolverineGrpcServices) — is now captured in the GrpcServiceRegistry manifest.
+// This compiles the registry code file and verifies the new DirectMappedServiceTypes() accessor
+// round-trips the captured types (emit -> compile -> read).
+public class grpc_direct_mapped_manifest
+{
+    [Fact]
+    public void registry_round_trips_direct_mapped_service_types()
+    {
+        // Same minimal codegen harness as the HTTP manifest test; AssemblyGenerator is qualified inline
+        // to avoid importing JasperFx.RuntimeCompiler (whose obsolete InitializeSynchronously extension
+        // would collide with the JasperFx.CodeGeneration one used below).
+        var registry = new ServiceCollection();
+        registry.AddTransient<IServiceVariableSource>(c =>
+            new ServiceCollectionServerVariableSource((ServiceContainer)c.GetRequiredService<IServiceContainer>()));
+        registry.AddSingleton<IServiceCollection>(registry);
+        registry.AddSingleton<IServiceContainer, ServiceContainer>();
+        registry.AddSingleton<IAssemblyGenerator, JasperFx.RuntimeCompiler.AssemblyGenerator>();
+
+        var container = registry.BuildServiceProvider().GetRequiredService<IServiceContainer>();
+        var parent = new GrpcGraph(new WolverineOptions { ApplicationAssembly = GetType().Assembly }, container);
+
+        // Only the direct-mapped slot is populated.
+        var codeFile = new GrpcServiceRegistryCodeFile([], [], [], [typeof(SampleManifestService)]);
+
+        codeFile.As<ICodeFile>().InitializeSynchronously(parent.Rules, parent, parent.Container.Services);
+
+        codeFile.RegistryType.ShouldNotBeNull();
+
+        var instance = (GrpcServiceRegistry)Activator.CreateInstance(codeFile.RegistryType!)!;
+
+        instance.DirectMappedServiceTypes().ShouldBe([typeof(SampleManifestService)]);
+        instance.ProtoFirstStubTypes().ShouldBeEmpty();
+        instance.CodeFirstContractTypes().ShouldBeEmpty();
+        instance.HandWrittenServiceTypes().ShouldBeEmpty();
+    }
+}
+
+// Plain type used only as a manifest payload — deliberately NOT named *GrpcService and not attributed,
+// so it is never picked up by gRPC service discovery in other tests.
+public class SampleManifestService;
diff --git a/src/Wolverine.Grpc.Tests/grpc_service_manifest.cs b/src/Wolverine.Grpc.Tests/grpc_service_manifest.cs
index c1ae8c0f2..0a225e301 100644
--- a/src/Wolverine.Grpc.Tests/grpc_service_manifest.cs
+++ b/src/Wolverine.Grpc.Tests/grpc_service_manifest.cs
@@ -2,9 +2,11 @@
 using JasperFx;
 using JasperFx.CodeGeneration;
 using JasperFx.CodeGeneration.Model;
+using JasperFx.Core.Reflection;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Hosting;
 using Shouldly;
+using Wolverine.Runtime;
 using Xunit;
 
 namespace Wolverine.Grpc.Tests;
@@ -46,13 +48,25 @@ public async Task generated_registry_captures_discovered_service_types()
             registryFile.AssembleTypes(generatedAssembly);
             var code = generatedAssembly.GenerateCode(serviceVariableSource);
 
-            // All three discovery flavors get their own accessor...
+            // All four discovery flavors get their own accessor (GH-2907 adds direct-mapped)...
             code.ShouldContain("ProtoFirstStubTypes()");
             code.ShouldContain("CodeFirstContractTypes()");
             code.ShouldContain("HandWrittenServiceTypes()");
+            code.ShouldContain("DirectMappedServiceTypes()");
 
             // ...and the discovered proto-first stub type is captured.
             code.ShouldContain(nameof(GreeterGrpcService));
+
+            // Satellite parity (GH-2907): every direct-mapped service the live map-time scan would find
+            // across the registered assemblies — including the referenced GreeterProtoFirstGrpc.Server
+            // satellite — is captured in the manifest, so MapWolverineGrpcServices can skip its scan
+            // under TypeLoadMode.Static.
+            var assemblies = ((WolverineRuntime)host.Services.GetRequiredService<IWolverineRuntime>())
+                .Options.Assemblies;
+            foreach (var type in WolverineGrpcExtensions.FindGrpcServiceTypes(assemblies, graph))
+            {
+                code.ShouldContain($"typeof({type.FullNameInCode()})");
+            }
         }
         finally
         {