Skip to content
Kumar Chandrachooda
.NET

An Event Bus and a Command Bus, No Extra Package Required

FastEndpoints ships in-process messaging that covers most MediatR use cases - events with three wait modes, commands with middleware pipelines, generic and streaming handlers. The dispatch code makes some sharp lifetime choices worth knowing. Part 11 of FastEndpoints in Depth.

By Kumar Chandrachooda 23 Apr 2026 4 min read
One event fans out to subscribers; one command finds exactly one handler

Somewhere around 2023, half the .NET community discovered it was using MediatR as a glorified method call, and the other half kept using it anyway because decoupling. FastEndpoints took a position in that argument by shipping the two useful shapes — publish/subscribe events and request/response commands — inside the framework. Having read the dispatch code for both, this post covers how they work, where the bodies are buried (spoiler: handler lifetimes, again), and when the in-process story runs out.

Events: fan-out with a chosen wait

An event is a DTO implementing IEvent; handlers implement IEventHandler<TEvent> and are discovered by the Part 2 scanner. Publishing happens from anywhere with a PublishAsync — endpoints have it built in:

public class OrderPlaced : IEvent
{
    public Guid OrderId { get; init; }
    public decimal Total { get; init; }
}

// in the endpoint handler:
await PublishAsync(new OrderPlaced { OrderId = id, Total = total },
                   Mode.WaitForAll, ct);

The Mode parameter is the API making you answer the question most event systems let you dodge — what does publishing mean for my request latency? The implementation in EventBus<TEvent>.Execute is compact enough to quote:

// Src/Messaging/Messaging/Events/EventBus.cs (FastEndpoints, MIT)
case Mode.WaitForNone:
    _ = Parallel.ForEachAsync(handlers, ct, async (h, c) => await h.HandleAsync(eventModel, c));
    return Task.CompletedTask;

case Mode.WaitForAny:
    return Task.WhenAny(handlers.Select(h => Task.Run(() => h.HandleAsync(eventModel, ct), ct)));

case Mode.WaitForAll:
    return Parallel.ForEachAsync(handlers, ct, async (h, c) => await h.HandleAsync(eventModel, c));

WaitForAll runs handlers in parallel and completes when all finish — your HTTP response waits for every subscriber. WaitForNone is fire-and-forget: the same parallel dispatch with the task discarded. Read that discard (_ =) with respect and suspicion: an exception in a fire-and-forget handler vanishes unless the handler catches it, and the request that triggered it has long since returned 200. My rule: WaitForNone handlers own their error handling, always, no exceptions — literally.

Now the lifetime choice. Event handlers are instantiated as singletons — created once via the service resolver and cached (CreateSingleton in the bus constructor). Constructor-inject a scoped DbContext into an event handler and you have a one-instance context shared across all invocations, concurrently. The safe pattern inside handlers is explicit scoping (CreateScope() and resolve within), same as the singleton validators of Part 5. The library is consistent about this trade — allocation-free steady state, scoping is your job — but the messaging corner is where I have seen it actually bite people, because event handlers are exactly where you reach for a DbContext.

Commands: one handler, typed result, middleware

Commands are the request/response half — ICommand<TResult> (or plain ICommand for void), exactly one ICommandHandler<TCommand, TResult>, invoked as an extension method on the command object:

var status = await new RefundOrder { OrderId = id, Amount = amt }.ExecuteAsync(ct);

Dispatch goes through a CommandHandlerRegistry (a ConcurrentDictionary filled at scan time) and a cached, per-command CommandHandlerExecutor — reflection to build the executor once, delegate calls thereafter. Three capabilities layered on top are worth knowing:

Open-generic handlers. A PersistThing<T> : ICommand can be handled by one PersistThingHandler<T> — the registry closes the generic on first dispatch and caches the closed type. The dispatch code (InitGenericHandlerCore) validates the closed handler actually implements the right interface and throws a named error if not — the Part 2 crash-early philosophy extended to generics.

Streaming commands. IStreamCommand<TResult> handlers return IAsyncEnumerable<TResult> — which pairs beautifully with the server-sent-events response support (Part 16): an endpoint can execute a stream command and pipe the results straight out as SSE.

Command middleware. The MediatR pipeline-behavior equivalent:

public class Logged<TCmd, TRes>(ILogger<TCmd> log) : ICommandMiddleware<TCmd, TRes>
    where TCmd : ICommand<TRes>
{
    public async Task<TRes> ExecuteAsync(TCmd cmd, CommandDelegate<TRes> next, CancellationToken ct)
    {
        log.LogInformation("executing {cmd}", typeof(TCmd).Name);
        var result = await next();
        log.LogInformation("executed {cmd}", typeof(TCmd).Name);
        return result;
    }
}

// registration, closed or open generic:
app.UseFastEndpoints(c => c.Commands.AddMiddleware(typeof(Logged<,>)));

Middleware wraps in registration order, resolved from DI per execution — logging, retries, transactions, caching all compose here. This is the piece that makes the command bus a genuine MediatR replacement rather than a toy: the pipeline behaviors most codebases actually use (logging, validation, unit-of-work) port almost mechanically.

For testing, RegisterForTesting swaps a fake handler for a command type — the Part 14 integration story uses it to stub outbound side effects without touching DI registrations.

Where I draw the lines

Three boundaries, learned by shipping:

Endpoint-calls-endpoint is what commands are for. The anti-pattern the command bus kills is endpoints resolving other endpoints' services (or worse, HTTP-calling themselves). Shared operations become commands; both endpoints execute them; the operation has one owner.

Events are for this process's side effects. In-process events disappear on crash and don't cross instances. OrderPlaced triggering an email is fine if losing the email on a badly timed deploy is acceptable. If it isn't, you want the job queue (Part 13) or a real broker — the event bus is a convenience, not a guarantee.

Commands don't replace method calls between classes that are already coupled. If OrderService calls PricingService, inject it. The bus adds value at seams — cross-feature calls, test-stub points, middleware-worthy operations — not between neighbours. The absence of ceremony in FastEndpoints' version makes over-application tempting; a command whose handler is three lines and has one caller is a method with extra steps.

The honest comparison with MediatR: FastEndpoints' version has no notification-handler ordering, no polymorphic dispatch, and its handlers are lifetime-constrained singletons. In exchange: zero additional dependency, source-generator compatibility (Part 15), streaming support, and one less abstraction vocabulary in the codebase. For the CQRS-lite shape most APIs actually need, it covers the ground.

And it sets up the genuinely differentiating act. The same ICommand/ExecuteAsync you write for in-process dispatch can execute on another machine — over gRPC, with no .proto files, no controller, no HTTP client code — by swapping registration. That remote messaging layer is one of the most unusual pieces of the whole repository: Part 12, Commands Over gRPC, No Proto Files.