Skip to content

feat: add isolated api instances#243

Open
marcozabel wants to merge 11 commits into
open-feature:mainfrom
marcozabel:feat/isolated-api-instances
Open

feat: add isolated api instances#243
marcozabel wants to merge 11 commits into
open-feature:mainfrom
marcozabel:feat/isolated-api-instances

Conversation

@marcozabel
Copy link
Copy Markdown

This PR

This PR introduces support for creating isolated OpenFeature API instances, each with their own providers, hooks, context, and event handling - enabling multi-tenant or side-by-side usage without shared global state.

Related Issues

#229

Notes

Follow-up Tasks

How to test

@marcozabel marcozabel force-pushed the feat/isolated-api-instances branch from 5d5bab8 to ea362ff Compare April 24, 2026 09:45
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the OpenFeature SDK to support multiple isolated instances by moving core logic from the OpenFeatureAPI singleton into a new OpenFeatureInstance base class. Users can now create independent instances using OpenFeatureAPI.createInstance(), each maintaining its own provider, context, and hooks. A new identity-based registry ensures that a provider is not bound to multiple instances simultaneously. Feedback highlighted a bug in setProviderInternal where re-setting the same provider instance would lead to an unintended shutdown, as well as a thread-safety concern in clearProvider due to missing mutex synchronization.

@marcozabel marcozabel force-pushed the feat/isolated-api-instances branch 3 times, most recently from 565b450 to cbd8361 Compare April 24, 2026 10:16
@marcozabel marcozabel changed the title Feat/isolated api instances feat: add isolated api instances Apr 24, 2026
@marcozabel marcozabel force-pushed the feat/isolated-api-instances branch 2 times, most recently from 5a604fb to 023a067 Compare April 24, 2026 10:30
Copy link
Copy Markdown
Contributor

@bencehornak bencehornak left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I usually find it a good practice to make only interfaces public and the implementing classes internal.

val OpenFeatureAPI: OpenFeatureAPIInterface = OpenFeatureImpl()
fun createIsolatedOpenFeatureAPI(): OpenFeatureAPIInterface = OpenFeatureImpl()

interface OpenFeatureAPIInterface {...}
private /* or internal */ class OpenFeatureImpl {...}

This is what the official Kotlin libs often do too:

public interface MutableStateFlow<T> : StateFlow<T>, MutableSharedFlow<T> {...}
public fun <T> MutableStateFlow(value: T): MutableStateFlow<T> = StateFlowImpl(value ?: NULL)
private class StateFlowImpl<T> {...}

