Options pattern: Two option objects, one needs information from the other

Viewed 697

I'm using the Options pattern to configure my ASP.net Core 3.1 web app. There are two options classes:

public class SystemOptions
{
    public string RootPath { get; set; }
}

public class ModuleOptions
{
    public string SubPath { get; set; }
    
    // this should become something like RootPath + SubPath
    public string FullPath { get; }
}

And the associated appsettings.json

{
    "SystemOptions": {
        "RootPath": "\\webdav"
    },
    "ModuleOptions": {
        "SubPath": "\subdirformodule"
    }
}

And in Startup:

public void ConfigureServices(IServiceCollection services)
{
    services.Configure<SystemOptions>(configuration.GetSection("SystemOptions"));
    services.Configure<ModuleOptions>(configuration.GetSection("ModuleOptions"));
}

Now I would like to initialize the FullPath in ModuleOptions once during app startup. Therefore I need access to the SystemOptions.RootPath from within the ModuleOptions. Is this possible?

I tried the following: I added an InitializeFullPath() method to the ModuleOptions:

public string InitializeFullPath(string basePath)
{
    // concat basePath and SubPath and return
    ... return fullPath;
}

and tried to use this in ConfigureServices:

    services.Configure<SystemOptions>(configuration.GetSection("SystemOptions"));
    services.AddOptions<ModuleOptions>()
        .Configure<SystemOptions>((s, m) => m.FullPath = m.InitializeFullPath(s.RootPath));

But all I get is: "No service for type '...SystemOptions' has been registered." later on when Startup.Configure() is executed. (And by the time this error occured, the InitializeFullPath method has not been executed at all - a breakpoint set there was not hit.)

So I have two questions:

  1. how can I use the content of one option object during initialization of the second option object?
  2. When will the delegate that you can specify in Configure() be executed?
1 Answers

I am going to answer your second question first. The configuration delegate is invoked the first time the Value property of the IOptions<YourOptions> is invoked. This interface is registered as a singleton so it's a one-time only thing. For IOptionsMonitor/IOptionsSnapshot they are similarly invoked on every new instance of the options.

Now to your first question... You were close! This should work:

services.AddOptions<ModuleOptions>()
        .Configure<IOptions<SystemOptions>>(
            (mod, sys) => mod.FullPath = mod.InitializeFullPath(sys.Value.RootPath)
        );

Note that we are using IOptions<SystemOptions> and .Value. The Configure method that is chained to AddOptions is not the same as the one directly on the service collection; the generic arguments are the dependent service types and the first parameter is the options type from AddOptions. So that means that you reversed the arguments to the delegate (the option being configured is the first parameter).

Another...option is to use the IConfigureOptions interface. I typically go this route and don't use the form you have shown, even for "simple" dependent configuration:

public ModuleOptionsConfigurator : IConfigureOptions<ModuleOptions>
{
    private readonly SystemOptions _sys;
    public ModuleOptionsConfigurator(IOptions<SystemOptions> opts) 
        => _sys = opts.Value;

    public void Configure(ModuleOptions mod) 
    {
        mod.FullPath = mod.InitializeFullPath(_sys.RootPath);
    } 
}

Which you then register with DI like so:

services.Configure<SystemOptions>(configuration.GetSection("SystemOptions"));
services.Configure<ModuleOptions>(configuration.GetSection("ModuleOptions"))
// register the configurator
services.ConfigureOptions<ModuleOptionsConfigurator>();

This allows you to encapsulate any sort of configurarion logic into a class. You can take zero dependencies up to however many you need.

The IPostConfigureOptions<> interface works similarly, but will run after all other Configure callbacks and IConfigureOptions<> implementations (and allows you to act differently for named options). Based on your description, this may be the better interface:

public ModuleOptionsPostConfigurator : IPostConfigureOptions<ModuleOptions>
{
    private readonly SystemOptions _sys;
    public ModuleOptionsPostConfigurator(IOptions<SystemOptions> opts) 
        => _sys = opts.Value;

    public void PostConfigure(string name, ModuleOptions mod) 
    {
        mod.FullPath = mod.InitializeFullPath(_sys.RootPath);
    }
}

IPostConfigureOptions is registered the same way as IConfigureOptions:

// register the configurator
services.ConfigureOptions<ModuleOptionsPostConfigurator>();

You can also combine the two interfaces in one implementing class, which I have often found a case for.

See the official documentation for more information on the options patterns.

Related