Which request-scoped data may travel in a context.Context across teams, and how do you hold that line?
answer
- request-scoped, immutable, optional
- if the code cannot run without it, it is a parameter
- dependencies are constructed, not carried
- one package owns the keys and the accessors
- unexported keys force everyone through your API
basics
~20 sAdmit only data that is about the request, immutable, and optional for whoever reads it — trace and request ids. Anything a function cannot run without belongs in its signature. Enforce it with one package owning the key types and accessors.
solid answer
~40 sI use three tests: the value must be **request-scoped**, **immutable**, and **optional** to every reader, because nothing checks at compile time that a caller supplied it. Trace and request ids pass cleanly. A database handle, a retry policy or a client for another service fails — those are dependencies, decided at construction — and so does an ordinary parameter someone did not want to thread through five signatures. Auth subject and tenant id are the honest grey area: I allow them only when one middleware sets them, the accessor fails closed rather than defaulting, and no authorization decision reads them implicitly. Enforcement is structural — one internal package owns each key type, unexported, so every reader must come through our accessor — plus a short written allow-list and a review rule.
go deeper
Learn the short version and apply it literally: trace and request ids yes, function arguments and database handles no. If you are tempted to use it to skip changing a signature, change the signature.
Be able to justify the rule mechanically — no compile-time check on the reader, immutability meaning a value cannot change mid-request — and to name the alternative for each rejected case, such as constructor injection for a dependency.
Show how you would migrate a codebase that already carries too much: inventory the keys, find each writer and reader, decide the absent-value behaviour for the ones you keep, and move the rest into signatures without a flag day.
Own the convention and its enforcement across teams: one package holding the key types and accessors, a short written allow-list, a fail-closed rule for anything touching identity or tenancy, and a clear statement of what evidence would make you widen it.
## Why this is a decision somebody has to own `context.WithValue` is the only sanctioned way in Go to move data through code that does not know about it. That makes it enormously useful for cross-cutting request metadata, and enormously easy to abuse, because it also happens to be the cheapest way to avoid changing five function signatures. Nothing in the language distinguishes the two uses. The compiler will not stop anyone, the standard library only says values should be "request-scoped", and every team invents its own reading of that phrase. So somebody — the platform team, the owner of the shared library, whoever is rolling tracing out across several teams — has to write the rule down and be prepared to defend it in review. ## The rule I would set A value may travel in the context if all three hold: 1. **Request-scoped.** It is a property of *this* request or operation, created at the edge and dead when the operation ends. Not configuration, not a connection, not something that outlives the request. 2. **Immutable.** It is fixed when it is attached. Contexts are immutable, so anything that must change during the request will produce stale reads for every context derived before the change — a mutable value stored inside also becomes shared state with no synchronisation. 3. **Optional to every reader.** Because the requirement is invisible in the signature, a reader must have defined, tested behaviour when the value is absent. If the code cannot proceed without it, it is a parameter, and the compiler is the enforcement mechanism. Trace ids, request ids and similar correlation metadata satisfy all three, which is why they are the canonical example. ## What I refuse, and why - **Dependencies and handles** — a database handle, a client for another service, a cache. These are decided when the component is constructed, they outlive the request, and hiding them makes a package's real dependencies unreadable from its API. - **Configuration and policy** — retry budgets, page sizes, output formats. These are parameters with a well-understood home. - **Ordinary optional parameters** — the case where someone found threading an argument through five layers tedious. This is the most common abuse and the one I am strictest about, because each instance makes the package callable only from one particular entry point, and the failure it eventually causes appears far from the omission. ## The honest grey area: identity and tenancy Authenticated subject, tenant id, and locale are genuinely request-scoped and genuinely cross-cutting, and threading them everywhere is real cost. I allow them with conditions: - exactly **one** writer — the authenticating middleware — so there is one place to audit; - an accessor that **fails closed**: it returns "absent", and nothing anywhere substitutes a default tenant or an anonymous subject on its behalf; - authorization decisions are taken from an explicit argument, never from a silent lookup deep inside a repository. A data-scoping rule that reads its tenant implicitly is one refactor away from returning another customer's rows. If a team cannot meet those conditions, the value goes back into the signature. ## How you actually enforce it Exhortation does not hold a line across several teams. Structure does: - **One internal package owns the keys.** Each key type is unexported there, so the only way to read a value is to call that package's accessor. The moment someone needs a new value, they have to add it to a package other people review, which is exactly the checkpoint you want. - **One `With` helper and one getter per key**, with the missing-value behaviour documented and covered by a test whose first case is an empty context. - **A short written list** of the admitted values in the shared package's doc comment — three or four entries, not a policy document. - **A review question**, not a rule book: "what happens when this value is absent, and who guarantees it is not?" ## What I would be overruled on This is a judgment, and the evidence that would change it is concrete: a team that has to touch forty signatures to add one edge-set field, in a codebase where all readers already have sane absent behaviour, has a real argument for admitting a fourth value. What I would not trade away is the fail-closed rule on anything that participates in an authorization or data-scoping decision, because the cost of being wrong there is not a bug, it is a disclosure. ## One thing to say out loud Context values are **in-process**. They do not ride along with an outbound request by themselves. Whatever the convention allows, the code that crosses a service boundary has to serialise those values into headers on the way out and a middleware has to read them back on the way in — otherwise every team ends up debugging why the correlation stops at the first hop.
- Where exactly do you draw the line on an authenticated subject or a tenant id?I allow it when there is one writer — the authenticating middleware — an accessor that reports absence instead of substituting a default, and a rule that authorization and data scoping take the value as an explicit argument. The risk is not carrying the id; it is a repository quietly defaulting it when a new caller forgets, and returning the wrong customer's rows.
- A team has put a per-request client for another service into the context. What do you tell them?A client is a dependency: construct it once and inject it, so the package's requirements are visible in its API. If what they actually need is a per-request *setting* — a chosen endpoint, an override flag — carry that small immutable value and let the injected client read it. Carrying the client hides the dependency and ties the package to one entry point.
- Do context values reach a downstream service automatically?No. A context and its values live entirely inside one process. To correlate across a hop, the outbound code must read the value and write it into a request header explicitly, and the receiving side's middleware must read that header and attach it to its own context. Any team expecting propagation for free finds their trace ends at the first boundary.
saying these in an interview costs you the question
- Treats the context as a general-purpose dependency injection bag
- Moves required parameters into context to avoid changing signatures
- Lets every service define its own trace-id key
- Reads a tenant id from context deep in a repository and defaults it when absent
- Assumes context values travel across the network with the request