DEV Community

Cover image for Scheduling Background Jobs With Quartz in .NET (advanced concepts)
Milan Jovanović
Milan Jovanović

Posted on • Originally published at milanjovanovic.tech on

Scheduling Background Jobs With Quartz in .NET (advanced concepts)

Most ASP.NET Core applications need to handle background processing - from sending reminder emails to running cleanup tasks. While there are many ways to implement background jobs, Quartz.NETstands out with its robust scheduling capabilities, persistence options, and production-ready features.

In this article, we'll look at:

  • Setting up Quartz.NET with ASP.NET Core and proper observability
  • Implementing both on-demand and recurring jobs
  • Configuring persistent storage with PostgreSQL
  • Handling job data and monitoring execution

Let's start with the basic setup and build our way up to a production-ready configuration.

Setting Up Quartz With ASP.NET Core

First, let's set up Quartz with proper instrumentation.

We'll need to install some NuGet packages:

Install-Package Quartz.Extensions.Hosting
Install-Package Quartz.Serialization.Json

# This might be in prerelease
Install-Package OpenTelemetry.Instrumentation.Quartz
Enter fullscreen mode Exit fullscreen mode

Next, we'll configure the Quartz services and OpenTelemetry instrumentation and start the scheduler:

builder.Services.AddQuartz();

// Add Quartz.NET as a hosted service
builder.Services.AddQuartzHostedService(options =>
{
    options.WaitForJobsToComplete = true;
});

builder.Services.AddOpenTelemetry()
    .WithTracing(tracing =>
    {
        tracing
            .AddHttpClientInstrumentation()
            .AddAspNetCoreInstrumentation()
            .AddQuartzInstrumentation();
    })
    .UseOtlpExporter();
Enter fullscreen mode Exit fullscreen mode

This is all we need at the start.

Defining and Scheduling Jobs

To define a background job, you have to implement the IJob interface. All job implementations run as scoped services, so you can inject dependencies as needed. Quartz allows you to pass data to a job using the JobDataMap dictionary. It's recommended to only use primitive types for job data to avoid serialization issues.

When executing the job, there are a few ways to fetch job data:

  • JobDataMap - a dictionary of key-value pairs<!-- -->
    • JobExecutionContext.JobDetail.JobDataMap - job-specific data
    • JobExecutionContext.Trigger.TriggerDataMap - trigger-specific data
  • MergedJobDataMap - combines job data with trigger data

It's a best practice to use MergedJobDataMap to retrieve job data.

public class EmailReminderJob(ILogger<EmailReminderJob> logger, IEmailService emailService) : IJob
{
    public const string Name = nameof(EmailReminderJob);

