The Route Values It Throws Away
Trill's gateway parses the story id out of the URL, boxes it into a route-value dictionary, and uses the result as a boolean - so the synchronous and asynchronous versions of the same operation have different contracts and only one of them honours the URL.
The most expensive bugs are not the ones that throw. They are the ones where a value is computed correctly, at the right moment, in the right shape — and then nobody reads it. No exception, no log line, no failed test. The code looks like it works because ninety per cent of it does.
Part 5 showed how Trill's API gateway turns two configured HTTP endpoints into AMQP messages without knowing a single contract class. This part is about the four lines at the top of that loop, and the fifteen-line fix that would close the gap they leave.
A route matcher that returns everything and is asked nothing
Here is the matcher, in full. It is a small, correct, idiomatic use of the ASP.NET Core routing primitives:
public class RouteMatcher
{
public RouteValueDictionary Match(string routeTemplate, string requestPath)
{
var template = TemplateParser.Parse(routeTemplate);
var matcher = new TemplateMatcher(template, GetDefaults(template));
var values = new RouteValueDictionary();
return matcher.TryMatch(requestPath, values) ? values : null;
}
private static RouteValueDictionary GetDefaults(RouteTemplate parsedTemplate)
{
var result = new RouteValueDictionary();
foreach (var parameter in parsedTemplate.Parameters)
{
if (parameter.DefaultValue != null)
{
result.Add(parameter.Name, parameter.DefaultValue);
}
}
return result;
}
}
Trill.APIGateway/src/Trill.APIGateway/Framework/RouteMatcher.cs:6-30.
TemplateParser.Parse and TemplateMatcher are the same types MVC's own route matching uses. This is not a hand-rolled regex; it handles catch-alls, optional segments and defaults exactly as the framework does, and GetDefaults even threads template default values through so that {page=1} behaves the way a reader would expect. Somebody knew what they were doing.
TryMatch(requestPath, values) populates values as a side effect and returns a boolean. That is the routing API's shape: the dictionary is the output parameter, the boolean is the success flag. On success, values contains one entry per template parameter, parsed out of the URL.
The method returns the populated dictionary or null. A perfectly good signature — non-null means matched, and the caller gets the extracted values in the same call.
Now the caller, MessagingMiddleware.cs:51-55:
var match = _routeMatcher.Match(endpoint.Path, context.Request.Path);
if (match is null)
{
continue;
}
match is never read again. Not on the next line, not anywhere in the file. A fully populated RouteValueDictionary is constructed, filled from the URL, returned, assigned to a local, tested for null, and discarded.
What that costs, concretely
The gateway's two configured endpoints are:
POST stories-service/stories/async -> stories / send_story
POST stories-service/stories/{storyId}/rate/async -> stories / rate_story
The first has no template parameters, so nothing is lost. The second does. For a request to /stories-service/stories/1234567890/rate/async, the matcher parses storyId = "1234567890", boxes it into a dictionary, and the middleware throws it away. The message published to the stories exchange under routing key rate_story contains only the JSON request body.
Now look at what the Stories service expects. The command is three properties:
public class RateStory : ICommand
{
public long StoryId { get; }
public Guid UserId { get; }
public int Rate { get; }
public RateStory(long storyId, Guid userId, int rate)
Trill.Services.Stories/src/.../Application/Commands/RateStory.cs:6-12.
And here is the synchronous route for the same operation, from Stories' own Program.cs:66:
.Post<RateStory>("stories/{storyId}/rate")
Convey's dispatcher endpoints bind route values into the command. On the synchronous path, {storyId} from the URL populates RateStory.StoryId. On the asynchronous path it does not, because the gateway dropped it. The two paths to the same operation have different contracts, and only the synchronous one honours the URL. A caller who follows the REST convention that identity lives in the path — which is exactly what the route template invites — publishes a rate_story message with StoryId defaulted to 0.
The fair reading
I want to be careful not to overstate this, because the estate's own sample request would survive it. Trill.Services.Stories.rest posts a rating like this:
POST {{url}}/stories/{{storyId}}/rate
Content-Type: application/json
{
"userId": "{{userId}}",
"storyId": "{{storyId}}",
"rate": 1
}
The body carries storyId redundantly alongside the path segment. A caller who copies that request works on both paths. So this is not a bug that would bite the author on the first try; it is a bug that bites the second consumer, the one who reads the route template, concludes the id belongs in the URL, and omits it from the body. It is also, per part 5, a path no committed artefact in the estate ever exercises — the gateway's own .rest file contains six requests and all six are GET. Nothing here was ever going to be caught by running it.
And the design instinct behind the endpoint table is sound. Letting the async route mirror the sync route's shape — same path, /async suffix — is precisely right: it means the async endpoint is discoverable by anyone who knows the sync one, and it keeps one URL vocabulary across both transports. The gap is that the mirror is only skin-deep. The paths agree; the payloads do not.
The fix is about fifteen lines
This is the part that makes the defect worth an article rather than a footnote, because the correct version is right there. The middleware already has the parsed values and already has a mutable JObject:
var content = await new StreamReader(context.Request.Body).ReadToEndAsync();
var message = JsonConvert.DeserializeObject(content);
Merging one into the other is a handful of lines. Deserialise as JObject rather than object, and for each key in match that the body does not already define, add it:
var payload = string.IsNullOrWhiteSpace(content)
? new JObject()
: JObject.Parse(content);
foreach (var (name, value) in match)
{
if (value is null || payload.ContainsKey(name))
{
continue;
}
payload[name] = JToken.FromObject(value);
}
_rabbitMqClient.Send(payload, conventions, messageId, correlationId, spanContext, correlationContext);
That is fresh code written for this article, not from the repository — but every input it needs is already in scope at the point where the original discards them. Body wins over route value, which preserves the estate's existing sample requests exactly, and route values fill the gaps for callers who put the id where the template says it goes. It costs one JObject.Parse instead of a DeserializeObject, and it makes the two contracts converge.
Convey's own dispatcher endpoints already implement this merge on the synchronous side — route values, then query string, then body — which is why the sync path behaves correctly. The gateway reimplemented the front half of that pipeline and stopped one step short.
The other endpoint, and the one nobody has added yet
The first configured endpoint, stories-service/stories/async mapping to send_story, has no template parameters at all, so nothing is lost on that path. It is worth looking at anyway, because it shows the same divergence from a different angle.
The synchronous POST /stories route is not a plain dispatch:
.Post<SendStory>("stories", afterDispatch: (cmd, ctx) =>
{
var storage = ctx.RequestServices.GetRequiredService<IStoryRequestStorage>();
var storyId = storage.GetStoryId(cmd.Id);
return ctx.Response.Created($"stories/{storyId}");
})
Trill.Services.Stories/src/Trill.Services.Stories.Api/Program.cs:60-65. That is a genuinely clever piece of plumbing. SendStory carries a client-side identifier — public Guid Id { get; } = Guid.NewGuid(); on line 10 of the command — the handler stashes the generated story id against it, and the HTTP layer reads it back to build a Location header. A Correlation Identifier used to bridge a void-returning CQRS command back to an HTTP response.
It is also, structurally, unavailable to the asynchronous path. The bridge publishes and returns 202 before anything is dispatched, so there is no response left to enrich and no Location to emit. The caller of /stories/async gets an empty 202 and no way to learn the id of the thing they created — which is a perfectly reasonable trade for fire-and-forget, and is exactly the sort of asymmetry that ought to be documented next to the endpoint table and is not.
Now imagine adding a third entry. Six operations in this estate already have an AMQP subscription and no publisher: CreateUser, CreateAd, ApproveAd and RejectAd, alongside the two that are wired. ApproveAd is PUT /ads/{adId}/approve on the synchronous side — a route whose entire payload is a path segment. Add "path": "ads-service/ads/{adId}/approve/async" to the endpoint table and the published message contains the request body and nothing else, which for that operation means an empty object. The next endpoint anyone adds is more likely to hit this than the two that exist, because the operations still unwired are the identity-in-the-URL ones.
The lesson that generalises
Two things travel out of this one.
The first is a code-review heuristic that I now apply reflexively: when a method returns rich data and the caller uses it as a boolean, one of the two is wrong. Either the signature is over-specified and should return bool with an out parameter, or the caller is silently dropping something it was handed. Both are worth a comment. Here the signature is right and the caller is wrong, and a _ = discard or a bool TryMatch(...) overload would have made the intent explicit either way.
The second is about parallel paths. The moment a system offers the same operation over two transports, the two payload constructions must share code or share a test — because they will diverge, and the divergence will be silent. Trill's async path is a JObject assembled by a gateway; its sync path is a typed command assembled by Convey's binder. Nothing in the estate compares them, and nothing could: there is no shared contract to compare against.
That is not the only thing the gateway rewrites on its way past. Next: the token becomes a body field, where the middleware immediately upstream of this one takes the caller's JWT apart and edits the request body with it.