Skip to content

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:

#PatternNotes
1Aggregate-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.
2Wolverine handler chains — methods named Handle/Handles/Consume/Consumes (and their Async forms), methods annotated [WolverineHandler], and any method on a Wolverine.Saga subclassPublic 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.
3Apply / When methods on aggregate typesThe event the aggregate consumes is the first non-framework parameter (IQuerySession, IDocumentSession, CancellationToken, and IEvent are skipped). Populates AggregateDescriptor.AppliedEvents on the matching aggregate.
4yield return new SomeEvent(...) constructions inside IEnumerable<object> / IAsyncEnumerable<object> handlers, plus return new SomeEvent(...), return Cascade(...), and slice.PublishMessage(new SomeEvent(...)) patternsAppears 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.
5Saga subclasses — classes that derive (directly or transitively) from Wolverine.SagaStart / 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.cs

It contains an internal static partial class CritterWatchAppManifest with three public static readonly collections:

FieldTypeSource
AggregatesIReadOnlyList<JasperFx.Events.EventModeling.AggregateDescriptor>Patterns 1 + 3
HandlerRelationshipsIReadOnlyList<JasperFx.Events.EventModeling.HandlerRelationshipDescriptor>Patterns 2 + 4
SagasIReadOnlyList<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:

csharp
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:

csharp
services.AddSingleton<MergedAppManifest>(_ => ManifestAggregator.MergeAll());

The aggregator:

  1. Walks AppDomain.CurrentDomain.GetAssemblies() (skipping dynamic / Roslyn-emitted runtime assemblies).
  2. For each assembly, calls Assembly.GetType("CritterWatch.Generated.CritterWatchAppManifest"). Assemblies without one contribute nothing — not an error.
  3. Reflects the three public static readonly fields off each manifest type and reads the descriptor arrays.
  4. Deduplicates: aggregates by Type.FullName, sagas by SagaType.FullName, handler relationships by HandlerType.FullName + "/" + MessageType.FullName. First write wins, and the assembly sweep is sorted by simple name so "first" is reproducible across host restarts.
  5. Sorts the merged collections by FQN for stable UI ordering.
  6. Returns a MergedAppManifest snapshot 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:

bash
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:

xml
<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.cs

Inspect 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:

  1. Does the project reference Wolverine.CritterWatch? The source generator only attaches when Wolverine.CritterWatch (or a project that transitively references it via OutputItemType="Analyzer") is in the consumer's project file. A shared model assembly with no reference to Wolverine.CritterWatch won't run the generator — declare your aggregates in the service project that does, or add a direct reference there.
  2. Did the generator actually run? Inspect obj/Generated/... per the section above. No generated file means the analyzer isn't loading — typically a missing OutputItemType="Analyzer" or ReferenceOutputAssembly="false" on the analyzer project reference.
  3. Is the handler method public? The generator only walks public methods. protected override Wolverine MessageHandler<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.
  4. Does the static handler live on a class whose name ends in Handler or Consumer? Static handlers on differently-named classes need [WolverineHandler] on the method.
  5. Is the aggregate type the actual parameter type? The generator unwraps Nullable<T> and IEventStream<T> to T, but generic / interface parameters that aren't one of those wrappers don't qualify.
  6. Was an emitted-event slot rendered as ? on the swim-lane? That's deliberate — a yield return BuildEvent(cmd) (helper method) or return cond ? a : b (conditional) can't be statically resolved. Inline the construction or replace the helper with the literal new SomeEvent(...) if you want a typed arrow on the swim-lane.
  7. Is the saga type a direct subclass of Wolverine.Saga or 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.CritterWatch but declares no aggregates / handlers / sagas still produces a CritterWatchAppManifest with three empty arrays. The runtime aggregator's reflection sweep finds the type, contributes nothing, and the merge continues.
  • null placeholder for unresolvable event constructions. yield return BuildHelper() or return cond ? new A() : new B() emit a placeholder TypeDescriptor with an empty FullName (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.

Released under the MIT License.