Skip to main content
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’s task_local! takes a type only — there is no = None / = const { … } initializer.
  • with_<snake_case>(value, future) — install the value for the duration of future,
  • current_<snake_case>() — read it (returns Option<Self>, requires Self: Clone),
  • generic impl<S> axum::extract::FromRequestParts<S> for Self where S: Send + Sync, via #[::nestrs_core::als::async_trait]. Rejection is AlsError (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 — no Arc<RequestContext> plumbing through every layer:
The generated 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’s to_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

The error is 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:
Setting one doesn’t affect the other. The cell names (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’s FromRequestParts is async_trait-based
Those re-exports give the codegen a stable path that doesn’t require the user to add 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 reads S or Parts; it only looks at the task-local cell.
  • One AlsError variant — 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, not Arc<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 for AlsContext when the codegen would be in the way.

See also