-
Notifications
You must be signed in to change notification settings - Fork 0
Description
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.csfiles 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:
- Command: request → response
- Notification: fire-and-forget fan-out
- 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, controlledNotifications
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, controlledNotes
- 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.csAppDispatcher.Builder.g.csAppDispatcher.DispatchTables.g.csAppDispatcher.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)
Streamis 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:
PKD001Missing handler for command request typePKD002Multiple handlers for command request typePKD003Multiple stream handlers for stream request typePKD004Handler signature invalidPKD005Streaming enabled but unsupported target framework (if applicable)PKD006Invalid[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.