Articles · Mediator & CQRS ·001

Getting Started: Commands, Queries, and the Mediator

Runnable sample on GitHub

Sample: samples/001-getting-started — a database-free Catalog API you can run with dotnet run and test with dotnet test.

Overview

Relay is a framework for building .NET services around CQRS (Command Query Responsibility Segregation) and an in-process mediator. Instead of controllers reaching directly into services and repositories, every operation is modelled as a small immutable message — a command (it changes state) or a query (it reads state) — that is handed to a bus. The bus finds the one handler for that message, runs it through a pipeline of cross-cutting behaviors (validation, logging, transactions, authorization…), and returns the result.

This first article covers the foundation that every other capability builds on. By the end you will have a running HTTP service whose endpoints are generated from your messages, with validation and error handling wired in, and not a single line of handler-registration code.

Why this exists

The default way to structure an ASP.NET Core service — controllers calling “service” classes that call repositories — has a way of decaying. Controllers accumulate orchestration logic. “Service” classes grow into grab-bags of unrelated methods (OrderService with thirty methods). Cross-cutting concerns get copy-pasted: every method opens a transaction, logs, checks permissions, validates input. The same five lines of plumbing surround every meaningful line of business logic, and when the plumbing needs to change you change it in three hundred places.

The mediator pattern attacks this by making one message = one handler = one unit of behavior, and moving the plumbing into a pipeline that wraps every handler uniformly. The result is:

  • Vertical slices. Everything about “create a product” lives together — the command, its validation, its handler — instead of being smeared across a controller, a service, and a DTO layer.
  • Uniform cross-cutting concerns. Validation, logging, transactions and authorization are written once, as pipeline behaviors, and apply to every message automatically.
  • A typed contract. Execute<CreateProductCommand, Product>(...) is impossible to call with the wrong response type; the compiler enforces the shape of every operation.

Relay did not invent this pattern (libraries like MediatR popularised it in .NET). What Relay adds is a batteries-included, production-oriented pipeline — transactions that wrap the event store and outbox, fail-closed authorization, caching, multi-tenancy — that the rest of these articles explore.

When to use this

The mediator is the entry point to everything in Relay, so the question is less “should I use the mediator?” and more “is a CQRS-shaped service the right fit for this slice of my system?” It pays off when:

  • The domain has real behavior, not just storage. Order management: placing an order is not an INSERT — it reserves inventory, prices lines, applies promotions, and may be rejected. Each of those is a command with rules. Payments: authorising a charge has a dozen failure modes you want to model explicitly. Booking systems: reserving a seat is a decision, not a row write.
  • Cross-cutting concerns are non-negotiable. Payments and customer management need consistent authorization, audit logging, and transactional integrity on every write. A pipeline gives you that for free on every new message, so the hundredth command is as safe as the first.
  • The team is growing. Vertical slices localise change: a developer adding “discontinue product” touches one folder, not a shared CatalogService that five other features depend on. Logistics and inventory services with many small operations benefit most.
  • You expect to scale the operations, not just the data. Because commands and queries are reified, you can later route them differently — cache the hot queries, move heavy commands behind a queue, add a behavior that rate-limits a specific message — without rewriting handlers.

When not to use this

The mediator adds indirection, and indirection has a cost. Reach for a plainer design when:

  • It is genuinely CRUD. An admin panel that edits lookup tables, a settings page, an internal tool that is a thin skin over a database — here a command/query/handler trio per operation is three files where a controller calling a DbContext is one. The ceremony buys you nothing because there is no behavior to protect.
  • It is a short-lived prototype. If the code’s job is to be thrown away after a demo, the uniformity and testability the mediator buys you never pay off.
  • The team has no appetite for the pattern. The mediator asks everyone to learn “where does logic go?” (in a handler), “how do I add a header check?” (a behavior), “why isn’t my handler found?” (assembly scanning). On a team that will not invest in that, the pattern becomes cargo-culted boilerplate.

The honest cost summary: more files per feature, a learning curve around handlers and the pipeline, and a layer of indirection that makes “jump to the code that runs” a two-hop trip (message → handler). For a domain with behavior those costs are dwarfed by the payoff; for a CRUD table they are pure overhead.

Concepts

