A Gateway That Publishes What It Cannot Name
Trill's API gateway turns HTTP posts into AMQP messages using eighty lines of middleware, a route template and a raw JSON blob - it publishes onto the estate's bus with zero compile-time knowledge of any contract, which is the exact inverse of what every other repo does.
Most .NET services that publish to a message bus do it the same way: reference a contracts package, new up a record, call PublishAsync. The compiler knows the message type, the serialiser knows its shape, and the routing key is derived from the class name. It is safe, it is conventional, and it means the publisher cannot be a generic component — it has to be compiled against every contract it will ever send.
Trill's API gateway does the opposite, and it is the most interesting eighty lines in the estate.
Part 4 covered the AMQP topology those messages land on. This part is about the component that puts them there.
Two lines of configuration and a generic bridge
The gateway is a YARP reverse proxy — five routes, five clusters, one destination each, all config-driven. Alongside it sits a second, hand-written path:
"messaging": {
"enabled": true,
"endpoints": [
{
"method": "post",
"path": "stories-service/stories/async",
"exchange": "stories",
"routingKey": "send_story"
},
{
"method": "post",
"path": "stories-service/stories/{storyId}/rate/async",
"exchange": "stories",
"routingKey": "rate_story"
}
]
}
Trill.APIGateway/src/Trill.APIGateway/appsettings.json:64-80. A method, a route template, an exchange and a routing key. That is the entire declaration of an HTTP-to-AMQP bridge.
MessagingMiddleware reads it. The constructor groups the endpoints by uppercased HTTP method into a dictionary, so the per-request cost of a non-matching method is one dictionary lookup:
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
if (!_endpoints.TryGetValue(context.Request.Method, out var endpoints))
{
await next(context);
return;
}
foreach (var endpoint in endpoints)
{
var match = _routeMatcher.Match(endpoint.Path, context.Request.Path);
if (match is null)
{
continue;
}
var key = $"{endpoint.Exchange}:{endpoint.RoutingKey}";
if (!Conventions.TryGetValue(key, out var conventions))
{
conventions = new MessageConventions(typeof(object), endpoint.RoutingKey, endpoint.Exchange, null);
Conventions.TryAdd(key, conventions);
}
...
var content = await new StreamReader(context.Request.Body).ReadToEndAsync();
var message = JsonConvert.DeserializeObject(content);
_rabbitMqClient.Send(message, conventions, messageId, correlationId, spanContext, correlationContext);
context.Response.StatusCode = StatusCodes.Status202Accepted;
return;
}
await next(context);
}
Framework/MessagingMiddleware.cs:41-79. Every line of that is doing something worth naming.
typeof(object) as the conventions' message type is the whole trick. Convey's MessageConventions normally derives an exchange and a routing key from a CLR type. Here the type is object and the exchange and routing key are supplied literally from configuration. The gateway therefore never needs the contract class. It does not reference a shared contracts package, and there is no shared contracts package in this estate to reference.
JsonConvert.DeserializeObject(content) with no type argument produces a JObject — the request body, unexamined, unvalidated, re-serialised onto the bus as-is. The gateway does not know the message has a title, or a rate, or a userId. It knows the caller posted JSON to a path.
The ConcurrentDictionary<string, IConventions> keyed on exchange:routingKey is a small, correct memoisation: conventions objects are immutable and identical per endpoint, so building one per request would be pure waste. It is a static field on the middleware, which is fine because MessagingMiddleware is registered AddScoped and would otherwise rebuild them on every call.
StatusCodes.Status202Accepted with an immediate return. No Location header, no body, no acknowledgement that anything downstream will ever process the message. The pipeline stops here; next(context) is not called.
The foreach returns on the first match and falls through to next(context) if none match, which means the middleware is transparent to every request that is not one of the two configured endpoints — including all five YARP proxy routes.
The patterns, named
This is a textbook Channel Adapter in Hohpe and Woolf's vocabulary: a component that connects a system which does not speak messaging — here, an HTTP client — to a message channel. The endpoint table makes it a Content-Based Router as well, keyed on method plus path template, selecting one Datatype Channel per route. The 202 is Quick Acknowledgment, and from the caller's perspective the whole interaction is Fire-and-Forget.
What makes it genuinely unusual for a .NET estate is the direction of the coupling. Every other repository in Trill obtains its message contracts by copying the publisher's class into its own Events/External/ folder — a pattern this series takes apart in part 13. The gateway is the only unit in the estate with zero compile-time knowledge of any contract, and it is the one unit that can add a new asynchronous endpoint without a recompile. Two lines of JSON and a restart, and POST /users-service/users/lock/async exists.
That is a real architectural property, and it is undocumented. Nothing in the repository explains that the messaging block is an extension point, or that adding to it changes the public API surface of the estate.
If the shape looks familiar, it should: Convey's own Convey.WebApi.CQRS package does something adjacent, mapping HTTP verbs onto command and query dispatch through configuration. The gateway's bridge is the same instinct applied one layer out, at the edge rather than inside a service — and where the estate's messaging behaviour is Convey's rather than Trill's, one YAML flag turns REST into a message covers the framework side of it properly.
Machinery for six operations, wired for two
Now the honest half. That generic, config-driven, no-recompile bridge serves exactly two endpoints, and the estate is set up for six.
Walk the subscriptions across the estate and you find four commands with a subscriber and no publisher:
| Command | Subscribed by | Published by |
|---|---|---|
SendStory |
Stories | the gateway |
RateStory |
Stories | the gateway |
CreateUser |
Users | nobody |
CreateAd |
Ads | nobody |
ApproveAd |
Ads | nobody |
RejectAd |
Ads | nobody |
Trill.Services.Ads/src/.../Extensions.cs:100-104 subscribes to five commands over AMQP; three of them arrive from nowhere. Trill.Services.Users does the same for CreateUser. Those operations are reachable over HTTP, and their bus subscriptions exist so that a future messaging.endpoints entry could route to them.
The asynchronous-command capability is built out on the consumer side for six operations and wired on the producer side for two. That is the estate's clearest half-finished feature, and it explains something that otherwise looks like over-engineering: MessagingMiddleware is a general mechanism serving two lines of config because it was written for a table that was going to be six lines long.
There is a second tell. The gateway's own Trill.APIGateway.rest file — the estate's only executable documentation of its HTTP surface — contains six requests, and all six are GET:
@url = http://localhost:5000
@storiesService = {{url}}/stories-service
###
GET {{url}}
###
GET {{storiesService}}
Not one committed artefact in the estate ever exercises the asynchronous path. The most interesting feature the gateway has is the one nothing has ever called, which is why the defects the next two parts describe survived to the final commit.
An exchange with no publisher and no subscriber
There is a smaller tell in the same configuration file, and it took me a second read to notice. The gateway's RabbitMQ block declares its own exchange:
"exchange": {
"declare": true,
"durable": true,
"autoDelete": false,
"type": "topic",
"name": "api-gateway"
},
"queue": {
"declare": true,
"durable": true,
"exclusive": false,
"autoDelete": false,
"template": "api-gateway/{{exchange}}.{{message}}"
}
appsettings.json:107-120. Convey declares that exchange on connection, so an api-gateway topic exchange exists on the broker in every run.
Nothing ever publishes to it and nothing ever binds to it. The gateway's only publish call passes conventions built from the endpoint table — endpoint.Exchange is "stories" for both configured routes — so the exchange named in rabbitMq.exchange.name is never the destination. And the gateway subscribes to nothing, so the queue template never instantiates a queue either.
That is the estate's per-unit convention applied uniformly, which is mostly a good thing: every one of the eight units gets an exchange named after itself, and five of them use it. The gateway and the Pusher do not, and neither does the Saga. The cost is one empty durable exchange per non-publishing unit — harmless, and a small reminder that a convention applied without a check produces artefacts nobody can explain six months later.
The two things the bridge does carry
Before the criticism, credit where it is due. Look again at what MessagingMiddleware puts on the message besides the body:
var spanContext = _tracer.ActiveSpan is null ? string.Empty : _tracer.ActiveSpan.Context.ToString();
var messageId = Guid.NewGuid().ToString("N");
var correlationId = _correlationIdFactory.Create();
var resourceId = Guid.NewGuid().ToString("N");
var correlationContext = _correlationContextBuilder.Build(context, correlationId, spanContext,
endpoint.RoutingKey, resourceId);
A fresh message id, a Correlation Identifier taken from the same AsyncLocal-backed factory that LogContextMiddleware pushes into Serilog and that CustomProxyHttpClientFactory stamps on every proxied HTTP request as x-correlation-id, and a Jaeger span context extracted from the active span and carried onto the bus in a header. One correlation identifier spans HTTP and AMQP in this estate, and it is genuinely well done — it is why Jaeger and Seq are usable here at all, and it is better than a lot of production systems I have had to debug.
The correlationContext it builds is the one part I would change, and part 7 explains why: it serialises every claim on the principal into a bus header, while the logging configuration carefully scrubs Email, Password and Token from log output. A Content Filter applied to one channel and not the other.
Next, the bug hiding in the four lines above the publish call: the route values it throws away.