Skip to main content

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 in testplan/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:

OSArchNative filename inside JAR
Linux (glibc)x86_64linux-x86_64/libexd_client.so
Linux (glibc)aarch64linux-aarch64/libexd_client.so
Linux (musl)x86_64linux-musl-x86_64/libexd_client.so
macOS 11+x86_64darwin-x86_64/libexd_client.dylib
macOS 11+arm64darwin-aarch64/libexd_client.dylib
Windowsx86_64windows-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 symbolSourceWhy
exd_lint_files(dir_name, files_json) -> LintReport JSONexd_client::lint_filesSingle-validator entry.
exd_namespace_from_files / from_dir / from_uri / from_serversync Namespace::*Sync loaders.
exd_namespace_eval / eval_bool / eval_string / eval_i64 / eval_f64 / eval_jsonNamespace::eval*Typed eval.
exd_namespace_refresh / replace_from_filesNamespace::*Atomic state swap.
exd_namespace_bucketing_attribute / telemetry_enabled / raw_entity_ids / *_private_attributestelemetry accessorsMirrors WASM telemetry bridge.
exd_sha256_hexhashing helperTelemetry 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_streamexd_client::client::event_stream::runSSE consumer with closure-delta apply.
exd_build_variant() -> "java"identity tagTelemetry 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

MethodSourceRefresh
Namespace.fromDir(Path)local directoryns.refresh() re-reads from disk
Namespace.fromArchive(Path)local tar.gzstatic
Namespace.fromUri(String uri, LoadOptions opts)https://, git+ssh://, git+https://, file://poll via startPeriodicRefresh
Namespace.fromServer(ServerOptions opts)exd serverpoll via startPeriodicRefresh
Namespace.fromFiles(String dirName, Map<String, String> files)in-memory mapns.replaceFromFiles(files)

LoadOptions carries uriAuth, httpBackend. ServerOptions carries server, namespace, apiToken, httpBackend.

Eval

MethodReturnsOn failure
evalBool / evalString / evalLong / evalDouble / evalJsontyped Java valuethrows EvalException (sub-types: UnknownFlag, UnknownEnvironment, AttrTypeMismatch, FlagTypeMismatch)
eval(flag, env, ctx)EvalResultthrows EvalException
boolFlag(flag, env, ctx, default)typed value, never throwslogs at WARN, returns default
evalAll(env, ctx)Map<String, EvalResult>per-flag errors mapped into EvalResult.ruleMatched = SDK_DEFAULT

Refresh

MethodBehavior
ns.refresh()one-shot blocking re-fetch + lint; previous state preserved on error
ns.startPeriodicRefresh(Duration interval) -> RefreshHandlespawns 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().

MethodTypeDefault
namespaceInline(Namespace ns)Namespace
namespaceUri(String uri)String
server(String url) + namespace(String slug) + apiToken(String token)String
environment(String env)Stringrequired
uriAuth(UriAuth auth)Bearer | Basicnone
pollInterval(Duration interval)Duration30s
manifestCachePath(Path path)Pathnone
workingTree(Path path)Pathper-process tmpdir
eventStream(boolean enabled)booleanfalse
eventStreamFlags(List<String> flags)List<String>["*"]
safetyNetInterval(Duration interval)Duration30 min
httpBackend(HttpBackend backend)IN_PROCESS | CURLHttpBackend.fromEnvOrDefault()
dryRun(boolean enabled)booleanfalse
sinks(List<EvaluationSink> sinks)List<EvaluationSink>[]
onEvaluation(Consumer<EvaluationRecord> handler)callablenone

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 / interfacePurpose
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().
ClosureSinkWraps 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

ArtifactPulls inAdds
dev.exd:exd-client-parquetorg.apache.parquet:parquet-avroParquet output for FileSink.
dev.exd:exd-client-otlpio.opentelemetry:opentelemetry-sdkOtlpSink.
dev.exd:exd-client-object-storesoftware.amazon.awssdk:s3 + GCS + Azure SDKObjectStoreSink.

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):

  1. Accept records as-is.
  2. Non-blocking on the eval hot path — record(...) SHOULD return in micro-bounded time. Network / disk / serialization happens on a separate worker.
  3. 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.
  4. Best-effort flush on graceful shutdown — client.close() awaits every attached sink's flush().
  5. 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:

  1. Explicit .httpBackend(HttpBackend.CURL) builder method.
  2. EXD_HTTP_BACKEND=curl|in-process environment variable.
  3. 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

ExceptionWhen it fires
ExdExceptionbase class; unchecked
ConflictingNamespaceSourcesExceptionbuilder mis-config
MissingFieldExceptionbuilder mis-config
LoadExceptionmanifest load failed (Io, Fetch, Lint, SlugMismatch, Builder)
LintExceptioncarries LintReport; thrown when constructing a Namespace from an unlintable manifest
EvalExceptiontyped-eval mismatches (UnknownFlag, UnknownEnvironment, AttrTypeMismatch, FlagTypeMismatch)
RefreshExceptionwraps the underlying LoadException for periodic-refresh diagnostic surfacing
TransportExceptionthrown 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's exd_lint_files / exd_lint_dir, which delegate to exd_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 the exd-server validator are built from. There is no Java reimplementation of any operator, diagnostic, or type rule.
  • The exd_sha256_hex FFI helper is the only digest path the telemetry layer uses, so a Java-emitted unitIdHash matches 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.onError and the next tick retries. Wrap with your own backoff if needed.
  • OtlpSink requires the exd-client-otlp companion artifact; missing it means Class.forName fails 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 Cleaner API for the FFI lifecycle, both of which are LTS-stabilized only in 21.