Message. A record implementing a marker interface. ICommand<TResponse> for a state-changing operation that returns something, ICommand for one that returns nothing, IQuery<TResponse> for a read. Messages are immutable and carry only data.

Handler. A class implementing ICommandHandler<TCommand, TResponse> (or the query/void equivalents). It has exactly one method, Handle, and contains the behavior for that one message. Handlers are resolved from DI, so they can depend on repositories, clients, the clock — anything.

Bus. ICommandBus and IQueryBus are the entry points. Execute<TMessage, TResponse>(message, ct) dispatches a message to its handler through the pipeline. You inject the buses into controllers, minimal-API endpoints, or — as in the sample — let Relay generate the endpoints for you.

Pipeline behavior. A class implementing ICommandPipelineBehavior<TCommand, TResponse> (or the query equivalent). It wraps handler execution: it receives the message and a next delegate, can run code before and after next, short-circuit, retry, or transform the result. Validation, logging, transactions and authorization are all just behaviors.

Validator. A class implementing ICommandValidator<TCommand, TResponse> that returns a FluentValidation ValidationResult. The built-in ValidationBehavior runs every validator for a message and aggregates the failures.

Architecture

AddRelay(assembly) scans the assembly once at startup, registers every handler, validator and behavior it finds, and wires up the buses. At runtime, dispatching a message looks like this:

sequenceDiagram
    participant HTTP as HTTP endpoint (MapRelayEndpoints)
    participant Bus as ICommandBus
    participant Pipe as Pipeline (behaviors)
    participant H as CreateProductCommandHandler
    HTTP->>Bus: Execute<CreateProductCommand, Product>(cmd)
    Bus->>Pipe: build pipeline for this message type
    Pipe->>Pipe: Authorization (priority 0)
    Pipe->>Pipe: Validation (priority 1) — run validators, aggregate failures
    Pipe->>Pipe: Logging (priority 2)
    Pipe->>Pipe: Transaction (priority 1000) — begins only for non-[SkipTransaction] commands
    Pipe->>H: Handle(cmd, ct)
    H-->>Pipe: Product
    Pipe-->>Bus: Product (commit/rollback on the way out)
    Bus-->>HTTP: Product → 200 OK

The pipeline is composed from a list of behaviors ordered by ascending priority, wrapped innermost-out. The practical consequence — and it is worth getting right because the framework’s older prose states it backwards — is:

The lowest priority number runs first (outermost); the highest runs last (innermost, closest to the handler).

So Authorization (0) runs before anything else and can reject a request before any work happens; Validation (1) runs next; the Transaction behavior (1000) is the innermost wrapper, beginning a transaction immediately before the handler and committing immediately after. Authorization and validation therefore run outside the transaction — exactly what you want, since you should never open a database transaction for a request you are going to reject. A behavior with no attribute is treated as automatic with the default priority (1000); equal priorities keep registration order.

Building it step by step

The full project is in samples/001-getting-started; here is how it comes together.

1. Project setup

Reference the framework projects you need — for a CQRS HTTP service that is Relay (the buses), Relay.Core (the message interfaces), Relay.Http (endpoint generation) and Relay.Web (exception mapping):

<ItemGroup>
  <ProjectReference Include="$(RelaySrc)/Nuvora.Nexus.Relay/Nuvora.Nexus.Relay.csproj" />
  <ProjectReference Include="$(RelaySrc)/Nuvora.Nexus.Relay.Core/Nuvora.Nexus.Relay.Core.csproj" />
  <ProjectReference Include="$(RelaySrc)/Nuvora.Nexus.Relay.Http/Nuvora.Nexus.Relay.Http.csproj" />
  <ProjectReference Include="$(RelaySrc)/Nuvora.Nexus.Relay.Web/Nuvora.Nexus.Relay.Web.csproj" />
</ItemGroup>

2. A command

A command is a record implementing ICommand<TResponse>. The [RelayHttp*] attribute turns it into an HTTP endpoint; [SkipTransaction] opts it out of the transactional pipeline (this sample stores state in memory, so there is no transaction to open — article 005 removes that attribute once a real event store is in play); [AllowAnonymous] declares the endpoint public:

