avatarSiarhei Kharlap

Summary

The article discusses the implementation of tenant-specific options in multitenant applications using ASP.NET Core's Options Pattern, addressing the absence of built-in multitenancy support in Microsoft's Options feature.

Abstract

The author of the article addresses a specific challenge encountered in multitenant applications: the need to adapt shared features to tenant-specific requirements, particularly in configuring settings and databases per tenant. With a lack of responsiveness from Microsoft to address this issue, the author has independently developed a solution to extend the Options Pattern in ASP.NET Core to support multitenancy. The solution involves creating tenant-aware versions of the IOptionsFactory and IOptionsMonitorCache, as well as a TenantedOptionsMonitor to manage tenant-specific configurations. The article provides a detailed implementation of these components, demonstrating how to integrate them into the application's dependency injection container and discussing the intricacies of change tracking and notification in a multitenant environment. The author also shares links to practical examples and the GitHub repository containing the code discussed in the article.

Opinions

  • The author expresses a need for multitenancy support in the Options Pattern due to Microsoft's lack of responsiveness to requests for such features.
  • The author believes that the default IOptionsMonitor is essential but requires tenant-specific adaptations, hence the introduction of ITenantedOptionsFactory and ITenantedOptionsMonitorCache.
  • The author emphasizes the importance of being able to factor in the tenant when composing options objects and when working with the cache.
  • The author acknowledges the complexity of the implementation, particularly in the areas of cache invalidation and change tracking, and provides a rationale for the design choices made.
  • The author is optimistic that the provided solution will be informative and useful to

Tenanted Options for Multitenant Applications in ASP

Photo by Natasha Polyakova on Unsplash

While the issue is specific to multitenancy, there are still numerous applications that have been developed using this approach. As a developer of such systems, you may also encounter challenges related to the absence of multitenancy in the embedded Options feature. Unfortunately, Microsoft hasn’t been responsive to our requests, which is why I took it upon myself to implement it independently. I’m delighted to share my solution with you.

The scope of the problem

In multitenant applications, we often encounter a common problem where features designed for shared use need to be adapted for tenant-specific requirements. This typically involves configuring settings and databases on a per-tenant basis.

We recently faced a similar situation when tasked with building an OAuth server that had to meet the requirement of ‘maintaining unique and distinguishable secrets, tokens, and signing-encryption certificates for each tenant.’ While this may seem like a reasonable security feature, it brought about a lot of complex solutions. Most modern applications rely on third-party components, and integrating tenant-specific functionality can be challenging if these components don’t already offer such options.

If you’re working with third-party libraries built on top of Microsoft’s Options Pattern, you might find this article useful (fortunately with disassembling it is easy to catch up on it).

Build on top of Microsoft.Extensions.Options

Assumes that during one Request we are working in only one tenant scope

If you’re interested in delving deeper into the Options Pattern, I recommend reading my other story: Options Pattern in Asp Net Core: Easier than you think!

Additionally, if you’d like to see a real-life example for which the Options Pattern was originally designed, please check out my other story: Multitenant OAuth Server with OpenIdDict and Multitenant Authorities for JWT Authentication in ASP

Get Into Microsoft.Extensions.Options

The Options Pattern, in essence, involves a few key infrastructural elements, which can be boiled down to:

  • IOptions — It’s generally not recommended to directly modify IOptions due to performance considerations and their immutable nature since the initial configuration.
  • IOptionsSnapshot —When handling per-request configurations, you typically don’t need to perform specific actions. Instead, you should configure it carefully to align with the current request tenant, as it automatically reconstructs itself per request.
  • IOptionsMonitor — This serves as the pivotal entry point for customizing the library’s behaviour.

Now, let’s explore the components from which OptionsMonitor is constructed. To do this, we’ll delve into the AddOptions() method to gain a better understanding:

