Elarion

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 Query or Command),
  • 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 the IHandler<,> interface itself, so they may be declared anywhere — nesting them (as Command/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 declaring Full on 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 HTTP POST.
  • IQuery — a read. Maps to HTTP GET.
public sealed record Command(string Name) : ICommand;   // CreateClient.Command
public sealed record Query(Guid Id) : IQuery;            // GetClient.Query

The 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 call HandleAsync. 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 each IHandler<,>. Register it with AddElarionHandlerSender(). This is the in-process, in-transaction request/reply primitive (the typed replacement for the removed IDomainEventBus.RequestAsync).
  • HandlerInvoker.InvokeAsync<TRequest, TResponse>(provider, request, …) when you only hold the root IServiceProvider — 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.

On this page