Skip to content
Kumar Chandrachooda
Microservices

One Shop, Two Front Doors

DShop's gateway reads over HTTP and writes over a message bus - two dispatch mechanisms behind one controller. Reading it shows how a gateway can be a router and a publisher at the same time.

By Kumar Chandrachooda 28 Aug 2025 5 min read
A single door with two hinges - one opening to a wire, one to a queue

A microservices gateway usually does one thing: it forwards HTTP to the service that owns the route. DShop's gateway does two things, and which one it does depends entirely on the verb. A GET is forwarded synchronously over HTTP to a downstream service and its response comes straight back. A POST, PUT or DELETE never touches a downstream HTTP API at all — it is turned into a command and published to RabbitMQ, and the caller gets a 202 with a header pointing at work that hasn't happened yet. The same controller is a reverse proxy for reads and a message publisher for writes, and reading how it pulls that off is the clearest small lesson in the estate.

DShop.Api is the code-first gateway — netcoreapp2.2, seven controllers, the most-touched repository in the whole estate. This part reads its read path and its write path side by side. (The next part reads the same gateway rebuilt entirely in YAML, and where the two disagree.)

Reads are typed HTTP forwarders

The gateway never hand-writes an HttpClient call. Every downstream read is a RestEase interface — a C# interface with attributes that RestEase turns into a live HTTP client at runtime:

[SerializationMethods(Query = QuerySerializationMethod.Serialized)]
public interface IOrdersService
{
    [AllowAnyStatusCode]
    [Get("orders/{id}")]
    Task<OrderDetails> GetAsync([Path] Guid id);

    [AllowAnyStatusCode]
    [Get("orders")]
    Task<PagedResult<Order>> BrowseAsync([Query] BrowseOrders query);
}

The controller just injects IOrdersService and calls GetAsync(id); RestEase issues GET orders/{id} against the orders service and deserialises the JSON into OrderDetails. Two small attributes carry real intent. [AllowAnyStatusCode] tells RestEase not to throw on a 404 — a missing order should surface as a null the controller turns into a clean NotFound(), not an exception. And [SerializationMethods(Query = Serialized)] controls how the BrowseOrders query object becomes a querystring. This is a typed forwarder: the gateway describes the downstream API as an interface and lets a library be the plumbing.

The question the interface dodges is where orders-service actually is. That is answered once, in Startup:

services.RegisterServiceForwarder<IOperationsService>("operations-service");
services.RegisterServiceForwarder<ICustomersService>("customers-service");
services.RegisterServiceForwarder<IOrdersService>("orders-service");
services.RegisterServiceForwarder<IProductsService>("products-service");

RegisterServiceForwarder<T> lives in DShop.Common, and behind that one call sits a genuinely clever piece of machinery: depending on configuration, "orders-service" resolves to a static host:port, to a Consul client-side service lookup, or to a Fabio load-balancer address — three load-balancing strategies chosen by a string, which Series 1 reads in full. From the gateway's point of view, none of that exists. It has four typed interfaces and a promise that the names resolve.

Writes are commands on a bus

Now the other door. A write never calls a forwarder. The BaseController exposes a SendAsync that publishes the command and returns immediately:

protected async Task<IActionResult> SendAsync<T>(T command,
    Guid? resourceId = null, string resource = "") where T : ICommand
{
    var context = GetContext<T>(resourceId, resource);
    await _busPublisher.SendAsync(command, context);
    return Accepted(context);
}

There is no downstream HTTP call in that method — _busPublisher.SendAsync drops the command onto RabbitMQ, where a topic exchange routes it by type to whichever service owns it, and the gateway returns Accepted without waiting for the work. OrdersController.Post wires a CreateOrder command straight into it:

[HttpPost]
public async Task<IActionResult> Post(CreateOrder command)
    => await SendAsync(command.BindId(c => c.Id).Bind(c => c.CustomerId, UserId),
        resourceId: command.Id, resource: "orders");

Read that BindId carefully, because it is doing something the async model requires: it mints a brand-new Guid and stamps it into the command's Id before publishing. The gateway decides the order's identity — server-side, at the edge — so that the 202 it returns can name a resource that doesn't exist yet. (How BindId reflects a value into an immutable command's backing field is its own Series 1 part; how the resulting 202 and its headers work is part 8 here.) The essential point: the read path knows the answer and fetches it; the write path invents an id, fires a command, and trusts the fabric to make it true later.

Why split the doors at all

The asymmetry is not an accident — it is the CQRS split projected onto HTTP. Reads want to be synchronous: a caller asking for an order wants the order, now, and a request/response HTTP forward is exactly right. Writes in this estate are inherently asynchronous, because the work they trigger spans services — placing an order kicks off a reservation saga across Orders and Products that the gateway cannot wait on inside one request. So the gateway does the honest thing and stops pretending a write is instantaneous: it acknowledges receipt (202), hands back a way to track completion, and lets the bus carry the actual work.

The whole read-and-write core is astonishingly small — four forwarder registrations, one SendAsync, a handful of controllers. In perhaps forty lines of real logic the gateway is a typed reverse proxy, a service-discovery client, and a command publisher. That density is the appeal and the risk in equal measure.

The honest ledger of the split

To be fair to the design: routing reads over HTTP and writes over a bus is a coherent, defensible model — it is CQRS taken seriously at the network edge, and the 202-with-a-header contract is more honest than a gateway that blocks a request thread waiting for a cross-service saga. Plenty of production systems would be better for copying it.

But reading the source turns up the cost of the density:

  • Two transports, two failure modes. A read fails like HTTP — timeout, 5xx, a null from [AllowAnyStatusCode]. A write fails like a message — it succeeds at the gateway (202) and then fails invisibly downstream, with the caller long gone. The gateway's success is not the operation's success, and nothing in the write path makes that as loud as it should be.
  • The forwarder hides the topology. RegisterServiceForwarder<IOrdersService>("orders-service") is lovely until "orders-service" doesn't resolve, at which point the failure is three layers down in Consul or Fabio, not in the code you're reading.
  • The command is mutated at the edge. BindId stamping the id server-side is necessary for the 202 model, but it means the “immutable” command that arrives at the service was quietly rewritten by the gateway — an honesty a reader only discovers by following the reflection.

The rule of thumb: a gateway that reads over HTTP and writes over a bus has two different definitions of “it worked,” and every client needs to know which one it just got. A 200 with a body means the read happened. A 202 with a header means the write was accepted — the operation it named may still fail where nobody is looking.

Next, the same gateway with all of this deleted and rewritten as configuration: a gateway written in YAML.