Pass Context with Scoped Values
class MissionControl {
static final ThreadLocal<Astronaut> CURRENT =
new ThreadLocal<>();
private final Dispatcher dispatcher = new Dispatcher();
void handle(Astronaut astronaut, Command command) {
CURRENT.set(astronaut);
try {
dispatcher.execute(command);
} finally {
CURRENT.remove();
}
}
}
class Dispatcher {
void execute(Command command) {
var astronaut = MissionControl.CURRENT.get();
if (!astronaut.mayExecute(command)) {
throw new SecurityException("not allowed: " + command);
}
// ...
}
}
Mission control needs to know which astronaut sent the command, deep down in the Dispatcher, without passing the astronaut through every method in between. A ThreadLocal is the classic solution: handle() stores the astronaut for the current thread, and the dispatcher reads it.
But a ThreadLocal is a mutable global variable per thread. Any code can call set() at any time, so you can't tell where the value came from. It stays set until someone calls remove(): forget the finally block, and in a thread pool, the next request on the same thread runs as the previous astronaut, a real security bug. And with a million virtual threads, each holding its own copies of thread-local values, the memory adds up quickly.
class MissionControl {
static final ScopedValue<Astronaut> CURRENT =
ScopedValue.newInstance();
private final Dispatcher dispatcher = new Dispatcher();
void handle(Astronaut astronaut, Command command) {
ScopedValue.where(CURRENT, astronaut)
.run(() -> dispatcher.execute(command));
}
}
class Dispatcher {
void execute(Command command) {
var astronaut = MissionControl.CURRENT.get();
if (!astronaut.mayExecute(command)) {
throw new SecurityException("not allowed: " + command);
}
// ...
}
}
Since Java 25 (JEP 506), a ScopedValue passes such context in a safer way. ScopedValue.where() binds the astronaut for exactly the duration of run(). Within that block, including all the methods it calls, CURRENT.get() returns the astronaut. Afterwards, the binding is gone automatically. There's no remove() to forget, so the value can never leak into the next request.
Scoped values are immutable: there is no set() method, so code deep down the call stack can't change the context for its callers. A nested where() can rebind the value for an inner block, but only for that block. Scoped values are also cheap to read and to inherit: subtasks forked in a StructuredTaskScope see the same binding without copying it.
Note that the dispatcher didn't change at all. Reading CURRENT.get() outside a binding throws a NoSuchElementException, so a missing context fails fast instead of silently returning null.