[RelayHttpPost("/products")]
[RelayHttpTag("Catalog")]
[SkipTransaction]
[AllowAnonymous]
public sealed record CreateProductCommand(string Name, string Category, decimal Price) : ICommand<Product>;

Relay is fail-closed about authorization. Every command and query must declare an authorization posture; the default for an undecorated message is require authentication. If you wire no authorization behavior (this sample calls no AddRelayAuth()) and leave a message undecorated, the host refuses to start rather than serve it unauthenticated. Because this getting-started API has no authentication story, each endpoint is marked [AllowAnonymous] (from Nuvora.Nexus.Relay.Auth). Article 010 replaces these with real [RequireRole] / [RequirePermission] rules.

3. A validator

Validators are discovered and registered automatically. The ValidationBehavior runs them all and aggregates the failures into one ValidationException, which the Web layer turns into an HTTP 400 — so the caller sees every problem at once instead of fixing them one round-trip at a time:

public sealed class CreateProductCommandValidator : ICommandValidator<CreateProductCommand, Product>
{
    public Task<ValidationResult> ValidateAsync(CreateProductCommand command, CancellationToken ct)
    {
        var failures = new List<ValidationFailure>();
        if (string.IsNullOrWhiteSpace(command.Name))
            failures.Add(new ValidationFailure(nameof(command.Name), "Name is required."));
        if (command.Price <= 0m)
            failures.Add(new ValidationFailure(nameof(command.Price), "Price must be greater than zero."));
        return Task.FromResult(new ValidationResult(failures));
    }
}

4. A handler

The handler is where the work happens. It is auto-discovered, so it is never registered by hand; its dependencies (here the in-memory ProductCatalog) are injected:

public sealed class CreateProductCommandHandler(ProductCatalog catalog)
    : ICommandHandler<CreateProductCommand, Product>
{
    public Task<Product> Handle(CreateProductCommand command, CancellationToken cancellationToken)
    {
        var product = new Product(
            Id: $"p-{Guid.NewGuid():N}",
            Name: command.Name.Trim(),
            Category: command.Category.Trim().ToLowerInvariant(),
            Price: command.Price,
            Discontinued: false);
        return Task.FromResult(catalog.Save(product));
    }
}

5. A query

Queries are symmetrical. A query returning a reference type that resolves to null is mapped to HTTP 404 automatically, which is why GetProductQuery can be terse:

[RelayHttpGet("/products/{id}")]
[AllowAnonymous]
public sealed record GetProductQuery(string Id) : IQuery<Product?>;

public sealed class GetProductQueryHandler(ProductCatalog catalog) : IQueryHandler<GetProductQuery, Product?>
{
    public Task<Product?> Handle(GetProductQuery query, CancellationToken cancellationToken)
        => Task.FromResult(catalog.Get(query.Id));
}

6. Configuration

Everything is wired in one place. AddRelay(assembly) does the scanning and registration:

public static IServiceCollection AddCatalogServices(this IServiceCollection services)
{
    services.AddRouting();
    services.AddSingleton<ProductCatalog>();
    services.AddRelay(typeof(CatalogServiceCollectionExtensions).Assembly); // scans for handlers/validators/behaviors
    services.AddRelayInMemoryCache();          // an ICachingStrategy is required to dispatch any query
    services.AddRelayExceptionHandling(o => o.IncludeExceptionDetails = true);
    return services;
}

Why AddRelayInMemoryCache() in a sample with nothing cached? The query pipeline includes a QueryCachingBehavior that only caches [Cacheable] queries — but DI still constructs it for every query (the trigger attribute decides whether it runs, after the instance exists), and its constructor needs an ICachingStrategy. So any app that dispatches a query must register one or query handling fails. In-memory is the simplest; article 009 uses the same call to actually cache a [Cacheable] query.

var app = builder.Build();
app.UseRelayExceptionHandling();           // exceptions → ProblemDetails
app.UseRouting();
app.UseEndpoints(endpoints =>
{
    endpoints.MapRelayEndpoints();          // one endpoint per [RelayHttp*] message
});
app.Run();

Notice there is no services.AddScoped<ICommandHandler<...>>() anywhere, and no services.AddSingleton<ICommandBus, CommandBus>(). Registering a bus or handler by hand is a mistake (see Common mistakes); AddRelay owns that.

Complete source code