public static IServiceCollection AddOptions(this IServiceCollection services)
{
    ThrowHelper.ThrowIfNull(services);

    services.TryAdd(ServiceDescriptor.Singleton(typeof(IOptions<>), typeof(UnnamedOptionsManager<>)));
    services.TryAdd(ServiceDescriptor.Scoped(typeof(IOptionsSnapshot<>), typeof(OptionsManager<>)));
    services.TryAdd(ServiceDescriptor.Singleton(typeof(IOptionsMonitor<>), typeof(OptionsMonitor<>)));
    services.TryAdd(ServiceDescriptor.Transient(typeof(IOptionsFactory<>), typeof(OptionsFactory<>)));
    services.TryAdd(ServiceDescriptor.Singleton(typeof(IOptionsMonitorCache<>), typeof(OptionsCache<>)));
    return services;
}

As I mentioned earlier, when it comes to IOptions and IOptionsSnapshot, we don’t actually need them. What we do need is IOptionsMonitor to efficiently store our created options and prevent congestion in our application by loading composed option objects directly from memory. Additionally, we require IOptionsFactory as a crucial component for composing our objects, along with an IOptionsMonitorCache implementation.

Diving into their details:

public interface IOptionsFactory<TOptions>
    where TOptions : class
{ 
    /// <summary>
    /// Returns a configured <typeparamref name="TOptions"/> instance with the given name.
    /// </summary>
    TOptions Create(string name);
}

public interface IOptionsMonitor<out TOptions>
{
    /// <summary>
    /// Returns the current <typeparamref name="TOptions"/> instance with the <see cref="Options.DefaultName"/>.
    /// </summary>
    TOptions CurrentValue { get; }

    /// <summary>
    /// Returns a configured <typeparamref name="TOptions"/> instance with the given name.
    /// </summary>
    TOptions Get(string? name);

    /// <summary>
    /// Registers a listener to be called whenever a named <typeparamref name="TOptions"/> changes.
    /// </summary>
    /// <param name="listener">The action to be invoked when <typeparamref name="TOptions"/> has changed.</param>
    /// <returns>An <see cref="IDisposable"/> which should be disposed to stop listening for changes.</returns>
    IDisposable? OnChange(Action<TOptions, string?> listener);
}

public interface IOptionsMonitorCache<TOptions>
        where TOptions : class
{
    /// <summary>
    /// Gets a named options instance, or adds a new instance created with <paramref name="createOptions"/>.
    /// </summary>
    /// <param name="name">The name of the options instance.</param>
    /// <param name="createOptions">The func used to create the new instance.</param>
    /// <returns>The options instance.</returns>
    TOptions GetOrAdd(string? name, Func<TOptions> createOptions);

    /// <summary>
    /// Tries to adds a new option to the cache, will return false if the name already exists.
    /// </summary>
    /// <param name="name">The name of the options instance.</param>
    /// <param name="options">The options instance.</param>
    /// <returns>Whether anything was added.</returns>
    bool TryAdd(string? name, TOptions options);

    /// <summary>
    /// Try to remove an options instance.
    /// </summary>
    /// <param name="name">The name of the options instance.</param>
    /// <returns>Whether anything was removed.</returns>
    bool TryRemove(string? name);

    /// <summary>
    /// Clears all options instances from the cache.
    /// </summary>
    void Clear();
}

Unlike IOptionsMonitor, which is essential, it allows me to introduce ITenantedOptionsFactory and ITenantedOptionsMonitorCache as replacements for IOptionsFactory and IOptionsMonitorCache. I’ll explain why:

public interface ITenantedOptionsFactory<TOptions>
{
    TOptions Create(string name, string tenant);
}

public interface ITenantedOptionsMonitorCache<TOptions>
{
    void Clear();

    TOptions GetOrAdd(string name, string tenant, Func<TOptions> createOptions);

    bool TryAdd(string name, string tenant, TOptions options);

    bool TryRemove(string name);
}

