Source Generator
UnambitiousFx.Synapse.Generator is a Roslyn incremental source generator that reads your handler class attributes and emits a registration class at compile time. This eliminates manual RegisterRequestHandler / RegisterEventHandler calls and makes the codebase NativeAOT-compatible by capturing dispatch delegates without reflection.
Install
dotnet add package UnambitiousFx.Synapse.Generator
No further setup is needed — the generator runs automatically during the build.
Mark your handlers
Annotate each handler class with the corresponding attribute:
[RequestHandler<TRequest, TResponse>] — for IRequest<TResponse>
using UnambitiousFx.Synapse.Abstractions;
[RequestHandler<CreateTaskCommand, Guid>]
public class CreateTaskHandler : IRequestHandler<CreateTaskCommand, Guid>
{
public async ValueTask<Result<Guid>> HandleAsync(CreateTaskCommand cmd, CancellationToken ct = default)
{
// ...
return Result.Success(Guid.NewGuid());
}
}
[RequestHandler<TRequest>] — for IRequest (no response)
[RequestHandler<DeleteTaskCommand>]
public class DeleteTaskHandler : IRequestHandler<DeleteTaskCommand>
{
public async ValueTask<Result> HandleAsync(DeleteTaskCommand cmd, CancellationToken ct = default)
{
// ...
return Result.Success();
}
}
[EventHandler<TEvent>] — for IEventHandler<TEvent>
[EventHandler<TaskCreatedEvent>]
public class AuditTaskCreated : IEventHandler<TaskCreatedEvent>
{
public ValueTask<Result> HandleAsync(TaskCreatedEvent @event, CancellationToken ct = default)
{
// ...
return ValueTask.FromResult(Result.Success());
}
}
[StreamRequestHandler<TRequest, TItem>] — for IStreamRequestHandler<TRequest, TItem>
[StreamRequestHandler<StreamTasksQuery, TaskDto>]
public class StreamTasksQueryHandler : IStreamRequestHandler<StreamTasksQuery, TaskDto>
{
public async IAsyncEnumerable<Result<TaskDto>> HandleAsync(
StreamTasksQuery query,
[EnumeratorCancellation] CancellationToken ct = default)
{
// ...
yield return Result.Success(new TaskDto());
}
}
Generated class
By default the generator produces one class named RegisterGroup in your assembly's root namespace
— the MSBuild RootNamespace property (which may differ from the assembly name, e.g. assembly App
with <RootNamespace>Company.App</RootNamespace>). Prefer a specific namespace, name, or accessibility?
See Customize the generated class below.
RegisterGroup
Implements both IRegisterGroup and IEventDispatcherRegistration. Contains one RegisterXxxHandler<...>() call per annotated handler, plus one AOT-safe dispatch delegate per IEvent type found in the assembly:
// Generated — do not edit
public sealed class RegisterGroup : IRegisterGroup, IEventDispatcherRegistration
{
public void Register(IDependencyInjectionBuilder builder)
{
builder.RegisterRequestHandler<CreateTaskHandler, CreateTaskCommand, Guid>();
builder.RegisterRequestHandler<DeleteTaskHandler, DeleteTaskCommand>();
builder.RegisterEventHandler<AuditTaskCreated, TaskCreatedEvent>();
builder.RegisterStreamRequestHandler<StreamTasksQueryHandler, StreamTasksQuery, TaskDto>();
}
public void RegisterDispatchers(Action<Type, DispatchEventDelegate> register)
{
register(
typeof(TaskCreatedEvent),
static (e, dispatcher, ct) => dispatcher.DispatchAsync((TaskCreatedEvent)e, ct));
}
}
Wire up the generated class
Pass the generated class to AddSynapse:
services.AddSynapse(cfg =>
{
cfg.AddRegisterGroup(new RegisterGroup());
});
AddRegisterGroup automatically detects that RegisterGroup implements IEventDispatcherRegistration and registers the AOT-safe dispatch delegates — no second call needed.
:::caution NativeAOT requirement
RegisterGroup implements IEventDispatcherRegistration, so the single AddRegisterGroup call is sufficient for NativeAOT. Without the source generator, event dispatch falls back to runtime typeof(T) resolution which the AOT compiler cannot analyze.
:::
Customize the generated class
To choose the namespace, name, or accessibility of the generated class, declare your own partial
class and mark it with [RegisterGroup]. The generator then fills that class in as a partial —
implementing IRegisterGroup and IEventDispatcherRegistration — instead of emitting the default
RegisterGroup in the root namespace:
using UnambitiousFx.Synapse.Abstractions;
namespace Company.App.Composition;
[RegisterGroup]
public partial class AppRegistrations; // the generator supplies the implementation
Wire it up by its own name:
services.AddSynapse(cfg =>
{
cfg.AddRegisterGroup(new AppRegistrations());
});
Rules:
- The class must be a top-level, non-generic
partial class(diagnosticsMDG016/MDG017otherwise). - Declare at most one
[RegisterGroup]class per assembly (diagnosticMDG015otherwise). - The generator emits no accessibility modifier on its partial, so the visibility (
public,internal, …) is whatever you declare on your own partial.
When no [RegisterGroup] class is present, the default public sealed class RegisterGroup in the root
namespace is generated as shown above.
Incremental generation
The generator is an IIncrementalGenerator — it only re-generates when a handler class is added, removed, or its attributes change. Build times are not affected significantly in large solutions.
See also
- Commands and Queries —
IRegisterGroupand modular registration. - Events —
IEventDispatcherRegistrationand polymorphic dispatch. - ASP.NET Core Integration — NativeAOT setup with
CreateSlimBuilder.