The complete, runnable code is in the sample. The pieces:

File Contents
Catalog/Product.cs The Product record and the in-memory ProductCatalog store
Catalog/Commands.cs Commands, the validator, and command handlers
Catalog/Queries.cs Queries and query handlers (incl. pagination)
CatalogServiceCollectionExtensions.cs AddRelay + exception handling
Program.cs Host, middleware, MapRelayEndpoints()

Running the example

dotnet run --project samples/001-getting-started/Catalog.Api
# list active products (paginated)
curl http://localhost:5000/products
# → {"items":[{"id":"p-1000","name":"Aeron Chair",...}],"pagination":{"totalCount":3,"page":1,"pageSize":...}}

# create one
curl -X POST http://localhost:5000/products \
  -H 'content-type: application/json' \
  -d '{"name":"Laptop Stand","category":"Peripherals","price":59.95}'
# → 200 {"id":"p-...","name":"Laptop Stand","category":"peripherals","price":59.95,"discontinued":false}

# a validation failure
curl -i -X POST http://localhost:5000/products \
  -H 'content-type: application/json' -d '{"name":"","category":"","price":0}'
# → HTTP/1.1 400 Bad Request  (ProblemDetails body)

# an unknown product
curl -i http://localhost:5000/products/nope
# → HTTP/1.1 404 Not Found

Note the response codes are not written anywhere in your code: the endpoint layer maps a returned value to 200 OK, a null to 404 Not Found, a void command to 204 No Content, and any thrown NotFoundException/ValidationException to 404/400 via the Web exception mapper.

Testing

The sample ships two test styles, and the distinction matters.

Unit tests (CatalogHandlerTests.cs) construct a handler or validator directly and call it. A handler is just a class with constructor dependencies, so there is no framework to spin up:

[Fact]
public async Task CreateProduct_stores_a_normalised_product()
{
    var catalog = new ProductCatalog();
    var product = await new CreateProductCommandHandler(catalog)
        .Handle(new CreateProductCommand("  Webcam  ", "Peripherals", 89.00m), CancellationToken.None);

    product.Name.Should().Be("Webcam");          // trimmed
    product.Category.Should().Be("peripherals");  // lower-cased
}

These are fast and prove the behavior. They exist because most of your logic lives in handlers, and handlers should be testable without HTTP, DI, or a database.

Integration tests (CatalogEndpointTests.cs) build an in-process TestServer around the same service registration the real host uses, then make real HTTP calls. They prove the wiring: binding, the pipeline (validation runs!), and the status-code mapping:

[Fact]
public async Task Create_with_invalid_body_returns_400()
{
    using var server = CatalogTestHost.Create();
    using var client = server.CreateClient();
    var response = await client.PostAsJsonAsync("/products", new { name = "", category = "", price = 0m });
    response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}

They exist because unit tests cannot catch a forgotten MapRelayEndpoints(), a binding mistake, or an exception that maps to the wrong status code.

dotnet test samples/001-getting-started/Catalog.Api.Tests

Production considerations

  • Scaling. The mediator is in-process and stateless; scale the service horizontally as usual. The hot path is reads — article 009 adds query caching with one attribute, and the read side can be scaled independently of the write side (the whole point of CQRS).
  • Monitoring & observability. Relay emits an OpenTelemetry ActivitySource/Meter named Nuvora.Nexus.Relay, including per-command/per-query counters and duration histograms. Article 021 wires these into a dashboard; for now, know that every dispatch is already traced and timed.
  • Reliability. With no database in this sample there is nothing to make transactional. The moment a real write store appears, dropping [SkipTransaction] makes the Transaction behavior wrap the handler so state changes and emitted events commit atomically (article 005).
  • Deployment & versioning. Messages are your API contract. Treat command/query shapes like any public contract: add optional properties rather than reordering or removing, and prefer a new command over silently changing an old one’s meaning.
  • Migration. You can adopt the mediator incrementally — keep existing controllers and inject ICommandBus/IQueryBus into them, moving one operation at a time behind a message. There is no big-bang rewrite.

