scoped-values
Skill1 starsChanged 14 days agoMITView on GitHub
Use when writing or reviewing Java code that carries request context (request id, trace id, tenant, authenticated user) down the call stack without passing parameters, replacing a ThreadLocal or InheritableThreadLocal, writing a servlet filter or interceptor that sets per-request state, or handing that context to tasks on other threads. Key rule: on JDK 25+ use a static final ScopedValue (final in JDK 25, JEP 506) bound with ScopedValue.where(KEY, value).run/call at the edge, holding an immutable record; read it with get(), isBound() or orElse(); rebind for a nested scope instead of mutating; never InheritableThreadLocal, never ThreadLocal.set without remove; executor threads do not inherit bindings (only StructuredTaskScope forks, a preview API), so capture the value and bind it again inside the task.
We split this skill into the rules it gives an agent
5 rules. About 0 are common, 2 are distinctive, and 3 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈2
Distinctive · estimate
This author's own choices. Worth a look.
3
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 18
Read it with KEY.get() where it must be present, and with isBound() or orElse(default) in code that may also run outside a request.
- Very specific: line 20
When work moves to an executor, capture the value with get() on the caller and bind it again inside the task, because only threads forked …
- Very specific: line 21
Do not use InheritableThreadLocal for request context, and do not call ThreadLocal.set for it anywhere.
- Distinctive: line 105
When reviewing code under this skill, confirm:
- Distinctive: line 120
One or two parameters through a few methods: If the call chain is short, pass the value as an argument.
≈ “Common” is an estimate: at least 1 in 5 of the 308,232 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Scoped values
The decision
Some data belongs to the request, not to any one method: the request id for logs, the tenant for queries, the authenticated user for authorization checks. Passing it through every signature is noisy, so Java code has used ThreadLocal for twenty years: set it in a filter, read it anywhere, and hope someone calls remove(). That hope is where the bugs are: a pooled thread starts the next request with the previous user, an InheritableThreadLocal copies stale context into a worker thread that outlives the request, a callee mutates the context and every later reader sees it. ScopedValue, final in JDK 25 (JEP 506), gives the same "read it anywhere" convenience with a lifetime the code structure enforces. The decision is which one carries request context; on the floor JDK 25, the answer is ScopedValue.
The rule
- Declare request context as a
static final ScopedValue<T>created withScopedValue.newInstance(), holding an immutable value such as a record. - Bind it once at the edge (filter, interceptor, message listener) with
ScopedValue.where(KEY, value).run(...)or.call(...), around the whole unit of work. - Read it with
KEY.get()where it must be present, and withisBound()ororElse(default)in code that may also run outside a request. - To change the context for a sub-operation, bind a new value in a nested
where(...)scope; never mutate the bound object. - When work moves to an executor, capture the value with
get()on the caller and bind it again inside the task, because only threads forked byStructuredTaskScope(a preview API) inherit bindings. - Do not use
InheritableThreadLocalfor request context, and do not callThreadLocal.setfor it anywhere; if a library forces aThreadLocalon you, set andremove()it in the sametry/finally.
Why
Rules 1 and 2. A binding exists only while run or call executes, on the thread that executes it; when the lambda returns, the binding is gone (JEP 506: "After the run method finishes, the binding is destroyed"). There is no set and no remove, so there is no way to forget the cleanup that ThreadLocal needs. This matters more with virtual threads: there can be millions of them, and JEP 506 notes that each child thread created with InheritableThreadLocal must allocate storage for every inherited thread-local. A static final key keeps the scoped value a single well-known name, like a constant, instead of something passed around. call returns the handler's result and propagates its exception, so it wraps a request handler as easily as run wraps a void one.
Rule 3. The ScopedValue API throws NoSuchElementException from get() when the value is not bound. That is what you want in code that must never run without a request: a missing binding fails loudly at the read, not as a null three calls later. Code that legitimately runs both inside and outside a request (a shared audit helper, a scheduled job) uses isBound() or orElse(default); since JDK 25 orElse no longer accepts null.
Rule 4. A scoped value cannot be reassigned within a scope; a nested where(...) changes what callees see and reverts when it returns (JEP 506: once the nested call returns, the outer value is visible again). That makes "run this block as the system user" a scoped, visible operation instead of a mutation that silently persists for the rest of the request. The binding is only as immutable as the object you bind, which is why rule 1 asks for a record with no setters.
Rule 5. Inheritance is deliberately limited to structured cases. The API documentation says sharing across threads "is limited to structured cases where child threads are started and terminate within the bounded period of execution by a parent thread", and describes it only for threads forked by a StructuredTaskScope. A thread created by Executors.newVirtualThreadPerTaskExecutor() or any pool starts unbound, and JEP 506 adds that ForkJoinPool (so parallelStream() and CompletableFuture.supplyAsync without an executor) does not inherit either. The good example proves it: a task submitted from inside the scope sees isBound() == false. StructuredTaskScope is still a preview API in JDK 25, 26 and 27 (JEPs 505, 525, 533), final only in JDK 28 (JEP 543); until then, capture and rebind explicitly. It is two lines, and the hand-off is visible in review.
Rule 6. InheritableThreadLocal copies the parent's values when a thread is created, not when a task is submitted. A pool thread created during request A carries request A's context into every task it runs afterwards, so the audit log records request 2 under request 1's id; the bad example does exactly that. A plain ThreadLocal.set without remove() on a pooled server thread does the same across requests. JEP 506 does not deprecate ThreadLocal, and it says some uses (per-thread caches of expensive mutable objects) are still a better fit for thread-locals; with virtual threads, though, prefer shared immutable objects for caches too (see virtual-threads).
The cost: the code that binds must wrap the unit of work in a lambda, which changes the shape of filters and interceptors, and every hand-off to another thread is explicit. Libraries that read their own ThreadLocal (logging context, framework request holders) keep working as before and do not see your scoped value.
Good example
See examples/good/.
// Request context: immutable, bound once per request at the edge, read anywhere below.
record RequestContext(String requestId, String userId) {}
static final ScopedValue<RequestContext> REQUEST = ScopedValue.newInstance();// The binding exists only while handle() runs, on this thread.
String response = ScopedValue.where(REQUEST, ctx).call(() -> handle("/orders/1"));
check(response.equals("req-42 user-7 /orders/1 audit=req-42 worker=req-42"), response);
// After the scope ends the value is gone: nothing to remove, nothing to leak.
check(!REQUEST.isBound(), "REQUEST still bound after the scope");Handing the context to another thread is explicit:
// A thread started by an executor does not inherit the binding (only
// StructuredTaskScope forks do). Capture the value and bind it again.
RequestContext ctx = REQUEST.get();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<Boolean> leaked = executor.submit(REQUEST::isBound);
check(!leaked.get(), "executor thread saw the caller's binding");
Future<String> f = executor.submit(() ->
ScopedValue.where(REQUEST, ctx).call(() -> REQUEST.get().requestId()));
return f.get();
}The example checks that the value is visible deep in the call stack, unbound after the scope (get() throws NoSuchElementException), shadowed and then restored by a nested binding, not inherited by an executor thread, and visible there once rebound.
Bad example
See examples/bad/.
// MISTAKE: InheritableThreadLocal for request context. Every child thread copies
// the parent's map at creation; a pooled worker created during request A keeps
// request A's context for every later task it runs.
static final InheritableThreadLocal<RequestContext> REQUEST = new InheritableThreadLocal<>();// MISTAKE: set() with no remove() in a finally block. On a pooled thread the
// next request starts with this request's user, and the value lives as long
// as the thread does.
REQUEST.set(ctx);static void impersonate(String user) {
// MISTAKE: a callee mutates the shared context; the caller and every later
// reader now act as "admin", with no scope that ends the change.
REQUEST.get().userId = user;
}This is the shape most request-context code still has, often copied from pre-JDK 21 answers: a mutable holder, set in a filter, inherited "so async code works", and changed in place for impersonation. Each line works in a single-request test and fails under concurrency or thread reuse.
Checklist
When reviewing code under this skill, confirm:
- Request context is a
static final ScopedValuecreated withScopedValue.newInstance(), not aThreadLocalorInheritableThreadLocal. - The bound value is immutable (a record or final fields with immutable types).
- The binding is made once at the edge with
where(...).runor.callaround the whole unit of work. - Code that must run inside a request reads with
get(); code that may run outside usesisBound()ororElse(default), never a null check on aThreadLocal. - Context changes for a sub-operation use a nested
where(...)scope, and nothing mutates the bound object. - Every task submitted to an executor,
parallelStream()orCompletableFuturethat needs the context captures it before submission and binds it again inside the task. - No
InheritableThreadLocalexists for request data, and anyThreadLocalforced by a library is set and removed in onetry/finally. StructuredTaskScope, if used for inheritance, is in code built with--enable-previewfor one specific JDK.
When not to apply
- Library-owned thread-locals. Logging context maps and framework request holders are read by their own code from a
ThreadLocal; keep populating them the way the library documents, in atry/finally, and bind your own context in aScopedValuealongside. - Context that must change and be read back by the caller, such as a transaction or a counter that callees increment: that is mutable shared state, not context. Pass an explicit object, or use the framework's transaction support.
- One or two parameters through a few methods. If the call chain is short, pass the value as an argument; a scoped value is for data that crosses many layers you do not want to change.
- Per-thread caches on platform-thread pools. JEP 506 keeps
ThreadLocalfor caching expensive mutable objects on a small, long-lived pool; on virtual threads, share an immutable object instead. - Code that must still build on JDK 21.
ScopedValueis preview there; keepThreadLocalwith stricttry/finallyuntil the build moves to the JDK 25 floor.
In this repository26 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- new-skillSkill
- review-skillSkill
- background-jobs-outboxSkill
- ci-guardrailsSkill
- container-runtime-tuningSkill
- db-migrationsSkill
- exceptionsSkill
- fast-startup-aot-cacheSkill
- http-serverSkill
- java-version-policySkill
- json-jackson3Skill
- logging-structuredSkill
- mcp-server-javaSkill
- null-safety-jspecifySkill
- observability-otelSkill
- outbound-http-clientSkill
- overload-protectionSkill
- postgres-hikari-poolSkill
- profiling-jfrSkill
- project-layoutSkill
- secure-defaultsSkill
- sql-and-transactionsSkill
- testingSkill
- timeouts-and-deadlinesSkill
- virtual-threadsSkill
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
Reviews can't be read right now.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

