nestrs ships a small async-local-storage (ALS) surface — a typed cell
that propagates a value through every .await on the current task
without threading it through every function signature. The NestJS
analogue is nestjs-cls / cls-hooked: middleware installs the value
once, downstream handlers and services read it without injecting it.
In Rust the primitive is tokio::task_local!, and the #[als]
proc-macro in nestrs-macros (re-exported from nestrs / nestrs::prelude)
wraps it into the shape every handler actually wants: a typed cell,
install/read helpers, and an axum extractor.
The runtime is always-on (no feature flag). tokio::task_local! is
already in scope via nestrs-core’s tokio dependency.
Define an ALS type
#[als] is an attribute macro — it generates, per type:
- a
task_local!cell (REQUEST_CONTEXT_ALS: Option<RequestContext>), scoped to the type so different ALS values never share state. tokio 1.51’stask_local!takes a type only — there is no= None/= const { … }initializer. with_<snake_case>(value, future)— install the value for the duration offuture,current_<snake_case>()— read it (returnsOption<Self>, requiresSelf: Clone),- generic
impl<S> axum::extract::FromRequestParts<S> for SelfwhereS: Send + Sync, via#[::nestrs_core::als::async_trait]. Rejection isAlsError(HTTP 500).
RequestContext becomes request_context, UserID becomes
user_id, HTTPRequest becomes http_request. The conversion is
the same lower_snake_case you’d get from heck minus the
dependency.
Install it from middleware
with_request_context opens a scope; when the future it wraps
completes (success, error, or panic), the prior value — or absence
of one — is restored. Nesting joins the outer scope rather than
forking it: an inner with_* opens a new layer, and the outer value
is visible again when the inner one ends. tokio::task_local
semantics, just under a typed name.
Read it from a handler
Handlers extract the value as a normal axum extractor — noArc<RequestContext> plumbing through every layer:
FromRequestParts impl reads from the task-local cell
and ignores the request Parts entirely. If middleware forgot to
install the value, the extractor rejects with AlsError::NotSet,
which implements IntoResponse as HTTP 500. The intent: a missing
ALS value is a wiring bug, not a request-shape bug — surface it as
an internal error, not a 400 / 404.
Read it from anywhere on the task
The cell is a task-local, so any future awaited inside the scope can read it without being threaded through:current_* returns None outside any active scope — the typical
case is a background task spawned with bare tokio::spawn. ALS values
don’t cross the spawn boundary; either pass them in explicitly, or
spawn with nestrs_core::spawn_with_request_scope to carry the
request scope forward.
The runtime helper
If you don’t want the proc-macro (a small project, a one-off cell, a code path where the macro’sto_snake_case would pick the wrong
boundary), nestrs_core::als::AlsContext is the same primitive
without the codegen. It wraps a RefCell<Option<T>> cell (the
macro itself stores Option<Self> directly):
AlsContext::with and current mirror #[als]’s with_* /
current_* exactly — pick whichever fits your codebase.
AlsError
Clone + Copy + Eq so handler tests can assert_eq!(res, Err(AlsError::NotSet)) without any wrapping. The Display impl names the fix in the failure message:
async-local-storage value is not installed on this task —
middleware should set it via with_<name>(value, future).await
Multiple ALS types, independent cells
#[als] declares a per-type static cell, so two ALS types never
share state:
REQUEST_CTX_ALS,
TRACE_CTX_ALS) are also unique per type, so tests and nested scopes
don’t collide.
Re-exports the macro needs
The macro emits:::nestrs_core::als::task_local!—tokio::task_local!#[::nestrs_core::als::async_trait]— axum 0.7’sFromRequestPartsisasync_trait-based
tokio or async-trait as a direct dependency
(transitive visibility from nestrs-core isn’t enough on its own).
Reach for the upstream macros directly when you want them; the
re-exports are there so generated code has somewhere to point.
Why these choices
- Per-type cell — names derived from the type itself, not from a string the author had to keep in sync. Two types can’t collide because they emit distinct statics.
- Generic
FromRequestParts<S>— one impl covers every axum state type. The extractor never readsSorParts; it only looks at the task-local cell. - One
AlsErrorvariant — missing value, full stop. Anything more elaborate (decoding errors, auth-failed scope installs) is a different concern that belongs in the middleware, not the cell. - Returns
Self, notArc<Self>— the cell already clones on read; cloning once more at the extractor is the same cost, and handler signatures stay simple. - Macro is sugar over
AlsContext— pick the macro for the per-type generated names and the extractor; reach forAlsContextwhen the codegen would be in the way.
See also
tokio::task_local!— the underlying primitive.axum::extract::FromRequestParts— the extractor trait the generated impl satisfies.mintlify-docs/concepts/middleware-pipeline— for the per-request hook that installs the ALS value before handlers run.