diff --git a/pom.xml b/pom.xml
index 67b4c5722..f7916bf84 100644
--- a/pom.xml
+++ b/pom.xml
@@ -70,6 +70,12 @@
2.0.17
+
+ org.json
+ json
+ 20250517
+
+
com.tngtech.archunit
diff --git a/src/main/java/dev/openfeature/sdk/multiprovider/FirstMatchStrategy.java b/src/main/java/dev/openfeature/sdk/multiprovider/FirstMatchStrategy.java
new file mode 100644
index 000000000..546565be0
--- /dev/null
+++ b/src/main/java/dev/openfeature/sdk/multiprovider/FirstMatchStrategy.java
@@ -0,0 +1,53 @@
+package dev.openfeature.sdk.multiprovider;
+
+import static dev.openfeature.sdk.ErrorCode.FLAG_NOT_FOUND;
+
+import dev.openfeature.sdk.EvaluationContext;
+import dev.openfeature.sdk.FeatureProvider;
+import dev.openfeature.sdk.ProviderEvaluation;
+import dev.openfeature.sdk.exceptions.FlagNotFoundError;
+import java.util.Map;
+import java.util.function.Function;
+import lombok.NoArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * First match strategy. Return the first result returned by a provider. Skip providers that
+ * indicate they had no value due to FLAG_NOT_FOUND. In all other cases, use the value returned by
+ * the provider. If any provider returns an error result other than FLAG_NOT_FOUND, the whole
+ * evaluation should error and “bubble up” the individual provider’s error in the result. As soon as
+ * a value is returned by a provider, the rest of the operation should short-circuit and not call
+ * the rest of the providers.
+ */
+@Slf4j
+@NoArgsConstructor
+public class FirstMatchStrategy implements Strategy {
+
+ /**
+ * Represents a strategy that evaluates providers based on a first-match approach. Provides a
+ * method to evaluate providers using a specified function and return the evaluation result.
+ *
+ * @param providerFunction provider function
+ * @param ProviderEvaluation type
+ * @return the provider evaluation
+ */
+ @Override
+ public ProviderEvaluation evaluate(
+ Map providers,
+ String key,
+ T defaultValue,
+ EvaluationContext ctx,
+ Function> providerFunction) {
+ for (FeatureProvider provider : providers.values()) {
+ try {
+ ProviderEvaluation res = providerFunction.apply(provider);
+ if (!FLAG_NOT_FOUND.equals(res.getErrorCode())) {
+ return res;
+ }
+ } catch (FlagNotFoundError e) {
+ log.debug("flag not found {}", e.getMessage());
+ }
+ }
+ throw new FlagNotFoundError("flag not found");
+ }
+}
diff --git a/src/main/java/dev/openfeature/sdk/multiprovider/FirstSuccessfulStrategy.java b/src/main/java/dev/openfeature/sdk/multiprovider/FirstSuccessfulStrategy.java
new file mode 100644
index 000000000..85ca3b9bc
--- /dev/null
+++ b/src/main/java/dev/openfeature/sdk/multiprovider/FirstSuccessfulStrategy.java
@@ -0,0 +1,41 @@
+package dev.openfeature.sdk.multiprovider;
+
+import dev.openfeature.sdk.EvaluationContext;
+import dev.openfeature.sdk.FeatureProvider;
+import dev.openfeature.sdk.ProviderEvaluation;
+import dev.openfeature.sdk.exceptions.GeneralError;
+import java.util.Map;
+import java.util.function.Function;
+import lombok.NoArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * First Successful Strategy. Similar to “First Match”, except that errors from evaluated providers
+ * do not halt execution. Instead, it will return the first successful result from a provider. If no
+ * provider successfully responds, it will throw an error result.
+ */
+@Slf4j
+@NoArgsConstructor
+public class FirstSuccessfulStrategy implements Strategy {
+
+ @Override
+ public ProviderEvaluation evaluate(
+ Map providers,
+ String key,
+ T defaultValue,
+ EvaluationContext ctx,
+ Function> providerFunction) {
+ for (FeatureProvider provider : providers.values()) {
+ try {
+ ProviderEvaluation res = providerFunction.apply(provider);
+ if (res.getErrorCode() == null) {
+ return res;
+ }
+ } catch (Exception e) {
+ log.debug("evaluation exception {}", e.getMessage());
+ }
+ }
+
+ throw new GeneralError("evaluation error");
+ }
+}
diff --git a/src/main/java/dev/openfeature/sdk/multiprovider/MultiProvider.java b/src/main/java/dev/openfeature/sdk/multiprovider/MultiProvider.java
new file mode 100644
index 000000000..1689818c0
--- /dev/null
+++ b/src/main/java/dev/openfeature/sdk/multiprovider/MultiProvider.java
@@ -0,0 +1,148 @@
+package dev.openfeature.sdk.multiprovider;
+
+import dev.openfeature.sdk.EvaluationContext;
+import dev.openfeature.sdk.EventProvider;
+import dev.openfeature.sdk.FeatureProvider;
+import dev.openfeature.sdk.Metadata;
+import dev.openfeature.sdk.ProviderEvaluation;
+import dev.openfeature.sdk.Value;
+import dev.openfeature.sdk.exceptions.GeneralError;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+import org.json.JSONObject;
+
+/** Experimental: Provider implementation for Multi-provider. */
+@Slf4j
+public class MultiProvider extends EventProvider {
+
+ @Getter
+ private static final String NAME = "multiprovider";
+
+ public static final int INIT_THREADS_COUNT = 8;
+ private final Map providers;
+ private final Strategy strategy;
+ private String metadataName;
+
+ /**
+ * Constructs a MultiProvider with the given list of FeatureProviders, using a default strategy.
+ *
+ * @param providers the list of FeatureProviders to initialize the MultiProvider with
+ */
+ public MultiProvider(List providers) {
+ this(providers, null);
+ }
+
+ /**
+ * Constructs a MultiProvider with the given list of FeatureProviders and a strategy.
+ *
+ * @param providers the list of FeatureProviders to initialize the MultiProvider with
+ * @param strategy the strategy
+ */
+ public MultiProvider(List providers, Strategy strategy) {
+ this.providers = buildProviders(providers);
+ if (strategy != null) {
+ this.strategy = strategy;
+ } else {
+ this.strategy = new FirstMatchStrategy();
+ }
+ }
+
+ protected static Map buildProviders(List providers) {
+ Map providersMap = new LinkedHashMap<>(providers.size());
+ for (FeatureProvider provider : providers) {
+ FeatureProvider prevProvider =
+ providersMap.put(provider.getMetadata().getName(), provider);
+ if (prevProvider != null) {
+ log.warn("duplicated provider name: {}", provider.getMetadata().getName());
+ }
+ }
+ return Collections.unmodifiableMap(providersMap);
+ }
+
+ /**
+ * Initialize the provider.
+ *
+ * @param evaluationContext evaluation context
+ * @throws Exception on error
+ */
+ @Override
+ public void initialize(EvaluationContext evaluationContext) throws Exception {
+ JSONObject json = new JSONObject();
+ json.put("name", NAME);
+ JSONObject providersMetadata = new JSONObject();
+ json.put("originalMetadata", providersMetadata);
+ ExecutorService initPool = Executors.newFixedThreadPool(INIT_THREADS_COUNT);
+ Collection> tasks = new ArrayList<>(providers.size());
+ for (FeatureProvider provider : providers.values()) {
+ tasks.add(() -> {
+ provider.initialize(evaluationContext);
+ return true;
+ });
+ JSONObject providerMetadata = new JSONObject();
+ providerMetadata.put("name", provider.getMetadata().getName());
+ providersMetadata.put(provider.getMetadata().getName(), providerMetadata);
+ }
+ List> results = initPool.invokeAll(tasks);
+ for (Future result : results) {
+ if (!result.get()) {
+ throw new GeneralError("init failed");
+ }
+ }
+ metadataName = json.toString();
+ }
+
+ @Override
+ public Metadata getMetadata() {
+ return () -> metadataName;
+ }
+
+ @Override
+ public ProviderEvaluation getBooleanEvaluation(String key, Boolean defaultValue, EvaluationContext ctx) {
+ return strategy.evaluate(
+ providers, key, defaultValue, ctx, p -> p.getBooleanEvaluation(key, defaultValue, ctx));
+ }
+
+ @Override
+ public ProviderEvaluation getStringEvaluation(String key, String defaultValue, EvaluationContext ctx) {
+ return strategy.evaluate(providers, key, defaultValue, ctx, p -> p.getStringEvaluation(key, defaultValue, ctx));
+ }
+
+ @Override
+ public ProviderEvaluation getIntegerEvaluation(String key, Integer defaultValue, EvaluationContext ctx) {
+ return strategy.evaluate(
+ providers, key, defaultValue, ctx, p -> p.getIntegerEvaluation(key, defaultValue, ctx));
+ }
+
+ @Override
+ public ProviderEvaluation getDoubleEvaluation(String key, Double defaultValue, EvaluationContext ctx) {
+ return strategy.evaluate(providers, key, defaultValue, ctx, p -> p.getDoubleEvaluation(key, defaultValue, ctx));
+ }
+
+ @Override
+ public ProviderEvaluation getObjectEvaluation(String key, Value defaultValue, EvaluationContext ctx) {
+ return strategy.evaluate(providers, key, defaultValue, ctx, p -> p.getObjectEvaluation(key, defaultValue, ctx));
+ }
+
+ @Override
+ public void shutdown() {
+ log.debug("shutdown begin");
+ for (FeatureProvider provider : providers.values()) {
+ try {
+ provider.shutdown();
+ } catch (Exception e) {
+ log.error("error shutdown provider {}", provider.getMetadata().getName(), e);
+ }
+ }
+ log.debug("shutdown end");
+ }
+}
diff --git a/src/main/java/dev/openfeature/sdk/multiprovider/Strategy.java b/src/main/java/dev/openfeature/sdk/multiprovider/Strategy.java
new file mode 100644
index 000000000..49b9c8d06
--- /dev/null
+++ b/src/main/java/dev/openfeature/sdk/multiprovider/Strategy.java
@@ -0,0 +1,17 @@
+package dev.openfeature.sdk.multiprovider;
+
+import dev.openfeature.sdk.EvaluationContext;
+import dev.openfeature.sdk.FeatureProvider;
+import dev.openfeature.sdk.ProviderEvaluation;
+import java.util.Map;
+import java.util.function.Function;
+
+/** strategy. */
+public interface Strategy {
+ ProviderEvaluation evaluate(
+ Map providers,
+ String key,
+ T defaultValue,
+ EvaluationContext ctx,
+ Function> providerFunction);
+}
diff --git a/src/test/java/dev/openfeature/sdk/multiProvider/MultiProviderTest.java b/src/test/java/dev/openfeature/sdk/multiProvider/MultiProviderTest.java
new file mode 100644
index 000000000..50c3c86b9
--- /dev/null
+++ b/src/test/java/dev/openfeature/sdk/multiProvider/MultiProviderTest.java
@@ -0,0 +1,275 @@
+package dev.openfeature.sdk.multiProvider;
+
+import static dev.openfeature.sdk.ErrorCode.FLAG_NOT_FOUND;
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import dev.openfeature.sdk.EvaluationContext;
+import dev.openfeature.sdk.FeatureProvider;
+import dev.openfeature.sdk.Metadata;
+import dev.openfeature.sdk.MutableContext;
+import dev.openfeature.sdk.ProviderEvaluation;
+import dev.openfeature.sdk.Value;
+import dev.openfeature.sdk.exceptions.FlagNotFoundError;
+import dev.openfeature.sdk.exceptions.GeneralError;
+import dev.openfeature.sdk.multiprovider.FirstMatchStrategy;
+import dev.openfeature.sdk.multiprovider.FirstSuccessfulStrategy;
+import dev.openfeature.sdk.multiprovider.MultiProvider;
+import dev.openfeature.sdk.multiprovider.Strategy;
+import dev.openfeature.sdk.providers.memory.Flag;
+import dev.openfeature.sdk.providers.memory.InMemoryProvider;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+import java.util.function.Function;
+import lombok.SneakyThrows;
+import org.junit.jupiter.api.Test;
+
+class MultiProviderTest {
+
+ @SneakyThrows
+ @Test
+ public void testInit() {
+ FeatureProvider provider1 = mock(FeatureProvider.class);
+ FeatureProvider provider2 = mock(FeatureProvider.class);
+ when(provider1.getMetadata()).thenReturn(() -> "provider1");
+ when(provider2.getMetadata()).thenReturn(() -> "provider2");
+
+ List providers = new ArrayList<>(2);
+ providers.add(provider1);
+ providers.add(provider2);
+ Strategy strategy = mock(Strategy.class);
+ MultiProvider multiProvider = new MultiProvider(providers, strategy);
+ multiProvider.initialize(null);
+
+ assertNotNull(multiProvider);
+ assertEquals(
+ "{\"originalMetadata\":{\"provider1\":{\"name\":\"provider1\"},"
+ + "\"provider2\":{\"name\":\"provider2\"}},\"name\":\"multiprovider\"}",
+ multiProvider.getMetadata().getName());
+ }
+
+ @SneakyThrows
+ @Test
+ public void testInitOneFails() {
+ FeatureProvider provider1 = mock(FeatureProvider.class);
+ FeatureProvider provider2 = mock(FeatureProvider.class);
+ when(provider1.getMetadata()).thenReturn(() -> "provider1");
+ when(provider2.getMetadata()).thenReturn(() -> "provider2");
+ doThrow(new GeneralError()).when(provider1).initialize(any());
+ doThrow(new GeneralError()).when(provider1).shutdown();
+
+ List providers = new ArrayList<>(2);
+ providers.add(provider1);
+ providers.add(provider2);
+ Strategy strategy = mock(Strategy.class);
+ MultiProvider multiProvider = new MultiProvider(providers, strategy);
+ assertThrows(ExecutionException.class, () -> multiProvider.initialize(null));
+ assertDoesNotThrow(() -> multiProvider.shutdown());
+ }
+
+ @Test
+ public void testDuplicateProviderNames() {
+ FeatureProvider provider1 = mock(FeatureProvider.class);
+ FeatureProvider provider2 = mock(FeatureProvider.class);
+ when(provider1.getMetadata()).thenReturn(() -> "provider");
+ when(provider2.getMetadata()).thenReturn(() -> "provider");
+
+ List providers = new ArrayList<>(2);
+ providers.add(provider1);
+ providers.add(provider2);
+
+ assertDoesNotThrow(() -> new MultiProvider(providers, null).initialize(null));
+ }
+
+ @SneakyThrows
+ @Test
+ public void testRetrieveMetadataName() {
+ List providers = new ArrayList<>();
+ FeatureProvider mockProvider = mock(FeatureProvider.class);
+ when(mockProvider.getMetadata()).thenReturn(() -> "MockProvider");
+ providers.add(mockProvider);
+ Strategy mockStrategy = mock(Strategy.class);
+ MultiProvider multiProvider = new MultiProvider(providers, mockStrategy);
+ multiProvider.initialize(null);
+
+ assertEquals(
+ "{\"originalMetadata\":{\"MockProvider\":{\"name\":\"MockProvider\"}}," + "\"name\":\"multiprovider\"}",
+ multiProvider.getMetadata().getName());
+ }
+
+ @SneakyThrows
+ @Test
+ public void testEvaluations() {
+ Map> flags1 = new HashMap<>();
+ flags1.put(
+ "b1",
+ Flag.builder()
+ .variant("true", true)
+ .variant("false", false)
+ .defaultVariant("true")
+ .build());
+ flags1.put("i1", Flag.builder().variant("v", 1).defaultVariant("v").build());
+ flags1.put("d1", Flag.builder().variant("v", 1.0).defaultVariant("v").build());
+ flags1.put("s1", Flag.builder().variant("v", "str1").defaultVariant("v").build());
+ flags1.put(
+ "o1",
+ Flag.builder().variant("v", new Value("v1")).defaultVariant("v").build());
+ InMemoryProvider provider1 = new InMemoryProvider(flags1) {
+ public Metadata getMetadata() {
+ return () -> "old-provider";
+ }
+ };
+ Map> flags2 = new HashMap<>();
+ flags2.put(
+ "b1",
+ Flag.builder()
+ .variant("true", true)
+ .variant("false", false)
+ .defaultVariant("false")
+ .build());
+ flags2.put("i1", Flag.builder().variant("v", 2).defaultVariant("v").build());
+ flags2.put("d1", Flag.builder().variant("v", 2.0).defaultVariant("v").build());
+ flags2.put("s1", Flag.builder().variant("v", "str2").defaultVariant("v").build());
+ flags2.put(
+ "o1",
+ Flag.builder().variant("v", new Value("v2")).defaultVariant("v").build());
+
+ flags2.put(
+ "s2", Flag.builder().variant("v", "s2str2").defaultVariant("v").build());
+ InMemoryProvider provider2 = new InMemoryProvider(flags2) {
+ public Metadata getMetadata() {
+ return () -> "new-provider";
+ }
+ };
+ List providers = new ArrayList<>(2);
+ providers.add(provider1);
+ providers.add(provider2);
+ MultiProvider multiProvider = new MultiProvider(providers);
+ multiProvider.initialize(null);
+
+ assertEquals(true, multiProvider.getBooleanEvaluation("b1", false, null).getValue());
+ assertEquals(1, multiProvider.getIntegerEvaluation("i1", 0, null).getValue());
+ assertEquals(1.0, multiProvider.getDoubleEvaluation("d1", 0.0, null).getValue());
+ assertEquals("str1", multiProvider.getStringEvaluation("s1", "", null).getValue());
+ assertEquals(
+ "v1",
+ multiProvider.getObjectEvaluation("o1", null, null).getValue().asString());
+
+ assertEquals("s2str2", multiProvider.getStringEvaluation("s2", "", null).getValue());
+ MultiProvider finalMultiProvider1 = multiProvider;
+ assertThrows(FlagNotFoundError.class, () -> finalMultiProvider1.getStringEvaluation("non-existing", "", null));
+
+ multiProvider.shutdown();
+ multiProvider = new MultiProvider(providers, new FirstSuccessfulStrategy());
+ multiProvider.initialize(null);
+
+ assertEquals(true, multiProvider.getBooleanEvaluation("b1", false, null).getValue());
+ assertEquals(1, multiProvider.getIntegerEvaluation("i1", 0, null).getValue());
+ assertEquals(1.0, multiProvider.getDoubleEvaluation("d1", 0.0, null).getValue());
+ assertEquals("str1", multiProvider.getStringEvaluation("s1", "", null).getValue());
+ assertEquals(
+ "v1",
+ multiProvider.getObjectEvaluation("o1", null, null).getValue().asString());
+
+ assertEquals("s2str2", multiProvider.getStringEvaluation("s2", "", null).getValue());
+ MultiProvider finalMultiProvider2 = multiProvider;
+ assertThrows(GeneralError.class, () -> finalMultiProvider2.getStringEvaluation("non-existing", "", null));
+
+ multiProvider.shutdown();
+ Strategy customStrategy = new Strategy() {
+ final FirstMatchStrategy fallbackStrategy = new FirstMatchStrategy();
+
+ @Override
+ public ProviderEvaluation evaluate(
+ Map providers,
+ String key,
+ T defaultValue,
+ EvaluationContext ctx,
+ Function> providerFunction) {
+ Value contextProvider = null;
+ if (ctx != null) {
+ contextProvider = ctx.getValue("provider");
+ }
+ if (contextProvider != null && "new-provider".equals(contextProvider.asString())) {
+ return providerFunction.apply(providers.get("new-provider"));
+ }
+ return fallbackStrategy.evaluate(providers, key, defaultValue, ctx, providerFunction);
+ }
+ };
+ multiProvider = new MultiProvider(providers, customStrategy);
+ multiProvider.initialize(null);
+
+ EvaluationContext context = new MutableContext().add("provider", "new-provider");
+ assertEquals(
+ false, multiProvider.getBooleanEvaluation("b1", true, context).getValue());
+ assertEquals(true, multiProvider.getBooleanEvaluation("b1", true, null).getValue());
+ }
+
+ @Test
+ public void testFirstMatchStrategyErrorCode() {
+ FeatureProvider provider1 = mock(FeatureProvider.class);
+ FeatureProvider provider2 = mock(FeatureProvider.class);
+ FeatureProvider provider3 = mock(FeatureProvider.class);
+
+ when(provider1.getMetadata()).thenReturn(() -> "provider1");
+ when(provider2.getMetadata()).thenReturn(() -> "provider2");
+ when(provider3.getMetadata()).thenReturn(() -> "provider3");
+
+ ProviderEvaluation flagNotFoundResult = mock(ProviderEvaluation.class);
+ when(flagNotFoundResult.getErrorCode()).thenReturn(FLAG_NOT_FOUND);
+
+ ProviderEvaluation successResult = mock(ProviderEvaluation.class);
+ when(successResult.getErrorCode()).thenReturn(null);
+ when(successResult.getValue()).thenReturn("success");
+
+ when(provider1.getStringEvaluation("test", "default", null)).thenReturn(flagNotFoundResult);
+ when(provider2.getStringEvaluation("test", "default", null)).thenReturn(successResult);
+
+ Map providers = new LinkedHashMap<>();
+ providers.put("provider1", provider1);
+ providers.put("provider2", provider2);
+ providers.put("provider3", provider3);
+ FirstMatchStrategy strategy = new FirstMatchStrategy();
+ ProviderEvaluation result = strategy.evaluate(
+ providers, "test", "default", null, p -> p.getStringEvaluation("test", "default", null));
+
+ assertEquals("success", result.getValue());
+ }
+
+ @Test
+ public void testFirstSuccessfulStrategyErrorCode() {
+ FeatureProvider provider1 = mock(FeatureProvider.class);
+ FeatureProvider provider2 = mock(FeatureProvider.class);
+ when(provider1.getMetadata()).thenReturn(() -> "provider1");
+ when(provider2.getMetadata()).thenReturn(() -> "provider2");
+
+ ProviderEvaluation flagNotFoundResult = mock(ProviderEvaluation.class);
+ when(flagNotFoundResult.getErrorCode()).thenReturn(FLAG_NOT_FOUND);
+
+ ProviderEvaluation successResult = mock(ProviderEvaluation.class);
+ when(successResult.getErrorCode()).thenReturn(null);
+ when(successResult.getValue()).thenReturn("success");
+
+ when(provider1.getStringEvaluation("test", "default", null)).thenReturn(flagNotFoundResult);
+ when(provider2.getStringEvaluation("test", "default", null)).thenReturn(successResult);
+
+ Map providers = new LinkedHashMap<>();
+ providers.put("provider1", provider1);
+ providers.put("provider2", provider2);
+ FirstSuccessfulStrategy strategy = new FirstSuccessfulStrategy();
+ ProviderEvaluation result = strategy.evaluate(
+ providers, "test", "default", null, p -> p.getStringEvaluation("test", "default", null));
+
+ assertEquals("success", result.getValue());
+ }
+}