The Header That Names the Future
DShop's gateway answers a write with 202 Accepted and a header pointing at an operation that hasn't happened yet. Reading the contract shows a clean async-REST design - and two small bugs that undercut it.
When you POST an order to DShop's gateway, the work has not happened by the time you get a reply. The command is on its way to RabbitMQ and a saga will pick it up; the gateway cannot wait. So it does the honest thing an asynchronous API should do: it returns 202 Accepted and, crucially, hands you a way to find out how the work turns out. That “way” is a response header naming an operation that does not yet have a result. It is one of the cleaner ideas in the estate — a textbook async-REST conversation — and reading it closely also surfaces two small bugs that quietly defeat parts of it.
Accept now, name the result
The contract lives in the gateway's BaseController. Every write goes through SendAsync, which publishes the command and returns Accepted(context):
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);
}
protected IActionResult Accepted(ICorrelationContext context)
{
Response.Headers.Add(OperationHeader, $"operations/{context.Id}"); // X-Operation
if (!string.IsNullOrWhiteSpace(context.Resource))
Response.Headers.Add(ResourceHeader, context.Resource); // X-Resource
return base.Accepted();
}
Two headers carry the whole conversation. X-Operation: operations/{id} is a URL you can poll — the gateway's OperationsController will tell you whether that operation is pending, completed, or rejected. X-Resource: orders/{id} names the thing the operation is creating, before it exists. Together they say: “I've accepted your request, here is where to watch it finish, and here is the resource it will become.” In enterprise-integration terms this is a Quick Acknowledgment paired with a Subscribe–Notify conversation — the request returns immediately, and the result is retrieved out of band.
The design pattern here is worth naming for anyone building async APIs: a write endpoint's job is not to do the work, it's to make the work addressable. The 202 is a receipt; the headers are the tracking number.
Minting the future's name at the edge
For X-Resource: orders/{id} to work, the gateway needs the order's id before the order is created — the whole point is to name it in the acknowledgment. So the gateway mints 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");
BindId(c => c.Id) generates a fresh Guid and writes it into the command's Id, and that same id is passed as resourceId so GetContext can build resource = "orders/{id}". The identity of the order is decided at the front door, stamped into the command, and echoed back in the header — all before any service has seen the message. It is a small inversion of the usual “the database assigns the id” habit, and it is exactly what an async, id-in-the-acknowledgment contract requires. (BindId does its stamping by reflecting into an immutable command's backing field, which is a whole story Series 1 tells.)
Notice too that the operation id and the resource id are different Guids. context.Id — the operation you poll — is a Guid.NewGuid() created inside GetContext, distinct from the order's resourceId. One request, two identities: one for the async work, one for the thing it produces. That separation is right; an operation and its result are genuinely different entities.
A well-behaved client, then, does something specific with the 202: it reads X-Operation, polls operations/{id} until the state settles to completed or rejected, and only then follows X-Resource to fetch the order the operation built. That is the whole conversation, and it has one subtlety the contract quietly imposes — there is a window where the operation you were just told to poll does not exist yet, because the command is still in flight to Operations. The gateway hands you a URL to a resource the system is racing to create behind you, so the correct client polls with tolerance for an initial not-found rather than treating the first 404 as failure. The 202 is a promise, and a promise is a thing that is true slightly after you receive it.
Two bugs in the fine print
The contract is sound. The implementation of its neighbours is where reading the source pays off, because two small defects undercut it — and both are the kind you only catch by looking, never by testing the happy path.
The trailing comma in the Link header. The gateway also builds RFC-5988 pagination Link headers for reads, and the formatter has a fencepost bug:
private static string FormatLink(string path, string rel)
=> string.IsNullOrWhiteSpace(path) ? string.Empty : $"<{path}>; rel=\"{rel}\",";
Every link ends in a comma — including the last one. The Link header is a comma-separated list, so a trailing comma leaves a dangling empty element. Lenient clients shrug; strict RFC-5988 parsers choke on the empty entry. The bug is invisible until something downstream actually tries to parse the header it worked so hard to build.
The header CORS never exposes. This one is sharper, because it silently deletes a feature in the browser. The gateway's CORS policy lists exactly which response headers a browser is allowed to read:
private static readonly string[] Headers = new []{ "X-Operation", "X-Resource", "X-Total-Count" };
// ...
cors.WithExposedHeaders(Headers)
X-Operation, X-Resource and X-Total-Count are exposed — good. But the Link header the pagination code builds is not in that list. By the CORS spec, a header the server doesn't expose is unreadable to browser JavaScript. So the gateway carefully constructs pagination links (trailing comma and all) and then, for every browser client, makes them unreadable. A single-page app talking to this gateway can see X-Total-Count and know how many pages exist, but cannot read the Link header telling it where the next one is. The feature is built, shipped, and unreachable from the exact clients — the Angular and Blazor frontends — that would want it.
The honest ledger
To be fair to the gateway: the 202-plus-headers contract is genuinely good design, better than most production APIs I've inherited, which block a request thread on cross-service work and call it synchronous. Acknowledging receipt, naming the operation, and minting the resource id at the edge is the correct shape for an asynchronous write, and DShop got the shape right in an era when “just return 200 and hope” was the norm.
But the fine print costs it. The trailing comma and the un-exposed Link header are both contract bugs — the gateway promises a thing (RFC-5988 pagination) that clients can't actually consume. And there is a design smell lurking behind the anonymous OperationsController: anyone holding the operation GUID can poll its status with no token, which turns the correlation id into a capability. That last one is not a footnote — it is the doorway into the security inversion, which is where we go next.
The rule of thumb: a header-based contract is only as real as the client's ability to read the header. An async API that returns tracking information the CORS policy hides has, from the browser's side, returned nothing at all. Next: the security inversion, where the gateway guards everything and the services guard nothing.