Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add Dependency Injection and Hosting support for OpenFeature #310

Open
wants to merge 97 commits into
base: main
Choose a base branch
from

Conversation

arttonoyan
Copy link

Note

This initial version of OpenFeature.DependencyInjection and OpenFeature.Hosting introduces basic provider management and lifecycle support for .NET Dependency Injection environments. While I haven't included extensive tests, particularly for the Hosting project, if this approach is approved and the scope is considered sufficient for the first DI and Hosting release, I will expand the test coverage to include both unit and integration tests. Additionally, I'll provide a sample application to demonstrate the usage.

This pull request introduces key features and improvements to the OpenFeature project, focusing on Dependency Injection and Hosting support:

  • OpenFeature.DependencyInjection Project:

    • Implemented OpenFeatureBuilder, including OpenFeatureBuilderExtensions for seamless integration.
    • Added IFeatureLifecycleManager interface and its implementation.
    • Introduced AddProvider extension method for easy provider configuration.
    • Created OpenFeatureServiceCollectionExtensions for service registration.
  • OpenFeature.Hosting Project:

    • Added HostedFeatureLifecycleService to manage the lifecycle of feature providers in hosted environments.
  • Testing Enhancements:

    • Created unit tests for critical methods, including OpenFeatureBuilderExtensionsTests and OpenFeatureServiceCollectionExtensionsTests.
    • Replicated and tested NoOpFeatureProvider implementation for better test coverage.

These changes significantly improve OpenFeature's extensibility and lifecycle management for feature providers within Dependency Injection (DI) and hosted environments.


NuGet Packages for installation:

dotnet add package OpenFeature
dotnet add package OpenFeature.DependencyInjection
dotnet add package OpenFeature.Hosting

Usage Example:

builder.Services.AddOpenFeature(featureBuilder => {
    featureBuilder
        .AddHostedFeatureLifecycle() // From Hosting package
        .AddContext((context, serviceProvider) => {
            // Context settings are applied here. Each feature flag evaluation will
            // automatically have access to these context parameters.
            // Do something with the service provider.
            context
                .Set("kind", "tenant")
                .Set("key", "<some key>");
        })
        // Example of a feature provider configuration
        // .AddLaunchDarkly(builder.Configuration["LaunchDarkly:SdkKey"], cfg => cfg.StartWaitTime(TimeSpan.FromSeconds(10))); 
});

@arttonoyan arttonoyan requested a review from a team as a code owner October 13, 2024 19:12
@beeme1mr beeme1mr changed the title Add Dependency Injection and Hosting support for OpenFeature feat: Add Dependency Injection and Hosting support for OpenFeature Oct 13, 2024
@askpt askpt linked an issue Oct 14, 2024 that may be closed by this pull request
@askpt
Copy link
Member

askpt commented Oct 14, 2024

Hey @arttonoyan! The build is failing because of compliance. Could you please follow these steps? https://github.com/open-feature/dotnet-sdk/pull/310/checks?check_run_id=31479940982

var api = provider.GetRequiredService<Api>();
var client = api.GetClient();
var context = provider.GetRequiredService<EvaluationContext>();
client.SetContext(context);
Copy link
Member

Choose a reason for hiding this comment

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