So, why do we actually need these replacements? The answer is quite simple: instead of relying solely on the name, I would like to factor in the tenant as well. For instance, when I need to compose an object in a factory, I want to understand the tenant for which we are composing. When working with the cache, I want the ability to retrieve this cache using a combination of the name and tenant. Cache invalidation is done only by name for simplicity, as it is controlled by IOptionsChangeTokenSource, which provides only the name. Injecting a tenant into this chain of invocation and tracking tenancy during invalidation is a complex task that has been omitted, even if it may involve trade-offs in terms of performance and the need to recompose other tenanted objects in certain scenarios.

Implementation phase

Now that we’ve discussed the ideas, it’s time to bring them to life, starting with the new factory implementation that has been modified to construct options based on a tenant, inspired by OptionsFactory<TOptions>:

ublic class TenantedOptionsFactory<TOptions> : ITenantedOptionsFactory<TOptions>
    where TOptions : class, new()
{
    private readonly IConfigureOptions<TOptions>[] _setups;
    private readonly IPostConfigureOptions<TOptions>[] _postConfigures;
    private readonly IConfigureTenantedOptions<TOptions>[] _tenantSetups;
    private readonly IValidateOptions<TOptions>[] _validations;

    public TenantedOptionsFactory(
        IEnumerable<IConfigureOptions<TOptions>> setups,
        IEnumerable<IPostConfigureOptions<TOptions>> postConfigures,
        IEnumerable<IConfigureTenantedOptions<TOptions>> tenantSetups
        ) : this(setups, postConfigures, tenantSetups, validations: Array.Empty<IValidateOptions<TOptions>>())
    {
    }

    public TenantedOptionsFactory(
        IEnumerable<IConfigureOptions<TOptions>> setups,
        IEnumerable<IPostConfigureOptions<TOptions>> postConfigures,
        IEnumerable<IConfigureTenantedOptions<TOptions>> tenantSetups,
        IEnumerable<IValidateOptions<TOptions>> validations
        )
    {
        // The default DI container uses arrays under the covers. Take advantage of this knowledge
        // by checking for an array and enumerate over that, so we don't need to allocate an enumerator.
        // When it isn't already an array, convert it to one, but don't use System.Linq to avoid pulling Linq in to
        // small trimmed applications.

        _setups = setups as IConfigureOptions<TOptions>[] ?? new List<IConfigureOptions<TOptions>>(setups).ToArray();
        _postConfigures = postConfigures as IPostConfigureOptions<TOptions>[] ?? new List<IPostConfigureOptions<TOptions>>(postConfigures).ToArray();
        _tenantSetups = tenantSetups as IConfigureTenantedOptions<TOptions>[] ?? new List<IConfigureTenantedOptions<TOptions>>(tenantSetups).ToArray();
        _validations = validations as IValidateOptions<TOptions>[] ?? new List<IValidateOptions<TOptions>>(validations).ToArray();
    }

    /// <summary>
    /// Returns a configured <typeparamref name="TOptions"/> instance with the given <paramref name="name"/> and <paramref name="tenant"/>.
    /// </summary>
    public TOptions Create(string name, string tenant)
    {
        TOptions options = CreateInstance(name);
        foreach (IConfigureOptions<TOptions> setup in _setups)
        {
            if (setup is IConfigureNamedOptions<TOptions> namedSetup)
            {
                namedSetup.Configure(name, options);
            }
            else if (name == Options.DefaultName)
            {
                setup.Configure(options);
            }
        }
        foreach (IConfigureTenantedOptions<TOptions> setup in _tenantSetups)
        {
            setup.Configure(name, tenant, options);
        }
        foreach (IPostConfigureOptions<TOptions> post in _postConfigures)
        {
            post.PostConfigure(name, options);
        }

        if (_validations != null)
        {
            var failures = new List<string>();
            foreach (IValidateOptions<TOptions> validate in _validations)
            {
                ValidateOptionsResult result = validate.Validate(name, options);
                if (result is not null && result.Failed)
                {
                    failures.AddRange(result.Failures);
                }
            }
            if (failures.Count > 0)
            {
                throw new OptionsValidationException(name, typeof(TOptions), failures);
            }
        }

        return options;
    }

    protected virtual TOptions CreateInstance(string name)
    {
        return Activator.CreateInstance<TOptions>();
    }
}

