Skip to content
Kumar Chandrachooda
.NET

Rules That Plan Commands

FastEndpoints.CommandRules sounds like a Roslyn analyzer and is nothing of the sort - it is a small rules engine that turns an input into planned commands and dispatches each one now or onto a job queue. A close read of the newest package in the repo, capability matrix and all. Part 10 of FastEndpoints — The Missing Chapters.

By Kumar Chandrachooda 28 Jun 2026 5 min read
One input, ordered rules, planned commands - routed to run now or queue as jobs

When FastEndpoints.CommandRules appeared in my repository audit, I assumed - from the name - a Roslyn analyzer enforcing command-handler hygiene. It is nothing of the kind, and it is worth saying plainly because the name invites the mistake: there are no diagnostics here, no analyzer, no code fixes. CommandRules is a runtime library - a small, DI-native rules engine that maps an input object to zero-or-more planned commands and then dispatches each one, either immediately through the command bus or as a persistent job on the job queues. It is the connective tissue between two systems the parent series covered separately.

The problem it names

Every event-driven service grows a function shaped like this: something happened - now decide what work that implies. A webhook arrives, an order state changes, a file lands - and depending on its contents you might send an email, recompute a projection, schedule a cleanup, or all three. The naive version is an if-ladder in a handler that accretes clauses until nobody can test it. The heavyweight version is a workflow engine. CommandRules is the deliberately lightweight middle: each decision becomes a class, each class emits commands, and a shared engine handles ordering, aggregation and dispatch.

public sealed record InboundWebhook(string EventType, string PayloadJson);

sealed class RefundRule : CommandRule<InboundWebhook>
{
    public override int Order => -10;                       // evaluated first
    public override bool CanHandle(InboundWebhook w) => w.EventType == "payment.refunded";

    public override IEnumerable<PlannedCommand> Build(InboundWebhook w)
    {
        yield return new(new ReverseLedgerEntryCommand(w.PayloadJson));
        yield return new(new NotifyFinanceCommand(w.PayloadJson))
        {
            Mode = CommandDispatchMode.QueueAsJob            // this one can wait
        };
    }
}

Registration and use:

bld.Services.AddCommandRules(o =>
{
    o.Register<InboundWebhook, RefundRule>();
    o.Register<InboundWebhook, ChargebackRule>();
    o.DefaultMode = CommandDispatchMode.ExecuteNow;
});

// inside an endpoint
var result = await Resolve<ICommandDispatcher<InboundWebhook>>().DispatchAsync(webhook, ct);

AddCommandRules itself is a small novelty - it is written with C# 14's extension(IServiceCollection services) member syntax, one of several places this repo adopts brand-new language features the month they exist. Rules register additively via TryAddEnumerable (transient), while the engine and dispatcher are open-generic scoped registrations - so unlike most of this library's machinery, this package plays it straight with your DI container. No static caches of your objects; rules can constructor-inject scoped services without ceremony.

Evaluate, then dispatch - two phases on purpose

The engine (ICommandRuleEngine<TInput>) only plans. It sorts rules by Order - and because it uses LINQ's stable OrderBy, equal-order rules keep registration order, a guarantee the unit tests pin down explicitly - then evaluates each: CanHandle gates, Build yields PlannedCommands, and the results aggregate into a CommandRulePlan carrying the commands and how many rules matched. Two dials change the shape: MatchMode.First stops at the first matching rule (a routing table); the default All collects from every match (fan-out). And UnhandledRuleBehavior decides whether zero matches is an empty plan (NoOp) or a thrown CommandRuleNotFoundException naming the input type (Throw) - the difference between “nothing to do” and “unrecognized event,” which production systems conflate at their peril.

A distinction I appreciated only after reading the tests: a matched rule that emits nothing is not the same as no match. CanHandle returning true with an empty Build produces HasMatches == true, MatchedRuleCount == 1, zero commands - “I recognized this and decided nothing is needed” - observable in the result rather than collapsed into silence.

The dispatcher (ICommandDispatcher<TInput>) executes the plan, deciding each command's mode by a three-level precedence: a forced mode passed to DispatchAsync(input, mode) beats the per-command PlannedCommand.Mode, which beats the configured DefaultMode. Failures follow FailureBehavior: StopOnFirstFailure propagates; Continue records a failed CommandDispatchOutcome (command, mode, exception) and keeps going - except for OperationCanceledException, which always propagates, because “continue on failure” was never meant to mean “ignore shutdown.”

The capability matrix, and an honest “yet”

Dispatch is where FastEndpoints' three command shapes - ICommand, ICommand<TResult>, IStreamCommand<T> - meet reality, and the package refuses the combinations it cannot honour with named exceptions rather than best-effort behavior:

Command shape ExecuteNow QueueAsJob
ICommand (void) yes - straight through the command bus no - jobs need ICommand<TResult>
ICommand<TResult> no - “not supported by command rules yet” yes - returns a tracking Guid
IStreamCommand<T> no no

Every “no” throws UnsupportedPlannedCommandException with a reason string; the ICommand<TResult> inline case says “yet” in the actual exception message - a roadmap commitment embedded in a throw statement. The asymmetry makes sense once you think about ownership: an inline result would need somewhere to go (the dispatch outcome has no slot for it yet), while a queued result already has a home - the outcome's TrackingId feeds JobTracker<TCommand>.GetJobResultAsync(...), exactly the machinery from the job-queue chapter. Queued commands can also carry JobDispatchOptions(ExecuteAfter, ExpireOn), with the job queues' own guards (UTC-only, expiry after execution time) validating before anything persists.

There is also a full belt of runtime guard rails - null match, null command list, null planned command, negative match counts, commands-without-matches - each with a precise exception message naming the offending rule type. For a package whose extensibility points are user classes returning collections, that paranoia is what turns “my rule returned null somewhere” from an hour of debugging into a one-line stack trace.

Where I'd use it, and where not

CommandRules earns its keep when the decision logic changes more often than the work - pricing triggers, notification matrices, webhook routing - because each decision is one small class you can unit-test with no infrastructure (EvaluateAsync on the engine returns a plan you can assert against; the repo's own tests do exactly this). It is also, notably, AOT-compatible, with a single documented trimming suppression where it inspects a command's interfaces at dispatch time.

Skip it when you have one rule (that's an if), when the work needs sagas or compensation (this is fire-and-record, not a workflow engine), and - for now - when handlers must return results inline. And remember the options quirk: calling AddCommandRules twice replaces the options (last write wins) while rules accumulate - a config-merge asymmetry worth knowing before you split registration across modules.

Two chapters remain, and they share a theme with this one: FastEndpoints increasingly assumes your endpoints' callers might not be humans with HTTP clients. Next week, the package that hands your endpoints to AI agents as MCP tools.