Rask
Rask
v0.23.1-alpha.0.496
You're reading the Rask v0.23.1-alpha.0.496 guides. Updated .View source
All guides

CQRS (Rask.Cqrs)

Rask.Cqrs is an opt-in, source-generated CQRS/mediator. You define messages — queries, commands and events — and their handlers, then dispatch through IDispatcher. The generator wires every handler at compile time, so dispatch does no runtime reflection and no assembly scanning. That is the whole point: it publishes clean under the WASM/AOT trimmer, where a reflection-based mediator cannot. It is standalone — it depends only on Microsoft.Extensions.DependencyInjection.Abstractions and works in any .NET app, not just Rask.

Rask encourages vertical slices: a handler owns its own data access, through Rask.Data or plain EF Core, with no shared repository or service layer in between. CQRS is the natural way to structure those slices: each slice owns its query/command and handler. Reach for it when that structure earns its keep — a small app is fine calling services directly.

Included in Rask.Server and Rask.Wasm — nothing to install. It is on; an app that does without it says so:

app.Configure(c => c.Cqrs.Off());

The four message shapes

Interface Meaning Handler Returns
IQuery<TResult> read, no side effects IQueryHandler<TQuery, TResult> TResult
ICommand write, no value ICommandHandler<TCommand> —
ICommand<TResult> write, returns a value ICommandHandler<TCommand, TResult> TResult
IEvent event, fanned out to many IEventHandler<TEvent> —
public sealed record GetCounterState : IQuery<CounterState>;

public sealed class GetCounterStateHandler(CqrsCounterStore store)
    : IQueryHandler<GetCounterState, CounterState>
{
    public Task<CounterState> Handle(GetCounterState query) =>
        Task.FromResult(new CounterState(store.Count, store.Log));
}

Register and dispatch

Call AddRaskCqrs once at startup. It is host-agnostic — the same call works on the Rask Server and WASM hosts (the dispatcher captures the per-session scope on Server, the root scope on WASM), so you never split registration per host. All discovered handlers are registered for you; you never list them.

builder.Services.AddRaskCqrs();

Inject IDispatcher and say which of the three things you are doing: Query asks for data, Send tells the system to do something, Publish announces that something happened. The result type is inferred from the message, so you never state it.

public sealed partial class CounterView(IDispatcher dispatcher) : Component
{
    private CounterState _view = new(0, []);

    protected override async Task OnMount() =>
        _view = await dispatcher.Query(new GetCounterState(), CancellationToken);

    private async Task Increment()
    {
        await dispatcher.Send(new IncrementCounter(1), CancellationToken); // ICommand<int>
        _view = await dispatcher.Query(new GetCounterState(), CancellationToken);
    }
}

Events

A command handler can publish an IEvent; every registered IEventHandler runs. Choose Sequential (default) or WhenAll fan-out via CqrsOptions.EventPublishStrategy. Handlers are matched by the event's concrete runtime type (a handler declared against a base type is not invoked for a derived one — see Limitations), their run order is deterministic but not the declaration order, so don't depend on it, and an event with no handlers reaches only its subscribers (below).

public sealed class IncrementCounterHandler(CqrsCounterStore store, IDispatcher dispatcher)
    : ICommandHandler<IncrementCounter, int>
{
    public async Task<int> Handle(IncrementCounter command)
    {
        var value = store.IncrementBy(command.By);
        await dispatcher.Publish(new CounterIncremented(value), Current.Cancellation);
        return value;
    }
}

A page that handles an event

A component can implement IEventHandler<T> too. A service handler runs once per event; a component handler runs once per open page that has the component mounted, in that page's own session — after what the page is doing, as its user, and rendered once:

public sealed partial class OrdersPage : Component, IEventHandler<OrderPlaced>
{
    public async Task Handle(OrderPlaced e) => _orders = await Order.Where(o => o.Open).ToList();
}

The same Publish reaches both, and so does a domain event an aggregate raises, after its commit. A component is never registered as a DI service. See lifecycle.

Durable handlers

A handler that must not be lost implements IDurableHandler<T> instead. It is written to the outbox and runs from there after the commit, retried until it succeeds; each handler of the event chooses:

public sealed class RefreshDashboard : IEventHandler<OrderPlaced> { … }   // in memory, at once
public sealed class SendReceipt : IDurableHandler<OrderPlaced> { … }      // outbox: atomic, retried

Raised on an aggregate, the event's durable rows commit in the save's own transaction; published through the dispatcher, they are stored in a transaction of their own. With no outbox (a browser app, or Rask.Cqrs on its own) a durable handler runs in memory like any other.

Subscribing

An event is also what a screen subscribes to. Publish runs its handlers and hands it to every open subscription — a component's QueryClient.Subscribe<T>(), or Subscribe anywhere else:

await foreach (var incremented in dispatcher.Subscribe<CounterIncremented>(ct))
    Console.WriteLine(incremented.Value);

An ISubscription<T> record says which events one page wants, admitted by its own IWatchPolicy<T>. See subscriptions.

Who may send it

[Authorize] on a handler holds wherever the request comes from: over HTTP from a browser client, and in-process from a server page.

[Authorize(Roles = "admin")]
public sealed class PurgeLogsHandler : ICommandHandler<PurgeLogs> { /* … */ }

try
{
    await Dispatcher.Send(new PurgeLogs());
}
catch (ForbiddenException e) when (!e.IsAuthenticated)
{
    Routes.LoginPage().Go();
}

A caller the handler does not admit gets ForbiddenException before validation runs, so they do not learn what was wrong with a request they could not send. [AllowAnonymous] is never checked. Work that is nobody's — a job, a durable handler, a hosted service — runs as the system and is not checked either; the check that matters there was made when the user's own request enqueued it.

The attribute is read at compile time, so it has to be [Authorize] itself, on the handler class or a base class. One that derives from AuthorizeAttribute, or sits on the Handle method, is a build error (RASK101) rather than a check that silently never runs.

Pipeline behaviors (decorators)

Behaviors are the extension point for cross-cutting concerns — logging, transactions, caching. You implement IPipelineBehavior<TRequest, TResult> and register it.

Rask.Cqrs ships one: a ValidationBehavior registered outermost, which rejects an invalid request before its handler runs. See validation.md. Behaviors run as an onion in registration order (first-registered is outermost); call next to continue or return without it to short-circuit. A void ICommand flows through as TResult = Unit, so one behavior shape covers everything.

public sealed class DispatchLogBehavior<TRequest, TResult>(CqrsCounterStore store)
    : IPipelineBehavior<TRequest, TResult>
{
    public Task<TResult> Handle(TRequest request, RequestHandler<TResult> next)
    {
        store.Note($"⚙ dispatch {typeof(TRequest).Name}");
        return next();
    }
}

// register: open-generic wraps every request, closed wraps one request/result pair
builder.Services.AddRaskCqrs(o => o.AddOpenBehavior(typeof(DispatchLogBehavior<,>)));

Behaviors are code. The dispatcher's plain switches — HandlerLifetime, EventPublishStrategy, StopOnFirstEventException, ValidateRequests — can also be set in Rask:Cqrs in appsettings.json, and the callback runs after them. Unlike every other Rask section, Rask:Cqrs is read while AddRaskCqrs itself runs, because those switches decide which services get registered; it comes from the host builder's configuration, so a container that is not a host reads none.

It all fits together

The demo below is one vertical slice — the query, the result-command, the event the command publishes, and the logging behavior above — all dispatched reflection-free. Click Increment to watch the pipeline log build up.

One slice: a query (GetCounterState), a command that returns a value (IncrementCounter), an event the command publishes (CounterIncremented), and a pipeline behaviour (DispatchLogBehavior) that wraps every dispatch — the generator wires them with no runtime reflection.

namespace Rask.Site.Features;

// Injects the umbrella IDispatcher and drives the counter slice: load state with a query on mount,
// send a command on click, then re-read. The awaited handlers re-render automatically — no
// StateHasChanged() by hand.
public sealed partial class CqrsCounterDemo(IDispatcher dispatcher) : Component
{
    private CounterState _view = new(0, []);

    protected override async Task OnMount() =>
        _view = await dispatcher.Query(new GetCounterState(), CancellationToken);

    private async Task Increment()
    {
        await dispatcher.Send(new IncrementCounter(1), CancellationToken);
        _view = await dispatcher.Query(new GetCounterState(), CancellationToken);
    }

    protected override Component? Render() =>
        Div.Id("cqrs-counter").Class("flex flex-col gap-3")[
            Div.Class("flex gap-3 items-center flex-wrap items-center")[
                Span.Id("cqrs-count").Class("text-3xl font-semibold")[$"{_view.Count}"],
                Ui.Button.Primary.Id("cqrs-increment").OnClick(Increment)["Increment"]
            ],
            _view.Log.Count == 0
                ? Ui.Text["Loading the counter…"]
                // The behavior logs every dispatch (the on-mount query included), and the
                // event handler adds the "count is now N" line after each command.
                : Ui.Table.Id("cqrs-log")[
                    Ui.TableRows[_view.Log.Select((entry, i) => Ui.TableRow.Key(i)[Ui.TableCell[entry]])]
                ]
        ];
}
Live result
0
⚙ dispatch GetCounterState

Remote dispatch — a client and a server (Rask.Cqrs.Client / Rask.Cqrs.Server)

A page running in the browser reaches its server through the same IDispatcher call it already makes in-process. There is no HttpClient at the call site, no /api/* endpoint to write, and nothing on a message marks it as remote — you write a record and a handler exactly as above, and where the handler lives decides where it runs.

One package and one line per half. Neither references the other, so a browser bundle cannot compile the endpoint code and the server never carries the browser transport:

// The browser half. Every message it dispatches goes to the server.
host.Services.AddRaskCqrsClient();

// The server.
builder.Services.AddRaskCqrsServer();
...
app.MapRaskCqrs();

rask new --template wasm-hosted scaffolds all of it, in one project. The browser app lives in Client/, and three places say which half a file belongs to:

Folder Compiled into
Client/** the browser app only — its Program.cs, pages, components, wwwroot/index.html
Shared/** both — message records go here
everything else the server only — handlers go here

The server's Program.cs is RaskApp.Create(args).Serve(); — it registers AddRaskCqrsServer() and maps MapRaskCqrs() ahead of the browser app's fallback, so neither line is yours to write.

So a message record is shared by construction, and its handler — with the connection string, table name or pricing rule it touches — never reaches a download anybody can read. The build generates the browser app's project into obj/ from Client/ and Shared/; Client/Program.cs is what switches that on, and <RaskClient>false</RaskClient> switches it off.

What is left is keeping the two transports apart, which the csproj says in one line:

<!-- The browser app gets this one; the server must not. It is the half that CALLS the endpoints the
     server answers, and a plain PackageReference would ship it into the process answering them. -->
<RaskClientPackageReference Include="Rask.Cqrs.Client" Version="..."/>

and the browser app registers it in its own entry point:

// Client/Program.cs
var host = WasmHostBuilder.CreateDefault();
host.Services.AddRaskCqrsClient();
await host.Run<App>();

RaskClientUsing and RaskClientProjectReference do the same for a project-wide using and a project reference the browser app needs and the server must not have.

Without a database, the scaffold sets Rask:Cqrs:Server:RequireAuthenticatedUser to false in appsettings.json and says why. The default is on, and that is right for an app with accounts — but an app with no database has none to require, so it would answer 401 to every message, and the failure reads as broken transport rather than as the secure default working. Once the app has a database, delete the key. And because it is configuration, an environment variable can turn it off too — guard the deploy environment like code. A handler that carries [Authorize] still needs a signed-in caller with the key off: the attribute is the author saying so.

A client is a pure client. Every request message it dispatches travels; a stray client-side handler can never quietly intercept one. Events are the deliberate exception — they fan out, so a client's own handlers still run and the event travels.

Two projects instead of one works the same way, with one extra line. Where a bundle and its host are separate projects, the message records have to reach both compilations — link the file rather than duplicating it, and there is still exactly one declaration:

<!-- In the HOST's csproj. The messages live with the call sites; the handlers stay here. -->
<Compile Include="..\MyApp.Browser\Shared\RemoteMessages.cs" Link="Shared\RemoteMessages.cs"/>

A pair of sample projects held that arrangement end to end over cookie auth: the members page asked the server who was calling, and the answer was the identity the server saw — proof the session cookie rode the dispatch, since the message carries no name at all. They are gone, so the shape above is documented rather than exercised.

Two endpoints, not one per message

GET and POST on /_rask/cqrs/request/{name}. The verb carries what IQuery and ICommand already declare in C#: a query is safe and idempotent so it is a GET and can be cached; anything that mutates is a POST. So a command is 405 on GET and cannot be triggered by a URL, a prefetch or a link scanner. A query too long for a URL falls back to POST automatically, with an identical result.

Because the name is a route segment, logs, metrics and rate-limit partitions get it for free. MapRaskCqrs() returns the endpoint group, so .RequireRateLimiting(...), CORS or output caching is a one-line addition.

A third route under the same prefix, GET /_rask/cqrs/request/events/{name}, serves a browser's subscriptions as server-sent events — the same group, so the same header, authentication and rate limit.

It fails closed

  • Authenticated by default. [AllowAnonymous] on the handler is the only way past; [Authorize] supplies a policy and roles, both enforced — and a handler carrying several [Authorize] attributes, its own or a base class's, has to pass every one, exactly as a page does. AuthenticationSchemes is not read: the endpoint authenticates with the app's default scheme.

  • An anonymous caller cannot enumerate your messages. A real name and a typo get the same answer, so the endpoint can't be walked to discover what the app has.

  • Both verbs require the X-Rask-Cqrs header, which no cross-site markup can set — adding the GET surface adds no cross-site trigger.

  • Handler exceptions become RFC 9457 problem+json with no exception text: an exception message is written for an operator, not a browser, and routinely names tables, paths and credentials. The 500 carries a reference, and the exception is logged at Error on Rask.Errors with the same one:

    { "type": "…/docs/cqrs.md#remote-errors", "title": "Handler failed", "status": 500, "reference": "K7WQ2MZP" }
    
    Rask:Cqrs:Server:IncludeExceptionDetail Development Any other environment
    false (default) generic problem + reference generic problem + reference
    true adds the exception as detail ignored — generic problem + reference, and a warning at startup

    The environment is the host's IHostEnvironment; a host that names none is Production. The switch cannot take effect on a deployed server, so an environment variable cannot turn the endpoint into a view of your stack traces.

A handler knows who is calling without being told. In a Rask app with its data layer on, a remote request's handler runs with the caller's principal ambient, as a live session's in-process dispatch does: Current.UserId is the caller, and a tenant-scoped read filters to the caller's tenant. A command therefore does not carry a user id the client could forge — the handler reads it. A job's handler gets the user and tenant it was enqueued for, the same way.

Failure to arrive is the one thing remote dispatch adds to the in-process call, and it is a RemoteDispatchException — a null StatusCode means the request never reached the server.

[LocalOnly]

Keeps a message off the wire entirely, and on an interface covers a whole family. This matters more than it looks: IJob derives from ICommand, so without it every job payload in the app would become an internet-reachable endpoint. Mark a domain event [LocalOnly] too: an event travels from a browser like any message, so an unmarked OrderPlaced could be published for an order nobody placed, and its handlers would run.

It is also how a client keeps a message in-process. "A client is a pure client" is not a figure of speech — AddRaskCqrsClient() replaces the invoker for every request message it has a contract for, so a handler sitting in the client project stops being reached and the message goes to the server instead. If a client genuinely owns a message end to end — a browser-local counter, an offline queue, anything the server has no handler for — mark it [LocalOnly] and it never leaves:

[LocalOnly]                                        // stays in the browser
public sealed record IncrementLocalCounter(int By) : ICommand<int>;

Without it the dispatch travels, the server answers 404 (it has no handler for that name), and the failure looks like a transport problem rather than the design decision it is.

Files, both directions

A message declares its file as a IRaskFile — the same type a file input hands a component. So the file a user picked is passed straight to the handler, with nothing to convert and nothing to learn:

public sealed record AttachReceipt(int OrderId, IRaskFile File) : ICommand;

// The call site. Identical whether this page is server-rendered or running in the browser.
await dispatcher.Send(new AttachReceipt(orderId, picked));

// Download: the file the handler returned, saved by the browser.
Download.File(await dispatcher.Query(new ExportOrders(year)));

The handler receives a IRaskFile too, and reads it exactly as it would in-process:

public sealed class AttachReceiptHandler : ICommandHandler<AttachReceipt>
{
    public async Task Handle(AttachReceipt command)
    {
        await using var stream = command.File.OpenReadStream(command.File.Size, Current.Cancellation);
        // ...
    }
}

Where the message runs in-process the handler simply gets the picked file. Where it travels, the generated codec carries the bytes and hands the handler a IRaskFile over what arrived — so the host changes and the code does not. Every host reads a IRaskFile in bounded slices (the browser ones through Blob.slice), and a download is streamed back headers-first.

Bounded by the server's MaxUploadBytes and MaxFileCount. A chunked upload sits on the server's disk until its message arrives, so what one caller may leave there is bounded as well: MaxOpenUploads (4) uploads begun and not yet sent, holding MaxOpenUploadBytes (64 MB) between them. Past either, the next chunk answers 429 until an upload is sent or lapses after UploadSessionLifetime. A caller is the signed-in user; with RequireAuthenticatedUser off it is the address the request came from (an IPv6 /64 counts as one), so behind a reverse proxy wire UseForwardedHeaders or every visitor shares the proxy's budget.

ChunkedUploadThreshold is load-bearing in the browser, not a tuning knob. fetch has no request streaming, so a browser reads a whole request body into memory before sending it — a single-shot multipart upload of a 500 MB file costs 500 MB in the tab. Above the threshold the file goes up in bounded pieces first and the message follows carrying only the session id, which is what keeps the request small as well as the read. Raising it raises the browser's peak memory by the same amount.

The download direction is the one fetch does stream, and one property decides it. The transport asks for the response headers first (HttpCompletionOption.ResponseHeadersRead), but in the browser that only means anything while WasmEnableStreamingResponse is on — the WASM SDK defaults it to true, and false makes BrowserHttpHandler buffer the whole body before the caller is handed a stream. So a constant-memory export stops being constant-memory the moment an app sets:

<!-- Don't. A FileDownload is then materialised whole in the tab. -->
<WasmEnableStreamingResponse>false</WasmEnableStreamingResponse>

The published bundle is checked for that flag by the browser-rung publish gate, because the default belongs to the SDK rather than to Rask and could otherwise change under the claim (#894).

A dropped chunk resumes. Every chunk response echoes X-Rask-Upload-Offset, and a chunk that does not follow on from what the server holds is answered 409 carrying the offset it does hold — 409 rather than 400 because the request is well-formed, it is just out of step. The client restarts from that offset, bounded by a small retry count per file, so one lost chunk does not fail an upload that the protocol can recover.

The limit is in-session: a browser's File handle dies with the page, so resuming across a reload is not possible and the upload starts again.

Wire codecs, and RASK053

The codecs are source-generated Utf8JsonWriter/Utf8JsonReader code, not reflection, so remote dispatch publishes clean under the WASM/AOT trimmer. RASK053 reports a message or result shape that has no wire encoding. It reaches no existing code: codecs are generated only for a compilation that references one of the two transport packages, so an app using Rask.Cqrs in-process is unconstrained.

Diagnostics

The generator validates your handlers at compile time:

  • RASK028 (error) — a query or command has more than one handler; dispatch would be ambiguous.
  • RASK029 (warning) — a handler is an open generic or has no public constructor, so it can't be registered and is skipped.

A request with no handler is not a compile error (the handler may live in another assembly) — it throws a clear InvalidOperationException at dispatch time.

Limitations

These follow directly from the reflection-free, compile-time design — the same thing that makes it trim-safe:

  • Handlers must live in a loaded assembly. Registration is driven by a per-assembly [ModuleInitializer], which runs when the CLR first loads that assembly. If your handlers are in a separate library that nothing touches before AddRaskCqrs(), they won't be registered. In practice a Rask/Blazor app already uses types from its handler assemblies at startup; if yours doesn't, reference any type from that assembly (or keep handlers in the app assembly).
  • One handler per query/command, app-wide. RASK028 catches duplicates within a compilation. Two handlers for the same request type in different assemblies aren't diagnosed (no whole-program view) — keep each query/command's handler unique across the whole app.
  • Events match the concrete published type. There is no base-type/polymorphic fan-out (that would need runtime type-hierarchy reflection). Declare a handler for the exact IEvent type you publish.

Trimming

Every handler's constructor is preserved with a generated [DynamicDependency], and dispatch is a compile-time closed-generic map, so a WASM app using Rask.Cqrs publishes with zero IL warnings.

Testing

Handlers are plain classes — unit-test them directly, no dispatcher required. To test wiring, register AddRaskCqrs into a ServiceCollection, build the provider, and dispatch. See tests/Rask.Cqrs.Tests and the generator tests in tests/Rask.Batteries.Generators.Tests.

The transport is tested with both halves in front of each other, in tests/Rask.Cqrs.Transport.Tests — the real client building the request, the real endpoint receiving it. Note what that project cannot do, because it is a property of the design rather than of the test: AddRaskCqrsClient() installs its remote invokers into the process-wide CqrsRegistry, and the endpoint dispatches through that same registry, so a process that registers both halves sends every message it receives straight back out. A client process and a server process are two processes — that is the assumption, and it is worth knowing before you try to register both in one app.

Confirm