Common mistakes

  • Registering handlers or buses by hand. services.AddScoped<ICommandHandler<...>, ...>() or re-registering ICommandBus fights AddRelay and leads to “which one wins?” confusion. Let AddRelay(assembly) do it; just make sure your handlers live in a scanned assembly.
  • Forgetting [SkipTransaction] on a command in a service with no unit of work. The Transaction behavior (priority 1000) will try to resolve a unit of work and fail at runtime. Either register a persistence stack (article 005) or mark stateless commands [SkipTransaction].
  • Two handlers for one message. The RelayHandlerAnalyzer flags this at compile time (RELAY002), and a message with no handler (RELAY001). Heed the warnings — at runtime the first registration wins, which is rarely what you meant.
  • Putting orchestration in the controller/endpoint. If your minimal-API endpoint does three Execute calls and some ifs, that orchestration belongs in a handler (or a saga, article 015). The endpoint should bind and dispatch, nothing more — which is why MapRelayEndpoints generates it.
  • Doing validation inside the handler “to be safe.” Validators run before the handler and before any transaction; duplicating their checks in the handler is noise. Keep input validation in validators and reserve the handler for domain rules that need loaded state.

Tradeoffs

Benefits. Uniform cross-cutting concerns; vertical slices that localise change; a typed, introspectable catalogue of every operation (ICommandBus.GetAllCommands()); trivially testable handlers; and generated, consistent HTTP endpoints.

Costs. More types per feature (a command, maybe a validator, a handler); a layer of indirection between “HTTP request” and “code that runs”; and a pattern the whole team must understand. For small CRUD this is a poor trade; for a behavior-rich domain it is a clear win.

Alternatives

  • Controllers + service classes (the default). Less indirection, fewer files. Fine until cross-cutting concerns and orchestration start to sprawl across services; then you are hand-rolling the pipeline Relay gives you.
  • Plain minimal APIs calling repositories. Great for genuine CRUD and prototypes. You lose the uniform pipeline and the typed message catalogue, and you will reinvent validation/transaction wrapping per endpoint as the service grows.
  • A bare mediator library (e.g. MediatR). The same pattern without Relay’s production pipeline — no built-in transactional event-store/outbox integration, fail-closed authorization, caching or multi-tenancy. If you only ever need the dispatch mechanism, a bare mediator is lighter; if you are heading toward event sourcing and reliable messaging, those integrations are the reason to choose Relay.

Remain honest with yourself: if the answer to “what behavior am I protecting?” is “none,” the alternatives win.

Lessons from production systems

  • The pipeline is where teams get the most leverage, and the most surprise. A new behavior applies to every message instantly — a gift (add audit logging once) and a footgun (a slow behavior taxes every request). Treat behaviors like middleware: few, fast, and well-understood. Use priority deliberately so authorization is always outermost and transactions innermost.
  • “Handler not found” is almost always an assembly-scanning problem. The message and handler must be in an assembly passed to AddRelay. Teams hit this when they split messages and handlers across projects; the fix is to scan both assemblies, not to register by hand.
  • Validators that mirror data annotations drift. Pick one home for input validation — Relay validators — and keep it there. Two validation mechanisms inevitably disagree, and the bug surfaces as “the API rejected something the UI allowed.”
  • CQRS is a spectrum. This article uses one model for reads and writes. That is fine, and common. You do not have to split read/write models or event-source anything to benefit from the mediator; add that complexity only when a specific problem demands it (articles 005, 007).

Should you use this?

Situation Recommendation
A service with real domain behavior (orders, payments, inventory) Strong fit — the pipeline and slices pay off immediately
A growing team adding many small operations Strong fit — vertical slices localise change
You intend to adopt event sourcing / reliable messaging later Strong fit — this is the on-ramp to the rest of Relay
A CRUD admin panel over lookup tables Usually avoid — a controller + DbContext is simpler
A throwaway prototype or spike Usually avoid — the uniformity never pays off
An internal tool with no cross-cutting concerns Lean against — start plain; adopt the mediator if it grows behavior

The deciding question is always: is there behavior worth protecting with a uniform pipeline? If yes, the mediator earns its keep from the first command.

Next steps

You now have commands, queries, handlers, validation and HTTP endpoints. But the Product here is a plain record and the “store” is a dictionary — there is no domain model guarding its own invariants. The next article introduces the aggregate: an object that owns its rules and expresses every change as a domain event.

➡️ 002 — First Aggregate