Read More on Telerik Blogs
October 13, 2025 Web, ASP.NET Core
Get A Free Trial

Quartz enables flexible task scheduling in .NET applications, making it ideal for systems that require automation, such as notifications, report generation and periodic synchronization. In this post, we'll build a job for periodic history-cleaning with Quartz.

Running repetitive routines is just one of a backend developer’s many tasks, and, to be honest, writing a while(true) loop with Thread.Sleep isn’t exactly the most elegant solution and certainly not the most scalable. This is where Quartz.NET shines as a great option for scheduling tasks for .NET applications.

In this post, we’ll learn how to automate recurring tasks using Quartz.NET by implementing a job to clear process history. Additionally, we’ll look at how to set up a schedule for automated job service execution.

⏰ What Is Quartz.NET?

Quartz.NET is an open-source job scheduling system (under the Apache License, Version 2.0) designed for .NET applications that need to execute automated tasks accurately and reliably.

It is designed to handle complex scheduling scenarios, such as cron-based executions, custom intervals, task dependencies and distributed execution, making it a great choice for enterprise systems and backend applications.

🤔 Why Choose Quartz?

Quartz.NET stands out when it comes to task scheduling in .NET applications due to its simplicity and available features. It allows you to create everything from simple tasks with fixed intervals to complex schedules with cron expressions, supporting recurring executions, delays, job dependencies and database persistence.

Furthermore, it is open source (Apache 2.0), has good documentation, wide community adoption and easy integration with dependency injection and ASP.NET Core applications.

While there are alternative solutions, Quartz.NET stands out as an excellent choice for automations such as email delivery, report generation, data synchronization and other critical processes that demand reliability and runtime control.

🫧 Creating a History Cleaning Job with Quartz

As a use case, we will build a job to clean up the process history. The application will have the following functionalities:

  • A job called TaskMonitor that searches for processes that occurred before the last 30 days and deletes them; it then inserts a record into a notification table
  • Integration with SQL Server and EF Core
  • Sample data to verify the job’s operation
  • Configuring the execution interval with a cron expression

The complete source code for the application built in the post is available in this GitHub repository: TaskMonitor Source Code.

🪄 Creating the Application

To create the sample application, you can use the following command:

dotnet new web -n TaskMonitor

Installing the Packages

Then, install the packages required to create the job.

Quartz packages:

dotnet add package Quartz
dotnet add package Quartz.AspNetCore

Other packages:

dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Tools

Model Classes

To create the model classes, first create a new folder called “Models” and, inside it, add the following classes:

namespace TaskMonitor.Models;

public class ProcessHistory
{
    public int Id { get; set; }                
    public string ProcessName { get; set; }     
    public DateTime CreatedAt { get; set; }     
    public string Status { get; set; }           
    public string Details { get; set; }          
    public string CreatedBy { get; set; }
}
namespace TaskMonitor.Models;
public class Notification
{
    public int Id { get; set; }
    public string Message { get; set; }
    public DateTime CreatedAt { get; set; }
}

Creating the Context and Seed Classes

The next step is to create the context class to configure EF Core entities and a class to initialize sample data. So, create a new folder called “Data” and add the following classes inside it:

using Microsoft.EntityFrameworkCore;
using TaskMonitor.Models;

namespace TaskMonitor.Data; 
public class AppDbContext : DbContext
{
    public DbSet<ProcessHistory> ProcessHistory { get; set; }
    public DbSet<Notification> Notifications { get; set; }

    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
}
using Microsoft.EntityFrameworkCore;
using TaskMonitor.Data;
using TaskMonitor.Models;

namespace FinanceTracker.Data;

public static class DbInitializer
{
    public static async Task SeedAsync(AppDbContext dbContext)
    {
        await dbContext.Database.MigrateAsync();

        if (!await dbContext.ProcessHistory.AnyAsync())
        {
            var now = DateTime.UtcNow;

            var sampleEntries = Enumerable.Range(1, 100)
                .Select(i => new ProcessHistory
                {
                    CreatedAt = now.AddDays(-i),
                    ProcessName = $"Sample Process: #{i}",
                    Status = "Completed",
                    Details = $"Process details: #{i}",
                    CreatedBy = "admin"
                })
                .ToList();

            await dbContext.ProcessHistory.AddRangeAsync(sampleEntries);
            await dbContext.SaveChangesAsync();
        }
    }
}

Creating the Job Class

Now, let’s create the job class that will perform the data cleanup and insert the notification log.

Create a new folder called “Jobs” and add the following class to it:

using Microsoft.EntityFrameworkCore;
using Quartz;
using TaskMonitor.Data;
using TaskMonitor.Models;

namespace TaskMonitor.Jobs;
public class CleanupHistoryJob : IJob
{
    private readonly AppDbContext _dbContext;
    private readonly ILogger<CleanupHistoryJob> _logger;

    public CleanupHistoryJob(AppDbContext dbContext, ILogger<CleanupHistoryJob> logger)
    {
        _dbContext = dbContext;
        _logger = logger;
    }

    public async Task Execute(IJobExecutionContext context)
    {
        var cutoffDate = DateTime.UtcNow.AddDays(-30);

        var oldEntries = await _dbContext.ProcessHistory
            .Where(h => h.CreatedAt < cutoffDate)
            .ToListAsync();

        if (oldEntries.Count == 0)
        {
            _logger.LogInformation("No old history entries found for cleanup.");
            return;
        }

        await RemoveHistory(oldEntries);

        var notification = new Notification
        {
            Message = $"{oldEntries.Count} history entries cleaned up at {DateTime.UtcNow:O}",
            CreatedAt = DateTime.UtcNow
        };

        await SaveNotification(notification);
    }

