Java Scoped Values: A Practical Example
ScopedValue provides a way to share contextual data with methods without passing that data explicitly through every method parameter. A scoped value is bound only while a specific block of code is executing. Once that block finishes, the binding automatically disappears. This makes scoped values useful for data such as request IDs, user information, security context, tracing information, and other request-specific metadata. Scoped Values became a permanent API in Java 25. They are especially useful with virtual threads because their lifetime is explicit and bounded. The basic pattern looks like this:
private static final ScopedValue<String> USER = ScopedValue.newInstance(); ScopedValue.where(USER, "Alice").run(() -> processRequest());
During the execution of processRequest(), and any method called from it, USER.get() returns "Alice". After run() completes, the value is no longer bound.
1. Introduction to Scoped Values in Java
Application code frequently needs to make contextual information available across several layers of a call stack. Consider a typical request flow: Controller -> Service -> Repository -> Logger. Suppose every layer needs access to a request ID or the currently authenticated user. One option is to pass this information through every method:
processRequest(requestId); processOrder(requestId); saveOrder(requestId); writeLog(requestId);
This works, but it adds parameters to methods that may not actually use the value themselves. Traditionally, Java applications often solved this with ThreadLocal. A scoped value provides another approach:
private static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance(); ScopedValue.where(REQUEST_ID, "REQ-1001").run(() -> processRequest());
Methods inside the dynamic scope can retrieve the value: String requestId = REQUEST_ID.get();.
1.1 Why use ScopedValue instead of ThreadLocal?
The main difference is lifecycle and mutability. A ThreadLocal value can remain attached to a thread until it is explicitly removed:
USER.set("Alice");
try {
processRequest();
} finally {
USER.remove();
}
Forgetting the cleanup can cause values to remain associated with long-lived or reused threads. A scoped value instead has a clearly defined lifetime: ScopedValue.where(USER, "Alice").run(() -> processRequest());. When processRequest() finishes, the binding automatically ends. There is no remove() call. Scoped values are also intended primarily for one-way transmission. Code reads the current binding rather than modifying the binding with set(). A nested scope can create a new binding, but the previous value is automatically restored when that nested scope finishes.
1.2 When should you still use ThreadLocal?
ThreadLocal still makes sense when you specifically need mutable state associated with a thread rather than immutable contextual data associated with a bounded operation. Typical cases include:
- Existing libraries or frameworks whose APIs are built around
ThreadLocal. - Code that genuinely needs to call
set()multiple times during execution. - State whose lifetime is intentionally tied to a particular thread.
- Applications that must run on Java versions where the required ScopedValue API is unavailable.
For request context, user identity, tracing information, or similar read-mostly contextual values, ScopedValue is usually the better model.
1.3 Key Characteristics of ScopedValue
Some important characteristics of ScopedValue are:
- Bounded lifetime: A value is available only within the scope in which it is bound.
- Automatic cleanup: The binding disappears automatically when execution leaves the scope.
- Immutable binding: Code inside a scope can read the value but cannot modify the existing binding.
- Nested scopes: A scoped value can be rebound in a nested scope, and the previous binding is restored when the nested scope ends.
- Virtual-thread friendly: Scoped values are designed to work efficiently with modern Java concurrency, including virtual threads.
1.4 ScopedValue vs ThreadLocal
| Feature | ScopedValue | ThreadLocal |
|---|---|---|
| Value lifecycle | Limited to a defined scope | Associated with a thread until removed or replaced |
| Cleanup | Automatic | Often requires explicit remove() |
| Mutation | Existing binding cannot be modified | Value can be changed using set() |
| Nested values | Supports nested rebinding with automatic restoration | Usually requires manual value management |
| Typical use | Request context, tracing, security context | Mutable thread-specific state and legacy APIs |
2. Java Scoped Values Code Example
The following example uses a scoped value to make request information available to several methods without passing it through each method parameter.
public class ScopedValueExample {
private static final ScopedValue < RequestContext > REQUEST_CONTEXT = ScopedValue.newInstance();
record RequestContext(String requestId, String username) {}
public static void main(String[] args) {
RequestContext context = new RequestContext("REQ-1001", "Alice");
System.out.println("Before scope: " + REQUEST_CONTEXT.isBound());
ScopedValue.where(REQUEST_CONTEXT, context).run(ScopedValueExample::processRequest);
System.out.println("After scope: " + REQUEST_CONTEXT.isBound());
}
private static void processRequest() {
System.out.println("Processing request: " + REQUEST_CONTEXT.get().requestId());
authenticateUser();
saveData();
}
private static void authenticateUser() {
RequestContext context = REQUEST_CONTEXT.get();
System.out.println("Authenticating user: " + context.username());
}
private static void saveData() {
RequestContext context = REQUEST_CONTEXT.get();
System.out.println("Saving data for request: " + context.requestId());
}
}
2.1 Code Explanation
The example defines a ScopedValue<RequestContext> named REQUEST_CONTEXT to store request-specific information, while the RequestContext record groups the request ID and username into a single object. In the main() method, a RequestContext instance is created and bound to REQUEST_CONTEXT using ScopedValue.where(REQUEST_CONTEXT, context).run(...). This binding exists only while processRequest() and the methods called from it are executing. Inside processRequest(), authenticateUser(), and saveData(), the current context is retrieved using REQUEST_CONTEXT.get(), so the request information does not need to be passed explicitly as a method parameter. The isBound() calls before and after the scope demonstrate that the value is unavailable outside the scoped block. Once run() completes, the binding is automatically removed, providing a clear and predictable lifecycle without requiring manual cleanup such as ThreadLocal.remove().
2.2 Code Output
Before scope: false Processing request: REQ-1001 Authenticating user: Alice Saving data for request: REQ-1001 After scope: false
The output shows that REQUEST_CONTEXT is not bound before entering the scoped block. Inside the scope, the request ID and username are accessible to all methods through REQUEST_CONTEXT.get(). After ScopedValue.where(...).run(...) completes, the binding is automatically removed, so isBound() returns false again.
2.3 Nested Scoped Values
A ScopedValue can be rebound within a nested scope. When the nested scope finishes, the previous binding becomes visible again.
private static final ScopedValue<String> USER = ScopedValue.newInstance();
ScopedValue.where(USER, "Alice").run(() -> {
System.out.println(USER.get());
ScopedValue.where(USER, "Bob").run(() -> {
System.out.println(USER.get());
});
System.out.println(USER.get());
});
The output is:
Alice Bob Alice
This shows that the nested scope temporarily binds USER to "Bob". After the nested scope completes, the original "Alice" binding is automatically restored.
3. Conclusion
Java Scoped Values provide a clean way to propagate contextual information through a call chain without adding parameters to every method and without relying on long-lived mutable thread-local state. The most common pattern is straightforward: create a ScopedValue, bind it with ScopedValue.where(), execute an operation using run() or call(), and read the value inside that scope using get(). For request IDs, user context, tracing metadata, and similar bounded contextual data, ScopedValue should generally be preferred. ThreadLocal remains useful when mutable, thread-associated state is genuinely required or when existing APIs depend on it.

