exd Java SDK (dev.exd:exd-client)
The Java SDK is a thin Java facade around an FFI build of the
exd-client Rust crate. The Rust core owns the lint pipeline, eval
engine, HTTP transport, SSE consumer, and closure-delta apply; Java
handles ergonomic wrappers, sink registration, and JVM stdlib
integration. This is the same architecture Statsig's Server Core and
Unleash's Yggdrasil converged on, and is the JVM analogue of the
TypeScript SDK's WASM bridge and the Python SDK's PyO3 bridge.
Phase status. This document is a v0 spec. No source code lives at
sdks/java/yet. The test contract is intestplan/17-java-sdk.md.
Installation
<!-- Maven -->
<dependency>
<groupId>dev.exd</groupId>
<artifactId>exd-client</artifactId>
<version>1.0.0</version>
</dependency>
// Gradle
implementation("dev.exd:exd-client:1.0.0")
The exd-client artifact is a single fat JAR that bundles the
Rust cdylib for every supported platform. The runtime extracts
the matching native into a temp directory and loads it via
System.load. Supported native targets:
| OS | Arch | Native filename inside JAR |
|---|---|---|
| Linux (glibc) | x86_64 | linux-x86_64/libexd_client.so |
| Linux (glibc) | aarch64 | linux-aarch64/libexd_client.so |
| Linux (musl) | x86_64 | linux-musl-x86_64/libexd_client.so |
| macOS 11+ | x86_64 | darwin-x86_64/libexd_client.dylib |
| macOS 11+ | arm64 | darwin-aarch64/libexd_client.dylib |
| Windows | x86_64 | windows-x86_64/exd_client.dll |
Operators on unsupported platforms can use the
exd-client-remote sub-API
(pure-Java, no JNI) or run the exd CLI as a sidecar.
Java 21+ supported (uses virtual threads for the periodic-refresh worker pool). Kotlin and Scala consumers are supported via the same artifact — no separate idiomatic-Kotlin module is published. The package is a single artifact — unlike the TypeScript SDK, there is no lite/full split. Sub-packages are all in the one JAR.
Architecture
The package is one Maven artifact dev.exd:exd-client → root
package dev.exd.client. The Rust core is built with cargo build --release and bundled into the JAR's resources; FFI is
mediated by Java 22 Foreign Function & Memory API
(stable, no JNI hand-rolling required). For Java 21 LTS support,
the FFI shims fall back to JNI via a hand-rolled glue layer
generated by uniffi-bindgen-java.
sdks/java/
├── settings.gradle.kts
├── build.gradle.kts
├── client/ # the published artifact
│ ├── build.gradle.kts
│ └── src/main/java/dev/exd/client/
│ ├── ExdClient.java # async (CompletableFuture)
│ ├── Namespace.java # sync
│ ├── EvalContext.java # builder + AttrValue
│ ├── EvalResult.java # record
│ ├── ExdError.java # base exception
│ ├── HttpBackend.java # enum
│ ├── poll/Poller.java
│ ├── poll/ArchiveSource.java
│ ├── sse/EventStream.java
│ ├── remote/ExdRemote.java # pure-Java; no FFI
│ ├── group/ExdClientGroup.java
│ ├── telemetry/EvaluationRecord.java # record
│ ├── telemetry/EvaluationSink.java # interface
│ ├── telemetry/sinks/ConsoleSink.java
│ ├── telemetry/sinks/StdoutSink.java
│ ├── telemetry/sinks/FileSink.java # NDJSON
│ ├── telemetry/sinks/InMemorySink.java
│ ├── telemetry/sinks/OtlpSink.java
│ ├── telemetry/sinks/ObjectStoreSink.java
│ ├── telemetry/BuildEvaluationRecord.java
│ └── ffi/ # internal: Panama / JNI shims
│ ├── Native.java
│ ├── NamespaceHandle.java
│ └── AsyncClientHandle.java
├── ffi-rust/ # the JNI/Panama bindings crate
│ ├── Cargo.toml
│ └── src/lib.rs
└── client/src/test/java/dev/exd/client/
├── LintTest.java
├── EvalTest.java
├── PollTest.java
├── SseTest.java
├── RemoteTest.java
└── telemetry/RecordsTest.java
The Rust core's surface
The FFI bindings expose only the byte-for-byte equivalents of
what crates/exd-client-wasm/src/lib.rs exposes to TypeScript and
what the Python SDK's _core exposes, plus the asynchronous
surface that lives in crates/exd-client/src/client/. The
ffi-rust/ bindings crate depends on crates/exd-client/ (no
fork, no reimplementation).
| FFI symbol | Source | Why |
|---|---|---|
exd_lint_files(dir_name, files_json) -> LintReport JSON | exd_client::lint_files | Single-validator entry. |
exd_namespace_from_files / from_dir / from_uri / from_server | sync Namespace::* | Sync loaders. |
exd_namespace_eval / eval_bool / eval_string / eval_i64 / eval_f64 / eval_json | Namespace::eval* | Typed eval. |
exd_namespace_refresh / replace_from_files | Namespace::* | Atomic state swap. |
exd_namespace_bucketing_attribute / telemetry_enabled / raw_entity_ids / *_private_attributes | telemetry accessors | Mirrors WASM telemetry bridge. |
exd_sha256_hex | hashing helper | Telemetry parity with the Rust SDK. |
exd_async_client_* | exd_client::ExdClient (async-tokio feature) | Drives the Tokio runtime; futures bridged to CompletableFuture. |
exd_async_client_start_event_stream | exd_client::client::event_stream::run | SSE consumer with closure-delta apply. |
exd_build_variant() -> "java" | identity tag | Telemetry sdk_name = "exd-client-java". |
Tokio runtime
ExdClient (the async surface) shares a single Tokio runtime per
JVM, lazily started on the first ExdClient.builder().build() call
and shut down on JVM exit (via Runtime.getRuntime().addShutdownHook).
All CompletableFutures returned by the SDK complete on Tokio
worker threads; callers SHOULD use
thenApplyAsync(..., executor) if they want continuations on an
application-managed pool. Java 21 virtual threads are used for the
periodic-refresh worker on the sync surface (Namespace).
Memory model
The FFI exposes opaque handles (long JVM-side, *mut c_void
Rust-side). Every handle is wrapped in a java.lang.ref.Cleaner-
managed Java object whose action calls the FFI's _drop function
when the Java wrapper becomes unreachable. Eval calls take the
read lock on the typed model (the same Arc<NamespaceState>
swap-on-success primitive the Rust SDK uses), so concurrent eval
from many JVM threads never blocks on refresh.
Public API
Sync — Namespace
The thread-safe sync surface. Mirrors the Rust crate's
Namespace and the Python SDK's Namespace.
import dev.exd.client.Namespace;
import dev.exd.client.EvalContext;
try (Namespace ns = Namespace.fromDir(Path.of("./manifest"))) {
EvalContext ctx = EvalContext.builder()
.str("user.country", "US")
.bool("user.is_premium", true)
.integer("user.age", 28)
.strList("user.roles", List.of("admin", "beta"))
.build();
boolean enabled = ns.evalBool("checkout-redesign", "production", ctx);
EvalResult result = ns.eval("checkout-redesign", "production", ctx);
}
Namespace is AutoCloseable and close() releases the FFI
handle. It is also kept alive by a Cleaner action so a leaked
instance is eventually freed.
Constructors
| Method | Source | Refresh |
|---|---|---|
Namespace.fromDir(Path) | local directory | ns.refresh() re-reads from disk |
Namespace.fromArchive(Path) | local tar.gz | static |
Namespace.fromUri(String uri, LoadOptions opts) | https://, git+ssh://, git+https://, file:// | poll via startPeriodicRefresh |
Namespace.fromServer(ServerOptions opts) | exd server | poll via startPeriodicRefresh |
Namespace.fromFiles(String dirName, Map<String, String> files) | in-memory map | ns.replaceFromFiles(files) |
LoadOptions carries uriAuth, httpBackend. ServerOptions
carries server, namespace, apiToken, httpBackend.
Eval
| Method | Returns | On failure |
|---|---|---|
evalBool / evalString / evalLong / evalDouble / evalJson | typed Java value | throws EvalException (sub-types: UnknownFlag, UnknownEnvironment, AttrTypeMismatch, FlagTypeMismatch) |
eval(flag, env, ctx) | EvalResult | throws EvalException |
boolFlag(flag, env, ctx, default) | typed value, never throws | logs at WARN, returns default |
evalAll(env, ctx) | Map<String, EvalResult> | per-flag errors mapped into EvalResult.ruleMatched = SDK_DEFAULT |
Refresh
| Method | Behavior |
|---|---|
ns.refresh() | one-shot blocking re-fetch + lint; previous state preserved on error |
ns.startPeriodicRefresh(Duration interval) -> RefreshHandle | spawns a background virtual thread |
ns.flagVersion() | monotonic counter; bumps only on successful refresh |
ns.lastRefreshError() | Optional<String> |
RefreshHandle.close() signals the virtual thread to exit and
joins it.
Namespace is thread-safe; the typed model is held behind an
internal handle whose backing Arc<NamespaceState> is the same
atomic-swap primitive the Rust SDK uses. Concurrent eval calls
never block each other.
Async — ExdClient
The asyncio counterpart. Builder pattern matches the Rust async
client, but returns CompletableFuture<T> instead of awaitable
futures.
import dev.exd.client.ExdClient;
import dev.exd.client.EvalContext;
import java.time.Duration;
ExdClient client = ExdClient.builder()
.server("https://flags.example.com")
.namespace("payments")
.environment("production")
.apiToken("exd_read_...")
.pollInterval(Duration.ofSeconds(30))
.manifestCachePath(Path.of("/var/cache/exd/payments.tar.gz"))
.build()
.join(); // CompletableFuture<ExdClient>
EvalContext ctx = EvalContext.builder().str("user.country", "US").build();
CompletableFuture<Boolean> enabled =
client.boolFlag("checkout-redesign", ctx, false);
client.close().join();
ExdClient is AutoCloseable; close() returns a
CompletableFuture<Void> that completes when the background poll
task and SSE stream have shut down and every attached sink has
flushed.
Builder methods
Mirror the Rust ExdClient builder field-for-field.
namespaceInline / namespaceUri / server are mutually
exclusive; combining two raises ConflictingNamespaceSourcesException
from build().
| Method | Type | Default |
|---|---|---|
namespaceInline(Namespace ns) | Namespace | — |
namespaceUri(String uri) | String | — |
server(String url) + namespace(String slug) + apiToken(String token) | String | — |
environment(String env) | String | required |
uriAuth(UriAuth auth) | Bearer | Basic | none |
pollInterval(Duration interval) | Duration | 30s |
manifestCachePath(Path path) | Path | none |
workingTree(Path path) | Path | per-process tmpdir |
eventStream(boolean enabled) | boolean | false |
eventStreamFlags(List<String> flags) | List<String> | ["*"] |
safetyNetInterval(Duration interval) | Duration | 30 min |
httpBackend(HttpBackend backend) | IN_PROCESS | CURL | HttpBackend.fromEnvOrDefault() |
dryRun(boolean enabled) | boolean | false |
sinks(List<EvaluationSink> sinks) | List<EvaluationSink> | [] |
onEvaluation(Consumer<EvaluationRecord> handler) | callable | none |
build() returns CompletableFuture<ExdClient>; for
namespaceUri and server sources it performs the initial
manifest fetch and starts the background poll task before
completing.
Multi-namespace — ExdClientGroup
import dev.exd.client.group.ExdClientGroup;
ExdClientGroup group = ExdClientGroup.builder()
.server("https://flags.example.com")
.apiToken("exd_read_…")
.member("billing", "production")
.member("growth", "production", List.of("onboarding"))
.workingTreeRoot(Path.of("/var/cache/exd"))
.build()
.join();
ExdClient billing = group.client("billing");
boolean on = billing.boolFlag("checkout-redesign", ctx, false).join();
group.close().join();
Same single-SSE-connection-per-group semantics as the Rust SDK.
group.client(slug) returns an ExdClient whose refresh is
driven by the shared consumer.
Remote eval — dev.exd.client.remote.ExdRemote
import dev.exd.client.remote.ExdRemote;
ExdRemote remote = ExdRemote.builder()
.server("https://flags.example.com")
.token("exd_read_...")
.namespace("payments")
.environment("production")
.build();
CompletableFuture<Boolean> enabled = remote.evalBool("checkout-redesign", ctx, false);
CompletableFuture<Map<String, EvalResult>> all = remote.evalAll(ctx);
Pure Java; no FFI, no manifest cache, no telemetry emission (the
server emits records with sdk_name = "exd-server"). HTTP
transport via the JDK's built-in java.net.http.HttpClient. Falls
back to caller-supplied default on transport error (5xx, network
timeout).
A blocking analogue (SyncExdRemote) is provided for
non-async callers.
Polling — dev.exd.client.poll
import dev.exd.client.poll.Poller;
import dev.exd.client.poll.ArchiveSource;
ArchiveSource source = ArchiveSource.fromUrl(
"https://flags.example.com/api/v1/tenants/acme/namespaces/demo/manifest",
"exd_read_..."
);
Poller poller = Poller.builder()
.client(client)
.source(source)
.interval(Duration.ofSeconds(30))
.onError(err -> log.warn("refresh failed", err))
.build();
poller.start(); // background virtual thread
// ...
poller.stop().join();
ArchiveSource.fromUrl tracks ETag between calls and short-
circuits on 304 (returns Optional.empty() from fetch() →
Poller skips the swap). Equivalent to the TS
@exd/client/poll.
SSE — dev.exd.client.sse
import dev.exd.client.sse.EventStream;
import dev.exd.client.sse.SubscriptionMember;
EventStream stream = EventStream.builder()
.url("https://flags.example.com/api/v1/events")
.client(client)
.members(List.of(SubscriptionMember.allFlags("payments")))
.token("exd_read_...")
.onUpdate(e -> log.info("flags updated to closure {}", e.closureHash()))
.build();
stream.start();
// ...
stream.stop().join();
Drives the v2 closure-delta protocol. Reconnects with exponential
backoff (1 s → 30 s cap) and resumes via Last-Event-ID. The
underlying state machine and apply-loop guarantees mirror
docs/sdks/01-rust.md § Event-stream refresh
exactly — same single-validator round trip, same fallback
hierarchy.
Telemetry — dev.exd.client.telemetry
Mirrors crates/exd-client/src/telemetry/ field-for-field. Same
EvaluationRecord JSON wire format, same suppression matrix
(dryRun, namespace.telemetry_enabled, raw_entity_ids,
flag-namespace + per-flag private_attributes), same SHA-256 hashing
of bucketing identifiers (delegating to the FFI's
exd_sha256_hex so the digest is byte-identical to the Rust
SDK).
import dev.exd.client.telemetry.sinks.StdoutSink;
import dev.exd.client.telemetry.sinks.FileSink;
import dev.exd.client.telemetry.sinks.InMemorySink;
InMemorySink records = new InMemorySink();
ExdClient client = ExdClient.builder()
// ... source / env / token ...
.sinks(List.of(
new StdoutSink(),
FileSink.builder().path(Path.of("/var/log/exd/records.ndjson")).build(),
records
))
.build()
.join();
client.boolFlag("ship-it", ctx, false).join();
client.close().join(); // flushes every attached sink
| Class / interface | Purpose |
|---|---|
EvaluationRecord (record) | Wire type — schemaVersion, evaluationId, timestamp, namespace, environment, flagKey, variantKey, variantValue, evaluationReason, matchedRuleId, manifestVersion, unitIdHash, unitIdType, secondaryUnitIds, contextAttributes, sdkName, sdkVersion, requestId, traceId, spanId. |
EvaluationReason (enum) | MATCHED_RULE, FALLTHROUGH, OFF, PREREQUISITE_FAILED, ERROR, SDK_DEFAULT. |
VariantValue (sealed) | Bool(boolean), Str(String), Int(long), Float(double), Json(String). |
ContextValue (sealed) | Str, Int, Float, Bool, StrList. |
EvaluationSink (interface) | void record(EvaluationRecord); CompletableFuture<Void> flush(). |
ClosureSink | Wraps a Consumer<EvaluationRecord> as a sink. |
ConsoleSink() | NDJSON via java.util.logging. |
StdoutSink() | NDJSON via System.out. |
InMemorySink() | Records into getRecords(): List<EvaluationRecord>; useful for tests. |
FileSink(path, format, rotation) | NDJSON (always). Parquet support is opt-in via the exd-client-parquet companion artifact. |
OtlpSink(endpoint, …) | Built on io.opentelemetry. Emits as span event when an OTel context is active, otherwise as LogRecord. Same attributes the Rust OtlpSink uses. |
ObjectStoreSink(uri, …) | Parquet to s3 / gs / az / file. Same column layout as the Rust sink. (Companion artifact exd-client-object-store.) |
Ndjson.encode(record) | One NDJSON line, trailing \n. |
BuildEvaluationRecord.build(bridge, …) | The exposed equivalent of the Rust emit::build_record and TS buildEvaluationRecord. Short-circuits on dryRun = true and telemetryEnabled = false. |
Optional companion artifacts
| Artifact | Pulls in | Adds |
|---|---|---|
dev.exd:exd-client-parquet | org.apache.parquet:parquet-avro | Parquet output for FileSink. |
dev.exd:exd-client-otlp | io.opentelemetry:opentelemetry-sdk | OtlpSink. |
dev.exd:exd-client-object-store | software.amazon.awssdk:s3 + GCS + Azure SDK | ObjectStoreSink. |
exd-client (no companions) covers the read path + telemetry to
console / stdout / NDJSON file; everything else is opt-in.
Sink contract
Same five rules as the Rust + TS sinks (sinks § The sink contract):
- Accept records as-is.
- Non-blocking on the eval hot path —
record(...)SHOULD return in micro-bounded time. Network / disk / serialization happens on a separate worker. - Survive transient downstream failure — a sink throwing from
record(...)MUST NOT propagate to the caller; the SDK logs at WARN and continues fanning to the remaining sinks. - Best-effort flush on graceful shutdown —
client.close()awaits every attached sink'sflush(). - No record mutation — sinks MAY encode but MUST NOT rewrite fields.
Custom sinks from Java
import dev.exd.client.telemetry.EvaluationRecord;
import dev.exd.client.telemetry.EvaluationSink;
public final class KafkaSink implements EvaluationSink {
private final Producer<byte[], byte[]> producer;
private final String topic;
public KafkaSink(Producer<byte[], byte[]> producer, String topic) {
this.producer = producer;
this.topic = topic;
}
@Override public void record(EvaluationRecord rec) {
producer.send(new ProducerRecord<>(topic, rec.toJson().getBytes(UTF_8)));
}
@Override public CompletableFuture<Void> flush() {
return CompletableFuture.runAsync(producer::flush);
}
}
The Rust core invokes sink.record(...) from inside the Tokio
runtime via a JNI upcall (or Panama upcall on Java 22+). The
upcall acquires the JVM's monitor for the sink instance; Java
sinks SHOULD be safe under concurrent invocation.
HTTP backend
Same selector semantics as the Rust SDK. HttpBackend.IN_PROCESS
uses Rust's reqwest over the FFI boundary (zero JDK network
deps required); HttpBackend.CURL shells out to curl.
Selection precedence:
- Explicit
.httpBackend(HttpBackend.CURL)builder method. EXD_HTTP_BACKEND=curl|in-processenvironment variable.- Compiled-in default (
IN_PROCESS).
tar and git always shell out, regardless of backend. The
Java SDK does not use java.net.http.HttpClient for any
manifest-fetch path — that would create a second transport
implementation and a second observability surface. The
remote-eval client (ExdRemote) is the one exception: it's the
no-FFI surface, so it uses the JDK HttpClient directly.
Working tree
The Java SDK reuses the Rust core's working-tree machinery
verbatim — workingTree(path) on the builder maps to the same
on-disk mirror documented for the Rust SDK. Configure to a
persistent path for warm-restart determinism; default is a
per-process subdirectory under System.getProperty("java.io.tmpdir").
Error model
| Exception | When it fires |
|---|---|
ExdException | base class; unchecked |
ConflictingNamespaceSourcesException | builder mis-config |
MissingFieldException | builder mis-config |
LoadException | manifest load failed (Io, Fetch, Lint, SlugMismatch, Builder) |
LintException | carries LintReport; thrown when constructing a Namespace from an unlintable manifest |
EvalException | typed-eval mismatches (UnknownFlag, UnknownEnvironment, AttrTypeMismatch, FlagTypeMismatch) |
RefreshException | wraps the underlying LoadException for periodic-refresh diagnostic surfacing |
TransportException | thrown by ExdRemote and the polling driver on transport failures (5xx, network) — boolFlag / evalBool etc. swallow this and return the caller's default |
The Rust panic-free invariant is preserved across the FFI: a panic
in the Rust core surfaces as ExdException("internal SDK panic: ...") with the panic message; eval methods (boolFlag etc.)
never propagate. The strict typed-eval methods (evalBool etc.)
throw EvalException on type mismatch.
Build pipeline
# Build the Rust cdylib for every supported target into sdks/java/client/build/native/.
./gradlew :ffi-rust:buildAllNatives
# Assemble the fat JAR (Rust natives + Java classes + resources).
./gradlew :client:build
# Publish to the local Maven cache for downstream testing.
./gradlew :client:publishToMavenLocal
CI uses cross for the Linux cross-compilations and dedicated
runners for macOS / Windows; the per-target cdylib outputs are
collected into the Java module's src/main/resources/native/<os>-<arch>/
before the final JAR assembly step.
Single-validator invariant
The Java SDK preserves the project-wide single-validator invariant:
Namespace.from*constructors always route through the FFI'sexd_lint_files/exd_lint_dir, which delegate toexd_client::lint::lint_files/exd_client::lint::lint_dir— the same entries the Rust SDK and the WASM bridge use.- The Rust core is built from the same
crates/exd-client/source the Rust SDK and theexd-servervalidator are built from. There is no Java reimplementation of any operator, diagnostic, or type rule. - The
exd_sha256_hexFFI helper is the only digest path the telemetry layer uses, so a Java-emittedunitIdHashmatches a Rust-emitted one byte-for-byte for the same input.
testplan/17-java-sdk.md § lite-equivalent equivalence enforces
the round-trip.
Limitations
- The polling driver does not retry on transport failure on its
own — failures surface via
Poller.onErrorand the next tick retries. Wrap with your own backoff if needed. OtlpSinkrequires theexd-client-otlpcompanion artifact; missing it meansClass.forNamefails at construction time, rather than at first emit.- Java 21 is the minimum supported runtime. Java 17 LTS is
intentionally not supported — the SDK uses virtual threads for
the periodic-refresh worker pool and the
CleanerAPI for the FFI lifecycle, both of which are LTS-stabilized only in 21.