Skip to content
Kumar Chandrachooda
.NET

From Route to Handler with No Controller in Between

Convey.WebApi.CQRS wires HTTP verbs straight to command and query dispatchers - one line per endpoint - and publishes your message catalog at /_contracts. A source read of the thinnest HTTP layer I've shipped to production.

By Kumar Chandrachooda 23 Jan 2026 6 min read
A verb, a message, a dispatcher - the whole HTTP layer

Once a service has a CQRS spine — commands, queries, dispatchers, handlers (part 3) — its HTTP endpoints degenerate into ritual. Deserialize the request into a command. Send it to the dispatcher. Return a status code. Every endpoint, the same three lines, wrapped in whatever routing ceremony your framework demands. When a layer becomes pure ritual, a good toolkit makes it declarative.

Convey.WebApi.CQRS, from the open-source Convey toolkit by DevMentors, is that move. It fuses the endpoint DSL from part 5 with the CQRS dispatchers from part 3, so an endpoint declaration is nothing but a verb, a route, and a message type. It also does something quietly radical that I have not seen another .NET toolkit do: it serves your service's entire message catalog as a machine-readable HTTP endpoint. Both deserve a close read.

The endpoint is the message type

Here is the Orders service from Convey's own samples — its complete HTTP surface:

app.UseDispatcherEndpoints(endpoints => endpoints
    .Get("", ctx => ctx.Response.WriteAsync("Orders Service"))
    .Get("ping", ctx => ctx.Response.WriteAsync("pong"))
    .Get<GetOrder, OrderDto>("orders/{orderId}")
    .Post<CreateOrder>("orders",
        afterDispatch: (cmd, ctx) => ctx.Response.Created($"orders/{cmd.OrderId}")))

Get<GetOrder, OrderDto>("orders/{orderId}") is a full endpoint. No handler lambda, no controller, no MediatR-style Send call in your code. The builder generates the plumbing: bind GetOrder from route and query string, resolve IQueryDispatcher, dispatch, write the result. The defaults are opinionated and, in my experience, correct:

// Convey.WebApi.CQRS/Builders/DispatcherEndpointsBuilder.cs
var dispatcher = ctx.RequestServices.GetRequiredService<IQueryDispatcher>();
var result = await dispatcher.QueryAsync<TQuery, TResult>(query);
if (afterDispatch is null)
{
    if (result is null)
    {
        ctx.Response.StatusCode = 404;
        return;
    }

    await ctx.Response.WriteJsonAsync(result);
    return;
}

await afterDispatch(query, result, ctx);

A null query result is a 404, automatically. Your GetOrderHandler returns null when the order doesn't exist and never touches an HttpContext — the HTTP translation lives in exactly one place. Commands (Post<T>, Put<T>, Delete<T>) get the mirror treatment: dispatch via ICommandDispatcher, default to a bare 200 when no afterDispatch is supplied.

The escape hatches are the two hooks. beforeDispatch runs after binding, before the handler — the natural home for enriching a command with the authenticated user's identity. afterDispatch replaces the default response — which is how the sample turns CreateOrder into a proper 201: the command's OrderId is generated client-side-or-constructor-side (a Guid, assigned in the command's constructor if the caller omits it), so the endpoint already knows the new resource's URL without waiting for a database round trip. That one line — ctx.Response.Created($"orders/{cmd.OrderId}") — encodes a whole architectural stance: commands don't return values, so if HTTP needs an identifier, the identifier must exist before the handler runs.

That stance is the real trade-off of this package. The day your product owner wants the created resource echoed back in the response body, you either fight the toolkit (dispatch, then immediately query — two scopes, two handler invocations) or bend your CQRS purity. Convey chose purity and made the common cases free; you should know which cases stop being free.

For code that needs to dispatch outside the endpoint table, the package adds HttpContext.SendAsync(command) / QueryAsync(query) extensions and an IDispatcher facade (AddInMemoryDispatcher()) that unifies command, event, and query dispatch behind one interface. Minor grumble from the source: unlike nearly every other Convey module, AddInMemoryDispatcher skips the TryRegister idempotency guard, so calling it twice registers the singleton twice. Harmless with AddSingleton, but it breaks the framework's own convention — the kind of inconsistency you only notice reading packages side by side.

/_contracts: the API your messages already were

The second half of the package answers a question every event-driven team eventually asks: what messages does this service actually publish and consume? The usual answers — a wiki page, a shared contracts repo, tribal memory — all rot. Convey's answer is UsePublicContracts<T>():

app.UsePublicContracts<PublicContractAttribute>();

Hit /_contracts and the service returns a JSON document of every command and event type it knows, each rendered as an example instance with default values — property names, casing, shape, everything a consuming team needs to build a deserializer against you. The implementation (PublicContractsMiddleware) is worth reading precisely because it is so blunt:

// Convey.WebApi.CQRS/Middlewares/PublicContractsMiddleware.cs
var assemblies = AppDomain.CurrentDomain.GetAssemblies();
var contracts = assemblies.SelectMany(a => a.GetTypes())
    .Where(t => (!_attributeRequired || t.GetCustomAttribute(attributeType) is not null)
                && !t.IsInterface)
    .ToArray();

At first request-pipeline construction it sweeps every loaded assembly for ICommand and IEvent implementations (optionally filtered to types carrying [PublicContract]), builds a default instance of each, serializes the whole catalog once into a static string, and serves that same string forever. Zero per-request cost. Duplicate type names across namespaces throw at startup — rude, but arguably the right rudeness: two different OrderCreated events in one service is a bug in your naming, and I'd rather hear it at boot than in a consumer's parser.

I've used this endpoint as the backbone of a poor-man's schema registry: a CI job in each consuming service pulls /_contracts from its upstreams and diffs against the committed snapshot. Breaking contract change, failed build, no Confluent bill. For teams not ready for Avro-and-registry infrastructure, an endpoint like this is 80% of the value for 2% of the operational weight.

The cracks, because there are always cracks

Reading this middleware is also a lesson in the failure modes of static state:

  • The response write is not awaited. InvokeAsync calls context.Response.WriteAsync(_serializedContracts); and then returns Task.CompletedTask — the write is fire-and-forget. It works because the payload is small and the connection outlives the middleware, but it is a latent truncation bug and would fail any async code review I've ever sat in.
  • Initialization races, benignly. The constructor checks _initialized == 1 unguarded, then Load re-checks with Interlocked.Exchange. The double-check saves nothing and the first check without a fence is exactly the pattern the Interlocked call exists to replace. Correct outcome, untidy path.
  • Assembly.GetTypes() with no safety net. One assembly with an unloadable reference and startup dies with ReflectionTypeLoadException. The fix (catching and using ex.Types) is three lines the middleware doesn't have.
  • The catalog is process-global. Static dictionary, static string — fine for one host per process, surprising in integration tests that boot multiple services in one test host and find each other's contracts.

None of these has ever actually burned me in production — single host, small catalog, contracts stable after boot. I list them because this series is a source read, not a brochure, and because they mark exactly where I'd harden the pattern if I reimplemented it today (I'd also swap the assembly sweep for an explicit type registration — implicit discovery is how a TestOrderCreated fixture class ends up published in your public catalog).

Where this leaves the HTTP layer

Stack parts 5 and 6 together and the shape of Convey's web story is clear: HTTP is a transport adapter over a message-driven core. Endpoints don't contain logic; they name a message and optionally decorate the response. The same CreateOrder command that arrives here via POST will, in part 11, arrive via RabbitMQ with the same handler untouched — that symmetry is the entire payoff of routing HTTP through dispatchers instead of controllers.

Before the series crosses to the broker, though, one loose end: we deleted the controllers, so ApiExplorer has nothing to document — yet the sample still serves Swagger. Next part is the small, clever package that rebuilds an OpenAPI document from the endpoint definitions this DSL has been quietly recording all along.