Algebraic Effect Handlers for Java — bind effect handlers to a dynamic scope so they are discoverable from anywhere in the call stack, without threading explicit parameters through every layer.
Algebraic Effect Handlers let you separate what an effect does from where the effect is handled. Code deep in a call stack can invoke a logging effect, a metrics effect, or a request-reply effect without knowing who handles it. The caller decides, at the boundary, what each effect means.
This library implements that model using Java's ScopedValue (ambient context propagation) and Proxxy (partitioned virtual-thread actors). Each handler is backed by a Proxxy proxy: method calls are routed to partition threads by a configurable router function, so the same routing key always reaches the same thread and the same target instance — no synchronization required.
- Java 25+ (
ScopedValueis a standard API from Java 25)
// build.gradle.kts
dependencies {
implementation("io.github.joohyung-park:effectivejava:0.4.1")
}| Term | Meaning |
|---|---|
| Effect interface | A plain Java interface whose methods represent effects. No annotations required. |
bind(Type.class, factory) |
Register a handler implementation for an effect type. |
find(Type.class) |
Discover the proxy bound to an effect type in the current scope. |
run(body) |
Start all handlers, execute body with them discoverable, then tear down. |
| Router | A ToIntBiFunction<Method, Object[]> that maps a call to a partition. Default: BY_FIRST_ARG. |
interface Logger {
void log(String userId, String message);
}
HandlerScope.open()
.bind(Logger.class, () -> (userId, msg) -> System.out.println("[LOG] " + msg))
.run(() -> {
Logger log = HandlerScope.find(Logger.class);
log.log("alice", "application started"); // routed to alice's partition thread
log.log("alice", "processing req"); // same thread, same Logger instance
});Void methods are fire-and-forget — the caller does not block.
Non-void methods block the caller until the handler returns.
interface Greeter {
String greet(String userId, String name);
}
HandlerScope.open()
.bind(Greeter.class, () -> (userId, name) -> "Hello, " + name + "!")
.run(() -> {
String result = HandlerScope.find(Greeter.class).greet("alice", "World");
System.out.println(result); // "Hello, World!"
});HandlerScope.open()
.bind(Logger.class, MyLogger::new)
.bind(Greeter.class, MyGreeter::new)
.run(() -> {
HandlerScope.find(Logger.class).log("alice", "user login");
String greeting = HandlerScope.find(Greeter.class).greet("alice", "World");
});By default, calls are routed by the first argument's hash code (BY_FIRST_ARG). Supply an explicit router for finer control.
// Route every call to partition 0 — fully ordered, single-threaded handler
HandlerScope.open()
.bind(Logger.class, MyLogger::new, (method, args) -> 0)
.run(() -> { ... });
// Route by second argument instead of first
HandlerScope.open()
.bind(Logger.class, MyLogger::new, (method, args) -> args[1].hashCode())
.run(() -> { ... });find throws IllegalStateException when called outside a scope or for an unregistered type. A missing handler is always a programming error — never silence it.
// ✗ throws — no scope active
HandlerScope.find(Logger.class);
// ✗ throws — Logger not bound
HandlerScope.open()
.bind(Greeter.class, MyGreeter::new)
.run(() -> HandlerScope.find(Logger.class));
// ✓ correct
HandlerScope.open()
.bind(Logger.class, MyLogger::new)
.run(() -> HandlerScope.find(Logger.class).log("alice", "safe here"));HandlerScope.open()
.bind(Logger.class, MyLogger::new) ← factory registered, not yet started
.run(body) ← Proxxy proxy created (2 partition threads per handler)
body executes ← find() returns the proxy; calls routed by router
← same routing key → same thread → same target instance
← body exits (normal or exception) ← all proxies closed
← pending non-void calls complete before shutdown
← run() returns only after all handlers finish
MIT — see LICENSE.