Skip to content

[Feature] Source-generated message dispatcher (commands, notifications, streams) with zero PatternKit runtime dependency + dual-mode API (class-based + fluent) #43

@JerrettDavis

Description

@JerrettDavis

Summary

We want a source generator that produces a standalone message dispatcher for an application:

  • Commands (request → response)
  • Notifications (fan-out)
  • Streams (request → async stream of items) — first-class, not an afterthought
  • Pipelines (pre/around/post hooks)

Critical requirement

The generated output must be 100% independent of PatternKit at runtime:

  • consuming projects should be able to ship the generated dispatcher without referencing PatternKit
  • the generator package may depend on PatternKit and Roslyn, but generated .g.cs files may not

The goal is to let teams write “transpilable” / standardized message flows with minimal manual wiring, while keeping the runtime surface small, fast, and distinct.


Goals

G1 — Generated runtime has no dependency on PatternKit

  • No using PatternKit.* in emitted code
  • No generated types referencing PatternKit interfaces/delegates/helpers/collections
  • The generated output compiles as a clean unit with only BCL dependencies (and optional DI adapter packages if enabled)

G2 — First-class async and streaming

  • Command handlers are async-first (ValueTask)
  • Notification handlers are async-first (ValueTask)
  • Stream requests are native: IAsyncEnumerable<T> and streaming pipelines are supported

G3 — Dual-mode consumption

The generated dispatcher supports both:

Mode A: class-based, structured (framework-like)

  • request/handler types
  • composable pipelines (behaviors)
  • deterministic ordering
  • explicit diagnostics when invalid

Mode B: lightweight, fluent (minimal ceremony)

  • register handlers via delegates
  • module-style wiring
  • minimal generics in the app code
  • easy to read in Program.cs

G4 — AOT-friendly, low-overhead runtime

  • no reflection-based dispatch
  • no runtime scanning required
  • deterministic dispatch tables generated at compile time
  • avoid per-invocation allocations where possible

Non-goals

  • We’re not building a “kitchen sink integration platform”.
  • We’re not forcing a specific DI container.
  • We’re not requiring runtime assembly scanning.
  • We’re not trying to be API-compatible with anything else; we want our own identity.

Desired Public API (generated)

Core concepts

We want three categories of messages:

  1. Command: request → response
  2. Notification: fire-and-forget fan-out
  3. Stream: request → async stream of items

And a unified dispatcher surface:

namespace MyApp.Messaging;

// Generated, dependency-free runtime type
public sealed partial class AppDispatcher
{
    // Commands
    public ValueTask<TResponse> Send<TRequest, TResponse>(TRequest request, CancellationToken ct = default);

    // Notifications
    public ValueTask Publish<TNotification>(TNotification notification, CancellationToken ct = default);

    // Streams (first-class)
    public IAsyncEnumerable<TItem> Stream<TRequest, TItem>(TRequest request, CancellationToken ct = default);
}

Overloads and “ergonomics”

We want overloads that cover common needs and reduce ceremony, while staying distinct:

Commands

public ValueTask<TResponse> Send<TResponse>(object request, CancellationToken ct = default);
public ValueTask<object?> Send(object request, CancellationToken ct = default); // optional, controlled

Notifications

public ValueTask Publish(object notification, CancellationToken ct = default);

Streams

public IAsyncEnumerable<TItem> Stream<TItem>(object request, CancellationToken ct = default);
public IAsyncEnumerable<object?> Stream(object request, CancellationToken ct = default); // optional, controlled

Notes

  • Object-based overloads are optional, behind config flags, but they are extremely useful for bridging layers (RPC, HTTP endpoints, file-based DSL).
  • We should strongly prefer generic overloads in examples and docs.

Pipeline behavior model (commands + streams)

We want a pipeline model that can be used in both class-based and fluent modes.

Command pipeline

  • Pre hook(s): run before handler
  • Around hook(s): wrap handler invocation (compose)
  • Post hook(s): run after handler succeeds (and optionally after failure)

Proposed delegates:

public delegate ValueTask<TResponse> CommandNext<TResponse>();

public interface ICommandPipeline<TRequest, TResponse>
{
    ValueTask Pre(TRequest request, CancellationToken ct);
    ValueTask<TResponse> Around(TRequest request, CancellationToken ct, CommandNext<TResponse> next);
    ValueTask Post(TRequest request, TResponse response, CancellationToken ct);

    // Optional error hook
    ValueTask OnError(TRequest request, Exception ex, CancellationToken ct);
}

Stream pipeline

Streaming needs its own pipeline because it has different semantics:

  • Pre: before stream starts
  • Around: wrap the stream enumeration (must handle lazy execution)
  • Post: when stream completes normally
  • OnError: when enumeration throws
public delegate IAsyncEnumerable<TItem> StreamNext<TItem>();

public interface IStreamPipeline<TRequest, TItem>
{
    ValueTask Pre(TRequest request, CancellationToken ct);
    IAsyncEnumerable<TItem> Around(TRequest request, CancellationToken ct, StreamNext<TItem> next);
    ValueTask Post(TRequest request, CancellationToken ct);
    ValueTask OnError(TRequest request, Exception ex, CancellationToken ct);
}

Key requirement: streaming pipelines must be able to wrap enumeration without forcing buffering.


Handler model (class-based mode)

We want conventional interfaces (defined in the generated runtime or a small “Contracts” package) that are independent:

Command handler

public interface ICommandHandler<TRequest, TResponse>
{
    ValueTask<TResponse> Handle(TRequest request, CancellationToken ct);
}

Notification handler

public interface INotificationHandler<TNotification>
{
    ValueTask Handle(TNotification notification, CancellationToken ct);
}

Stream handler

public interface IStreamHandler<TRequest, TItem>
{
    IAsyncEnumerable<TItem> Handle(TRequest request, CancellationToken ct);
}

Fluent registration model (lightweight mode)

We want a builder with a minimal, declarative feel:

var dispatcher = AppDispatcher.Create()
    .Command<Ping, Pong>((Ping req, CancellationToken ct) => new ValueTask<Pong>(new Pong(...)))
    .Notification<UserCreated>((UserCreated n, CancellationToken ct) => ValueTask.CompletedTask)
    .Stream<SearchQuery, SearchHit>((SearchQuery q, CancellationToken ct) => Search(q, ct))

    // Command pipelines
    .Pre<Ping>((Ping req, CancellationToken ct) => ValueTask.CompletedTask)
    .Around<Ping, Pong>((Ping req, CancellationToken ct, CommandNext<Pong> next) => next())
    .Post<Ping, Pong>((Ping req, Pong res, CancellationToken ct) => ValueTask.CompletedTask)

    // Stream pipelines
    .StreamPre<SearchQuery>((SearchQuery q, CancellationToken ct) => ValueTask.CompletedTask)
    .StreamAround<SearchQuery, SearchHit>((SearchQuery q, CancellationToken ct, StreamNext<SearchHit> next) => next())
    .StreamPost<SearchQuery>((SearchQuery q, CancellationToken ct) => ValueTask.CompletedTask)

    .Build();

Builder requirements

  • deterministic ordering rules (documented)

  • allow registering:

    • concrete handler instances
    • factories
    • delegates
  • allow “module” registration:

    • .AddModule(IMessagingModule module)
    • .AddModule(Action<DispatcherBuilder> configure)

Source generator input model

We need a deterministic way to define what gets generated.

Option A (recommended): assembly marker

[assembly: GenerateDispatcher(
    Namespace = "MyApp.Messaging",
    Name = "AppDispatcher",
    IncludeObjectOverloads = false,
    IncludeStreaming = true,
    Visibility = GeneratedVisibility.Public
)]

Discovery rules (v1)

The generator should discover handlers by:

  • finding implementations of our handler interfaces
  • building a dispatch table (request type → handler invoker)
  • building a multicast map for notifications
  • building a stream map for stream requests

No runtime scanning is required; everything is compiled into generated code.


Generated runtime structure (no PatternKit dependency)

The generator emits (example):

  • AppDispatcher.g.cs
  • AppDispatcher.Builder.g.cs
  • AppDispatcher.DispatchTables.g.cs
  • AppDispatcher.Pipeline.g.cs
  • optionally AppDispatcher.Contracts.g.cs (or separate “Contracts” package if we prefer)

Hard rule: the generated code cannot reference PatternKit types.


Behavioral rules

Commands

  • exactly one handler per request type (diagnostic if 0 or >1)
  • missing handler at runtime throws a clear exception (unless configured to return a failure result type)

Notifications

  • 0..N handlers supported

  • publishing with 0 handlers is a no-op

  • handler execution strategy must be explicit/configurable:

    • sequential (default)
    • parallel (optional)

Streams

  • exactly one stream handler per stream request type (diagnostic if 0 or >1)
  • Stream is lazy; pipelines must not force buffering
  • cancellation must flow into handler and enumeration

Pipelines

  • deterministic ordering

  • separate pipeline paths for commands vs streams

  • clear semantics for OnError:

    • command: can choose rethrow vs swallow vs fallback (configurable later)
    • stream: must rethrow by default (enumeration semantics)

Diagnostics (must-have)

Stable generator diagnostics:

  • PKD001 Missing handler for command request type
  • PKD002 Multiple handlers for command request type
  • PKD003 Multiple stream handlers for stream request type
  • PKD004 Handler signature invalid
  • PKD005 Streaming enabled but unsupported target framework (if applicable)
  • PKD006 Invalid [GenerateDispatcher] configuration

Diagnostics must point at the offending symbol.


Testing expectations

Add TinyBDD tests covering:

Commands

  • happy path send returns response
  • pipeline ordering pre/around/post
  • missing handler throws
  • multiple handlers yields diagnostic (generator test)

Notifications

  • fan-out to multiple handlers
  • 0 handlers is no-op
  • pipeline (if we support notification pipelines in v1; optional)

Streams (first-class)

  • stream yields items lazily
  • stream pipeline wraps enumeration correctly
  • cancellation interrupts enumeration
  • missing stream handler throws
  • multiple stream handlers yields diagnostic

Acceptance Criteria

Runtime independence

  • A consuming project can reference the generator as an analyzer and ship the generated dispatcher without PatternKit referenced at runtime.
  • Generated code contains no PatternKit types and no using PatternKit.*.

API completeness

  • Commands, notifications, and streams are supported, async-first.
  • Streaming is first-class: Stream<TRequest, TItem> and pipelines.
  • Fluent builder mode and class-based handler mode both work.

Performance & determinism

  • Dispatch uses generated tables (no reflection dispatch).
  • Deterministic ordering for pipelines and notification handlers.
  • No runtime assembly scanning required.

Test coverage

  • TinyBDD coverage for all three categories + pipelines + key failure modes.

Metadata

Metadata

Labels

enhancementNew feature or request

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions