The pipeline
Middleware
A hook on every action: observe it after it applies, or veto it before it runs.
The interface
Middleware runs synchronously on the dispatch path. BeforeAction sees an action
that is about to mutate state and can cancel it; OnAction sees one that already
has, together with its payload and the resulting state tree.
public interface IBlexMiddleware
{
// After the action has been applied to its store.
void OnAction(BlexActionContext context);
// Before it runs. The default implementation does nothing.
void BeforeAction(BlexPreActionContext context) { }
// When the action body throws. The default implementation does nothing.
void OnActionError(BlexActionErrorContext context) { }
}Registering
Four ways, depending on what the middleware needs:
builder.Services.AddBitBlexServices(options =>
{
// 1. Resolved from DI (scoped) -- can take dependencies.
options.UseMiddleware<AuditMiddleware>();
// 2. A pre-built instance (shared across scopes).
options.UseMiddleware(new MetricsMiddleware(meterFactory));
// 3. A delegate, for quick logging.
options.UseMiddleware(ctx => Console.WriteLine($"[blex] {ctx.QualifiedName}"));
// 4. A veto filter.
options.UseFilter(ctx => ctx.Store.Name != "settings" || _user.CanEditSettings);
});Registration
| Call | Description |
|---|---|
options.UseMiddleware<T>() |
Registers the type as scoped and resolves it from DI - use this whenever the middleware has dependencies or per-scope state. |
options.UseMiddleware(instance) |
Registers a pre-built instance. Shared across every scope. |
options.UseMiddleware(Action<BlexActionContext>) |
Wraps a delegate in BlexDelegateMiddleware. Handy for one-line logging. |
options.UseFilter(Func<BlexPreActionContext, bool>) |
Wraps a predicate in BlexFilterMiddleware. Return false to veto. |
Instances are shared across scopes
UseMiddleware(instance) and the delegate overloads store the object on the
singleton options, so under Blazor Server it is shared by every circuit. Never
hold per-user state in one. Use UseMiddleware<T>() for anything scoped.
Ordering is registration order, whichever kind you mix - which matters, because the
first veto short-circuits the rest. Middleware registered straight into the service
collection rather than through the options runs after everything registered here.
Observing
This is how you get logging, analytics or an audit trail. The Inspector at the bottom of this
page is exactly this - an ActionFeedMiddleware registered in the site's
Program.cs, writing into a service the UI renders.
public sealed class AuditMiddleware(IAuditLog log) : IBlexMiddleware
{
public void OnAction(BlexActionContext context)
{
log.Write(new AuditEntry(
Action: context.QualifiedName,
Args: context.Args,
At: context.Timestamp,
Sequence: context.Sequence));
}
}Dispatch an action and watch the same middleware that powers the Inspector pick it up.
Vetoing
BeforeAction runs ahead of the mutation. Calling Cancel() stops the
action completely: the body never runs, nothing changes, nothing is recorded and no
notification fires. Use it for read-only modes, permission checks and payload validation.
public sealed class ReadOnlyMiddleware(AppMode mode) : IBlexMiddleware
{
public void OnAction(BlexActionContext context) { }
public void BeforeAction(BlexPreActionContext context)
{
if (mode.IsReadOnly)
context.Cancel(); // the mutation never runs
}
}Turn the guard on and every action is cancelled before it can mutate anything. The counter stops responding - and the action feed stays silent, because a vetoed action is never recorded.
Direct assignment is vetoable too - a Set Count goes through the same
pipeline as any named action.
The pre-action context
BlexPreActionContext
| Member | Description |
|---|---|
IBlexStore Store |
The store about to produce the action. |
string ActionName |
The bare action name. |
string QualifiedName |
"storeName/ActionName". |
IReadOnlyList<BlexActionArg> Args |
The arguments the action was called with - available before anything mutates. |
bool IsCancelled |
Whether a middleware has already vetoed this action. |
void Cancel() |
Veto: the mutation does not run and nothing is recorded. |
Because the payload is available before the mutation, a filter can validate arguments and reject the action rather than letting it write bad state:
options.UseFilter(ctx =>
{
if (ctx.QualifiedName != "cart/SetQuantity")
return true;
var quantity = ctx.Args.FirstOrDefault(a => a.Name == "quantity").Value;
return quantity is int q && q is >= 0 and <= 99; // false -> vetoed
});When the action itself throws
The third hook, OnActionError, fires when an action body throws. It is
the counterpart to Pinia's $onAction onError, and it is where a
crash reporter belongs: an effect swallows its exception into XxxError so the UI
can render it, which means nothing else would ever hear about the failure.
public sealed class CrashReporter(ITelemetry telemetry) : IBlexMiddleware
{
public void OnAction(BlexActionContext context) { }
public void OnActionError(BlexActionErrorContext context)
{
telemetry.TrackException(context.Exception, new()
{
["action"] = context.QualifiedName,
["args"] = string.Join(", ", context.Args.Select(a => $"{a.Name}={a.Value}")),
});
}
}
The hook observes; it cannot handle. The exception continues on its way exactly as it would
have - captured by the effect wrapper, or surfaced to the caller of a plain action - so
adding a reporter never changes what the application does. For the one-liner case, wire
options.OnActionError instead of writing a middleware.
builder.Services.AddBitBlexServices(options =>
{
// Isolated pipeline failures (a throwing subscriber, a failed storage write):
options.OnError = err => logger.LogWarning(err.Exception, "[blex:{Source}]", err.Source);
// The application's own action bodies failing:
options.OnActionError = ctx => logger.LogError(ctx.Exception, "[blex] {Action}", ctx.QualifiedName);
});Both buttons fail on purpose. The failure still reaches SearchError for the UI to render - and the reporter below hears about it too.
Nothing reported yet. Cancellation is not a failure, so superseded runs never appear here.
BlexActionErrorContext
| Member | Description |
|---|---|
IBlexStore Store |
The store whose action failed. |
string ActionName / QualifiedName |
The action that threw. |
IReadOnlyList<BlexActionArg> Args |
The arguments it was called with, so a report can name the input that failed. |
Exception Exception |
The exception the body threw. It still propagates after the hook returns. |
DateTimeOffset Timestamp |
When the failure was observed (UTC). |
Cancellation is not a failure
An OperationCanceledException is the documented outcome of
CancelXxx(), of a Latest run being superseded, and of disposing
a store with work in flight. Reporting those would bury the real failures under a
type-ahead's worth of noise, so they never reach this hook - exactly as they never reach
XxxError.
Error isolation
Middleware must not be able to break dispatch. An exception thrown from
BeforeAction or OnAction is caught, reported to
OnError with the source "middleware", and the
pipeline carries on to the next middleware. A throwing filter therefore does not
veto the action - if you mean to cancel, call Cancel().
Cost when nothing is listening
With no middleware, no DevTools connection and no subscribers, the manager reports
HasObservers == false and the whole recording step is skipped. Generated
wrappers check the same flag before allocating their BlexActionArg[], so an app that
observes nothing pays nothing for the machinery.
Middleware is also a coordination point
Anything you can do in a manager subscription you can do in middleware, with the difference that middleware is registered at startup and always on, while subscriptions come and go with the component or service that owns them.