Skip to main content

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 (diagnostics MDG016/MDG017 otherwise).
  • Declare at most one [RegisterGroup] class per assembly (diagnostic MDG015 otherwise).
  • 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