While the code appears relatively straightforward and accomplishes the same tasks as options composing and validation if you examine the original implementation, you’ll notice that this version accepts a collection of IConfigureTenantedOptions<TOptions> and employs them for options construction, akin to what the default IConfigureOptions<TOptions> does. The IConfigureTenantedOptions<TOptions> code is provided below:

public interface IConfigureTenantedOptions<TOptions>
    where TOptions : class
{
    /// <summary>
    /// Invoked to configure a <typeparamref name="TOptions"/> instance.
    /// </summary>
    /// <param name="name">The name of the options instance being configured.</param>
    /// <param name="tenant">The tenant of the options instance being configured.</param>
    /// <param name="options">The options instance to configure.</param>
    void Configure(string name, string tenant, TOptions options);
}

The next element that captures our attention is the TenantedOptionsMonitorCache<TOptions>:

public class TenantedOptionsMonitorCache<TOptions> : ITenantedOptionsMonitorCache<TOptions>
    where TOptions : class
{
    protected readonly IOptionsMonitorCache<IOptionsMonitorCache<TOptions>> _cache;

    public TenantedOptionsMonitorCache(
        IOptionsMonitorCache<IOptionsMonitorCache<TOptions>> cache
        )
    {
        _cache = cache;
    }
    public void Clear()
        => _cache.Clear();

    public TOptions GetOrAdd(string name, string tenant, Func<TOptions> createOptions)
    {. 
        name ??= Options.DefaultName;
        var tenantCache = GetTenantOptionsCache(name);

        return tenantCache.TryAdd(tenant, options);
    }

    public bool TryRemove(string name)
        => _cache.TryRemove(name ?? Options.DefaultName);

    protected IOptionsMonitorCache<TOptions> GetTenantOptionsCache(string name)
        => _cache.GetOrAdd(
            name,
            () => new OptionsCache<TOptions>()
        );
}

As you may notice, this _cache is actually an instance of IOptionsMonitorCache<TOption> (a cache of cache), and in GetTenantOptionsCache(), it uses the default implementation from Microsoft—OptionsCache<TOptions>. Even when injected from DI, it remains OptionsCache<TOptions>. So, it's essential to be mindful of this if you ever decide to reimplement it for any reason.

While things are relatively straightforward with the factory and cache, the TenantedOptionsMonitor might appear intimidating when you first examine it:

/// <summary>
/// Implementation of <see cref="IOptionsMonitor{TOptions}"/>.
/// code is copright of <see cref=OptionsMonitor{TOptions}"/>
/// </summary>
/// <typeparam name="TOptions">Options type.</typeparam>
public class TenantedOptionsMonitor<TOptions> : IOptionsMonitor<TOptions>, IDisposable
   where TOptions : class
{
    private readonly ITenantedOptionsFactory<TOptions> _factory;
    private readonly ITenantProvider _tenantProvider;
    private readonly ITenantedOptionsMonitorCache<TOptions> _cache;

    private readonly List<IDisposable> _registrations = new List<IDisposable>();
    internal event Action<TOptions, string> _onChange;

    public TenantedOptionsMonitor(
        ITenantProvider tenantProvider,
        ITenantedOptionsFactory<TOptions> factory,
        ITenantedOptionsMonitorCache<TOptions> cache,
        IEnumerable<IOptionsChangeTokenSource<TOptions>> sources
        )
    {
        _factory = factory;
        _tenantProvider = tenantProvider;
        _cache = cache;

        void RegisterSource(IOptionsChangeTokenSource<TOptions> source)
        {
            IDisposable registration = ChangeToken.OnChange(
                      () => source.GetChangeToken(),
                      (name) => InvokeChanged(name),
                      source.Name);

            _registrations.Add(registration);
        }

        // The default DI container uses arrays under the covers. Take advantage of this knowledge
        // by checking for an array and enumerate over that, so we don't need to allocate an enumerator.
        if (sources is IOptionsChangeTokenSource<TOptions>[] sourcesArray)
        {
            foreach (IOptionsChangeTokenSource<TOptions> source in sourcesArray)
            {
                RegisterSource(source);
            }
        }
        else
        {
            foreach (IOptionsChangeTokenSource<TOptions> source in sources)
            {
                RegisterSource(source);
            }
        }
    }

    /// <summary>
    /// The present value of the options.
    /// </summary>
    public TOptions CurrentValue => Get(Options.DefaultName);

    /// <summary>
    /// Returns a configured <typeparamref name="TOptions"/> instance with the given <paramref name="name"/>.
    /// </summary>
    public TOptions Get(string name)
    {
        name ??= Options.DefaultName;
        var tenant = _tenantProvider.GetCurrentTenant();

        return _cache.GetOrAdd(name, tenant, () => _factory.Create(name, tenant));
    }

    /// <summary>
    /// Registers a listener to be called whenever <typeparamref name="TOptions"/> changes.
    /// </summary>
    /// <param name="listener">The action to be invoked when <typeparamref name="TOptions"/> has changed.</param>
    /// <returns>An <see cref="IDisposable"/> which should be disposed to stop listening for changes.</returns>
    public IDisposable OnChange(Action<TOptions, string> listener)
    {
        var disposable = new ChangeTrackerDisposable(this, listener);
        _onChange += disposable.OnChange;
        return disposable;
    }

    private void InvokeChanged(string name)
    {
        name = name ?? Options.DefaultName;
        _cache.TryRemove(name);
        TOptions options = Get(name);
        if (_onChange != null)
        {
            _onChange.Invoke(options, name);
        }
    }

    /// <summary>
    /// Removes all change registration subscriptions.
    /// </summary>
    public void Dispose()
    {
        // Remove all subscriptions to the change tokens
        foreach (IDisposable registration in _registrations)
        {
            registration.Dispose();
        }

        _registrations.Clear();
    }
    
    internal sealed class ChangeTrackerDisposable: IDisposable
    {
        private readonly Action<TOptions, string> _listener;
        private readonly TenantedOptionsMonitor<TOptions> _monitor;

        public ChangeTrackerDisposable(TenantedOptionsMonitor<TOptions> monitor, Action<TOptions, string> listener)
        {
            _listener = listener;
            _monitor = monitor;
        }

        public void OnChange(TOptions options, string name) => _listener.Invoke(options, name);

        public void Dispose() => _monitor._onChange -= OnChange;
    }
}

While it involves several sub-infrastructural elements, they are adaptations from the default OptionsMonitor<TOptions> implementation. I believe that the easier part of this class is options retrieval (CurrentValue and Get()). So let’s not linger on them and move on to more interesting aspects. In addition to obtaining an options object, IOptionsMonitor allows us to subscribe to options changes.:

/// <summary>
/// Registers a listener to be called whenever a named <typeparamref name="TOptions"/> changes.
/// </summary>
/// <param name="listener">The action to be invoked when <typeparamref name="TOptions"/> has changed.</param>
/// <returns>An <see cref="IDisposable"/> which should be disposed to stop listening for changes.</returns>
IDisposable? OnChange(Action<TOptions, string?> listener);

//and its implementation

public IDisposable OnChange(Action<TOptions, string> listener)
{
    var disposable = new ChangeTrackerDisposable(this, listener);
    _onChange += disposable.OnChange; //subscribe listener to the event
    return disposable;
}

internal sealed class ChangeTrackerDisposable: IDisposable
{
    private readonly Action<TOptions, string> _listener;
    private readonly TenantedOptionsMonitor<TOptions> _monitor;

    public ChangeTrackerDisposable(TenantedOptionsMonitor<TOptions> monitor, Action<TOptions, string> listener)
    {
        _listener = listener;
        _monitor = monitor;
    }

    public void OnChange(TOptions options, string name) => _listener.Invoke(options, name);

    //unsubscribe listener from the event
    public void Dispose() => _monitor._onChange -= OnChange;
}

The OnChange method returns a disposable object that should be disposed of when a listener is no longer needed. As you can see, the ChangeTrackerDisposable is essentially a wrapper that turns a listener function into a ‘disposable’ object, and it will automatically unsubscribe the listener when this object is disposed.

The second remarkable aspect is how OptionsMonitor tracks changes in its sources. It obtains an IEnumerable<IOptionsChangeTokenSource<TOptions>> collection and subscribes to changes from these sources. While IOptionsChangeTokenSource is indeed a complex topic and requires its own article, so I’ll provide a simplified code illustration to give you a better idea:

public TenantedOptionsMonitor(
    ...
    IEnumerable<IOptionsChangeTokenSource<TOptions>> sources
    )
{
...
   
    foreach (IOptionsChangeTokenSource<TOptions> source in sources)
    {
        //subscribe to changes from source 
        //and delegate execution to InvokeChanged when sorce notifies
        IDisposable registration = ChangeToken.OnChange(
              () => source.GetChangeToken(),
              (name) => InvokeChanged(name),
              source.Name);

        _registrations.Add(registration);
    }
    
}

private void InvokeChanged(string name)
{
    name = name ?? Options.DefaultName;
    _cache.TryRemove(name);
    TOptions options = Get(name);
    if (_onChange != null)
    {
        _onChange.Invoke(options, name);
    }
}

So that’s about it. We just need some code to integrate our tenanted options into the ecosystem using Dependency Injection (DI):

public static class AuthTenantOptionsServiceCollectionExtensions
{
    public static IServiceCollection AddTenantedOptions<TTenantProvider, TOptions>(this IServiceCollection services)
        where TTenantProvider : class, ITenantProvider
        where TOptions : class, new()
    {
        services.AddOptions();

        services.TryAdd(ServiceDescriptor.Singleton(typeof(ITenantProvider), typeof(TTenantProvider)));
        services.TryAdd(ServiceDescriptor.Singleton(typeof(IOptionsMonitor<TOptions>), typeof(TenantedOptionsMonitor<TOptions>)));
        services.TryAdd(ServiceDescriptor.Transient(typeof(ITenantedOptionsFactory<TOptions>), typeof(TenantedOptionsFactory<TOptions>)));
        services.TryAdd(ServiceDescriptor.Singleton(typeof(ITenantedOptionsMonitorCache<>), typeof(TenantedOptionsMonitorCache<>)));

        return services;
    }
}

//and use it like:
builder.Services.AddTenantedOptions<HttpContextTenantProvider, JwtBearerOptions>();

And that’s a wrap!

Check how it works in practice with my other article: Multitenant OAuth Server with OpenIdDict and Multitenant Authorities for JWT Authentication in ASP

The code from the article can be found on the GitHub repo

Thank you for being with me during this complicated topic explanation, I hope that this article was not only informative but also gave you an idea of way to implement Tenanted Options. Also, if you enjoyed this story and found it insightful, be sure to follow me for more engaging content like this in the future. You can also follow me on LinkedIn where I post notifications about my new publication with free access links.

If you have an interest in the Options Pattern itself, you may be interested in my other article: Options Pattern in Asp Net Core: Easier than you think!

Aspnetcore
C Sharp Programming
Multitenancy
Configuration Management
Recommended from ReadMedium