Skip to content

Descriptors and the Wire

Every source of an Event Model — Wolverine's chains, the Bobcat generator, a source generator, your overlay — writes into the same two records, and every viewer reads from them. This page is the shape.

EventModelSliceDescriptor

One slice. The positional constructor is the original 2.x shape and is kept source- and binary-compatible; everything added since is an init property with a safe default, so older payloads and precompiled callers keep working.

SlotFilled byHolds
NameBothDisplay name; also the merge key across sources
PatternDerivedCommand, View, Automation or Translation
TriggerKindDerivedHttp, Grpc, MessageHandler, JobScheduler, Human, External
TriggerOriginDerivedHTTP verb + route, gRPC service + method, or a label
TriggerTypeDerivedCLR type of the trigger, e.g. an inbound request DTO
TriggerLabelOverlay"Agent clicks Close"
CommandTypeDerivedThe inbound message type
HandlerTypeDerivedThe handler or endpoint type — distinct from the aggregates
AggregateTypesDerivedProjected write models the handler decides against
EmittedEventsDerivedEvents the slice writes, in declaration order
PublishedMessagesDerivedNon-event messages — cascaded commands, integration messages
ProjectionTypesDerivedProjections consuming the slice's events
ReadModelTypesDerivedRead models the slice reads or produces
ExternalSystemsDerivedSystems on either end of a translation
SpecificationsDerived (mostly)Bound specs by {Feature}/{Scenario} plus resolved types
HotspotsBothPending specs (derived) and prose (overlay)
DomainOverlayBounded context

This is what a derived source produces for CloseIncident:

cs
// This is what a source builds — Wolverine reading its own HTTP chain for
// CloseIncidentEndpoint. You never hand-write this; it is here so you can see
// exactly which slots the overlay is *not* allowed to fill.
var derived = new EventModelSliceDescriptor(
    "CloseIncident",
    TriggerLabel: null,
    TriggerType: null,
    CommandType: TypeDescriptor.For(typeof(CloseIncident)),
    HandlerType: TypeDescriptor.For(typeof(CloseIncidentEndpoint)),
    EmittedEvents: [TypeDescriptor.For(typeof(IncidentClosed))],
    ProjectionTypes: [],
    ReadModelTypes: [TypeDescriptor.For(typeof(Incident))])
{
    Pattern = SlicePattern.Command,
    TriggerKind = TriggerKind.Http,
    TriggerOrigin = new PublisherOrigin
    {
        HttpMethod = "POST",
        HttpRoute = "/api/incidents/close/{id}",
        Label = "POST /api/incidents/close/{id}"
    },
    AggregateTypes = [TypeDescriptor.For(typeof(Incident))],
    PublishedMessages = [TypeDescriptor.For(typeof(ArchiveIncident))]
};

snippet source | anchor

EventModelDescriptor

The whole model: its Slices, the Aggregates those slices reference by type (each with its kind and applied events), and model-level Hotspots.

The rendering contract

Elements and Edges are computed from the typed roles on every read. They are not stored and cannot disagree with the roles underneath them; a deserializer simply ignores whatever arrived and recomputes.

Each element carries a deterministic id — {slice}/{kind}/{type full name or label} — a kind, a lane, a label, and the CLR type identity when it has one. Edges reference elements by that id.

cs
// Elements and Edges are computed from the typed roles on every read, so a viewer
// draws straight from the descriptor with no second transform
foreach (var element in slice.Elements)
{
    Console.WriteLine($"{element.Lane,-12} {element.Kind,-15} {element.Label} " +
                      $"({EventModelPalette.ColorFor(element.Kind)})");
}

foreach (var edge in slice.Edges)
{
    Console.WriteLine($"{edge.FromId} -> {edge.ToId}");
}

snippet source | anchor

EventModelPalette.ColorFor is the shared reference so two viewers of one descriptor agree on what a colour means:

KindLaneColour
TriggerWireframe#FFFFFF white
ExternalSystemWireframe#F8BBD0 pink
HotspotWireframe#E91E63 magenta
CommandCommand#5B9BD5 blue
HandlerCommand#5B9BD5 blue, outlined
AggregateCommand#FFF2A8 pale yellow
EventEventStream#F5A623 orange
MessageEventStream#5B9BD5 blue, dashed
ProjectionReadModel#7ED321 green, outlined
ReadModelReadModel#7ED321 green

Discovery and assembly

EventModelDiscovery walks every registered IEventModelDefinitionSource, asks each for its descriptor (skipping any that return null), and folds the results into one descriptor per model name:

cs
// Ask every registered source — Wolverine's chains, the Bobcat generator, your
// overlays — for its view, then fold them into one descriptor per model name
var models = await EventModelDiscovery.AssembleAsync(services);

var helpdesk = models.Single(x => x.Name == "Helpdesk");

foreach (var slice in helpdesk.Slices)
{
    Console.WriteLine($"{slice.Domain}/{slice.Name}: {slice.Pattern}");

    foreach (var hotspot in slice.Hotspots)
    {
        Console.WriteLine($"  ⚠ {hotspot.Origin}: {hotspot.Text}");
    }
}

// Questions that belong to the model rather than to one slice
foreach (var hotspot in helpdesk.Hotspots)
{
    Console.WriteLine($"⚠ {hotspot.Text}");
}

snippet source | anchor

Sources are enumerated in registration order, and Merge lets earlier sources win on scalars — so register derived sources before overlays. Concretely:

  • Scalars keep the first non-null value.
  • Lists are unioned in order and deduplicated by identity: types by full name, external systems by direction + name, specifications by identity, hotspots by origin + text.
  • Slices fold by name; slice order is first appearance.
  • Aggregates union by type full name.

Merging two slices with different names throws — slices merge by name, and a mismatch means a bug in whoever assembled the list.

Serialization

The descriptors are plain records and serialize with System.Text.Json as-is. CritterWatch's wire shape is camelCase with camelCase string enums:

cs
var options = new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
};

var json = JsonSerializer.Serialize(model, options);

snippet source | anchor

Because Elements and Edges are computed properties, they go out on the wire — a viewer gets the rendering contract without a second transform — and are ignored coming back in.

Released under the MIT License.