Skip to content
Kumar Chandrachooda
Microservices

The Correlation Spine

One JSON blob born at Pacco's gateway rides the Correlation-Context HTTP header into services and the message_context AMQP header across the bus, surfacing in SignalR pushes - carried by a class copy-pasted into eleven repos and a broker that forwards exactly one header.

By Kumar Chandrachooda 31 Dec 2025 6 min read
One context, two carriers, eleven copies of the vertebra

Part 6 ended on a question of provenance: when the SignalR hub pushes an operation to users:{id}, the user id it trusts was not minted by Operations. It arrived inside a JSON blob that was born at the API gateway, travelled over HTTP as one header, crossed RabbitMQ as another, and passed through a copy-pasted broker class in every service along the way. Pacco has no shared library for this blob — no package, no versioned contract. It has a class called CorrelationContext, pasted into eleven of the thirteen repos, and a convention that everyone keeps forwarding it.

This part follows the spine end to end: where the context is built, which two headers carry it, the one extra header the estate forwards by hand, and what it costs to have eleven unversioned copies of your most-travelled contract.

Born at the edge

The Ntrada gateway builds the context once per request. Pacco.APIGateway/Infrastructure/CorrelationContextBuilder.cs implements Ntrada's IContextBuilder, and the object it returns is the estate's whole notion of “who is asking and why”:

return new CorrelationContext
{
    CorrelationId = executionData.RequestId,
    User = new CorrelationContext.UserContext
    {
        Id = executionData.UserId,
        Claims = executionData.Claims,
        Role = executionData.Claims.FirstOrDefault(c => c.Key == ClaimTypes.Role).Value,
        IsAuthenticated = !string.IsNullOrWhiteSpace(executionData.UserId)
    },
    ResourceId = executionData.ResourceId,
    TraceId = executionData.TraceId,
    ConnectionId = executionData.Context.Connection.Id,
    Name = name,
    CreatedAt = DateTime.UtcNow,
    SpanContext = spanContext
};

Every field earns its place downstream. CorrelationId is the operation id the caller will poll (part 5); User.Id is what the SignalR hub will scope pushes by; ResourceId is the aggregate id the gateway minted before any service existed to own it (part 3); SpanContext splices the request into Jaeger. Name is a small delight: it prefers the RabbitMQ routing key from the route's config — create_order — and falls back to "METHOD /path" for plain proxy routes, which is why the operation names users see in pushes read like bus vocabulary. Identity, in other words, is resolved once, at the edge, and everything behind the gateway consumes it second-hand.

Two carriers, one blob

The context travels by different vehicles depending on the wire. For HTTP, HttpRequestHook.cs — all of fifteen effective lines — serialises it onto every proxied request:

var context = JsonConvert.SerializeObject(_contextBuilder.Build(data));
request.Headers.TryAddWithoutValidation("Correlation-Context", context);

For the bus, the carrier is configuration rather than code. Every service's appsettings.json — and the gateway's ntrada-async.yml — contains the same fragment under rabbitMq:

"context": {
  "enabled": true,
  "header": "message_context"
}

Convey's RabbitMQ transport does the rest: the serialised context rides the message_context AMQP header on every published message, and ICorrelationContextAccessor rehydrates it on the consuming side. So a single request that enters the async gateway produces one blob with two lives — Correlation-Context when the hop is HTTP, message_context when the hop is AMQP — and the receiving service does not much care which door it came through. The services make that symmetry explicit: eight of them carry an identical extension method that checks the HTTP header as a fallback,

internal static CorrelationContext GetCorrelationContext(this IHttpContextAccessor accessor)
    => accessor.HttpContext?.Request.Headers.TryGetValue("Correlation-Context", out var json) is true
        ? JsonConvert.DeserializeObject<CorrelationContext>(json.FirstOrDefault())
        : null;

and the copied MessageBroker.cs in each repo prefers the bus context, falling back to HTTP: _contextAccessor.CorrelationContext ?? _httpContextAccessor.GetCorrelationContext(). Whichever carrier delivered it, the blob that leaves the service on the next publish is the blob that arrived. Correlation in Pacco is not an id — it is a passenger manifest, re-boarded whole at every hop.

