Why is dependency resolution resolving my service's options AFTER the service itself?

462 Views Asked by At

I have a .NET Core 2.2 WebAPI project in which I'm registering three services (we'll call them MailerService, TicketService, and AuditServce), plus a middleware (ExceptionMiddleware) that depends on one of those services (MailerService). MailerService and TicketService both depend on strongly-typed options objects, which I register with service.Configure<TOption>(). I've made sure that the options objects are registered before the services, and the options dependencies themselves are wired into the services' constructors.

The issue is that TicketService resolves its options object just fine from DI, but for some reason the config for MailerService resolves AFTER the service itself. Rough sketch of relevant code below.

I've set breakpoints to watch the order of resolution, and the delegate for setting MailerConfig consistently fires AFTER the MailerService constructor. So every time I get an instance of MailerSerivce, its options parameter is NULL. And yet, watching the same resolution for TicketService, TicketConfig resolves before the TicketService constructor fires, and TicketService gets a properly-configured options object. Aside from MailerService being a dependency of the middleware, I can't figure out what might be different between them.

I've been banging my head on this for hours now, but can't find any decent documentation explaining why the DI resolution order might get out of whack, or what I might have done wrong here. Does anyone have a guess at what I might be doing wrong? Does the exception middleware need to be registered as a service as well?

Startup

public class Startup
{
  public void ConfigureServices(IServiceCollection services)
  {
    services.AddMvcCore()
      .AddAuthorization()
      .AddJsonFormatters()
      .AddJsonOptions(options => options.SerializerSettings.ContractResolver = new DefaultContractResolver());

    services.Configure<MailerConfig>(myOpts =>
    {
      // this always resolves AFTER MailerService's constructor
      myOpts = Configuration.GetSection("MailerSettings").Get<MailerConfig>();
      myOpts.SecretKey = _GetApiKey(Configuration.GetValue<string>("MailerApiKeyFile"));
    });

    services.Configure<ExceptionMiddlewareConfig>(myOpts =>
    {
      myOpts.AnonymousUserName = Configuration.GetValue<string>("AnonymousUserName");
      myOpts.SendToEmailAddress = Configuration.GetValue<string>("ErrorEmailAddress");
    });

    services.Configure<TicketConfig>(myOpts =>
    {
      // this always resovles BEFORE TicketService's constructor
      myOpts.ApiRoot = Configuration.GetValue<string>("TicketApiRoot");
      myOpts.SecretKey = _GetApiKey(Configuration.GetValue<string>("TicketApiKeyFile"));
    });

    services.AddTransient(provider =>
    {
      return new AuditService
      {
        ConnectionString = Configuration.GetValue<string>("Auditing:ConnectionString")
      };
    });

    services.AddTransient<ITicketService, TicketService>();
    services.AddTransient<IMailerService, AuditedMailerService>();
  }

  public void Configure(IApplicationBuilder app, IHostingEnvironment env)
  {
    app.UseMiddleware<ExceptionMiddleware>();

    //app.UseHttpsRedirection();
    app.UseAuthentication();
    app.UseMvc();
  }
}

MailerService Constructor

public AuditedMailerService(AuditService auditRepo, IOptions<MailerConfig> opts)
{
  // always gets a NULL opts object??????
  _secretKey = opts.Value.SecretKey;
  _defaultFromAddr = opts.Value.DefaultFromAddress;
  _defaultFromName = opts.Value.DefaultFromName;
  _repo = auditRepo;
}

TicketService Constructor

public TicketService(IOptions<TicketConfig> opts)
{
  // always gets an initialized opts object with proper values assigned
  ApiRoot = opts.Value.ApiRoot;
  SecretKey = opts.Value.SecretKey;
}

Middleware Constructor

public ExceptionMiddleware(RequestDelegate next, IMailerService mailer, IOptions<ExceptionMiddlewareConfig> config)
{
  _mailer = mailer;
  _next = next;
  _anonymousUserName = config.Value.AnonymousUserName;
  _sendToEmailAddress = config.Value.SendToEmailAddress;
}
2

There are 2 best solutions below

0
On BEST ANSWER

While this isn't much of an answer (I still have no idea why DI was only resolving the options after the service), I have found a solution to the problem. I'm just making an end-run around the Options Pattern and resolving all dependencies explicitly inside the delegate where I register the mailer service. I also tweaked ExceptionMiddleware to take the mailer service as a method argument in InvokeAsync, rather than a constructor argument. It's not terribly important that the services be transient or singletons, but for the time being I just prefer transients.

The notable downside of this approach is that I can no longer use the live-update mechanisms built into the options system - if I change a value in my appsettings on the fly, the app will need to be recycled to pick it up. That isn't an actual need in my app, so I can live with it, but others should be mindful before following my approach.

New MailerService registration delegate:

  services.AddTransient<IMailerService>(provider =>
  {
    var cfg = Configuration.GetSection("MailerSettings").Get<MailerConfig>();
    cfg.SecretKey = _GetApiKey(Configuration.GetValue<string>("MailerApiKeyFile"));

    var auditor = provider.GetService<AuditService>();

    return new AuditedMailerService(auditor, Options.Create(cfg));
  });
1
On

Because what you're doing kinda doesn't make sense.

You are registering middleware with a dependency on a service you've marked as transient, i.e create-on-demand.

But middleware is always instantiated on app startup (singleton). Therefore any dependencies are also instantiated on app startup. Therefore the instance of your "transient" service created by your middleware, is also a singleton!

Further, if your middleware is the only thing that depends on that transient service, then registering the service as anything but a singleton is pointless!

What you have is a dependency lifestyle mismatch, which is generally a bad idea for numerous reasons. The way to avoid this is, as stated above, to ensure that all services in your dependency chain are registered with the same scope - i.e., anything that your ExceptionMiddleware depends on - in this case, AuditedMailerService - should be a singleton.

If - if - you implicitly intend or need to have AuditedMailerService be transient, then instead of injecting it in your middleware's constructor, inject it via the Invoke method:

public ExceptionMiddleware(RequestDelegate next, IOptions<ExceptionMiddlewareConfig> config)
{
  _mailer = mailer;
  _anonymousUserName = config.Value.AnonymousUserName;
  _sendToEmailAddress = config.Value.SendToEmailAddress;
}

public async Task Invoke(HttpContext httpContext, IMailerService mailer)
{
  ...
}

But here's a more interesting question that follows on from the symptoms of this lifestyle mismatch: why does the IOptions<MailerConfig> instance end up being null?

My guess - and it is only a guess - is that you are falling afoul of the fact that ASP.NET Core 2.x's WebHost (the component that runs your web app) actually creates two IServiceProvider instances. There is an initial, "dummy" one that is created to inject services during the earliest stages of app startup, and then the "real" one that is used for the rest of the app's lifetime. The linked issue discusses why this is problematic: in short, it was possible to get an instances of a service registered by the dummy container, then a second instance of the same service would be created by the real container, causing issues. I believe that because middleware runs so early in the pipeline, the IoC container it uses is the dummy one with no knowledge of IOptions<MailerConfig>, and since the default service location in ASP.NET Core returns null when a requested service isn't found instead of throwing an exception, you get null returned.