    private async Task SaveNotification(Notification notification)
    {
        _dbContext.Notifications.Add(notification);
        await _dbContext.SaveChangesAsync();

        _logger.LogInformation("Cleanup notification created.");
    }

    private async Task RemoveHistory(List<ProcessHistory> oldEntries)
    {
        _dbContext.ProcessHistory.RemoveRange(oldEntries);
        await _dbContext.SaveChangesAsync();

        _logger.LogInformation("Deleted {Count} old history entries.", oldEntries.Count);
    }
}

Note that the CleanupHistoryJob class implements Quartz’s IJob interface, which means that whenever the job is executed, the Execute(IJobExecutionContext context) method will be automatically called by the scheduler, containing the main history cleaning logic.

Configuring the Program Class

Now let’s add the job settings we created earlier to the Program class. Add the following code to it:

using Microsoft.EntityFrameworkCore;
using TaskMonitor.Data;
using Quartz;
using TaskMonitor.Jobs;
using FinanceTracker.Data;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

builder.Services.AddQuartz(q =>
{
    var jobKey = new JobKey("CleanupHistoryJob");

    q.AddJob<CleanupHistoryJob>(opts => opts.WithIdentity(jobKey));

    q.AddTrigger(opts => opts
        .ForJob(jobKey)
        .WithIdentity("CleanupHistoryTrigger")
        ); 
});

builder.Services.AddQuartzHostedService(opt => opt.WaitForJobsToComplete = true);

var app = builder.Build();

using (var scope = app.Services.CreateScope())
{
    var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    await DbInitializer.SeedAsync(dbContext);
}

app.Run();

Note that the execution of the job is done through the code:

builder.Services.AddQuartz(q =>
{ 
var jobKey = new JobKey("CleanupHistoryJob"); 

q.AddJob<CleanupHistoryJob>(opts => opts.WithIdentity(jobKey)); 

q.AddTrigger(opts => opts 
.ForJob(jobKey) 
.WithIdentity("CleanupHistoryTrigger") ); 
});

builder.Services.AddQuartzHostedService(opt => opt.WaitForJobsToComplete = true);

Here we configure Quartz.NET to run the CleanupHistoryJob job. First, the Quartz service is registered using builder.Services.AddQuartz(...), passing an internal configuration through the q => { ... } function.

Within this configuration, we create an identification key (jobKey) for the job using new JobKey("CleanupHistoryJob"). This key serves to uniquely identify the job within the scheduler.

Next, the CleanupHistoryJob job is added using q.AddJob<CleanupHistoryJob>(...), associating it with the created key.

After that, a trigger is configured for this job using q.AddTrigger(...). This trigger determines when the job should run. In the example, it is linked to the job using .ForJob(jobKey) and is given its own identity called CleanupHistoryTrigger.

Finally, builder.Services.AddQuartzHostedService(...) registers the hosted service that keeps Quartz running while the application is active. The opt.WaitForJobsToComplete = true option sets it so that, when the application closes, Quartz waits for all running jobs to finish before shutting down.

🚀 Running the Job

Now that everything is set up, we can run the job and verify its execution through the data. When the job runs, the database will be loaded, and the job will delete records created up to 30 days ago from the history table and finally insert a record into the notification table, as seen in the images below:


Job execution showing that 71 records were found to be deleted.

After finishing the job execution, when checking the data, it is possible to notice that both the deletion of the 71 records and the insertion into the history were executed:

This way, we have a functional job that will execute the scheduled routine.

⌛ Understanding the Cron Concept

In technology, “cron” is a concept commonly associated with routines, originating from the Unix/Linux cron utility used to schedule the automatic execution of commands or scripts at specific times and intervals. The name was inspired by “chronos” (from the Greek “time”).

In the specific context of backend jobs, a cron expression is a string that indicates when the task should be executed, using a standardized format with fields separated by spaces, such as:

Each field can contain numbers, ranges, commas and wildcards (* for any value):

  • Minute: 0-59
  • Hour: 0-23
  • Day of the month: 1-31
  • Month: 1-12 or abbreviated names (JAN, FEB…)
  • Day of the week: 0-6 (Sunday=0) or names (SUN, MON…)

In the job we built above, to run the job locally, we did not make any additional time configuration. However, it is possible to configure a time interval for execution, with the .WithCronSchedule("CRON") ); extension method in the Quartz configuration in the Program class.

The complete configuration would look like this:

builder.Services.AddQuartz(q =>
{
    var jobKey = new JobKey("CleanupHistoryJob");

    q.AddJob<CleanupHistoryJob>(opts => opts.WithIdentity(jobKey));

    q.AddTrigger(opts => opts
        .ForJob(jobKey)
        .WithIdentity("CleanupHistoryTrigger")
        .WithCronSchedule("0 3 * * *"));
});

This way, the job will run every day at 3:00 a.m.

🌱 Conclusion

Dealing with daily routines like cleaning up old data, sending emails and generating reports is part of the daily routine for most backend developers. In this context, finding alternatives that simplify this work is always a goal, and it’s in these situations that Quartz shines. Its simple configuration and easy integration with .NET are its main strengths.

In this post, we saw how to implement a history-cleanup job integrated with SQL Server and learned, hands on, how to configure a cron to run the job at defined intervals. However, there are still functions in Quartz that can be explored, such as recording job execution history using native resources like Persistent Store, where Quartz records the execution script in its own tables. This is especially useful if you need scheduling telemetry.

I hope this post helped you better understand how to use Quartz in your projects and that it serves as a starting point for further automating your backend routines.


About the Author

Assis Zang

Assis Zang is a software developer from Brazil, developing in the .NET platform since 2017. In his free time, he enjoys playing video games and reading good books. You can follow him at: LinkedIn and Github.

Related Posts