    public async Task Execute(IJobExecutionContext context)
    {
        // Best practice: Prefer using MergedJobDataMap
        var data = context.MergedJobDataMap;

        // Get job data - note that this isn't strongly typed
        string? userId = data.GetString("userId");
        string? message = data.GetString("message");

        try
        {
            await emailService.SendReminderAsync(userId, message);

            logger.LogInformation("Sent reminder to user {UserId}: {Message}", userId, message);
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Failed to send reminder to user {UserId}", userId);

            // Rethrow to let Quartz handle retry logic
            throw;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

One thing to note: JobDataMap isn't strongly typed. This is a limitation we have to live with, but we can mitigate it by:

  1. Using constants for key names
  2. Validating data early in the Execute method
  3. Creating wrapper services for job scheduling

Now, let's discuss scheduling jobs.

Here's how to schedule one-time reminders:

public record ScheduleReminderRequest(
    string UserId,
    string Message,
    DateTime ScheduleTime
);

// Schedule a one-time reminder
app.MapPost("/api/reminders/schedule", async (
    ISchedulerFactory schedulerFactory,
    ScheduleReminderRequest request) =>
{
    var scheduler = await schedulerFactory.GetScheduler();

    var jobData = new JobDataMap
    {
        { "userId", request.UserId },
        { "message", request.Message }
    };

    var job = JobBuilder.Create<EmailReminderJob>()
        .WithIdentity($"reminder-{Guid.NewGuid()}", "email-reminders")
        .SetJobData(jobData)
        .Build();

    var trigger = TriggerBuilder.Create()
        .WithIdentity($"trigger-{Guid.NewGuid()}", "email-reminders")
        .StartAt(request.ScheduleTime)
        .Build();

    await scheduler.ScheduleJob(job, trigger);

    return Results.Ok(new { scheduled = true, scheduledTime = request.ScheduleTime });
})
.WithName("ScheduleReminder")
.WithOpenApi();
Enter fullscreen mode Exit fullscreen mode

The endpoint schedules one-time email reminders using Quartz. It creates a job with user data, sets up a trigger for the specified time, and schedules them together. The EmailReminderJob receives a unique identity in the email-reminders group.

Here's a sample request you can use to test this out:

POST /api/reminders/schedule
{
    "userId": "user123",
    "message": "Important meeting!",
    "scheduleTime": "2024-12-17T15:00:00"
}
Enter fullscreen mode Exit fullscreen mode

Scheduling Recurring Jobs

For recurring background jobs, you can use cron schedules:

public record RecurringReminderRequest(
    string UserId,
    string Message,
    string CronExpression
);

// Schedule a recurring reminder
app.MapPost("/api/reminders/schedule/recurring", async (
    ISchedulerFactory schedulerFactory,
    RecurringReminderRequest request) =>
{
    var scheduler = await schedulerFactory.GetScheduler();

    var jobData = new JobDataMap
    {
        { "userId", request.UserId },
        { "message", request.Message }
    };

    var job = JobBuilder.Create<EmailReminderJob>()
        .WithIdentity($"recurring-{Guid.NewGuid()}", "recurring-reminders")
        .SetJobData(jobData)
        .Build();

    var trigger = TriggerBuilder.Create()
        .WithIdentity($"recurring-trigger-{Guid.NewGuid()}", "recurring-reminders")
        .WithCronSchedule(request.CronExpression)
        .Build();

    await scheduler.ScheduleJob(job, trigger);

    return Results.Ok(new { scheduled = true, cronExpression = request.CronExpression });
})
.WithName("ScheduleRecurringReminder")
.WithOpenApi();
Enter fullscreen mode Exit fullscreen mode

Cron triggers are more powerful than simple triggers. They allow you to define complex schedules like "every weekday at 10 AM" or "every 15 minutes". Quartz supports cron expressions with seconds, minutes, hours, days, months, and years.

Here's a sample request if you want to test this:

POST /api/reminders/schedule/recurring
{
    "userId": "user123",
    "message": "Daily standup",
    "cronExpression": "0 0 10 ? * MON-FRI"
}
Enter fullscreen mode Exit fullscreen mode

Job Persistence Setup

By default, Quartz uses in-memory storage, which means your jobs are lost when the application restarts. For production environments, you'll want to use a persistent store. Quartz supports several database providers, including SQL Server, PostgreSQL, MySQL, and Oracle.

Let's look at how to set up persistent storage with proper schema isolation:

builder.Services.AddQuartz(options =>
{
    options.AddJob<EmailReminderJob>(c => c
        .StoreDurably()
        .WithIdentity(EmailReminderJob.Name));

    options.UsePersistentStore(persistenceOptions =>
    {
        persistenceOptions.UsePostgres(cfg =>
        {
            cfg.ConnectionString = connectionString;
            cfg.TablePrefix = "scheduler.qrtz_";
        },
        dataSourceName: "reminders"); // Database name

        persistenceOptions.UseNewtonsoftJsonSerializer();
        persistenceOptions.UseProperties = true;
    });
});
Enter fullscreen mode Exit fullscreen mode

A few important things to note here:

  • The TablePrefix setting helps organize Quartz tables in your database - in this case, placing them in a dedicated scheduler schema
  • You'll need to run the appropriate database scripts to create these tables
  • Each database provider has its own setup scripts - check the Quartz documentation for your chosen provider

Durable Jobs

Notice how we're configuring the EmailReminderJob with StoreDurably? This is a powerful pattern that lets you define your jobs once and reuse them with different triggers. Here's how to schedule a stored job:

public async Task ScheduleReminder(string userId, string message, DateTime scheduledTime)
{
    var scheduler = await _schedulerFactory.GetScheduler();

    // Reference the stored job by its identity
    var jobKey = new JobKey(EmailReminderJob.Name);

    var trigger = TriggerBuilder.Create()
        .ForJob(jobKey) // Reference the durable job
        .WithIdentity($"trigger-{Guid.NewGuid()}")
        .UsingJobData("userId", userId)
        .UsingJobData("message", message)
        .StartAt(scheduledTime)
        .Build();

    await scheduler.ScheduleJob(trigger); // Note: just passing the trigger
}
Enter fullscreen mode Exit fullscreen mode

This approach has several benefits:

  • Job definitions are centralized in your startup configuration
  • You can't accidentally schedule a job that hasn't been properly configured
  • Job configurations are consistent across all schedules

Summary

Getting Quartz set up properly in .NET involves more than just adding the NuGet package.

Pay attention to:

  1. Proper job definition and data handling with JobDataMap
  2. Setting up both one-time and recurring job schedules
  3. Configuring persistent storage with proper schema isolation
  4. Using durable jobs to maintain consistent job definitions

Each of these elements contributes to a reliable background processing system that can grow with your application's needs. A good example of using background jobs is when you want to build asynchronous APIs.

Good luck out there, and I'll see you next week.


P.S. Whenever you're ready, there are 3 ways I can help you:

  1. Pragmatic Clean Architecture: Join 3,600+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.

  2. Modular Monolith Architecture: Join 1,600+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.

  3. Patreon Community: Join a community of 1,050+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

Top comments (0)