Handlers
Handlers are Elarion's primary use-case unit — a request in, a Result out, with no transport concerns.
A handler is the primary application use-case unit. It receives a request object and returns a
response, usually a Result<T>. Handlers contain business
orchestration — not transport, serialization, or HTTP concerns.
What is a use case?
A use case is a single thing your application can do, named from the user's point of view: "get a client", "create an order", "cancel a subscription". It is one complete unit of behavior — it takes some input, does the work (reads or changes data, applies the rules), and produces a result. That is the whole idea.
It is deliberately a smaller thing than a "service", a "controller", or a "screen". A use case is not an HTTP endpoint, a database table, or a class bundling loosely related methods — it is exactly one action. If you can phrase it as a short verb-plus-noun — get client, list invoices, approve request — it is most likely a single use case.
If you have read about Clean Architecture, Hexagonal/Ports-and-Adapters, or vertical slice designs, a use case is the same thing those call an interactor, an application-service method, or a slice. You need none of that background to use Elarion — the plain definition above is all that matters here.
How a use case materializes in Elarion
In Elarion, one use case is one handler class. The three parts of the definition map directly onto the class:
- the input is the handler's request type (the nested
QueryorCommand), - the work is the body of
HandleAsync, - the output is the
Result<T>it returns.
Because the handler is a plain class — not tied to HTTP, JSON-RPC, MCP, or any specific database
framework — the same use case can be exposed over several transports
or called straight from a test, unchanged. The GetClient handler below is the entire "get a client"
use case: input (Query), work (the EF Core query), output (Result<Response>).
using Elarion.Abstractions;
using Microsoft.EntityFrameworkCore;
[Handler("clients.get")]
public sealed class GetClient(AppDbContext db)
: IHandler<GetClient.Query, Result<GetClient.Response>> {
public sealed record Query(Guid Id);
public sealed record Response(Guid Id, string Name);
public async ValueTask<Result<Response>> HandleAsync(Query query, CancellationToken ct) {
var client = await db.Clients
.Where(c => c.Id == query.Id)
.Select(c => new Response(c.Id, c.Name))
.FirstOrDefaultAsync(ct);
if (client is null) {
return AppError.NotFound($"Client {query.Id} was not found.");
}
return client;
}
}The contract
A handler implements IHandler<TRequest, TResponse>:
public interface IHandler<in TRequest, TResponse> {
ValueTask<TResponse> HandleAsync(TRequest request, CancellationToken ct);
}ValueTask<TResponse> keeps synchronous and cached paths allocation-free. Constructor parameters are
injected by DI through the generated handler factory.
Conventions
The generators discover and wire handlers from their shape and location:
- The class lives under a module namespace so the module owns its registration.
- It implements
IHandler<TRequest, TResponse>. - For transports (JSON-RPC, HTTP, MCP) the handler returns
Result<TResponse>; the request and response types are read from theIHandler<,>interface itself, so they may be declared anywhere — nesting them (asCommand/Query/Response) is an organizational convention, not a requirement. See JSON-RPC usage.
A handler exposes itself over the dispatcher transports with [Handler]. The operation name is
optional: with [Handler("clients.get")] you name it explicitly; with a bare [Handler] the name
is inferred by convention as {module}.{operation}, where operation is the handler type name with a
trailing Handler/Command/Query/Request suffix removed and the rest camelCased — so a
CreateClient handler in the Clients module becomes clients.createClient. Prefer an explicit name
for stable public or wire-facing contracts.
The handler registration generator emits an Add{HandlerName}() method and aggregates them into the
module's Add{Module}Handlers() method. You never register a handler by hand.
Lifetime and telemetry
A handler is registered scoped by default — one instance per dispatch scope, the classical unit-of-work-per-message shape. Two opt-in declarations tune the hot path (ADR-0066):
[Handler(Scope = ServiceScope.Singleton)]— the same vocabulary as[Service(Scope = …)]— removes scope participation from dispatch entirely: the chain is built once from the root provider. It is compile-time verified: every constructor dependency must be provably singleton (ELSG011/ELSG012— the framework knows its own service lifetimes and reads[Service(Scope = …)]declarations in the compilation; anything unverifiable is an error, deliberately without an escape hatch), and the pipeline must not attach scope-dependent features such as transactions, idempotency, authorization, validation, caching, or auditing (ELSG013). Per-caller log enrichment is definitionally unavailable on a singleton handler; its span and execution metric remain.[Resilient]composes fine.[HandlerTelemetry(HandlerTelemetryMode.None)]— on the handler, the module class, or the assembly (nearest wins) — makes the generator not emit the observability decorator at all: no span, metric, enrichment, or log scope, and no decorator object per call. A cold handler inside an opted-down module re-enables by declaringFullon itself. Failures are unaffected.
Both exist for high-rate dispatch (a game-server-like connection); a classical web handler keeps the defaults. See the low-allocation dispatch profile.
Request-driven server streams
A cold, request-driven operation with a deferred response implements IStreamHandler<TRequest, TItem>, not
IHandler. Its upfront Result<IAsyncEnumerable<TItem>> accepts or rejects before a transport commits its
response; thereafter items remain lazy and completion, cancellation, and faults belong to the enumerator. Use it
when one request owns one response sequence; see request-driven server streaming
for the full choice between this contract, client events, actor-owned StreamHub, and connections.
Generated stream registration is module-gated like unary handlers, but has an independent pipeline. A mixed
[DecoratorList] retains only decorators implementing IStreamHandler<,> and preserves their relative order.
Authorization, feature gates, validation, observability, and context enrichment apply; caching, idempotency,
transactions, audit, and unary resilience do not. Use StreamHandlerInvoker.InvokeAsync in a typed custom
transport: its accepted invocation owns the DI scope until enumeration completes, faults, cancels, or is disposed.
For SSE, bind the request in a direct minimal-API MapGet lambda and return
ElarionHttpResults.ToStreamResult<TRequest, TItem>(request). Keeping MapGet in the host compilation
leaves route and query binding to ASP.NET Core (hand-written call sites are the ones its Request Delegate
Generator can compile ahead of time in an AOT host); the lazy result owns decorated handler invocation,
canonical JSON, startup-error translation, and native TypedResults.ServerSentEvents framing.
endpoints.MapGet("reports/{reportId}/export", static (Guid reportId) =>
ElarionHttpResults.ToStreamResult<ExportRows.Query, ExportRow>(new(reportId)));JSON-RPC and MCP remain single-response. This cold
request response is distinct from an actor StreamHub, which is the hot, sequenced, resumable model.
Command vs. query
A request declares its CQRS kind by implementing a marker interface (optional, but it is the only thing the framework reads — naming and nesting carry no semantic weight):
ICommand— a state change. Maps to HTTPPOST.IQuery— a read. Maps to HTTPGET.
public sealed record Command(string Name) : ICommand; // CreateClient.Command
public sealed record Query(Guid Id) : IQuery; // GetClient.QueryThe marker is read structurally at compile time, so it drives HTTP verb inference, decorator generic
constraints (where TRequest : ICommand), and runtime branching (request is IQuery) — see
decorator pipelines. Naming the nested type Command/Query
remains a useful readability convention, but a request without the marker is treated as kind-less.
Self-typed markers: declaring the response type
Each marker also has a self-typed generic form that additionally declares the request's success
response type — ICommand<TSelf, TResponse>, IQuery<TSelf, TResponse>, IRequest<TSelf, TResponse>,
and IStreamRequest<TSelf, TItem> for stream requests:
public sealed record Command(string Name) : ICommand<Command, Guid>; // handler returns Result<Guid>
public sealed record Query(Guid Id) : IQuery<Query, Response>; // handler returns Result<Response>Requests carrying the self-typed form get fully inferred dispatch at every typed entry point —
IHandlerSender.SendAsync, HandlerInvoker, StreamHandlerInvoker, and ConnectionHandlerInvoker
all accept the request alone and infer both generic arguments:
var result = await sender.SendAsync(new GetClient.Query(id), ct);
// instead of: await sender.SendAsync<GetClient.Query, GetClient.Response>(new(id), ct);Inference is purely compile-time — dispatch stays statically typed through the same IHandler<,>
resolution with no reflection, registry, or boxing of class requests. One caveat for value types: the
inferred overloads take the request through its marker interface, which boxes a readonly record struct
request per call (C# constraints do not participate in inference). A hot value-type request should use
the explicit-generic overload — invoker.InvokeAsync<Move, MoveAck>(move, ct) — which dispatches it
unboxed (ADR-0066). The declared response type must match the handler's
Result<TResponse>; the marker analyzer enforces both invariants at build time (ELREQ001 when
TSelf names a different type, ELREQ002/ELREQ003 when a handler's response drifts from the
declared TResponse/TItem — see Diagnostics).
Prefer the self-typed form for requests you dispatch by type (cross-handler sends, custom transports,
connection codecs); the plain markers remain sufficient for requests only reached through generated
transports.
Accessing data
Handlers query and persist through the concrete AppDbContext, injected like any other dependency.
Elarion does not prescribe a repository layer — the
EF Core source generator generates the context's DbSets and
entity configuration so the DbContext is your data-access surface:
// Read: query the generated DbSet directly with EF Core async LINQ
var client = await db.Clients
.Where(c => c.Id == query.Id)
.Select(c => new Response(c.Id, c.Name))
.FirstOrDefaultAsync(ct);
// Write: add/modify entities and persist
db.Clients.Add(new Client { Id = Guid.CreateVersion7(), Name = command.Name });
await db.SaveChangesAsync(ct);The database is application logic, accessed directly — not an abstraction to hide behind. You abstract
intent-only dependencies (sending email → IEmailSender); you do not abstract the database, because
you depend on its specifics: constraints, indexes, raw SQL. So there is no repository and no context
interface — handlers inject the AppDbContext itself, which lives in the application's shared persistence
layer alongside its [EntityConfiguration] classes and migrations. See
Entity Framework Core for how the context's DbSets and entity
configuration are generated.
Wrapping each table in a hand-written IClientRepository adds a layer that the AppDbContext
already provides — with full LINQ, projections, and EF Core change tracking. Reach for a dedicated
abstraction only when a query genuinely needs to be reused or hidden behind a domain operation.
Keep handlers transport-agnostic
A handler should not know whether it was invoked over JSON-RPC, HTTP, a scheduled job, or a test. It
returns a Result<T>; the host decides how to map success and AppError failures onto the wire.
Cross-cutting behavior — logging, validation, transactions, caching, resilience — belongs in
decorators, not inside the handler body.
Calling a handler from other code
In-process, call handlers typed-directly — you keep compile-time safety, cross no serialization boundary, and still run the full decorator pipeline. Typed paths, in rough order of preference:
- Inject
IHandler<TRequest, Result<TResponse>>straight into another handler, a[Service], or a test and callHandleAsync. The handler resolves fully decorated from DI. IHandlerSender.SendAsync<TRequest, TResponse>(request, ct)— a typed mediator send: it resolves the handler by type from the ambient scope (your transaction), so one injection dispatches several handlers without naming eachIHandler<,>. Register it withAddElarionHandlerSender(). This is the in-process, in-transaction request/reply primitive (the typed replacement for the removedIDomainEventBus.RequestAsync).HandlerInvoker.InvokeAsync<TRequest, TResponse>(provider, request, …)when you only hold the rootIServiceProvider— a custom transport, a gRPC/CLI adapter, a background job. It creates a seeded per-call scope, resolves the decorated handler, invokes it, and disposes the scope.[GenerateModuleApi]for calling another module — a generated, typed facade over that module's handlers that a[ModuleContract]implementation invokes by method (still typed, still the full pipeline, no wire). This is the sanctioned cross-module path; see Cross-module communication.
Prefer these everywhere in application code. The whole point is that a rename or a signature change is a compile error, not a runtime surprise — the opposite of dispatching by string.
The named bus is for transports, not application code
HandlerDispatcher — the named request/reply bus — maps an operation
name (a string) to a handler. It exists so that name-keyed transports (JSON-RPC and MCP, whose
wire carries a method/tool name) can reach a handler without referencing its type. HTTP does not use it:
a [HttpEndpoint] route resolves its handler typed-directly ([FromServices] IHandler<…>), because the
route already pins the type at generation time.
The bus is a registered singleton, so you can inject it and call DispatchAsync(name, request, scope, ct)
— but it takes the request as object and returns Result<object> (the value boxed, no compile-time
response type), so you trade away type safety and the schema. Reach for it from application code only in
the rare case where you must dispatch by a dynamic or string-supplied name and genuinely cannot
reference the handler's type — the niche the classic mediator fills. To decouple one module from
another's internals, use a [ModuleContract] (typed) instead: same "don't reference the handler" benefit,
without losing compile-time safety.
The event bus is pub/sub-only (ADR-0010): PublishAsync for fan-out
notifications, never request/reply. For a typed in-process reply (what IDomainEventBus.RequestAsync
used to do) use IHandlerSender/IHandler above — not the event bus, and not the named bus.