(From https://github.com/Kotlin/kotlinx.coroutines/blob/1.10.2/kotlinx-coroutines-core/common/src/flow/StateFlow.kt)

This allows separating what's public and what's internal. Additionally, it's easier to mock the interfaces in unit tests than objects.

var hooks: List<Hook<*>> = listOf()
private set

object OpenFeatureAPI : OpenFeatureInstance() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer val OpenFeatureAPI = OpenFeatureInstance(), because this is not an actual inheritance (no behavior is overridden).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, no behavior is overridden. We keep object for binary compatibility because it generates a JVM class with a static final INSTANCE field that existing compiled consumers reference. A top-level val removes that class entirely causing NoClassDefFoundError at runtime.

Comment thread kotlin-sdk/src/commonMain/kotlin/dev/openfeature/kotlin/sdk/OpenFeatureAPI.kt Outdated
@bencehornak
Copy link
Copy Markdown
Contributor

bencehornak commented Apr 25, 2026

If it was a green field project, I'd suggest the names:

val OpenFeature: OpenFeatureAPI = OpenFeatureImpl() // BREAKING CHANGE
fun createIsolatedOpenFeatureAPI(): OpenFeatureAPI = OpenFeatureImpl()

interface OpenFeatureAPI {...}
private /* or internal */ class OpenFeatureImpl {...}

instead of the names I used in my previous comment:

val OpenFeatureAPI: OpenFeatureAPIInterface = OpenFeatureImpl()
fun createIsolatedOpenFeatureAPI(): OpenFeatureAPIInterface = OpenFeatureImpl()

interface OpenFeatureAPIInterface {...}
private /* or internal */ class OpenFeatureImpl {...}

But it's a breaking change, so I'm not sure if it's worth annoying all current lib users.

@marcozabel
Copy link
Copy Markdown
Author

marcozabel commented Apr 27, 2026

Thanks for the review @bencehornak!

interface + internal impl pattern: I agree that's the ideal Kotlin pattern for new APIs. For this PR though, OpenFeatureAPI is an existing public object with consumers depending on OpenFeatureAPI.INSTANCE at the JVM bytecode level. Introducing an interface and hiding the impl would be a larger breaking change that deserves its own discussion/PR. Happy to explore that as a follow-up.

I updated moving createInstance() to a top-level function and making the OpenFeatureInstance constructor internal. 👍

Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
… method

Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
@marcozabel marcozabel force-pushed the feat/isolated-api-instances branch from 023a067 to 899775f Compare April 27, 2026 12:42
@marcozabel marcozabel requested a review from bencehornak April 28, 2026 13:27
@bencehornak
Copy link
Copy Markdown
Contributor

@marcozabel I think breaking changes are still allowed, esp. in the jvm compatibility layer, since we are pre 1.0.

However, you are right, we should try to avoid them, if possible. One more thought on my previous line of thought: with the following trick both the Kotlin (OpenFeatureAPI.xxx) and the JVM signatures (OpenFeatureAPI.INSTANCE.xxx) can be the same as before:

@file:JvmName("OpenFeatureAPI")

@JvmName("INSTANCE")
val OpenFeatureAPI = OpenFeatureInstance()

IMO this pattern describes the specs better than the current proposal, because the convenience OpenFeatureAPI is an instance of OpenFeatureInstance, just like any other instance returned by the factory method.

And this pattern would allow the smooth introduction of the separation between the interface and implementation classes in the same PR without noticeable impact from the outside.

Copy link
Copy Markdown
Contributor

@bencehornak bencehornak left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good otherwise, see my suggestions above.

Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
Copy link
Copy Markdown
Contributor

@bencehornak bencehornak left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One more thing: would you mind adding a Kotlin and a Java snippet to the README about the two usage patterns? (Singleton+factory)

Comment thread kotlin-sdk/api/android/kotlin-sdk.api Outdated
Copy link
Copy Markdown
Contributor

@bencehornak bencehornak left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Signed-off-by: marcozabel <marco.zabel@dynatrace.com>
Comment on lines +16 to +19
@get:JvmName("INSTANCE")
@JvmField
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIA, these two are apparently mutually exclusive. This way the the singleton will need to be accessed like OpenFeatureAPI.OpenFeatureAPI from Java code (as opposed to OpenFeatureAPI.INSTANCE) in versions after this:

public final class dev/openfeature/kotlin/sdk/OpenFeatureAPI {
-	 public static final fun INSTANCE ()Ldev/openfeature/kotlin/sdk/OpenFeatureAPIInstance;
+	 public static final field OpenFeatureAPI Ldev/openfeature/kotlin/sdk/OpenFeatureAPIInstance;
  ...
}

I'm fine with this, and I wouldn't worry too much about changing the JVM binary interface. We are in the 0.x range, where every version is allowed to contain breaking changes according to the semver specs.

Comment on lines +8 to +28
/**
* Global singleton entry point for the OpenFeature SDK.
*
* Use this directly for typical single-provider usage. For isolated, independent instances
* (e.g., for DI frameworks or testing), use [createOpenFeatureAPIInstance].
*
* This is an instance of [OpenFeatureAPIInstance], just like any instance returned by
* the [createOpenFeatureAPIInstance] factory method.
*
* @apiNote Isolated API instances (per spec section 1.8) are experimental and subject to change.
*/
@JvmField
val OpenFeatureAPI: OpenFeatureAPIInstance = OpenFeatureAPIInstance()

/**
* Create a new, independent [OpenFeatureAPIInstance] with its own provider, context, hooks,
* and events — completely isolated from the global [OpenFeatureAPI] singleton and other instances.
*
* @return a new [OpenFeatureAPIInstance]
*/
fun createOpenFeatureAPIInstance(): OpenFeatureAPIInstance = OpenFeatureAPIInstance()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: @apiNote is javadoc, afaik it doesn't work with kdoc. And the note rather belongs to the factory method, doesn't it?

Suggested change
/**
* Global singleton entry point for the OpenFeature SDK.
*
* Use this directly for typical single-provider usage. For isolated, independent instances
* (e.g., for DI frameworks or testing), use [createOpenFeatureAPIInstance].
*
* This is an instance of [OpenFeatureAPIInstance], just like any instance returned by
* the [createOpenFeatureAPIInstance] factory method.
*
* @apiNote Isolated API instances (per spec section 1.8) are experimental and subject to change.
*/
@JvmField
val OpenFeatureAPI: OpenFeatureAPIInstance = OpenFeatureAPIInstance()
/**
* Create a new, independent [OpenFeatureAPIInstance] with its own provider, context, hooks,
* and events — completely isolated from the global [OpenFeatureAPI] singleton and other instances.
*
* @return a new [OpenFeatureAPIInstance]
*/
fun createOpenFeatureAPIInstance(): OpenFeatureAPIInstance = OpenFeatureAPIInstance()
/**
* Global singleton entry point for the OpenFeature SDK.
*
* Use this directly for typical single-provider usage. For isolated, independent instances
* (e.g., for DI frameworks or testing), use [createOpenFeatureAPIInstance].
*
* This is an instance of [OpenFeatureAPIInstance], just like any instance returned by
* the [createOpenFeatureAPIInstance] factory method.
*/
@JvmField
val OpenFeatureAPI: OpenFeatureAPIInstance = OpenFeatureAPIInstance()
/**
* Create a new, independent [OpenFeatureAPIInstance] with its own provider, context, hooks,
* and events — completely isolated from the global [OpenFeatureAPI] singleton and other instances.
*
* **Note:** isolated API instances (per spec section 1.8) are experimental and subject to change.
*
* @return a new [OpenFeatureAPIInstance]
*/
fun createOpenFeatureAPIInstance(): OpenFeatureAPIInstance = OpenFeatureAPIInstance()

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants