The Envelope That Scrambled Itself
DShop.Common's correlation context threads request identity through every message hop - and a long positional argument list quietly transposes half its fields.
Part three ended on the correlation context — the small object both publish methods attach to every message so a single user request can be followed across service hops. It is, on paper, the estate's observability spine: correlation id, user id, trace id, span context, connection id, origin, culture. It is also the clearest example in this whole series of a bug that reading the source finds and running the system never will, because the field that everyone actually looks at is correct and the ones that are wrong are the ones nobody checks.
This is what a long positional argument list does to you. Let me show you the object, then the two places it is built, then the transposition.
The object
CorrelationContext is an immutable envelope with eleven read-only fields and one settable retry counter:
public class CorrelationContext : ICorrelationContext
{
public Guid Id { get; }
public Guid UserId { get; }
public Guid ResourceId { get; }
public string TraceId { get; }
public string SpanContext { get; }
public string ConnectionId { get; }
public string Name { get; }
public string Origin { get; }
public string Resource { get; }
public string Culture { get; }
public int Retries { get; set; }
public DateTime CreatedAt { get; }
}
Count the strings: TraceId, SpanContext, ConnectionId, Name, Origin, Resource, Culture — seven consecutive string fields, several of which look alike to a compiler and to a tired human. Hold that thought; it is the entire problem.
The object is constructed through static factories, and the interesting one is Create<T>:
public static ICorrelationContext Create<T>(Guid id, Guid userId, Guid resourceId, string origin,
string traceId, string spanContext, string connectionId, string culture, string resource = "")
=> new CorrelationContext(id, userId, resourceId, traceId, spanContext, connectionId,
string.Empty, typeof(T).Name, origin, culture, resource, 0);
Look carefully at the parameter order: id, userId, resourceId, **origin, traceId, spanContext, connectionId, culture**. The author put origin before traceId. That single ordering choice — origin first — is the landmine, because no caller expects it. Everyone building a correlation context thinks “trace id, then connection id”; the signature wants “origin, then trace id.” Nothing about the type system defends the gap, because every one of those parameters is a string.
The live call: the gateway builds every envelope
The correlation context that rides real production messages is minted in the API gateway, once per accepted request, in BaseController.GetContext<T>:
protected ICorrelationContext GetContext<T>(Guid? resourceId = null, string resource = "")
where T : ICommand
{
// ...
return CorrelationContext.Create<T>(Guid.NewGuid(), UserId, resourceId ?? Guid.Empty,
HttpContext.TraceIdentifier, HttpContext.Connection.Id, _tracer.ActiveSpan.Context.ToString(),
Request.Path.ToString(), Culture, resource);
}
Read the arguments in the order the author clearly intended them: a fresh correlation id, the user id, the resource id, then HttpContext.TraceIdentifier — ASP.NET's own trace identifier, obviously meant to be the trace id — then HttpContext.Connection.Id, obviously the connection id, then the span context, then Request.Path, obviously the origin. The intent is legible and correct. Now line those arguments up against the parameters they actually bind to, position by position:
| Argument passed | Author meant it as | Parameter it lands in | Field it becomes |
|---|---|---|---|
HttpContext.TraceIdentifier |
trace id | origin |
Origin |
HttpContext.Connection.Id |
connection id | traceId |
TraceId |
_tracer.ActiveSpan.Context… |
span context | spanContext |
SpanContext ✓ |
Request.Path |
origin | connectionId |
ConnectionId |
Culture |
culture | culture |
Culture ✓ |
Because origin sits in front of traceId in the signature, everything from the fourth argument onward slides by one slot until culture re-aligns it. The result, on every single request that passes through the gateway:
TraceIdholds the connection id. The field a log aggregator would use to stitch a request across services carries the HTTP connection identifier instead.Originholds the ASP.NET trace identifier, not the request path it was supposed to record.ConnectionIdholds the request path.SpanContextandCulturehappen to land correctly, because their positions coincide either side of the shift.
This is not the dead From<T> overload three feet up the file — which has its own transposition and is called nowhere in the estate. This is the live path, the one that fires on every accepted command through the gateway.
Why nobody ever saw it
Here is the cruel part, and the reason this is a teaching bug rather than an outage. The one field everyone actually reads is correct. Id — the correlation id, the Guid.NewGuid() in argument position one — is passed right, lands right, and is what every dashboard, every “trace this request” query, and every structured log entry keys on. Open the logs, filter by correlation id, and the story is coherent end to end. Meanwhile TraceId, Origin and ConnectionId — the diagnostic filler nobody filters on — are quietly wearing each other's clothes. The shop sells products; the sagas complete; the demo works. The defect lives entirely in fields that are written and never read.
A long list of same-typed arguments is a defect waiting for a maintenance edit, and this is what it looks like when the wait is over. There is nothing exotic here — no race, no serialization quirk, no framework misbehaviour. It is six strings in the wrong drawer because one parameter was declared out of the order every caller assumes.
The fix costs nothing and there are two of them. Reorder Create<T>'s parameters so traceId precedes origin and matches how callers think — or, at the call site, use named arguments (traceId: HttpContext.TraceIdentifier, origin: Request.Path.ToString()) so the compiler binds by intent instead of position and the whole class of bug evaporates. C# had named arguments for a decade before this code was written; the envelope scrambled itself only because they weren't used.
To be fair to the estate: this is teaching code, the scrambled fields are diagnostic metadata rather than business data, and the genuinely load-bearing identifier is intact — so calling it “broken” would overstate the blast radius. But it is exactly the kind of scar worth keeping visible. When the same authors rebuilt this into Convey, the hand-assembled multi-field context gave way to a slimmer correlation object with far fewer positional strings to transpose — the refactor that a named argument would have pre-empted, generalised into the design.
If you take one habit from this part: when a constructor or factory takes three or more arguments of the same type, pass them by name. The compiler cannot see meaning in a string, but it can see a label. Next, the receiving end of all this — the two-lane error taxonomy that decides, purely from the type of exception a handler throws, whether your message gets retried or rejected.