C#
Sam Lau  

Migrate a Topshelf service with Quartz to .NET 8 Worker service as Windows service

A common approach in the old days (.NET framework days) to create a windows service that run on a schedule is to use Topshelf as a hosting framework and use Quartz to schedule the job. Moving forward some time, Microsoft has introduced Worker service back in .NET Core 3, which basically replaced Topshelf. Today, I am going to show you how to migrate a Topshelf Windows service with Quartz to a .NET 8 Worker service. In the sample, I am also using Serilog to simulate some actual work but that is not the focus of this post. I have another post (Application Insights and Serilog in .NET 8 Worker Services with HostApplicationBuilder) about using Serilog with Application Insight.

Topshelf service sample

Let’s start by establishing a sample app with Topshelf and Quartz. In fact, both Topshelf and Quartz support netstandard 2.0 so this sample app is in .NET 8. So, if all you want to do is migrate from .NET framework to .NET 8, you can just reference this sample and keep Topshelf. However, Worker service is quite a bit cleaner and you may want to check it out.

NuGet packages

These are the packages you need.

  • Quartz
  • Topshelf

Program.cs

using Serilog;
using Topshelf;
using TopshelfWithQuartzSample;

HostFactory.Run(hostConfigurator =>
{
    hostConfigurator.Service<MyService>(serviceConfigurator =>
    {
        serviceConfigurator.ConstructUsing(settings => new MyService());
        serviceConfigurator.WhenStarted(service => service.Start());
        serviceConfigurator.WhenStopped(service => service.Stop());
    });

    hostConfigurator.RunAsLocalSystem();

    Log.Logger = new LoggerConfiguration()
                .WriteTo.Console()
                .CreateLogger();

    hostConfigurator.UseSerilog(Log.Logger);

    hostConfigurator.SetServiceName("MyService");
});

If you are familiar with Topshelf, this is a very common setup. The main setup is within hostConfigurator.Service<MyService>(). It instructs how to construct the service class and which method should be called when service start and stop.

MyService.cs

using Quartz;
using Quartz.Impl;

namespace TopshelfWithQuartzSample
{
    internal class MyService
    {
        private readonly IScheduler scheduler;

        public MyService()
        {
            var factory = new StdSchedulerFactory();
            scheduler = factory.GetScheduler().ConfigureAwait(false).GetAwaiter().GetResult();
        }

        public void Start() 
        {
            scheduler.Start().ConfigureAwait(false).GetAwaiter().GetResult();

            IJobDetail job = JobBuilder.Create<MyJob>()
            .WithIdentity("Myjob")
            .Build();

            ITrigger trigger = TriggerBuilder.Create()
                .WithIdentity("MyTrigger")
                .StartNow()
                .WithSimpleSchedule(scheduleBuilder => scheduleBuilder
                    .WithIntervalInSeconds(10)
                    .RepeatForever())
                .Build();

            scheduler.ScheduleJob(job, trigger).ConfigureAwait(false).GetAwaiter().GetResult();
        }

        public void Stop() 
        {
            scheduler.Shutdown().ConfigureAwait(false).GetAwaiter().GetResult();
        }
    }
}

This is the service class and where Quartz is setup. A scheduler is created when service is constructed. A job which implement IJob (in this case, MyJob) is scheduled with a trigger, where the scheduling is setup (in this case, every 10 seconds).

MyJob.cs

using Quartz;
using Serilog;

namespace TopshelfWithQuartzSample
{
    internal class MyJob : IJob
    {
        public Task Execute(IJobExecutionContext context)
        {
            var fireTime = context.FireTimeUtc.DateTime.ToString();
            Log.Information("MyJob run: {fireTime}", fireTime);
            return Task.CompletedTask;
        }
    }
}

This is where the actual work happen. Execute() will run on a schedule. In this case, it just fire a log. In fact, I will not change anything from this class when I do the migration.

Migrate to Worker service

In order to migrate to Worker service, the first thing is changing the .NET project sdk. In the .csproj file, change <Project Sdk="Microsoft.NET.Sdk"> to <Project Sdk="Microsoft.NET.Sdk.Worker">. Also, remove <OutputType>Exe</OutputType> in <PropertyGroup>.

NuGet packages

These are the packages you need.

  • Microsoft.Extensions.Hosting
  • Microsoft.Extensions.Hosting.WindowsServices
  • Quartz.Extensions.Hosting

You should also remove Quartz and Topshelf.

Program.cs

using Quartz;
using Serilog;
using WorkerServiceWithQuartzSample;

var builder = Host.CreateApplicationBuilder(args);

builder.Services.AddWindowsService(options =>
{
    options.ServiceName = "MyService";
});

builder.Services.AddQuartz(configurater =>
{
    configurater
    .AddJob<MyJob>(jobConfigurater => jobConfigurater
        .WithIdentity("Myjob"))
    .AddTrigger(triggerConfigurater => triggerConfigurater
        .WithIdentity("MyTrigger")
        .ForJob("Myjob")
        .StartNow()
        .WithSimpleSchedule(scheduleBuilder => scheduleBuilder
            .WithIntervalInSeconds(10)
            .RepeatForever()));
});

builder.Services.AddQuartzHostedService(serviceOptions => { serviceOptions.WaitForJobsToComplete = true; });

Log.Logger = new LoggerConfiguration()
    .WriteTo.Console()
    .CreateLogger();
builder.Services.AddSerilog(Log.Logger);

var host = builder.Build();
host.Run();

Instead of setting up a service class in Program.cs, we will directly inject Quartz into the hosted service through AddQuartz() and add the hosted service by AddQuartzHostedService(). serviceOptions.WaitForJobsToComplete = true; is here to ensure jobs complete gracefully when shutting down, not strictly necessary though.

We are calling .AddWindowsService() to keep it as a Windows service. If you are interested in migrating this to run in a docker container. You don’t need this and it is not actually that hard to do but that will be a topic for another day.

You start to see how this is cleaner than Topshelf. There is no messing with scheduler factory, starting and stopping the scheduler manually, no pesky .GetAwaiter().GetResult()(Why is .GetAwaiter().GetResult() bad in C#? (nikouusitalo.com)). Not only that, this is injecting Quartz through dependency injection, alongside other dependency. It works better with other modern library that also do dependency injection.

That’s all! You can remove your service class since Program.cs already handle all Quartz setup.

Conclusion

This is all the code changes you will need. I have not touch appoint how to publish this as an executable and creating the Windows service. For that, you can follow this Microsoft documentation and the rest should be the same as how the Topshelf service was deployed as Windows service. Here is the full Topshelf sample in GitHub: https://github.com/samtc-lau/TopshelfWithQuartzSample and full Worker service sample in GitHub: https://github.com/samtc-lau/WorkerServiceWithQuartzSample.