Minimal APIs Before Minimal APIs Existed
Convey.WebApi threw away controllers, replaced every MVC formatter with its own, and bound route values into immutable commands by writing to compiler-generated backing fields. A source read of a routing layer built years before .NET shipped the same idea.
Before .NET 6 shipped Minimal APIs, every microservice paid the controller tax. A service with four endpoints carried ControllerBase inheritance, attribute routing, action filters, model binding conventions, ApiExplorer metadata — an entire MVC pipeline built for applications fifty times its size. Teams felt the mismatch; most just lived with it.
The authors of Convey, the open-source .NET microservices toolkit from DevMentors, didn't. Convey.WebApi is their answer: a controller-less routing DSL over raw IEndpointRouteBuilder, with its own JSON pipeline, its own model binding, and its own error handling. Reading it in 2026 is a strange pleasure, because it is recognisably Minimal APIs, hand-built years early — same instincts, same shape, and some sharp edges the official feature later sanded off. This part of the series is a tour of the machinery; the sharp edges are half the value.
The DSL
Endpoints are lambdas registered against paths, fluent all the way down:
app.UseEndpoints(endpoints => endpoints
.Get("ping", ctx => ctx.Response.WriteAsync("pong"))
.Get<GetParcel>("parcels/{parcelId}", async (query, ctx) =>
await ctx.Response.WriteJsonAsync(await FindParcel(query)))
.Post<RegisterParcel>("parcels", async (cmd, ctx) =>
{
await Register(cmd);
ctx.Response.Created($"parcels/{cmd.ParcelId}");
}, auth: true, roles: "courier"));
EndpointsBuilder wraps the route builder and does three things per registration: maps the route (MapGet/MapPost/…), applies authorization (a policies array wins; otherwise an AuthorizeAttribute with optional roles when auth: true), and — quietly, and this matters later — records a WebApiEndpointDefinition describing the method, path, input type and response type into a singleton list. There is no reflection over controllers because there are no controllers; the definitions list is the API metadata, and it is what the Swagger integration (part 7) will feed on.
Notice auth: true, roles: "courier" as plain method arguments. Minimal APIs eventually gave us .RequireAuthorization(); Convey put it in the signature. Cruder, but every endpoint's security posture is readable at the call site — something I've missed on codebases where authorization hides in policy conventions three files away.
A JSON pipeline with no negotiation
AddWebApi() calls AddMvcCore() and then does something bold: it clears every input and output formatter MVC registered and substitutes two of its own. Both are unconditional — CanRead and CanWriteResult simply return true. Content negotiation is not simplified; it is abolished. The service speaks JSON, full stop.
The upside is uniformity: one IJsonSerializer abstraction (System.Text.Json by default, pluggable to Newtonsoft — which Convey detects by, I promise, checking whether the serializer's namespace string contains "Newtonsoft", and flipping AllowSynchronousIO on Kestrel if so). The downside is that anything expecting standard MVC behaviour — ProblemDetails, XML for that one legacy consumer, Accept header handling — is gone, and nothing tells you at compile time.
The backing-field trick
Here is the most interesting ten lines in the package. Convey's commands are typically immutable — get-only properties, values set through constructors. So how does POST parcels/{parcelId} get the route's parcelId into the deserialized command? By going around the language:
// Convey.WebApi/Extensions.cs (ReadJsonAsync<T>)
var field = payload.GetType().GetFields(BindingFlags.Instance | BindingFlags.NonPublic)
.SingleOrDefault(f => f.Name.ToLowerInvariant().StartsWith($"<{key}>",
StringComparison.InvariantCultureIgnoreCase));
if (field is null) continue;
var fieldValue = TypeDescriptor.GetConverter(field.FieldType)
.ConvertFromInvariantString(value.ToString());
field.SetValue(payload, fieldValue);
When the C# compiler sees public Guid ParcelId { get; }, it generates a private field named <ParcelId>k__BackingField. Convey deserializes the body, then walks the route values and writes each one directly into the matching backing field, bypassing the property, the constructor, and immutability itself. TypeDescriptor converts the route string into a Guid, int, whatever the field wants.
I have genuinely mixed feelings about this. As a user it is wonderful: the URL is the source of truth for the identifier, the body cannot lie about it, and commands stay immutable in every line of code you write. As a source reader it is a hazard inventory: the lookup is SingleOrDefault over lower-cased names, so two properties differing only by case throw at runtime; the <name> prefix convention is a Roslyn implementation detail, not a contract; obfuscators and future compilers can break it silently. Minimal APIs later solved the same problem with [AsParameters] and explicit binding — the boring, durable way. This is the exciting way. It is enabled by the package's single option (webApi section, BindRequestFromRoute: true), which — one more wart — is stashed in a private static bool, global to the process, so two hosts in one test fixture share it whether they like it or not.
Validation, at least, is pleasantly conventional: after binding, Validator.TryValidateObject runs DataAnnotations and a failure writes a 400 with the validation results as JSON before your handler ever runs.
ReadQuery and the string surgery
GET endpoints bind their input from route values plus the query string. The implementation takes a route I'd never dare in review: it merges both into a dictionary, serializes the dictionary to JSON, then deserializes that JSON into T. And because every value in the dictionary is a string, nested objects arrive as escaped JSON-in-JSON, which the code fixes with literal string replacement:
var serialized = serializer.Serialize(values.ToDictionary(k => k.Key, k => k.Value))
?.Replace("\\\"", "\"")
.Replace("\"{", "{")
.Replace("}\"", "}")
.Replace("\"[", "[")
.Replace("]\"", "]");
It works, for the query shapes services actually use — flat objects with primitive properties. It also corrupts any legitimate value that happens to contain "{, and it round-trips every GET through a serializer twice. This is the kind of code that tells you the truth about a toolkit's priorities: pragmatism over polish, ship the 95% case. Knowing exactly where the 5% lives is why I read the source before trusting a dependency with production traffic.
Errors as a mapping, not a middleware you write
The last piece is the one I've stolen for other stacks. Instead of each service hand-rolling exception middleware, Convey ships the middleware and asks you for a single function:
public class ExceptionToResponseMapper : IExceptionToResponseMapper
{
public ExceptionResponse Map(Exception exception) => exception switch
{
ParcelNotFoundException ex => new ExceptionResponse(
new { code = "parcel_not_found", reason = ex.Message }, HttpStatusCode.NotFound),
DomainException ex => new ExceptionResponse(
new { code = ex.Code, reason = ex.Message }, HttpStatusCode.BadRequest),
_ => new ExceptionResponse(
new { code = "error", reason = "There was an error." }, HttpStatusCode.InternalServerError)
};
}
AddErrorHandler<ExceptionToResponseMapper>() plus UseErrorHandler() and every unhandled exception in the service becomes a consistent JSON error envelope. The exception-to-contract mapping lives in one reviewable file per service. The only trap: if you forget AddErrorHandler, a no-op mapper is silently registered and unmapped exceptions fall through with nothing useful — the fallback returns null, not a default response. Register a real mapper on day one.
What Minimal APIs kept, and what it didn't
Score the design against where the platform landed:
- Lambdas on routes, no controllers — vindicated completely. This is just Minimal APIs with a fluent accent.
- Endpoint metadata recorded at registration — vindicated; .NET does this with
EndpointMetadataCollection, Convey did it with aList<WebApiEndpointDefinition>. - Route-into-body binding via backing fields — replaced by explicit, source-generated binding. Rightly so, but Convey's version needed zero annotations, and I understand why users loved it.
- Owning the whole JSON pipeline — half-vindicated. Minimal APIs also went JSON-first, but kept content negotiation reachable. Convey burned the bridge.
If you are on .NET 8+ starting fresh, use Minimal APIs; this layer is the one part of Convey the platform has fully absorbed. But if you want to understand why Minimal APIs look the way they do — what problems each design choice was answering — this package is the best archaeology I know. Next part: what happens when these endpoints stop taking lambdas and start dispatching CQRS messages directly.