Core Java

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

FeatureScopedValueThreadLocal
Value lifecycleLimited to a defined scopeAssociated with a thread until removed or replaced
CleanupAutomaticOften requires explicit remove()
MutationExisting binding cannot be modifiedValue can be changed using set()
Nested valuesSupports nested rebinding with automatic restorationUsually requires manual value management
Typical useRequest context, tracing, security contextMutable 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.

Yatin Batra

An experience full-stack engineer well versed with Core Java, Spring/Springboot, MVC, Security, AOP, Frontend (Angular & React), and cloud technologies (such as AWS, GCP, Jenkins, Docker, K8).
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Back to top button