Java Server SDK
Introduction
The Java Server SDK is a server-side SDK that evaluates configs and their targeting rules. Upon initialization, it retrieves configs and targeting rules from ConfigDirector services. From that point on, it evaluates configs locally for any context, and receives updates via server sent events (SSE) when configs are updated on the dashboard or via the admin API.
The minimum Java version supported is 17.
The client is thread safe. Create one instance when your application starts, share it for the lifetime of the process, and close it on shutdown. Evaluations read config state the client already holds in memory, so they make no network calls on the request path.
Installation
The SDK is published to the ConfigDirector Maven repository. Add the repository and the dependency to your build. If your Gradle build declares its repositories in settings.gradle.kts or settings.gradle, under dependencyResolutionManagement, the repository block goes there instead.
repositories {
mavenCentral()
exclusiveContent {
forRepository {
maven {
name = "ConfigDirector"
url = uri("https://maven.configdirector.com")
}
}
filter {
includeGroup("com.configdirector")
}
}
}
dependencies {
implementation("com.configdirector:server-sdk:1.11.0")
}
repositories {
mavenCentral()
exclusiveContent {
forRepository {
maven {
name = 'ConfigDirector'
url = 'https://maven.configdirector.com'
}
}
filter {
includeGroup 'com.configdirector'
}
}
}
dependencies {
implementation 'com.configdirector:server-sdk:1.11.0'
}
<repositories>
<repository>
<id>configdirector</id>
<url>https://maven.configdirector.com</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</repository>
</repositories>
<dependency>
<groupId>com.configdirector</groupId>
<artifactId>server-sdk</artifactId>
<version>1.11.0</version>
</dependency>
Configure and initialize the client
- Create an instance of the client providing your server SDK key. You can retrieve a server SDK key for each environment under
SDK keysin the dashboard's navigation panel. - Initialize the client to initiate its connection lifecycle.
import com.configdirector.ConfigDirector;
import com.configdirector.ConfigDirectorClient;
// IMPORTANT: Do not commit the server SDK key to your source code, it is a secret value.
// Building the client makes no network calls.
ConfigDirectorClient client = ConfigDirector.client("YOUR-SERVER-SDK-KEY");
// Blocks until the client is initialized or it times out.
// If initialization times out, the client will continue attempting to
// initialize in the background.
client.initialize();
The client is thread safe. Create one at startup, share it for the lifetime of the process, and call close on shutdown.
initialize blocks until the initial config state arrives or the configured timeout elapses, and it never throws on a connection failure. Check isReady to find out whether config state actually arrived. An overload accepts a Duration to override the configured timeout for that call:
client.initialize(Duration.ofSeconds(5));
Additional configuration options
Options are adjusted through the lambda passed to ConfigDirector.client. Every setter returns the options object, so calls chain. They are read once when the client is built; changing them afterwards has no effect.
metadata
The metadata option allows you to provide your application's name and version. These values can be used in targeting rules conditionals. For example, if a certain feature should only be enabled starting with a certain version of your application.
import com.configdirector.ConfigDirector;
import com.configdirector.ConfigDirectorClient;
ConfigDirectorClient client = ConfigDirector.client(
"YOUR-SERVER-SDK-KEY",
options -> options.metadata("YOUR-APP-NAME", "1.0.2"));
client.initialize();
logger
The SDK logs through SLF4J. Left to itself it writes to the logger named com.configdirector, so logging levels, formatting, and destinations can be controlled through your usual SLF4J configuration:
<logger name="com.configdirector" level="DEBUG" />
Alternatively, pass any SLF4J Logger to the logger option to put the SDK's output under your application's own logging namespace, where existing appenders and level configuration already apply:
import org.slf4j.LoggerFactory;
ConfigDirectorClient client = ConfigDirector.client(
"YOUR-SERVER-SDK-KEY",
options -> options.logger(LoggerFactory.getLogger("my-app.configdirector")));
connection
The connection option accepts a lambda receiving a builder with the following optional values:
mode- The connection mode, which can be
ConnectionMode.STREAMING,ConnectionMode.POLLINGorConnectionMode.OFFLINE. It is recommended to use the default ofSTREAMINGunless you have a specific need to usePOLLINGinstead. InOFFLINEmode the client never connects to ConfigDirector and serves the values you give it; see Run without a connection. - Defaults to
ConnectionMode.STREAMING
- The connection mode, which can be
file- The
Pathof a JSON file holding the values to serve inOFFLINEmode, resolved against the working directory of the process. Only one offileandvaluescan be given, and only inOFFLINEmode. See Run without a connection.
- The
values- The values to serve in
OFFLINEmode, as aMapkeyed by config key. Only one offileandvaluescan be given, and only inOFFLINEmode. See Run without a connection.
- The values to serve in
pollingInterval- Only used in
POLLINGmode. The interval to poll ConfigDirector services for updates. A value below the minimum of60seconds is raised to60seconds and a warning is logged. See Polling intervals. - Defaults to
5minutes
- Only used in
timeout- The timeout to be used in initialization. This is how long the
initializemethod will wait for data from ConfigDirector services. If the timeout is reached,initializewill return but the client will still be in an unready status and returning default values. While streaming, the client will continue to attempt to connect and retrieve config values in the background. It has no effect inOFFLINEmode. - Defaults to
3seconds
- The timeout to be used in initialization. This is how long the
url- The base URL used to connect to ConfigDirector services
- This should only be provided if your environment requires you to configure a proxy server in order to connect to ConfigDirector services. It has no effect in
OFFLINEmode.
import com.configdirector.ConfigDirector;
import com.configdirector.ConnectionMode;
import java.time.Duration;
ConfigDirectorClient client = ConfigDirector.client(
"YOUR-SERVER-SDK-KEY",
options -> options.connection(connection -> connection
.mode(ConnectionMode.POLLING)
.pollingInterval(Duration.ofMinutes(10))
.timeout(Duration.ofSeconds(5))));
Connection settings can also be built once with ConnectionOptions.builder() and shared by several clients:
import com.configdirector.ConnectionOptions;
ConnectionOptions connection = ConnectionOptions.builder()
.mode(ConnectionMode.POLLING)
.pollingInterval(Duration.ofMinutes(10))
.build();
ConfigDirectorClient client = ConfigDirector.client(
"YOUR-SERVER-SDK-KEY",
options -> options.connection(connection));
telemetry
Telemetry tuning. It is unlikely these settings need to be adjusted. However, in cases where your application has a large number of evaluations per second, you can adjust these settings to tune the memory footprint and frequency of telemetry requests.
Keep in mind that ConfigDirector relies on these telemetry events to provide insights and features related to the configs being used.
From version 1.11.0 of the SDK, each evaluation it reports says which variation was served and which percentage rollout and share served it. That is how ConfigDirector counts the contexts each variation of an experiment reached. Tracked events are sent with the same reports.
The telemetry builder accepts the following optional values:
eventQueueLimit- The size limit of telemetry event queues. If the size limit is reached before the events are flushed to the network, older events will be dropped.
- ConfigDirector keeps a count of dropped events. If the number of dropped events is higher than 50% of the total events, ConfigDirector will issue an alert in the dashboard.
- A number between 100 and 100,000. Defaults to 5,000.
flushInterval- How often events are flushed and sent over the network.
- Decrease this number if your application consistently captures a large number of events in short periods of time in order to reduce memory footprint from a large event queue.
- Defaults to 30 seconds.
import java.time.Duration;
ConfigDirectorClient client = ConfigDirector.client(
"YOUR-SERVER-SDK-KEY",
options -> options.telemetry(telemetry -> telemetry
.eventQueueLimit(10_000)
.flushInterval(Duration.ofSeconds(15))));
A value outside the accepted range throws a ConfigDirectorValidationException when the client is built.
Hooks
Hooks are events you can subscribe to in order to be notified of some key actions from the client. Each registration method takes a handler and returns a Subscription, which cancels the registration when closed:
Subscription subscription = client.onConfigsUpdated(event -> System.out.println(event.keys()));
subscription.close(); // Cancels the registration
The following hooks are available:
onClientReady(Consumer<ClientReadyEvent>)- Emitted when the client is initialized. When this event is emitted it means the client has received a payload from the ConfigDirector servers, or has loaded its offline values, and is ready to evaluate configs. It is emitted once, and a handler registered after that point is never called.
onConfigsUpdated(Consumer<ConfigsUpdatedEvent>)- Emitted when a payload is received from the ConfigDirector servers with config data. It is emitted during initialization when an entire config payload is received. After initialization, it is emitted when updates are pushed from the server (or discovered via polling if that connection mode is used).
keys(): List<String>- The config keys that were included in the payload from the server, and the keys of configs whose targeting rules use a segment the payload included, sorted.removedKeys(): List<String>- The keys of configs the payload removed, sorted. A config is removed when a full payload no longer includes it. Empty when nothing was removed.- Handlers run on the transport thread, or in offline mode on the thread that calls
initialize, so one that blocks delays later updates.
onConfigEvaluated(Consumer<ConfigEvaluatedEvent>)- Emitted whenever a config is evaluated. This includes calls to the getters and
evaluate, and evaluations delivered to watchers. evaluation(): ConfigEvaluation- AConfigEvaluationrecord containing the details of the evaluation:key(): String- The config key that was evaluatedvalue(): Object- The value the config evaluated to, in the type the caller's default asked for. It can be the default value provided to the getter or to the watch. For example, if the config was evaluated before the client was initialized.valueId(): String- The value ID, a stable hash of the value that you can send to a third party rather than thevalue. This can be useful for large values, like a JSON config, or to avoid disclosing the values themselves. To report an experiment to an analytics tool, send the variation instead: the pages for Amplitude, Mixpanel, Google Analytics 4, Segment and PostHog name the event each one reads.variation(): Variation- The variation that was served, with itsid()andname(). Theid()stays the same when the variation is renamed, andname()is null for a variation without one. It is null when the config has no variations or the in-code default value is returned. Versions of the SDK before 1.11.0 do not report it.isDefault(): boolean- Whether or not the evaluation fell back to the default value provided in code.reason(): EvaluationReason- The reason for the evaluation resolution. In the case the config successfully evaluated based on server-provided targeting rules, or served an offline value, it will beFOUND_MATCH. A context without anidthat is served the default targeting rule value after skipping a percentage rollout reportsROLLOUT_SKIPPED_NO_IDENTIFIER, withisDefault()false; see Contexts without anid. If the evaluation had to fall back to the default value, thereasonwill encode why the fallback was required:CLIENT_NOT_READY- The evaluation happened before the client finished initializationCONFIG_STATE_MISSING- The requested config key was not present in the payload received from the server. This could be due to an incorrect config key, or a config that was not enabled to be available to client SDKs.INVALID_NUMBER- The config was requested as a number but the value received from the server could not be converted to a number.INVALID_BOOLEAN- The config was requested as a boolean but the value received from the server could not be converted to a boolean.INVALID_JSON- The config was requested as JSON but the value received from the server could not be converted to a JSON object or array.TYPE_MISMATCH- The config was requested with a data type that did not match the type of the config and no reasonable type conversion was possible.VALUE_MISSING- The config key was present in the payload received from the server, but it carried no value.
context(): Context- The context that was provided to the evaluation function, ornullif no context was provided.
- Handlers run on the calling thread, so one that blocks delays the getter that triggered it.
- Emitted whenever a config is evaluated. This includes calls to the getters and
Run without a connection
Set the connection mode to ConnectionMode.OFFLINE to run your application without connecting to ConfigDirector, for example in a suite of UI tests where each run pins every flag to a known value. An offline client opens no connection and sends nothing, so the application is invisible to the ConfigDirector dashboard: no evaluations are counted and no lifecycle state changes. Every context receives the same value and targeting rules are not applied.
An offline client needs no server SDK key. Build it with ConfigDirector.client(options -> ...), as below, or pass a key that is null or blank, such as an environment variable that is not set.
Give the values in a JSON file, with one value per config key, or inline:
import com.configdirector.ConfigDirector;
import com.configdirector.ConnectionMode;
import java.nio.file.Path;
ConfigDirectorClient client = ConfigDirector.client(
options -> options.connection(connection -> connection
.mode(ConnectionMode.OFFLINE)
.file(Path.of("flags/new-checkout-on.json"))));
client.initialize();
{
"new-checkout": true,
"max-items": 20,
"greeting": "hello",
"theme": { "color": "blue" }
}
import com.configdirector.ConfigDirector;
import com.configdirector.ConnectionMode;
import java.util.Map;
ConfigDirectorClient client = ConfigDirector.client(
options -> options.connection(connection -> connection
.mode(ConnectionMode.OFFLINE)
.values(Map.of("new-checkout", true, "max-items", 20))));
client.initialize();
The file path is resolved against the working directory of the process. The config type of each value follows from the value itself: true or false (boolean), a whole number (integer), any other number (float), a string, or an object or array (json). Inline values follow the same rule with Java types: a Boolean, an Integer or a Long, a Float or a Double, a String, or a Map with String keys or a List. A value read with an in-code default value of another type returns the in-code default value, as it does against ConfigDirector. A watch registered before initialize is called with the offline value when initialize loads it. Offline values never change on their own, so the watch is called again only if initialize is called again, which loads the values, and reads the file, once more. pollingInterval, timeout, url and the timeout passed to initialize have no effect.
With ConnectionMode.OFFLINE and neither file nor values, the client is ready at once and every config returns its in-code default value.
initialize throws a ConfigDirectorInitializationException when the values cannot be loaded: the file is missing, cannot be read, is not a JSON object or holds a value such as null; an inline value is null or of a type not listed above; file and values are both given; or either is given in STREAMING or POLLING mode. The client stays not ready, so a test run that starts from a misspelled path fails instead of passing on in-code default values. A later initialize that cannot load the values throws the same way and keeps serving the values loaded before.
If you come from LaunchDarkly, a file of values is what LaunchDarkly calls "flags from files", and OFFLINE with no values is its "offline mode".
Retrieve config values
Retrieve config values via the typed getters, and subscribe to updates via the matching typed watches. The first argument is the config key and the second is the default value, which is returned if the config is not available or cannot be parsed as the given type:
import com.configdirector.Subscription;
import java.util.Map;
// Retrieve config values
int retries = client.getInteger("max-retries", 3);
String theme = client.getString("theme", "light");
boolean newCheckout = client.getBoolean("new-checkout", false);
Map<String, Object> limits = client.getJsonObject("rate-limits", Map.of("per_minute", 60)); // JSON config
// Watch for config updates
Subscription subscription = client.watchBoolean(
"new-checkout",
false,
value -> System.out.println("new-checkout is now " + value));
// Call close when the subscription is no longer needed
subscription.close();
The type a config is parsed as is decided by the getter you call. Each one takes the value to return when the config is missing, the service is unreachable, or the value will not convert to the requested type — so the default should always be the safe choice:
getBoolean(String configKey, boolean defaultValue)getString(String configKey, String defaultValue)getInteger(String configKey, int defaultValue)getDouble(String configKey, double defaultValue)getJsonObject(String configKey, Map<String, Object> defaultValue)getJsonArray(String configKey, List<Object> defaultValue)
There is also getValue, the counterpart to getValue in the other ConfigDirector SDKs. It takes the type from the default value, which must be a Boolean, String, Integer, Long, Double, Float, Map, or List, and must not be null:
boolean newCheckout = client.getValue("new-checkout", false);
String theme = client.getValue("theme", "light");
onConfigEvaluated.Evaluate config values with a context
Every getter has an overload taking a Context as its last argument, which is what targeting rules are evaluated against. The same key can therefore resolve differently per user:
import com.configdirector.Context;
Context context = Context.builder()
.id("user-id")
.name("Example User")
.trait("region", "North America")
.build();
client.getBoolean("my-boolean-config-key", false, context);
To evaluate configs for an entity other than a user, such as a tenant, set the context kind with kind:
Context tenant = Context.builder()
.id("acme")
.kind("tenant")
.name("Acme Inc.")
.trait("plan", "enterprise")
.build();
client.getBoolean("my-boolean-config-key", false, tenant);
Context is built with Context.builder() and accepts the following:
id- The context's identifier. With the context kind, it decides the context's bucket in a percentage rollout, so changing it can move the context into a different percentile. A context without one skips percentage rollouts; see Contexts without an
id.
- The context's identifier. With the context kind, it decides the context's bucket in a percentage rollout, so changing it can move the context into a different percentile. A context without one skips percentage rollouts; see Contexts without an
kind- The context kind, such as
tenant. Leaving it out meansuser. A context kind is 1 to 64 lowercase letters, digits, dashes, or underscores. Contexts of different kinds are separate contexts even when they share an identifier, and a percentage rollout can place them in different shares. - A context with an invalid context kind is ignored: the SDK logs an error, and configs are evaluated as if no context had been given.
- Context kinds are available from version 1.9.0 of the SDK.
- The context kind, such as
name- The context's display name.
trait(String key, Object value)/traits(Map<String, Object> traits)- Arbitrary traits which can be referenced in targeting rules. Values are JSON-shaped:
String,Number,Boolean,List,Map, or null. Anything else has no text form and will not match a targeting rule. traitadds a single trait, keeping the rest.traitsreplaces every trait set so far.
- Arbitrary traits which can be referenced in targeting rules. Values are JSON-shaped:
anonymous- Keeps the context out of the dashboard: it is evaluated but never persisted, and telemetry reports neither the context nor its id.
Contexts without an id
A percentage rollout places each context in a share by its id, so a context without an id skips percentage rollouts: a rollout does not apply to it, and the targeting rules after the rollout are tried, as they are after a conditional rule the context does not match. The default targeting rule value is served when none of them applies. A rule that serves a single value still applies to it. To keep an anonymous visitor in a rollout, give the context a stable id, such as a session ID. Version 1.11.0 of the SDK ended the evaluation at the skipped rollout and served the default targeting rule value even when a later rule matched, and versions before 1.11.0 served every context without an id the first share of the rollout.
Watch for updates
The watches mirror the getters, one per type. Each accepts the config key, the default value used when an update will not convert to that type, a handler that runs when the config value is updated, and an optional context as its last argument:
watchBoolean(String configKey, boolean defaultValue, Consumer<Boolean> onChange)watchString(String configKey, String defaultValue, Consumer<String> onChange)watchInteger(String configKey, int defaultValue, Consumer<Integer> onChange)watchDouble(String configKey, double defaultValue, Consumer<Double> onChange)watchJsonObject(String configKey, Map<String, Object> defaultValue, Consumer<Map<String, Object>> onChange)watchJsonArray(String configKey, List<Object> defaultValue, Consumer<List<Object>> onChange)
Registering a watch before initialize means it is called for the first config state as well; one registered afterwards only sees later updates. When a full payload no longer includes the watched config, the handler is called with the default value, so it always agrees with what the getter would return. Handlers run on the transport thread, or in offline mode on the thread that calls initialize, so one that blocks delays later updates.
import com.configdirector.Context;
import com.configdirector.Subscription;
Subscription subscription = client.watchBoolean(
"new-checkout",
false,
value -> System.out.println("new-checkout is now " + value),
Context.builder()
.id("user-id")
.name("Example User")
.trait("region", "North America")
.build());
Each watch returns a Subscription that cancels it when closed. Closing it twice is harmless.
watch, which takes its type from the default value the way getValue does, is deprecated as of 1.1.0 in favor of the typed watches above. It still works and is not scheduled for removal, and a Long or Float default — which no typed watch covers — still goes through it.Track events
track reports to ConfigDirector that something happened in your application, such as a completed checkout. ConfigDirector shows tracked events on the Live feed and counts them for each variation of a running experiment; see Tracked events.
track accepts the name of the event, an optional context the event happened for, and optional TrackOptions with a value and details. Either can be null, so an event can carry a value without a context:
import com.configdirector.Context;
import com.configdirector.TrackOptions;
import java.math.BigDecimal;
import java.util.Map;
client.track(
"checkout-completed",
Context.builder().id("user-id").build(),
TrackOptions.builder()
.value(new BigDecimal("49.90"))
.details(Map.of("plan", "pro", "seats", 3))
.build());
client.track("export-finished", Context.builder().id("acme").kind("tenant").build());
client.track("nightly-sync-finished");
valueis a number the event measures, such as an amount of money, a count or a duration. It can be anyNumber, and anInteger, aLong, aBigIntegeror aBigDecimalis sent with every digit, sonew BigDecimal("49.90")arrives as49.90.detailsis aMap<String, Object>describing the event. Its values areString,Number,Boolean, anInstant,java.util.Date,OffsetDateTimeorZonedDateTime, aMapwithStringkeys, aCollection, or null. A date is sent as ISO 8601 text in UTC, such as2026-12-01T00:00:00Z.
An event counts for an experiment only when it is tracked for a context with an id, the same one the experiment's config was evaluated for, so pass the context you evaluate with when you track the outcome of an experiment. Without a context, or for a context without an id, the event is still reported and counts for no experiment. For a context marked anonymous, the event is reported without the id, so it counts for no experiment either. A context with an invalid context kind is ignored as it is in an evaluation: the SDK logs an error and reports the event without a context.
track returns nothing and never throws. The event is sent with the SDK's next telemetry report, every 30 seconds unless you changed flushInterval. In OFFLINE mode and after close, track does nothing. The SDK applies these limits, and logs one warning for each of them per telemetry report:
| Limit | Beyond it |
|---|---|
| An event name of 1 to 150 characters | The event is not reported. |
| 1,000 tracked events in one telemetry report | Further events are not reported until that report is sent. |
| Details of up to 20 top-level keys and 1 KB as JSON, holding only the types of value above | The details are left out and the event is still reported. |
A value that is a finite number | The value is left out and the event is still reported. |
ConfigDirector applies limits of its own to the events it stores; see Limits.
track is available from version 1.11.0 of the SDK. A date inside details is sent from version 1.11.1; version 1.11.0 left the details out.
Other useful client features
The client provides additional methods.
evaluate
Evaluates a config the way getValue does, with the same arguments, and returns the ConfigEvaluation behind it — the value, whether it is the in-code default value, the reason, the valueId, the variation, and the context — instead of the value alone. value() returns an Object in the type of the in-code default value. It is the same record the onConfigEvaluated hook reports, so use it where the reason matters at the point of the read, such as when reporting an evaluation to another system; getValue is enough everywhere else. Like every read, it is reported to onConfigEvaluated and counted in telemetry.
import com.configdirector.ConfigEvaluation;
import com.configdirector.Context;
import com.configdirector.EvaluationReason;
ConfigEvaluation evaluation =
client.evaluate("my-config-key", false, Context.builder().id("user-id").build());
if (evaluation.reason() == EvaluationReason.CONFIG_STATE_MISSING) {
System.out.println("No config with this key reached the SDK.");
}
evaluate is available from version 1.11.0 of the SDK.
isReady
Returns a boolean indicating if the client has received config state and is ready to evaluate configs. It is initially false and becomes true once the first config state arrives. Until it does, every getter returns its default.
isClosed
Returns a boolean indicating if close has been called. A closed client cannot be reopened.
getAllConfigs
Returns every config the client currently holds as a Map<String, ConfigState>, evaluated but before type parsing. It optionally accepts a Context and a list of config keys to restrict the result to.
This is intended for handing state to a client SDK to hydrate with. It records no telemetry, since the SDK that receives the state reports its own evaluations.
Each ConfigState carries the config's id, key and type, the value served as text, its valueId and its variation. When a percentage rollout served the value, it also carries the rollout's ruleId and shareId. variation, ruleId and shareId are available from version 1.11.0 of the SDK.
unwatch
Cancels every watch on one config key.
unwatchAll
Cancels every watch on every config key.
close
Closes all connections to ConfigDirector services, reports whatever telemetry is pending, and cancels every watch and event handler. Calling it twice is harmless.
Only call close when your application shuts down and it will no longer make use of the client instance. ConfigDirectorClient implements AutoCloseable, so in a container such as Spring it is enough to let the container own the lifecycle:
@Bean(destroyMethod = "close")
public ConfigDirectorClient configDirectorClient() {
ConfigDirectorClient client = ConfigDirector.client(System.getenv("CONFIGDIRECTOR_SERVER_KEY"));
client.initialize();
return client;
}
Test your code
Use the SDK's testing artifact, com.configdirector:server-sdk-testing, to test the code that reads your configs and flags. It creates a test client: the SDK's real client connected to an in-memory server that your test controls. Only the connection to ConfigDirector is replaced. Everything else is the same code that runs in production, so the code under test behaves exactly as it does against ConfigDirector, and no network connection is opened and no telemetry is sent.
The artifact is released together with the SDK and must be the same version as the server-sdk on your test classpath. Add it in test scope:
testImplementation 'com.configdirector:server-sdk-testing:1.11.0'
import com.configdirector.testing.ConfigDirectorTesting;
import com.configdirector.testing.TestClient;
try (TestClient testClient = ConfigDirectorTesting.createTestClient(Map.of("new-checkout", true, "max-items", 20))) {
CheckoutService service = new CheckoutService(testClient.client());
testClient.client().initialize();
assertThat(service.isNewCheckoutEnabled("user-123")).isTrue();
testClient.setValue("new-checkout", false);
assertThat(service.isNewCheckoutEnabled("user-123")).isFalse();
}
testClient.client() is a ConfigDirectorClient, so it goes anywhere your code accepts one. It starts uninitialized, like a production client, because the code under test usually owns the call to initialize; with no hold or failure armed, initialize completes at once with the stored values. TestClient is AutoCloseable and closes its client.
With JUnit Jupiter
ConfigDirectorTestExtension creates one test client per test, seeds it from annotations on the test class and the test method, injects it, and closes it after the test. Annotations on the class apply to every test; annotations on a method add to or override them.
@ExtendWith(ConfigDirectorTestExtension.class)
class CheckoutTest {
@Test
@BooleanConfigValue(key = "new-checkout", value = true)
@IntegerConfigValue(key = "max-items", value = 20)
@JsonConfigValue(key = "theme", value = "{\"color\": \"blue\"}")
void showsTheNewCheckout(TestClient testClient) {
testClient.client().initialize();
...
}
}
Each config type has its own annotation, because annotation attributes are compile-time constants: @BooleanConfigValue, @IntegerConfigValue (a long), @FloatConfigValue (a double), @StringConfigValue, and @JsonConfigValue, whose value is strict JSON text holding an object or an array. A ConfigDirectorClient parameter resolves to the same client as a TestClient parameter. The extension works with JUnit 5.10 or newer and JUnit 6.
Application contexts shared across tests
The extension is for code that receives the client from the test. A Spring, Micronaut, or Quarkus context that outlives a test keeps one test client in a static field, replaces the application's client bean with testClient.client(), initializes it where the application's own bean method would have, and calls replaceValues in each test's setup, along with trackedEvents().clear() when tests read tracked events. Replacing matters: a second bean leaves the application's own client in place, which connects to ConfigDirector on startup.
| Container | Replacement |
|---|---|
| Spring Framework 6.2 and later | @TestBean on a field of type ConfigDirectorClient, with a static factory method in the test class that initializes and returns the static test client's client(). A second @Primary bean is not enough, and reusing the bean name fails because Spring Boot disables bean overriding by default. |
| Micronaut | A test factory method with @Replaces(bean = ConfigDirectorClient.class, factory = YourConfigDirectorFactory.class). |
| Quarkus | A @io.quarkus.test.Mock producer, scoped @Singleton, plus a QuarkusTestProfile whose quarkus.arc.exclude-types names the application's producer class: @Startup on that producer still builds and initializes the original client even though injection points receive the mock (checked with Quarkus 3.39). Excluding the class removes every producer in it, so anything else it produced comes from the test producer too. |
Values
createTestClient, setValue, and replaceValues take native values, and the config type follows from each value: a Boolean, an Integer or Long (an integer config), a Float or Double (a float config), a String, or a Map with String keys or a List (a JSON config). setJsonValue takes strict JSON text holding an object or an array; a String given to setValue is always a string config. Every value is served as an unconditional config, so every context receives the same value; a test that needs different values per context is written as one test per value. A null value, a blank key, a non-finite number, another Number subclass such as BigDecimal, a Map with non-String keys, or JSON contents that cannot be encoded throw ConfigDirectorValidationException, and nothing changes.
Reads behave as they do in production: setValue("k", true) read as a string returns the in-code default value with the TYPE_MISMATCH reason, and setValue("k", "") serves the in-code default value with the VALUE_MISSING reason. Numbers inside JSON configs come back as Long and Double, so Map.of("n", 1) reads back as {n=1L}.
Controls
| Control | Effect |
|---|---|
setValue(key, value) | Stores the value and, once the client is connected, delivers an update carrying only key. Watches of key, configsUpdated handlers, and reads see it before the call returns. |
setJsonValue(key, json) | As setValue, for strict JSON text that becomes a JSON config. |
removeValue(key) | Removes the value and delivers a full update without it. Reads return the in-code default value with the CONFIG_STATE_MISSING reason, watches of key receive the default, and configsUpdated lists key in removedKeys(). |
replaceValues(values) | Replaces every stored value, disarms any armed hold or failure, and delivers a full update. Use it to reset a test client shared across tests. |
holdInitialization() | The next initialize waits until completeInitialization() or failInitialization(), or until the client's timeout elapses. initialize blocks, so a test calls it on another thread. |
completeInitialization() | Delivers the stored values to the held initialize, on the calling thread, so the client is ready when the call returns. Called while a hold is armed but not picked up, it disarms the hold. |
failInitialization() | Fails the held initialize the way an invalid SDK key does: it completes promptly, the client is not ready, and the error is logged. Called with no initialize held, it arms the next one to fail. |
trackedEvents() | The events the code under test tracked with track, in call order, each as a TrackedEvent(name, context, value, details), from com.configdirector.testing. An event whose name the SDK would not report is not recorded, and a value or details the SDK would leave out are null in the record. The limit of 1,000 events per telemetry report does not apply to the record. It is the same list on every call: later events are added to it, and a test client shared across tests can clear() it. |
After a failed attempt, the next initialize succeeds and delivers the values stored in the meantime.
try (TestClient testClient =
ConfigDirectorTesting.createTestClient(Map.of("new-checkout", true), options -> options.timeout(Duration.ofMillis(500)))) {
testClient.failInitialization();
testClient.client().initialize();
assertThat(testClient.client().isReady()).isFalse();
assertThat(service.isNewCheckoutEnabled("user-123")).isFalse();
}
Options
createTestClient(values, options -> ...) takes timeout (the client's connection timeout, which bounds how long a held initialize waits; defaults to the SDK's production timeout) and logger (defaults to the SDK's logger, com.configdirector).
What to expect
- The first update is delivered while
initializeconnects, so watches registered beforeinitializerun before it returns, andconfigsUpdatedfires beforeclientReady. - Watches and handlers run on the thread that calls
setValue,removeValue,replaceValues,completeInitialization, orinitialize, not on a transport thread. - A held
initializethat times out, or that is interrupted byclose, leaves the client not ready until the nextinitialize, and logs the SDK's timeout warning; production streaming would keep retrying in the background. - Watches fire on every update carrying their key, whether or not the value changed, as in production.
removeValueandreplaceValuesdeliver a full update, so they also fire the watches of every remaining key. - A
RuntimeExceptionthrown by a watch or handler is logged and does not propagate, as with the production transports. AnError, such as a failed assertion'sAssertionError, propagates out of the call that delivered the update. - After
close, reads return the in-code default value with theCLIENT_NOT_READYreason,getAllConfigsreturns an empty map,initializethrowsConfigDirectorValidationException, the test client's controls are silent no-ops, and no SDK thread is left running.