blog post image
Andrew Lock avatar

Andrew Lock

~6 min read

Using scoped services inside a Quartz.NET hosted service with ASP.NET Core

In my previous post I showed how you can create a Quartz.NET hosted service with ASP.NET Core and use it to run background tasks on a schedule. Unfortunately, due to the way way the Quartz.NET API works, using Scoped dependency injection services in your Quartz jobs is somewhat cumbersome.

In this post I show one way to make it easier to use scoped services in your jobs. You can use the same approach for managing "unit-of-work" patterns with EF Core, and other cross-cutting concerns.

This post follows on directly from the previous post, so I suggest reading that post first, if you haven't already.

Recap - the custom JobFactory and singleton IJob

In the last post, we had a HelloWorldJob implementing IJob that simply wrote to the console.

public class HelloWorldJob : IJob
{
    private readonly ILogger<HelloWorldJob> _logger;
    public HelloWorldJob(ILogger<HelloWorldJob> logger)
    {
        _logger = logger;
    }

    public Task Execute(IJobExecutionContext context)
    {
        _logger.LogInformation("Hello world!");
        return Task.CompletedTask;
    }
}

We also had an IJobFactory implementation that retrieved an instance of the job from the DI container when required:

public class SingletonJobFactory : IJobFactory
{
    private readonly IServiceProvider _serviceProvider;
    public SingletonJobFactory(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public IJob NewJob(TriggerFiredBundle bundle, IScheduler scheduler)
    {
        return _serviceProvider.GetRequiredService(bundle.JobDetail.JobType) as IJob;
    }

    public void ReturnJob(IJob job) { }
}

These services were registered in Startup.ConfigureServices() as singletons:

services.AddSingleton<IJobFactory, SingletonJobFactory>();
services.AddSingleton<HelloWorldJob>();

That was fine for this very basic example, but what if you need to use some scoped services inside your IJob? For example, maybe you need to use an EF Core DbContext to loop over all your customers, send them an email, and update the customer record. We'll call that hypothetical task EmailReminderJob.

The stop-gap solution

The solution I showed in the previous post is to inject the IServiceProvider into your IJob, create a scope manually, and retrieve the necessary services from that. For example:

public class EmailReminderJob : IJob
{
    private readonly IServiceProvider _provider;
    public EmailReminderJob( IServiceProvider provider)
    {
        _provider = provider;
    }

    public Task Execute(IJobExecutionContext context)
    {
        using(var scope = _provider.CreateScope())
        {
            var dbContext = scope.ServiceProvider.GetService<AppDbContext>();
            var emailSender = scope.ServiceProvider.GetService<IEmailSender>();
            // fetch customers, send email, update DB
        }
 
        return Task.CompletedTask;
    }
}

In many cases, this approach is absolutely fine. This is especially true if, instead of putting the implementation directly inside the job (as I did above), you use a mediator pattern to handle cross-cutting concerns like unit-of-work or message dispatching.

If that's not the case, you might benefit from creating a helper job that can manage those things for you.

The QuartzJobRunner

To handle these issues, you can create an "intermediary" IJob implementation, QuartzJobRunner, that sits between the IJobFactory and the IJob you want to run. I'll get to the job implementation shortly, but first lets update the existing IJobFactory implementation to always return an instance of QuartzJobRunner, no matter which job is requested:

using Microsoft.Extensions.DependencyInjection;
using Quartz;
using Quartz.Spi;
using System;

public class JobFactory : IJobFactory
{
    private readonly IServiceProvider _serviceProvider;
    public JobFactory(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public IJob NewJob(TriggerFiredBundle bundle, IScheduler scheduler)
    {
        return _serviceProvider.GetRequiredService<QuartzJobRunner>();
    }

    public void ReturnJob(IJob job) { }
}

As you can see, the NewJob() method always returns an instance of QuartzJobRunner. We'll register QuartzJobRunner as a Singleton in Startup.ConfigureServices(), so we don't have to worry about the fact it isn't explicitly disposed.

services.AddSingleton<QuartzJobRunner>();

We'll create the actual required IJob instance inside QuartzJobRunner. The job of QuartzJobRunner is to create a scope, instantiate the requested IJob, and execute it:

using Microsoft.Extensions.DependencyInjection;
using Quartz;
using System;
using System.Threading.Tasks;

public class QuartzJobRunner : IJob
{
    private readonly IServiceProvider _serviceProvider;
    public QuartzJobRunner(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public async Task Execute(IJobExecutionContext context)
    {
        using (var scope = _serviceProvider.CreateScope())
        {
            var jobType = context.JobDetail.JobType;
            var job = scope.ServiceProvider.GetRequiredService(jobType) as IJob;

            await job.Execute(context);
        }
    }
}

At this point, you might be wondering what we've gained by adding this extra layer of indirection? There's two main advantages:

