Scoped Values in Practice
Carry request context such as a request ID, user or tenant through a call chain with ScopedValue, and avoid the traps when work moves to other threads.
static final ScopedValue<User> USER = ScopedValue.newInstance();
ScopedValue.where(USER, user).run(() -> handler.handle(request));
T result = ScopedValue.where(USER, user).call(() -> service.load(id));A web request carries context that many layers need: a request or correlation ID for logs, the authenticated user, the tenant. Passing it through every method signature is noisy, and ThreadLocal has known problems: values can be changed by anyone, must be removed by hand, and leak between requests when a pooled thread forgets to clear them. ScopedValue (JEP 506, final in Java 25) is the replacement for this kind of one-way, read-only context.
The API in five calls
static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
ScopedValue.where(REQUEST_ID, "req-42").run(() -> handle()); // bind and run
String id = REQUEST_ID.get(); // read; throws if unbound
REQUEST_ID.isBound(); // check first
REQUEST_ID.orElse("none"); // default when unbound
call(...) works like run but returns a value, and it may throw checked exceptions. Chain where calls to bind several values at once.
Why it is safer than ThreadLocal
- The binding exists only while
runorcallis executing. When the method returns, the value is gone. There is noremove()to forget. - There is no
set. Code deeper in the stack can rebind a new value for a nested call, but it cannot change what the caller sees. - It is cheap to bind, which suits one virtual thread per request.
Threads: the part that surprises people
A binding belongs to the thread that made it. If the handler submits work to an ExecutorService or a CompletableFuture, the other thread sees the value as unbound. The structured concurrency API, still in preview in 25, is what passes bindings to forked subtasks automatically. Until then, read the value in the handler and bind it again inside the task.
Mistakes people make
- Calling
get()in code that can also run outside a request, such as a scheduled job. UseorElseor checkisBound(). - Storing a mutable object, such as a
Mapthat layers write into. Scoped values are meant to be read-only; return results through normal return values. - Declaring the key as a non-static field. Keys are usually
static finalconstants shared by everyone who reads them. - Expecting a value bound in a filter to survive an asynchronous hop.
Example
When to use it
- Putting a correlation ID in scope in a request filter so every log line from the service layer includes it.
- Making the authenticated user and tenant available to repository code for row-level filtering without adding parameters everywhere.
- Running part of a request as a system user for an internal clean-up step without changing what the rest of the request sees.
More examples
Bindings do not cross to executor threads
The executor's thread has no binding, so the first task falls back to the default. Capturing the value and binding it again inside the task carries the tenant across explicitly.
import java.util.concurrent.Executors;
static final ScopedValue<String> TENANT = ScopedValue.newInstance();
void main() throws Exception {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
ScopedValue.where(TENANT, "acme").run(() -> {
try {
var lost = executor.submit(() -> TENANT.orElse("unbound")).get();
IO.println("plain task sees: " + lost);
String tenant = TENANT.get(); // capture in the bound thread
var kept = executor.submit(() ->
ScopedValue.where(TENANT, tenant).call(() -> TENANT.get())).get();
IO.println("rebound task sees: " + kept);
} catch (Exception e) {
throw new IllegalStateException(e);
}
});
}
}
// Output:
// plain task sees: unbound
// rebound task sees: acmeSeveral values and a nested rebinding
Chained <code>where</code> calls bind the tenant and the user together. The nested binding only lasts for the inner <code>run</code>, and nothing is left bound once the request ends.
static final ScopedValue<String> USER = ScopedValue.newInstance();
static final ScopedValue<String> TENANT = ScopedValue.newInstance();
static void audit(String action) {
IO.println(TENANT.get() + "/" + USER.get() + " " + action);
}
static void nightlyCleanup() {
// Run part of the work as a system user without touching the caller's binding.
ScopedValue.where(USER, "system").run(() -> audit("purged expired sessions"));
}
void main() {
ScopedValue.where(TENANT, "acme").where(USER, "ann").run(() -> {
audit("viewed invoices");
nightlyCleanup();
audit("downloaded invoice 17");
});
IO.println("user bound after request: " + USER.isBound());
}
// Output:
// acme/ann viewed invoices
// acme/system purged expired sessions
// acme/ann downloaded invoice 17
// user bound after request: falseReturning values and checked exceptions with call
<code>call</code> returns the lambda's result and lets its checked exception through, so it can be caught with its real type. The trace ID is still visible deep inside the exception message.
import java.io.IOException;
static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();
static String fetchProfile(String userId) throws IOException {
if (userId.isBlank()) {
throw new IOException("upstream rejected empty id (trace " + TRACE_ID.get() + ")");
}
return "profile:" + userId + " trace=" + TRACE_ID.get();
}
void main() {
for (String id : List.of("u-1", " ")) {
try {
String profile = ScopedValue.where(TRACE_ID, "t-" + id.strip().length())
.call(() -> fetchProfile(id.strip()));
IO.println(profile);
} catch (IOException e) {
IO.println("failed: " + e.getMessage());
}
}
}
// Output:
// profile:u-1 trace=t-3
// failed: upstream rejected empty id (trace t-0)
Discussion