The estate's second gateway builds the same spine by a different route, and the difference is instructive. Where Ntrada hands its IContextBuilder a rich ExecutionData, the Ocelot twin from part 4 does the job in a DelegatingHandler hooked into Ocelot's outbound HttpClient: CorrelationContextHandler.SendAsync mints a fresh correlation id — Guid.NewGuid().ToString("N") — per proxied request, asks its own builder for the context, adds the identical Correlation-Context header, and stamps X-Operation: operations/{id} onto the response for every mutating verb. Same header name, same JSON shape, entirely separate implementation — the spine's contract survived the gateway swap even though not a line of its code did, which is at once a compliment to the convention and a warning about where that convention lives: in two codebases' agreement, not in any artefact either could validate against.

The one header forwarded by hand

Beyond the context blob, the broker forwards exactly one raw AMQP header, and the allow-list is short enough to quote in full — from Pacco.Services.Identity.Infrastructure/Extensions.cs:

internal static IDictionary<string, object> GetHeadersToForward(this IMessageProperties messageProperties)
{
    const string sagaHeader = "Saga";
    if (messageProperties?.Headers is null || !messageProperties.Headers.TryGetValue(sagaHeader, out var saga))
    {
        return null;
    }

    return saga is null
        ? null
        : new Dictionary<string, object>
        {
            [sagaHeader] = saga
        };
}

Saga is stamped by OrderMaker's Chronicle saga — AIOrderMakingSaga.cs writes Pending, Completed or Rejected into the headers of every command it publishes — and this little method is what keeps the stamp alive as the resulting events ripple through Orders, Availability and the rest. Operations reads it on the far end (GetSagaState in its handlers) to keep a mid-saga operation honestly Pending even while intermediate events complete. The mechanism works; notice, though, what it rests on. The forwarding logic is not in Convey. It is in the copied MessageBroker.cs of each service, which means the saga's telemetry survives precisely as long as every copy of a ninety-line file keeps agreeing about one magic string. A service whose copy drifts — or a new service whose author forgets the file exists — silently breaks the chain three hops away.

Eleven copies, zero packages

Which brings us to the spine's structural material. Grep for class CorrelationContext across the estate and it appears in eleven repos: both gateways and all nine bus-connected services — everywhere except Pricing (which has no broker) and the umbrella (which has no code). The copies live at different paths (Infrastructure/CorrelationContext.cs in the gateways and Operations, Infrastructure/Contexts/CorrelationContext.cs in the clean-architecture services, the repo root in single-project OrderMaker) and diff clean modulo namespace and an internal-versus-public modifier. Eleven files, one shape, no shared package.

That is not an accident; it is the estate's stated philosophy. Pacco's predecessor DShop tried the opposite — a shared DShop.Messages contracts package — and its authors walked away from the coupling it created; Pacco was born contract-by-copy, and the correlation context is simply the contract that got copied the most. The rationale deserves a fair hearing: copies keep every repo independently cloneable and buildable, JSON serialisation is structurally tolerant (a copy that lacks a field just ignores it), and each service could in principle trim the class to the fields it reads.

None of them do, and the tolerance cuts both ways. There is no version anywhere on this contract. If the gateway renames User.Id or adds a tenant field, nothing fails at build time in eleven other repos — consumers quietly deserialise null into whatever stopped matching, and the first symptom is a SignalR push that never arrives, because operation.UserId came through empty and the group users: matched nobody. The estate's own history shows how real that risk is: the copied external event contracts developed exactly this disease, and the wrong-exchange ParcelDeleted bug that part 14 dissects is the same failure mode with a louder symptom. The copy-paste economics across all ten services — what Convey leaves every consumer to own — is measured properly in the same file in seven repos.

My rule of thumb after tracing this spine: anything that crosses a process boundary in a header is an API contract, and a contract with eleven unversioned copies is eleven contracts. If you adopt Pacco's pattern — and the pattern itself is good — either extract the context class and the broker glue into one small internal package, or write the one test per repo that round-trips the canonical JSON through the local copy. Copies you verify are a trade-off; copies you merely trust are a countdown.

The spine also carries something more dangerous than ids: it carries claims — role, permissions, IsAuthenticated — from the edge into every service that acts on them. What the estate does with that inheritance, from self-service admin sign-up to a committed signing key to the ownership check that anonymous callers walk straight past, is the next part: security theatre and security reality.