  • We can register the EmailReminderJob as a scoped service, and directly inject any dependencies into its constructor
  • We can move other cross-cutting concerns into the QuartzJobRunner class.

Jobs can directly consume scoped services

Due to the fact the job instance is sourced from a scoped IServiceProvder, you can safely consume scoped services in the constructor of your job implementations. This makes the implementation of EmailReminderJob clearer, and follows the typical pattern of constructor injection. DI scoping issues can be tricky to understand if you're not familiar with them, so anything that stops you cutting yourself seems like a good idea to me:

[DisallowConcurrentExecution]
public class EmailReminderJob : IJob
{
    private readonly AppDbContext _dbContext;
    private readonly IEmailSender _emailSender;
    public EmailReminderJob(AppDbContext dbContext, IEmailSender emailSender)
    {
        _dbContext = dbContext;
        _emailSender = emailSender;
    }

    public Task Execute(IJobExecutionContext context)
    {
        // fetch customers, send email, update DB
        return Task.CompletedTask;
    }
}

These IJob implementations can be registered using any lifetime (scoped or transient) in Startup.ConfigureServices() (the JobSchedule can still be a singleton):

services.AddScoped<EmailReminderJob>();
services.AddSingleton(new JobSchedule(
    jobType: typeof(EmailReminderJob),
    cronExpression: "0 0 12 * * ?")); // every day at noon

QuartzJobRunner can handle cross-cutting concerns

QuartzJobRunner handles the whole lifecycle of the IJob being executed: it fetches it from the container, executes it, and disposes of it (when the scope is disposed). Consequently, it is well placed for handling other cross-cutting concerns.

For example, imagine you have a service that needs to update the database, and send events to a message bus. You could handle that all inside each of your individual IJob implementations, or you could move the cross-cutting "commit changes" and "dispatch message" actions to the QuartzJobRunner instead.

This example is obviously very basic. If the code here looks ok to you, I suggest watching Jimmy Bogard's "Six Little Lines of Fail" talk, which describes some of the issues!

public class QuartzJobRunner : IJob
{
    private readonly IServiceProvider _serviceProvider;

    public QuartzJobRunner(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public async Task Execute(IJobExecutionContext context)
    {
        using (var scope = _serviceProvider.CreateScope())
        {
            var jobType = context.JobDetail.JobType;
            var job = scope.ServiceProvider.GetRequiredService(jobType) as IJob;

            var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
            var messageBus = scope.ServiceProvider.GetRequiredService<IBus>();

            await job.Execute(context);

            // job completed, save dbContext changes
            await dbContext.SaveChangesAsync();

            // db transaction succeeded, send messages
            await messageBus.DispatchAsync();
        }
    }
}

This implementation of QuartzJobRunner is very similar to the previous one, but before we execute the requested IJob, we retrieve the message bus and DbContext from the DI container. Once the job has successfully executed (i.e. it didn't throw), we save any uncommitted changes in the DbContext, and dispatch the events on the message bus.

Moving these functions into the QuartzJobRunner should reduce the duplication in your IJob implementations, and will make it easier to move to a more formalised pipeline and other patterns should you wish to later.

Alternative solutions

I like the approach shown in this post (using an intermediate QuartzJobRunner class) for two main reasons:

  • Your other IJob implementations don't need any knowledge of this infrastructure for creating scopes, and can just just bog-standard constructor injection
  • The IJobFactory doesn't have to do anything special to handle disposing jobs. The QuartzJobRunner takes care of that implicitly by creating and disposing of a scope.

But the approach shown here isn't the only way to use scoped services in your jobs. Matthew Abbot demonstrates an approach in this gist that aims to implement the IJobFactory in a way that correctly disposes of jobs after they've been run. It's a little clunky due to the interface API you have to match, but it's arguably much closer to the way you should implement it! Personally I think I'll stick to the QuartzJobRunner approach, but choose whichever works best for you 🙂

Summary

In this post, I showed how you can create an intermediate IJob, QuartzJobRunner, that is created whenever the scheduler needs to execute a job. This runner handles creating a DI scope, instantiating the requested job, and executing it, so the end IJob implementation can consume scoped services in its constructor. You can also use this approach for configuring a basic pipeline in the QuartzJobRunner, although there are better solutions to this, such as decorators, or behaviours in the MediatR library.

Andrew Lock | .Net Escapades
Want an email when
there's new posts?