In a recent post I showed how you could populate an IOptions<T>
object from the database for the purposes of caching the query result. It wasn't the most flexible solution or really recommended but it illustrated the point.
However one of the issues I had with the solution was the need to access configured services from within the IOptions<T>
configuration lambda, inside ConfigureServices
itself.
The solution I came up with was to use the injected IServiceCollection
to build an IServiceProvider
to get the configured service I needed. As I pointed out at the time, this service-locator pattern felt icky and wrong, but I couldn't see any other way of doing it.
Thankfully, and inspired by this post from Ben Collins, there is a much better solution to be had by utilising the IConfigureOptions<T>
interface
The previous version
In my post, I had this (abbreviated) code, which was trying to access an Entity Framework Core DbContext
in the Configure
method to setup the MultitenancyOptions
class:
public class Startup
{
public Startup(IHostingEnvironment env) { /* ... build configuration */ }
public IConfigurationRoot Configuration { get; }
public void ConfigureServices(IServiceCollection services)
{
// add MVC, connection string etc
services.Configure<MultitenancyOptions>(
options =>
{
var scopeFactory = services
.BuildServiceProvider()
.GetRequiredService<IServiceScopeFactory>();
using (var scope = scopeFactory.CreateScope())
{
var provider = scope.ServiceProvider;
using (var dbContext = provider.GetRequiredService<ApplicationDbContext>())
{
options.AppTenants = dbContext.AppTenants.ToList();
}
}
});
// add other services
}
public void Configure(IApplicationBuilder app) { /* ... configure pipeline */ }
}
Yuk. As you can see, the call to ConfigureServices
is a mess. In order to obtain a scoped lifetime DbContext
it has to build the service collection to produce an IServiceProvider
, to then obtain an IServiceScopeFactory
. From there it can create the correct scoping, create another IServiceProvider
, and finally find the DbContext
we actually need. This lambda has way too much going on, and 90% of it is plumbing.
If you're wondering why you shouldn't just fetch a
DbContext
directly from the first service provider, check out this twitter discussion between Julie Lerman, David Fowler and Shawn Wildermuth.
The new improved answer
So, now we know what we're working with, how do we improve it? Luckily, the ASP.NET team anticipated this issue - instead of providing a lambda for configuring the MultitenancyOptions
object, we implement the IConfigureOptions<TOptions>
interface, where TOptions: MultitenancyOptions
. This interface has a single method, Configure
, which is passed a constructed MultitenancyOptions
object for you to update:
public class ConfigureMultitenancyOptions : IConfigureOptions<MultitenancyOptions>
{
private readonly IServiceScopeFactory _serviceScopeFactory;
public ConfigureMultitenancyOptions(IServiceScopeFactory serviceScopeFactory)
{
_serviceScopeFactory = serviceScopeFactory;
}
public void Configure(MultitenancyOptions options)
{
using (var scope = _serviceScopeFactory.CreateScope())
{
var provider = scope.ServiceProvider;
using (var dbContext = provider.GetRequiredService<ApplicationDbContext>())
{
options.AppTenants = dbContext.AppTenants.ToList();
}
}
}
}
We then just need to register our configuration class in the normal ConfigureServices
method, which becomes:
public void ConfigureServices(IServiceCollection services)
{
// add MVC, connection string etc
services.AddSingleton<IConfigureOptions<MultitenancyOptions>, ConfigureMultitenancyOptions>();
// add other services
}
The advantage of this approach is that the configuration class is created through the usual DI container, so can have dependencies injected simply through the constructor. There is still a slight complexity introduced by the fact we want MultitenancyOptions
to have a singleton lifecycle. To prevent leaking a lifetime scope, we must inject an IServiceScopeFactory
and create an explicit scope before retrieving our DbContext
. Again, check out Julie Lerman's twitter conversation and associated post for more details on this.
The most important point here is that we are no longer calling BuildServiceProvider()
in our Configure
method, just to get a service we need. So just try and forget that I ever mentioned doing that ;)
Under the hood
In hindsight, I really should have guessed that this approach was possible, as the lambda approach is really just a specialised version of the IConfigureOptions
approach.
Taking a look at the Options source code really shows how these two methods tie together. The Configure
extension method on IServiceCollection
that takes a lambda looks like the following (with precondition checks etc removed)
public static IServiceCollection Configure<TOptions>(
this IServiceCollection services, Action<TOptions> configureOptions)
{
services.AddSingleton<IConfigureOptions<TOptions>>(new ConfigureOptions<TOptions>(configureOptions));
return services;
}
All this method is doing is creating an instance of the ConfigureOptions<TOptions>
class, passing in the configuration lambda, and registering that as a singleton. That looks suspiciously like our tidied up approach, the difference being that we left the instantiation of our ConfigureMultitenancyOptions
to the DI system, instead of new-ing it up directly.
As is to be expected, the ConfigureOptions<TOptions>
, which implements IConfigureOptions<TOptions>
just calls the provided lambda in its Configure
method:
public class ConfigureOptions<TOptions> : IConfigureOptions<TOptions> where TOptions : class
{
public ConfigureOptions(Action<TOptions> action)
{
Action = action;
}
public Action<TOptions> Action { get; }
public virtual void Configure(TOptions options)
{
Action.Invoke(options);
}
}
So again, the only substantive difference between using the lambda approach and the IConfigureOptions
approach is that the latter allows you to inject services into your options class to be used during configuration.
One final useful point to be aware of: you can register multiple instances of IConfigureOptions<TOptions>
for the same TOptions
. They will all be applied, and in the order they were added to the service collection in ConfigureServices
. That allows you to do simple configuration in ConfigureServices
using the Configure
lambda, while using a separate implementation of IConfigureOptions
elsewhere, if you're so inclined.