Context can be added at both the global and client level. Global context is most useful for static values (for example, a value representing the timezone of the server or the cloud provider it's running on, or the application version).

We might need multiple contexts setting methods for the different scopes: global, client, and perhaps transaction level once we implement the transaction propagation feature. As it is, I'm not sure it's obvious to a user which one would be used when they do AddContext(...)

{
throw new InvalidOperationException("Feature provider is not registered in the service collection.");
}
await _featureApi.SetProviderAsync(featureProvider).ConfigureAwait(false);
Copy link
Member

@toddbaert toddbaert Oct 18, 2024

Choose a reason for hiding this comment

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

We might want an optional value for domain, or some other method for working with domain-scoped providers.

Comment on lines 11 to 17
#if NET8_0_OR_GREATER
ArgumentNullException.ThrowIfNull(value, name);
#else
if (value is null)
throw new ArgumentNullException(name);
#endif

Copy link
Member

Choose a reason for hiding this comment

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

Personally I think it's better just do use the version in the #else here; using both a "nice" API and a "less nice" API is more complex than just using the "less nice" API, IMO.

Unless there some performance implication here I think this just hurts readability a bit.

Copy link
Author

Choose a reason for hiding this comment

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

Fixed

@toddbaert
Copy link
Member

I think this is on the right track. Could you provide a usage guide in the README as well?

I'd also like to know your thoughts about how we could add some additional features; mostly because I'd like to prevent breaking changes: should we support the configuration of hooks? Request scoped injection of clients? Request scoped evaluation of flags?

@arttonoyan
Copy link
Author

arttonoyan commented Oct 18, 2024

I think this is on the right track. Could you provide a usage guide in the README as well?

I'd also like to know your thoughts about how we could add some additional features; mostly because I'd like to prevent breaking changes: should we support the configuration of hooks? Request scoped injection of clients? Request scoped evaluation of flags?

Regarding the README – Absolutely, I will provide a usage guide.

Regarding hooks – This requires some careful consideration. This is my first experience using feature flags with this standard, and I don’t have a solid example yet to test and determine the best approach.

Regarding scoped services – While request-scoped configurations may seem beneficial, they introduce several challenges:

  1. You cannot use singletons in classes.
  2. There are significant issues when integrating with HTTP client’s message handlers. Specifically, we’ve encountered cases where the context was lost (see: Understanding Scopes with IHttpClientFactory). This happens because message handlers are long-lived and don’t adhere to scoped lifetimes.
    For these reasons, features like HttpContextAccessor are often registered as singletons to avoid such issues.

I personally prefer having a singleton version, but it is a bit challenging to develop, and we should be very careful with it and conduct thorough testing.

The code I have written is already being used by several teams in our company, and there is real testing happening in these environments. I would prefer to have a preview version that allows me to replace most of our current code with this new package while continuing to work on the next version in parallel. This approach will enable me to test the changes in our projects first before proposing modifications here.

One potential path forward could be to create an initial package with these considerations and mark it as a preview. We can then develop a second version that registers IFeatureClient as a singleton, while providing IFeatureClientSnapshot for scoped registrations. This aligns with good practices that Microsoft also follows, for instance, in the use of IFeatureManagerSnapshot in the Feature Management library (reference).

/// Describes a <see cref="OpenFeatureBuilder"/> backed by an <see cref="IServiceCollection"/>.
/// </summary>
/// <param name="Services">The <see cref="IServiceCollection"/> instance.</param>
public sealed record OpenFeatureBuilder(IServiceCollection Services)
Copy link
Member

Choose a reason for hiding this comment

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

What is the pattern/reasoning behind using a sealed record and extensions here?
I have not too much experience with .NET so I am mostly wondering why this is the chosen/preferred way.

Copy link
Author

Choose a reason for hiding this comment

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

@lukas-reining Thanks for your review.

Reasoning for using a record in OpenFeatureBuilder and extension methods:

  1. Immutability:
    Using a record in OpenFeatureBuilder ensures immutability, which is crucial in configurations where state changes should not happen after initialization. Records in C# provide an easy way to define immutable types and handle value-based equality. This matches the pattern used in other parts of ASP.NET Core, like the AuthenticationBuilder class, where the object holds configuration data (like IServiceCollection) and doesn’t change its internal state directly.

  2. Following .NET Patterns (e.g., AuthenticationBuilder):
    The AuthenticationBuilder is a great example from ASP.NET Core, where the builder encapsulates IServiceCollection and allows adding services via extension methods. Our approach to OpenFeatureBuilder follows the same design, where extension methods add specific configuration options, making the builder pattern more extensible without altering its core structure. Microsoft actively uses this builder pattern across many of their packages, not just for authentication. It's proven to be a robust way to add functionality in a clean and modular manner.

  3. Extension Methods Simplify Extensibility:
    Like how GoogleExtensions.cs and other authentication schemes are added via extension methods on AuthenticationBuilder, the same idea applies to OpenFeatureBuilder. This allows developers to extend the functionality of OpenFeatureBuilder in a modular way, adding new features without needing to modify or subclass the builder itself. This aligns with Microsoft’s general approach to keep core objects clean and extensible via extensions.

  4. Many Uses Across Microsoft Packages:
    While AuthenticationBuilder is a useful example, this builder and extension pattern is widely used throughout Microsoft’s packages, such as in DI (Dependency Injection), logging, and configuration. It allows developers to write clean, easily maintainable code that is open to extensions without modifying the core framework.

  5. Reconsidering the sealed Keyword:
    In some cases, the use of sealed on a builder might restrict extensibility via inheritance. Although the builder pattern usually focuses on extension methods rather than inheritance, removing sealed can be beneficial if you envision a need for developers to extend OpenFeatureBuilder through inheritance. It opens up the possibility for advanced users to create custom builders while still maintaining the base functionality.


In summary, the use of a record for OpenFeatureBuilder fits well with established patterns in .NET, like AuthenticationBuilder, making it immutable and easily extensible through extension methods. The use of this pattern is not isolated; it’s widespread across Microsoft packages and proves to be a useful approach. The consideration of removing sealed can further enhance the extensibility of the builder, allowing more advanced use cases where inheritance might be needed.

Copy link
Author

Choose a reason for hiding this comment

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

I will remove the sealed modifier.

Copy link
Member

Choose a reason for hiding this comment

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

Thanks @arttonoyan!
I am still not sure why we use the record.

Using a record in OpenFeatureBuilder ensures immutability, which is crucial in configurations where state changes should not happen after initialization.

If I am not looking at the wrong place, we do not have any data.
The AuthenticationBuilder is a class and not a record.

The AuthenticationBuilder also provides the basic functionality directly so maybe we would only need the extensions from src/OpenFeature.Hosting/OpenFeatureBuilderExtensions.cs but we could inline src/OpenFeature.DependencyInjection/OpenFeatureBuilderExtensions.cs as they are directly defined in the same package.

I have no hard opinion on this, I would just like to understand the reasoning.

Copy link
Author

Choose a reason for hiding this comment

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

I think in our case, there isn't a significant difference between using a class or a record. The main reason I opted for a record is because it allows for a more concise and compact syntax, especially when the class primarily holds immutable data, like the Services property. Functionally, it would be nearly identical to using a class like this:

public class OpenFeatureBuilder
{
    public OpenFeatureBuilder(IServiceCollection services)
    {
        Services = services;
    }

    public IServiceCollection Services { get; }
}

Copy link
Member

@lukas-reining lukas-reining Oct 22, 2024

Choose a reason for hiding this comment

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

especially when the class primarily holds immutable data, like the Services property.

In our case we have one immutable property Services and one mutable IsContextConfigured.
This is not too important I guess but I am just wondering why we should do different then e.g. ASP.NET Core in your example.

As you said you removed sealed for extensibility, but I would argue that classes, as for AuthenticationBuilder, would be more idiomatic and extensible than extending a record as "a class can not inherit from a record" and the pattern for extending OpenFeature would be different than for the ASP.NET Core built-ins: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record#inheritance

Copy link
Member

Choose a reason for hiding this comment

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

For me the use of record also caused confusion. I had to read a bit to understand why we'd use it in this case.

Personally, since other builders we have are implemented as classes I think I'd prefer that, but it's not a strong preference.

Copy link
Author

Choose a reason for hiding this comment

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

@lukas-reining @toddbaert Thank you for your thoughtful feedback! I completely understand your point, especially regarding consistency with other builders being implemented as classes. I can see how using a record in this case might cause some confusion. It’s not a strong preference for me either, I will change with the more familiar pattern of using classes for builders.

Copy link
Author

Choose a reason for hiding this comment

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

Updated

Copy link
Member

Choose a reason for hiding this comment

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

To add, other reason why we should seal classes is in dotnet 6, we got some small performance improvements: https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-6/#“peanut-butter”

As for the record, I would prefer to start using it by the reasons that @arttonoyan mentioned before. But I am happy to keep as a class until we decide to move it to record.

/// This property is used to determine if specific configurations or services
/// should be initialized based on the presence of an evaluation context.
/// </summary>
internal bool IsContextConfigured { get; set; }
Copy link
Member

Choose a reason for hiding this comment

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

Just to be sure: why is this internal?
If our package benefits from IsContextConfigured could it be beneficial for external extensions too?

Copy link
Author

Choose a reason for hiding this comment

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

Great question! I initially made it internal because it was primarily used for internal configuration, and the public setter was intended for use within the internal scope. However, you raise a good point. There's no strong reason not to make it public, as it could provide useful information for consumers. We can certainly change it to:

public bool IsContextConfigured { get; internal set; }

Copy link
Author

Choose a reason for hiding this comment

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

Updated

@toddbaert
Copy link
Member

@arttonoyan @askpt @lukas-reining @kinyoklion What do you think about using Experimental on some of these classes/interfaces to make clear they may change? That might be easier than creating a preview branch (which is what I think we'd have to do to release a separate artifact).

That way, people would get a clear warning they are using a subset of our feature which are subject to change?

@lukas-reining
Copy link
Member

What do you think about using Experimental on some of these classes/interfaces to make clear they may change? That might be easier than creating a preview branch (which is what I think we'd have to do to release a separate artifact).

Makes sense to me. We did this for the TransactionContext Propagation in JS too. And as this is available from .NET out-of-the-box I think this is a good way to get this tested.

@arttonoyan
Copy link
Author

arttonoyan commented Oct 22, 2024

@toddbaert @askpt @lukas-reining @kinyoklion let's try to speed up the pace a bit. I think marking it as experimental is a solid approach. We can mark it as experimental, resolve all the conversations, and incorporate the other suggestions to have a first experimental version. Afterward, I can replace my changes in our company package with this version, which will allow for natural testing.

Additionally, I will work on improving it further, focusing on making IFeatureClient a singleton and adding a new IFeatureClientSnapshot interface as scoped. In our package, I've used a strategy pattern for the context because we often deal with a few common strategies. For example, many teams are using TenantContext, where the information always comes from the request, and we utilize OperationContextAccessor to resolve that information. Here's an example of how it looks:

builder.Services.AddOpenFeature(featureBuilder =>
{
    featureBuilder
        .AddHostedFeatureLifecycle()
        .AddTenantContext(cfg => cfg.UseOperationContext())
        .AddLaunchDarkly(builder.Configuration["LaunchDarkly:SdkKey"]);
});

In this setup, AddTenantContext adds some tenant-specific context definitions, while UseOperationContext pulls data from the operation context - it's a concrete strategy that we are using in our system (Note, the Strategy is just an idea that needs further discussion)

I'll continue refining this as we move forward.

@askpt
Copy link
Member

askpt commented Oct 23, 2024

@toddbaert @askpt @lukas-reining @kinyoklion let's try to speed up the pace a bit. I think marking it as experimental is a solid approach. We can mark it as experimental, resolve all the conversations, and incorporate the other suggestions to have a first experimental version. Afterward, I can replace my changes in our company package with this version, which will allow for natural testing.

Additionally, I will work on improving it further, focusing on making IFeatureClient a singleton and adding a new IFeatureClientSnapshot interface as scoped. In our package, I've used a strategy pattern for the context because we often deal with a few common strategies. For example, many teams are using TenantContext, where the information always comes from the request, and we utilize OperationContextAccessor to resolve that information. Here's an example of how it looks:

builder.Services.AddOpenFeature(featureBuilder =>

{

    featureBuilder

        .AddHostedFeatureLifecycle()

        .AddTenantContext(cfg => cfg.UseOperationContext())

        .AddLaunchDarkly(builder.Configuration["LaunchDarkly:SdkKey"]);

});

In this setup, AddTenantContext adds some tenant-specific context definitions, while UseOperationContext pulls data from the operation context - it's a concrete strategy that we are using in our system (Note, the Strategy is just an idea that needs further discussion)

I'll continue refining this as we move forward.

@arttonoyan I agree with you at 100%. As per the Experimental attribute, I suggest we have a section in the readme that explains why is experimental and use the URL property to point to that section.

If you need any help with the release let me know and we can sync.

@arttonoyan
Copy link
Author

@toddbaert @askpt @lukas-reining @kinyoklion let's try to speed up the pace a bit. I think marking it as experimental is a solid approach. We can mark it as experimental, resolve all the conversations, and incorporate the other suggestions to have a first experimental version. Afterward, I can replace my changes in our company package with this version, which will allow for natural testing.
Additionally, I will work on improving it further, focusing on making IFeatureClient a singleton and adding a new IFeatureClientSnapshot interface as scoped. In our package, I've used a strategy pattern for the context because we often deal with a few common strategies. For example, many teams are using TenantContext, where the information always comes from the request, and we utilize OperationContextAccessor to resolve that information. Here's an example of how it looks:

builder.Services.AddOpenFeature(featureBuilder =>

{

    featureBuilder

        .AddHostedFeatureLifecycle()

        .AddTenantContext(cfg => cfg.UseOperationContext())

        .AddLaunchDarkly(builder.Configuration["LaunchDarkly:SdkKey"]);

});

In this setup, AddTenantContext adds some tenant-specific context definitions, while UseOperationContext pulls data from the operation context - it's a concrete strategy that we are using in our system (Note, the Strategy is just an idea that needs further discussion)
I'll continue refining this as we move forward.

@arttonoyan I agree with you at 100%. As per the Experimental attribute, I suggest we have a section in the readme that explains why is experimental and use the URL property to point to that section.

If you need any help with the release let me know and we can sync.

Thanks, @askpt! I will go ahead and complete the README section, as suggested. I'll also work on fixing the DCO issue again, and once that's sorted, we can proceed with the release. It would be great to have your help with the release process - let's sync up when we're ready to move forward.

@toddbaert toddbaert self-requested a review October 23, 2024 17:16
Copy link
Member

@toddbaert toddbaert left a comment

Choose a reason for hiding this comment

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

@arttonoyan @askpt @lukas-reining I support releasing this as-is if we mark all new APIs as experimental and do something about this; I think we need to clarify which context level is being set here, preferably with the method name or at least with docs. Please let me know if I'm missing something on that.

I'll approve once those are resolved.

I would prefer to see this resolved as well, but it's not a blocker for me.

@arttonoyan
Copy link
Author

@arttonoyan @askpt @lukas-reining I support releasing this as-is if we mark all new APIs as experimental and do something about this; I think we need to clarify which context level is being set here, preferably with the method name or at least with docs. Please let me know if I'm missing something on that.

I'll approve once those are resolved.

I would prefer to see this resolved as well, but it's not a blocker for me.

@toddbaert and team,

I wanted to update you on the task you mentioned – I’m actively working on it, and while it’s a bit complex, I’ve completed most of the code. I’m also running thorough tests to ensure everything works as expected.

I’m working in a separate branch, and within the next few days, I’ll open a new PR for review. The process has taken a bit longer as I explored multiple solutions to find the best approach.

@arttonoyan
Copy link
Author

arttonoyan commented Oct 27, 2024

@arttonoyan @askpt @lukas-reining I support releasing this as-is if we mark all new APIs as experimental and do something about this; I think we need to clarify which context level is being set here, preferably with the method name or at least with docs. Please let me know if I'm missing something on that.
I'll approve once those are resolved.
I would prefer to see this resolved as well, but it's not a blocker for me.

@toddbaert and team,

I wanted to update you on the task you mentioned – I’m actively working on it, and while it’s a bit complex, I’ve completed most of the code. I’m also running thorough tests to ensure everything works as expected.

I’m working in a separate branch, and within the next few days, I’ll open a new PR for review. The process has taken a bit longer as I explored multiple solutions to find the best approach.

@toddbaert, @askpt and team, guys!
I've completed the Domain-Scoped Providers Configuration logic to support multiple providers. However, I created my changes in a new branch off this one, and now I'm trying to open a new PR back to it, but I'm only seeing the option to merge into the main branch. Could someone help me figure this out?

@beeme1mr
Copy link
Member

I'll take a look in a bit.

@askpt
Copy link
Member

askpt commented Oct 27, 2024

@arttonoyan it seems you created a new branch in your fork. So you will need to create a PR from di-domain-scoped-providers to add-dependency-injection. You then can share the PR which will have the diff from di-domain-scoped-providers against the PR you are proposing to the main repo. After it is merged, the changes will be visible in this PR. Makes sense?

@arttonoyan
Copy link
Author

@arttonoyan it seems you created a new branch in your fork. So you will need to create a PR from di-domain-scoped-providers to add-dependency-injection. You then can share the PR which will have the diff from di-domain-scoped-providers against the PR you are proposing to the main repo. After it is merged, the changes will be visible in this PR. Makes sense?

Thanks, @askpt ! You’re right; I created the branch from my fork. It's a bit late for me now, but I’ve opened the PR on my end and would love to share it with you for review. This way, you’ll still have a chance to take a look. Thanks in advance!
arttonoyan#1

@arttonoyan
Copy link
Author

dependency

@arttonoyan it seems you created a new branch in your fork. So you will need to create a PR from di-domain-scoped-providers to add-dependency-injection. You then can share the PR which will have the diff from di-domain-scoped-providers against the PR you are proposing to the main repo. After it is merged, the changes will be visible in this PR. Makes sense?

Thanks, @askpt ! You’re right; I created the branch from my fork. It's a bit late for me now, but I’ve opened the PR on my end and would love to share it with you for review. This way, you’ll still have a chance to take a look. Thanks in advance! arttonoyan#1

Guys, if you’re on board with my approach to developing multi-provider support, let me know. I can merge it into my current branch so we can continue our discussion on the main PR.

@toddbaert
Copy link
Member

@arttonoyan I've approved arttonoyan#1 (review) with some minor comments, but I also want to make sure before we release that we mark everything in the new DI namespace as experimental.

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.

Introduce OpenFeature.Extensions.Hosting package
7 participants