Minimal APIs, Written by Hand
Trill's monolith implements typed Get, Post, Put and Delete extensions over IEndpointRouteBuilder on .NET 5 - the framework .NET shipped eighteen months later, complete with query binding by string replacement.
Trill.Shared.Infrastructure/Api/Extensions.cs is 334 lines written against .NET 5 in early 2021. It contains typed Get, Post, Put and Delete extensions over IEndpointRouteBuilder, model binding from route values and query strings, DataAnnotations validation with a 400 response, declarative auth, and eleven HttpResponse result helpers.
That is Minimal APIs. Microsoft shipped it in .NET 6 in November 2021. This file predates it, is smaller than it, and is the reason six modules can expose HTTP endpoints without a single controller between them. Part 8 covered the framework's most expensive decision; this one is its most audacious.
What one endpoint costs
Here is the whole surface a module touches (UsersModule.cs:59-61):
.Post<RevokeRefreshToken>($"{Path}/refresh-tokens/revoke")
.Get<GetUser, UserDetailsDto>($"{Path}/users/{{userId:guid}}")
.Get<BrowseUsers, Paged<UserDto>>($"{Path}/users")
Three endpoints, three lines. No controller class, no attribute routing, no IActionResult, no [FromRoute]. The command or query type is the request contract, and the generic parameters are the binding instruction.
The implementation behind Get is eleven lines (Api/Extensions.cs:39-52):
public static IEndpointRouteBuilder Get<TQuery, TResult>(this IEndpointRouteBuilder builder, string path,
Action<IEndpointConventionBuilder> endpoint = null,
Func<TQuery, HttpContext, Task> before = null,
Func<TQuery, TResult, HttpContext, Task> after = null,
bool auth = false, string roles = null,
params string[] policies)
where TQuery : class, IQuery<TResult>
{
var conventionBuilder = builder.MapGet(path, ctx => HandleQueryAsync(ctx, before, after));
endpoint?.Invoke(conventionBuilder);
ApplyAuthRolesAndPolicies(conventionBuilder, auth, roles, policies);
return builder;
}
- It composes with ASP.NET Core rather than replacing it.
builder.MapGetis the framework's own call; everything else is a delegate and some metadata. If this file disappeared, the routes would still be routes. beforeandafterare the escape hatches. Commands returnvoidin this CQRS model, soPOST /sign-inusesafterto fish the JWT back out of a request-scoped store and write it, andPOST /storiesuses it to emit aLocationheader.auth,rolesandpoliciesare on every verb. They compile down toRequireAuthorizationwith anAuthorizeAttribute(:146-166) — a genuine ASP.NET Core policy, not a bespoke check.- The default handler behaviour is opinionated and correct. A query returning null becomes a 404 (
:133-137); a command with noafterbecomes a 200.
HandleQueryAsync then resolves IQueryDispatcher from the request services and writes JSON. The whole per-request path is about twenty-five lines, and the dispatcher creates its own scope, so nothing in here holds a captive dependency.
Eleven ways to answer
The result helpers are extension methods on HttpResponse, which is a shape .NET's own Minimal APIs deliberately avoided in favour of IResult:
Ok, Created, Accepted, NoContent, MovedPermanently, Redirect, BadRequest, Unauthorized, Forbidden, NotFound, InternalServerError.
Created is the one that earns its place (:255-269) — it sets 201, adds a Location header if one is supplied and not already present, and writes a body only if given one. UsersModule.cs:52-53 uses it to give sign-up a proper 201 with a location, which the distributed build never did; its CreateUser returned whatever Convey's default was.
Read against IResult, the design has one clear weakness: writing to HttpResponse directly means the result is a side effect rather than a value, so it cannot be inspected, wrapped, or unit-tested without an HttpContext. That is precisely the problem IResult was introduced to solve. In 2021, with IActionResult as the only alternative and a stated goal of not dragging MVC in, writing to the response was the pragmatic call.
The line that will bite somebody
Query binding is where the cleverness turns into a liability (Api/Extensions.cs:191-198):
var serialized = JsonConvert.SerializeObject(values.ToDictionary(k => k.Key, k => k.Value))
?.Replace("\\\"", "\"")
.Replace("\"{", "{")
.Replace("}\"", "}")
.Replace("\"[", "[")
.Replace("]\"", "]");
return JsonConvert.DeserializeObject<T>(serialized, SerializerSettings);
Walk it, because every line is doing something and one of them is a trap.
- Route values and query-string values are merged into one dictionary (
:169-184), soGET /stories?page=2andGET /stories/{storyId}bind through the same code path into the same query object. That unification is genuinely nice and is why a query type never needs to know where its values came from. - Everything in that dictionary is a string.
HttpUtility.ParseQueryStringreturns strings; route values arrive as strings. So serialising the dictionary produces{"page":"2","results":"10"}— every value quoted. - The five
Replacecalls un-quote nested JSON. If a caller sends a value that is itself a JSON object or array, the naive serialisation would double-encode it, and these replacements strip the wrapping quotes back off soJsonConvertcan parse the nested structure. - And that is the trap. These are unconditional string replacements over the whole payload. Any query-string or route value containing
{,},[or]adjacent to a quote will be mangled, and the failure is a deserialisation error or — worse — a silently wrong bind. A story title in a search parameter, a tag with a bracket, a base64 cursor: all fine today because nothing in this app sends one, all broken the day something does.
JsonConvert.DeserializeObject<T> also handles the string-to-long/Guid/DateTime conversions for free, which is the actual reason this route was taken instead of writing a converter per type. The right fix is a small binder that walks the target type's properties and calls TypeDescriptor.GetConverter per property — more code, no string surgery, no trap. It is a genuinely instructive specimen of clever-until-it-isn't.
Four HTTP idioms in six modules
The framework offers one style. The modules use four:
| Module | HTTP style |
|---|---|
| Users | Framework verb extensions, twelve endpoints |
| Stories | Framework verb extensions, four endpoints |
| Ads | An MVC AdsController |
| Analytics | An MVC TrendingController |
| Timeline | A raw endpoints.MapGet with a hand-written lambda |
| Saga | None — empty ConfigureEndpoints |
Timeline's is the odd one (TimelineModule.cs:32-44): it reads context.Request.RouteValues["userId"], parses the GUID by hand, resolves IStorage from request services and writes JSON — thirteen lines to do what .Get<GetTimeline, Paged<Story>>(...) would have done in one, using a framework that is right there in the same assembly.
The distributed build was no better and arguably worse. Trill.Services.Stories shipped both — three MVC controllers under api/... and Convey's UseDispatcherEndpoints mapping the same operations at the root (Program.cs:53-67). Two complete HTTP surfaces over one service. The monolith deleted the controllers and kept the dispatcher endpoints, which is a real cleanup: one of the ten features the rewrite dropped is an entire duplicate API.
The side channel under the void command
One more piece of the HTTP story deserves naming, because it is the tax the CQRS model levies on the API. Commands return void — ICommandHandler<T>.HandleAsync(T) has no result type — but POST /sign-in has to return a JWT and POST /stories has to return a story id.
The solution is a request-scoped side channel. Handlers stash their result in IRequestStorage, which is an IMemoryCache entry keyed by the command's own Id with a five-second TTL (Storage/RequestStorage.cs:16), and the after callback reads it back out (StoriesModule.cs:61-66):
.Post<SendStory>($"{Path}/stories", after: (cmd, ctx) =>
{
var storage = ctx.RequestServices.GetRequiredService<IStoryRequestStorage>();
var storyId = storage.GetStoryId(cmd.Id);
return ctx.Response.Created($"{Path}/stories/{storyId}");
})
Two module-level wrappers exist — TokenStorage keyed users:tokens:{commandId}, StoryRequestStorage keyed stories:{commandId} — and the distributed build does exactly the same thing with Convey's afterDispatch. The five-second TTL is a hard-coded correctness assumption about total request latency, uncommented, and the failure mode when it is exceeded is a Location header pointing at nothing.
The whole auth surface, unused
The sharpest thing in this file is what nobody calls. Every verb helper takes auth, roles and policies. IModule exposes IEnumerable<string> Policies => null and AddAuth reads it to register a policy per permission claim (Auth/Extensions.cs:137-145). AuthOptions has forty configuration properties. JWT bearer authentication is wired into the pipeline.
Grep the six modules for auth:, roles: or policies: at a call site and there are zero hits. No module implements Policies. Every endpoint in this application is anonymous, including PUT /users-module/users/{id}/lock, POST /users-module/users/{id}/funds — which mints money — and the ad approve, pay and publish flows.
The distributed build is no better; its gateway declares an "authenticatedUser" policy and applies it nowhere. But the monolith is where the framework's authorisation surface is fully built, fully wired and fully unused, and the test harness confirms it: WebApiTestBase.Authenticate(Guid) exists to inject a JWT into the client and no test ever calls it. A capability with zero call sites and zero coverage is a capability nobody has ever run.
That is the recurring shape of this repository — five subsystems shipped in the off position, an auth surface shipped in the unused position — and it is the subject of part 13. Before that, the claim modular monoliths make most often and support least: next, in-process is not synchronous.