Source Generator
CritterWatch's swim-lane view (the Event Modeling diagram on each service page) needs to know which aggregates, handlers, and sagas live in your application. Rather than asking you to declare them or rely on reflection at runtime, CritterWatch ships a Roslyn source generator that scans your code at build time and emits a small manifest each service carries with it. At service start-up, Wolverine.CritterWatch reflects across every loaded assembly's manifest and merges the pieces into a unified model the UI consumes.
This page covers what the generator looks for, what it produces, how the runtime merge works, and how to debug it when a slice you expected to see isn't on the swim-lane.
What the generator scans
CritterWatch.SourceGeneration runs as an analyzer on every project that references Wolverine.CritterWatch. It walks the compilation looking for five patterns:
| # | Pattern | Notes |
|---|---|---|
| 1 | Aggregate-marker attributes on handler parameters — [WriteAggregate], [ReadAggregate], [ConsistentAggregate], [BoundaryModel] | Matched by base-chain walk, so the Wolverine.Http.Marten.AggregateAttribute subclass resolves to WriteAggregate. Both Wolverine.Marten and Wolverine.Polecat namespaces are honored. |
| 2 | Wolverine handler chains — methods named Handle/Handles/Consume/Consumes (and their Async forms), methods annotated [WolverineHandler], and any method on a Wolverine.Saga subclass | Public methods only. Static handlers must live on a class whose name ends in Handler or Consumer (matches Wolverine's own convention). Abstract classes are skipped — Wolverine resolves the concrete subclass at runtime and so does the generator. |
| 3 | Apply / When methods on aggregate types | The event the aggregate consumes is the first non-framework parameter (IQuerySession, IDocumentSession, CancellationToken, and IEvent are skipped). Populates AggregateDescriptor.AppliedEvents on the matching aggregate. |
| 4 | yield return new SomeEvent(...) constructions inside IEnumerable<object> / IAsyncEnumerable<object> handlers, plus return new SomeEvent(...), return Cascade(...), and slice.PublishMessage(new SomeEvent(...)) patterns | Appears in source order in the handler's EmittedEvents. The recursor descends into Cascade / PublishMessage / SendMessage args and array / tuple initializers. Constructions the generator can't statically resolve (helper-method calls, conditional ?:, dynamic dispatch) become a ? placeholder rather than a build failure. |
| 5 | Saga subclasses — classes that derive (directly or transitively) from Wolverine.Saga | Start / StartOrHandle + their Async forms bucket into StartingMessages; Handle / Consume / Orchestrate + their Async forms bucket into ContinuingMessages. StorageProvider is left null — the saga's persistence backend isn't statically resolvable from source. |
The generator matches all Wolverine and Wolverine.Marten / Wolverine.Polecat types by fully-qualified name (walking base-type / containing-namespace chains as needed). It carries no hard dependency on the Wolverine assembly — services that don't reference Wolverine.Marten still get correct emission for Wolverine.Polecat-attributed code, and vice versa.
What it emits
For each compilation, a single source file lands in the consumer's compilation:
CritterWatch.Generated.CritterWatchAppManifest.Aggregates.g.csIt contains an internal static partial class CritterWatchAppManifest with three public static readonly collections:
| Field | Type | Source |
|---|---|---|
Aggregates | IReadOnlyList<JasperFx.Events.EventModeling.AggregateDescriptor> | Patterns 1 + 3 |
HandlerRelationships | IReadOnlyList<JasperFx.Events.EventModeling.HandlerRelationshipDescriptor> | Patterns 2 + 4 |
Sagas | IReadOnlyList<JasperFx.Descriptors.SagaTypeDescriptor> | Pattern 5 |
The class is internal because the generator owns the type — nothing outside CritterWatch should reference the generated symbol directly. The fields are public static readonly so reflection from a different assembly (the runtime aggregator does this) can read them.
Each descriptor uses JasperFx.Descriptors.TypeDescriptor for type identity:
public sealed record TypeDescriptor(string Name, string FullName, string AssemblyName);FullName + AssemblyName is the merge key the runtime aggregator uses — same short Name in different namespaces never collides.
The manifest is per project. A solution with three Wolverine services produces three independent CritterWatchAppManifest partials, one per output assembly. The generator never merges across project boundaries — that's the runtime aggregator's job.
How runtime aggregation works
Wolverine.CritterWatch.EventModeling.ManifestAggregator runs at service start-up. The DI registration is wired automatically by AddCritterWatchMonitoring:
services.AddSingleton<MergedAppManifest>(_ => ManifestAggregator.MergeAll());The aggregator:
- Walks
AppDomain.CurrentDomain.GetAssemblies()(skipping dynamic / Roslyn-emitted runtime assemblies). - For each assembly, calls
Assembly.GetType("CritterWatch.Generated.CritterWatchAppManifest"). Assemblies without one contribute nothing — not an error. - Reflects the three public static readonly fields off each manifest type and reads the descriptor arrays.
- Deduplicates: aggregates by
Type.FullName, sagas bySagaType.FullName, handler relationships byHandlerType.FullName + "/" + MessageType.FullName. First write wins, and the assembly sweep is sorted by simple name so "first" is reproducible across host restarts. - Sorts the merged collections by FQN for stable UI ordering.
- Returns a
MergedAppManifestsnapshot the SignalR layer (and the swim-lane UI) resolves from DI.
The merge is cheap (~ a few microseconds per assembly) and runs once per process lifetime via the singleton registration.
MergedAppManifest.Model carries an EventModelDescriptor whose Slices collection is empty in this chip — the chip-21 swim-lane will wire user-declared EventModelDefinition instances through the aggregator alongside the generator-emitted aggregates / handlers / sagas.
How to verify the generator ran for a given project
The simplest check is the output assembly — load it and look for the generated type:
dotnet build YourService
# In a quick C# scratch:
var asm = Assembly.LoadFrom("YourService.dll");
var t = asm.GetType("CritterWatch.Generated.CritterWatchAppManifest");
Console.WriteLine(t == null ? "generator did not run" : "generator ran");To see the emitted source itself, add this to your .csproj and rebuild:
<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>$(BaseIntermediateOutputPath)Generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>After a build, the file appears at:
obj/Generated/CritterWatch.SourceGeneration/CritterWatch.SourceGeneration.AggregateManifestGenerator/CritterWatchAppManifest.Aggregates.g.csInspect the three collections to confirm your aggregates / handlers / sagas were picked up.
Debugging missing slices
If an aggregate, handler, or saga you expect to see on the swim-lane is missing, walk this checklist in order:
- Does the project reference
Wolverine.CritterWatch? The source generator only attaches whenWolverine.CritterWatch(or a project that transitively references it viaOutputItemType="Analyzer") is in the consumer's project file. A shared model assembly with no reference toWolverine.CritterWatchwon't run the generator — declare your aggregates in the service project that does, or add a direct reference there. - Did the generator actually run? Inspect
obj/Generated/...per the section above. No generated file means the analyzer isn't loading — typically a missingOutputItemType="Analyzer"orReferenceOutputAssembly="false"on the analyzer project reference. - Is the handler method
public? The generator only walks public methods.protected overrideWolverineMessageHandler<T>subclasses are skipped by design — Wolverine's runtime handler graph picks those up, but they're not part of the user-authored chain the swim-lane displays. - Does the static handler live on a class whose name ends in
HandlerorConsumer? Static handlers on differently-named classes need[WolverineHandler]on the method. - Is the aggregate type the actual parameter type? The generator unwraps
Nullable<T>andIEventStream<T>toT, but generic / interface parameters that aren't one of those wrappers don't qualify. - Was an emitted-event slot rendered as
?on the swim-lane? That's deliberate — ayield return BuildEvent(cmd)(helper method) orreturn cond ? a : b(conditional) can't be statically resolved. Inline the construction or replace the helper with the literalnew SomeEvent(...)if you want a typed arrow on the swim-lane. - Is the saga type a direct subclass of
Wolverine.Sagaor a transitive one? Both work — the generator walks the base chain. If your saga sits below an abstract intermediate, the intermediate is filtered (abstract classes are skipped wholesale) and only the concrete leaf appears.
Failure-mode contracts
The generator is allergic to build failures. Three contracts spell out the behaviour on edge cases:
- Empty manifest when no scope. A project that references
Wolverine.CritterWatchbut declares no aggregates / handlers / sagas still produces aCritterWatchAppManifestwith three empty arrays. The runtime aggregator's reflection sweep finds the type, contributes nothing, and the merge continues. nullplaceholder for unresolvable event constructions.yield return BuildHelper()orreturn cond ? new A() : new B()emit a placeholderTypeDescriptorwith an emptyFullName(renders as?on the swim-lane). The build does not fail — operators see the gap and can decide whether to refactor.- Cyclic types are not a problem. The generator's traversal is one-pass per syntax node, never recursive on type symbols. Self-referential aggregates (
record Order(Order? Parent, ...)) emit normally.
See also
- Event Sourcing — the broader event-sourcing story this manifest feeds into.
- Message Flow — how generator-emitted data reaches the dashboard via SignalR.
- CritterWatch#144 — the issue this generator closes; full design discussion.
