Understanding Dependency Injection in .NET Core with Quartz.NET example
Introduction
Quartz.NET is a handy library that allows you to schedule recurring tasks via implementing IJob
interface. Yet the limitation of it is that, by default, it supports only parameterless constructor which complicates injecting external service inside of it, i.e., for implementing repository pattern. In this article, we’ll take a look at how we can tackle this problem using standard .NET Core DI container.
The whole project referred in the article is provided inside the following [Github repository](). In order to better follow the code in the article, you might want to take a look at it.
Project Overview
Let’s take a look at the initial solution structure.
The project QuartzDI.Demo.External.DemoService
represents some external dependency we have no control of. For the sake of simplicity, it does quite a humble job.
The project QuartzDI.Demo
is our working project which contains simple Quartz.NET job.
public class DemoJob : IJob
{
private const string Url = "https://i.ua";
public static IDemoService DemoService { get; set; }
public Task Execute(IJobExecutionContext context)
{
DemoService.DoTask(Url);
return Task.CompletedTask;
}
}
var props = new NameValueCollection
{
{ "quartz.serializer.type", "binary" }
};
var factory = new StdSchedulerFactory(props);
var sched = await factory.GetScheduler();
await sched.Start();
var job = JobBuilder.Create<DemoJob>()
.WithIdentity("myJob", "group1")
.Build();
var trigger = TriggerBuilder.Create()
.WithIdentity("myTrigger", "group1")
.StartNow()
.WithSimpleSchedule(x => x
.WithIntervalInSeconds(5)
.RepeatForever())
.Build();
await sched.ScheduleJob(job, trigger);
static
property
DemoJob.DemoService = new DemoService();
At this point, our project is up and running. And what is most important it is dead simple which is great. But we pay for that simplicity with a cost of application inflexibility which is fine if we want to leave it as a small tool. But that’s often not a case for production systems. So let’s tweak it a bit to make it more flexible.
Creating a Configuration File
One of the inflexibilities is that we hard-code URL we call into a DemoJob. Ideally, we would like to change it and also change it depending on our environment. .NET Core comes with appsettings.json mechanism for that matter.
In order to start working with .NET Core configuration mechanism, we have to install a couple of Nuget packages:
Microsoft.Extensions.Configuration
Microsoft.Extensions.Configuration.FileExtensions
Microsoft.Extensions.Configuration.Json
{
"connection": {
"Url": "http://i.ua"
}
}
var builder = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", true, true);
var configuration = builder.Build();
var connectionSection = configuration.GetSection("connection");
DemoJob.Url = connectionSection["Url"];
public static string Url { get; set; }
Using Constructor Injection
Injecting service via a static property is fine for a simple project, but for a bigger one, it might carry several disadvantages: such as job might be called without service provided thus failing or changing the dependency during the object runtime which makes it harder to reason about objects. To address these issues, we should employ constructor injection.
Although there is nothing wrong with Pure Dependency Injection and some people argue that you should strive for it in this article, we’ll use built-in .NET Core DI container which comes with a Nuget package Microsoft.Extensions.DependencyInjection
.
Now we specify service we depend on inside constructor arguments:
private readonly IDemoService _demoService;
public DemoJob(IDemoService demoService)
{
_demoService = demoService;
}
IJobFactory
interface. Here’s our implementation:
public class DemoJobFactory : IJobFactory
{
private readonly IServiceProvider _serviceProvider;
public DemoJobFactory(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public IJob NewJob(TriggerFiredBundle bundle, IScheduler scheduler)
{
return _serviceProvider.GetService<DemoJob>();
}
public void ReturnJob(IJob job)
{
var disposable = job as IDisposable;
disposable?.Dispose();
}
}
var serviceCollection = new ServiceCollection();
serviceCollection.AddScoped<DemoJob>();
serviceCollection.AddScoped<IDemoService, DemoService>();
var serviceProvider = serviceCollection.BuildServiceProvider();
IScheduler
has property JobFactory
just for that matter.
sched.JobFactory = new DemoJobFactory(serviceProvider);
Understanding service lifetimes
In the previous section, we have registered our services with scoped lifetime. However, there is no actual thinking presented here why we have chosen it over other options such as transient or singleton lifetime.
Let’s examine what are the other options. In order to achieve this, we’ll add some trace statements to our classes constructors.
public DemoService()
{
Console.WriteLine("DemoService started");
}
public DemoJob(IDemoService demoService, IOptions<DemoJobOptions> options)
{
_demoService = demoService;
_options = options.Value;
Console.WriteLine("Job started");
}
serviceCollection.AddTransient<DemoJob>();
serviceCollection.AddTransient<IDemoService, DemoService>();
DemoService started
Job started
calling http://i.ua
DemoService started
Job started
calling http://i.ua
DemoService started
Job started
calling http://i.ua
The output is pretty self-explanatory: we create a new instance each time we call service.
Changing both registrations to AddScoped
or AddSingleton
produces the same result:
DemoService started
Job started
calling http://i.ua
calling http://i.ua
calling http://i.ua
Both instances are constructed just once at application startup. Let’s consult with the documentation to see what are the difference between those lifetimes and why the produce the same result for a given example.
Scoped lifetime services are created once per client request (connection).
Here is what singleton does
Singleton lifetime services are created the first time they’re requested.
So in our case, we have a single request because we use console application. This is the reason why both service lifetimes act the same.
The last topic most of DI-related articles do not cover is a composition of services with different lifetimes. Although there is something worth mentioning. Here is the example of registration.
serviceCollection.AddSingleton<DemoJob>();
serviceCollection.AddTransient<IDemoService, DemoService>();
IDemoService
as transient it will be constructed each time. The output, however, is quite different:
DemoService started
Job started
calling http://i.ua
calling http://i.ua
calling http://i.ua
So again both services are constructed at the application startup. Here we see that lifetime of transient service gets promoted by the service that uses it. This leads to an important application. The service we’ve registered as transient might be not designed to be used as a singleton because it is not written in thread-safe fashion or for some other reasons. However, it becomes singleton in this case which may lead to some subtle bugs. This brings us to the conclusion that we shouldn’t register services as singletons unless we have some good reason for it i.e. service that manages global state. It’s is preferable to register services as transient.
The opposite, however, yields no surprises.
serviceCollection.AddTransient<DemoJob>();
serviceCollection.AddSingleton<IDemoService, DemoService>();
DemoService started
Job started
calling http://i.ua
Job started
calling http://i.ua
Job started
calling http://i.ua
Here each new instance of a job reuses the same singleton DemoService
.
Using Options Pattern
Now we can pull the same trick with configuration options. Again, our routine starts with a Nuget package. This time Microsoft.Extensions.Options
.
Let’s create a strongly typed definition for configuration options:
public class DemoJobOptions
{
public string Url { get; set; }
}
serviceCollection.AddOptions();
serviceCollection.Configure<DemoJobOptions>(options =>
{
options.Url = connectionSection["Url"];
});
IOptions<T>
, not the options instance directly.
public DemoJob(IDemoService demoService, IOptions<DemoJobOptions> options)
{
_demoService = demoService;
_options = options.Value;
}