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.

Syntaxstatic 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 run or call is executing. When the method returns, the value is gone. There is no remove() 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. Use orElse or check isBound().
  • Storing a mutable object, such as a Map that 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 final constants shared by everyone who reads them.
  • Expecting a value bound in a filter to survive an asynchronous hop.

Example

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

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.

Example · java
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: acme

Several 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.

Example · java
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: false

Returning 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.

Example · java
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

  • Be the first to comment on this lesson.