Scoped Values

Share request context such as the current user with ScopedValue, final in Java 25, instead of leaky ThreadLocals.

Syntaxstatic final ScopedValue<T> KEY = ScopedValue.newInstance(); ScopedValue.where(KEY, value).run(() -> work()); R r = ScopedValue.where(KEY, value).call(() -> compute()); T v = KEY.get();

Back-end code constantly needs context that belongs to the current request: the authenticated user, a tenant id, a request id for logging. Passing it as a parameter through every layer is noisy, and the traditional alternative, ThreadLocal, has real problems. ScopedValue (JEP 506, final in Java 25) is the replacement designed for virtual threads.

How it works

static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();

ScopedValue.where(CURRENT_USER, user).run(() -> handle(request));
String body = ScopedValue.where(CURRENT_USER, user).call(() -> render(request));

User u = CURRENT_USER.get();   // anywhere inside handle(...)

where binds a value, and run or call executes code with that binding. Inside, any method on the same thread can read it with get(). When the lambda returns, the binding is gone. There is no set and no remove: the value is fixed for the lifetime of the call, and it disappears automatically.

Why not ThreadLocal?

  • Leaks between requests. A pooled thread keeps its ThreadLocal after the request ends. Forget remove() and the next request on that thread sees the previous user, which is a security bug.
  • Mutable from anywhere. Any code can call set, so it is hard to know who changed the value.
  • Costly inheritance. InheritableThreadLocal copies values into every child thread, which adds up with millions of virtual threads.

Reading safely

get() throws NoSuchElementException if nothing is bound. Use isBound() or orElse(fallback) in code that may run outside a request, such as a logging helper called at startup. A nested where can rebind the same key for an inner call, for example to run an audit step as a system user; the outer value returns when the inner call finishes.

Threads and inheritance

Bindings are per thread. A thread you start with Thread or an ExecutorService does not see them. Subtasks forked inside a StructuredTaskScope do inherit them, which is how context travels into parallel work; that API is still a preview in Java 25.

The usual pattern is to bind a scoped value in a filter at the edge of the request and read it deep in services or repositories. Keep the key private static final in the class that owns it and expose a small accessor, so the rest of the code cannot rebind it by accident.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Bind the authenticated user in a request filter and check permissions in a service several layers down.
  • Attach a request id to every log line without passing it to each method.
  • Run one step of a request as a system user by rebinding the actor for a nested call.

More examples

orElse for code that runs outside a request

The logging helper works both at startup and inside a request. Calling get() without a binding fails, so shared helpers should use orElse or isBound.

Example · java
static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();

void log(String message) {
    IO.println("[" + REQUEST_ID.orElse("no-request") + "] " + message);
}

void main() {
    log("starting up");
    ScopedValue.where(REQUEST_ID, "req-7f3a").run(() -> log("loading cart"));
    try {
        REQUEST_ID.get();
    } catch (NoSuchElementException e) {
        IO.println("get() outside a binding throws NoSuchElementException");
    }
}

// Output:
// [no-request] starting up
// [req-7f3a] loading cart
// get() outside a binding throws NoSuchElementException

Rebinding for a nested call

The inner binding applies only to the inner lambda. Nobody can overwrite the outer value, so the audit trail stays correct.

Example · java
static final ScopedValue<String> ACTOR = ScopedValue.newInstance();

void audit(String action) {
    IO.println(ACTOR.get() + ": " + action);
}

void main() {
    ScopedValue.where(ACTOR, "alice").run(() -> {
        audit("update profile");
        ScopedValue.where(ACTOR, "system").run(() -> audit("send confirmation email"));
        audit("view profile");   // the outer binding is back
    });
}

// Output:
// alice: update profile
// system: send confirmation email
// alice: view profile

A forgotten ThreadLocal leaks; a scoped value cannot

Both tasks run on the pool's single thread. The ThreadLocal value survives into the next task, which in a real server means another customer's tenant id; the scoped binding ended with its lambda.

Example · java
static final ThreadLocal<String> TENANT_LOCAL = new ThreadLocal<>();
static final ScopedValue<String> TENANT = ScopedValue.newInstance();

void main() throws Exception {
    try (ExecutorService pool = Executors.newSingleThreadExecutor()) {
        pool.submit(() -> TENANT_LOCAL.set("tenant-a")).get();   // request 1 forgets remove()
        IO.println("next task sees ThreadLocal: " + pool.submit(() -> TENANT_LOCAL.get()).get());

        pool.submit(() -> ScopedValue.where(TENANT, "tenant-a").run(() -> {})).get();
        IO.println("next task sees scoped value: " + pool.submit(() -> TENANT.isBound()).get());
    }
}

// Output:
// next task sees ThreadLocal: tenant-a
// next task sees scoped value: false

Discussion

  • Be the first to comment on this lesson.