DEV Community

Andres Correa
Andres Correa

Posted on

Clean Up Your Quartz.NET Jobs with Dynamic Registration ๐Ÿงน

If you are using Quartz.NET in your ASP.NET Core application, you probably know the struggle. You start with one background job, and itโ€™s fine. But fast forward a few months, and your Program.cs (or Startup.cs) is 500 lines long, filled with repetitive AddJob and AddTrigger calls.

It looks messy, itโ€™s hard to read, and if you want to change a schedule from "every hour" to "every day," you have to recompile and deploy your whole application.

There is a better way.

In this tutorial, weโ€™re going to refactor that mess. We will build a system where you define your jobs and schedules in appsettings.json, and your code automatically finds and registers them at startup.

The Goal ๐ŸŽฏ

We want to move from Hardcoded Instructions to Configuration.

  • Old Way: You manually write C# code to register "EmailJob".
  • New Way: You add "EmailJob" to a JSON list, and the code figures it out automatically.

Let's build it step-by-step.


Step 1: The Configuration ๐Ÿ“

First, let's stop hardcoding values. Open your appsettings.json and add a section for your jobs. This defines what runs and when.

{
  "QuartzJobs": [
    {
      "Name": "InvoiceGeneratorJob",
      "Cron": "0 0 12 * * ?",
      "Group": "Finance",
      "Enabled": true
    },
    {
      "Name": "CacheClearJob",
      "Cron": "0 */15 * * * ?",
      "Group": "Maintenance",
      "Enabled": true
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Note: The Name property here must match your C# class name exactly.


Step 2: The Data Model ๐Ÿ—๏ธ

We need a simple C# class to represent those JSON settings so we can work with them in our code.

namespace MyApp.Configuration
{
    public class QuartzJobConfig
    {
        public string Name { get; set; } = string.Empty;
        public string Cron { get; set; } = string.Empty;
        public string Group { get; set; } = "Default";
        public bool Enabled { get; set; } = true;
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: The Logic (The "Magic" Part) โœจ

This is the core of the tutorial. We will create an Extension Method. This keeps your Program.cs clean by hiding the complex logic in a separate file.

Create a file named QuartzExtensions.cs.

Here is what this code does:

  1. Reads the list of jobs from appsettings.json.
  2. Scans your project (Assembly) to find all classes that implement the IJob interface.
  3. Matches the JSON Name to the C# Class.
  4. Registers them with Quartz.
using System.Reflection;
using Quartz;
using Microsoft.Extensions.Configuration;

namespace MyApp.Extensions
{
    public static class QuartzExtensions
    {
        public static void AddQuartzJobsFromConfig(
            this IServiceCollectionQuartzConfigurator configurator,
            IConfiguration config,
            Assembly assembly)
        {
            // 1. Convert JSON to a list of objects
            var jobConfigs = config.GetSection("QuartzJobs").Get<List<QuartzJobConfig>>();

            // If the config is empty, stop here
            if (jobConfigs == null || jobConfigs.Count == 0) return;

            // 2. Get all IJob classes from the assembly 
            // We do this ONCE to avoid scanning the assembly inside the loop (Performance optimization)
            var validJobTypes = assembly.GetTypes()
                .Where(t => typeof(IJob).IsAssignableFrom(t) 
                         && !t.IsAbstract 
                         && t.IsClass)
                .ToList();

            foreach (var jobConfig in jobConfigs)
            {
                // Skip disabled jobs
                if (!jobConfig.Enabled) continue;

                // 3. Find the C# Type that matches the Name in JSON
                // We use case-insensitive search so "emailjob" matches "EmailJob"
                var jobType = validJobTypes.FirstOrDefault(t =>
                    t.Name.Equals(jobConfig.Name, StringComparison.InvariantCultureIgnoreCase));

                // If we can't find the class, log an error or throw an exception
                if (jobType == null)
                {
                    throw new InvalidOperationException(
                        $"Quartz Error: Job class '{jobConfig.Name}' not found in assembly {assembly.FullName}");
                }

                // 4. Register the Job and Trigger
                var jobKey = new JobKey(jobConfig.Name, jobConfig.Group);

                configurator.AddJob(jobType, jobKey, opts => opts.WithIdentity(jobKey));

                configurator.AddTrigger(opts => opts
                    .ForJob(jobKey)
                    .WithIdentity($"{jobConfig.Name}-trigger", jobConfig.Group)
                    .WithCronSchedule(jobConfig.Cron));
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Wiring It Up ๐Ÿ”Œ

Now, go to your Program.cs. Look how simple the registration becomes!

var builder = WebApplication.CreateBuilder(args);

// Add Quartz services
builder.Services.AddQuartz(q =>
{
    // ๐Ÿ‘‡ The new one-liner that replaces all your manual code
    // We pass the Configuration and the Assembly where our Jobs live
    q.AddQuartzJobsFromConfig(builder.Configuration, typeof(Program).Assembly);
});

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

var app = builder.Build();
app.Run();
Enter fullscreen mode Exit fullscreen mode

How It Works (Visualized) ๐Ÿ“Š

When your app starts, the data flows like this:

[ appsettings.json ]      [ Assembly (.dll) ]
         |                        |
         v                        v
   (List of Names)          (List of Classes)
         \                       /
          \                     /
           \                   /
          [  Extension Method  ]
          (Matches Name == Class)
                    |
                    v
          [ Quartz Scheduler ] 
          (Job Registered!) ๐Ÿš€
Enter fullscreen mode Exit fullscreen mode

โš ๏ธ Important Warnings (Read This!)

This method is cleaner, but "dynamic" code comes with a few risks you need to know about.

1. The "Rename" Trap ๐Ÿชค

Since your configuration uses text strings ("Name": "InvoiceJob"), your IDE doesn't know about it.

  • The Risk: If you rename your C# class to BillingJob but forget to update the JSON, your app will crash (or skip the job) on startup.
  • The Fix: Be careful when renaming, or write a Unit Test that checks your config file against your classes.

2. Startup Speed ๐ŸŽ๏ธ

The command assembly.GetTypes() scans every single class in your project.

  • The Risk: In a massive project (monolith), this might add a tiny delay to your application startup.
  • The Fix: If your project is huge, move your jobs into a separate library (e.g., MyApp.Jobs.dll) and only scan that assembly.

3. Native AOT Incompatibility ๐Ÿงฑ

If you are using the new .NET Native AOT (Ahead-of-Time compilation) to make your app super fast and small, you cannot use this method. The AOT compiler removes code it thinks is unused. Since we are finding jobs dynamically, the compiler might delete your job classes!


Alternative Approaches ๐Ÿค”

Is this the only way? No. Depending on your needs, you might prefer:

  1. Attributes: You put [CronJob("0 0 12 * * ?")] directly on top of your C# class. It's easier to read but requires recompiling to change the schedule.
  2. Database Storage: If you have multiple servers running the same app, you should use Quartz with a database (JDBC/ADO JobStore) so the servers don't duplicate the work.

Final Thoughts

This pattern is a fantastic way to clean up Program.cs for standard Web APIs and Worker Services. It separates Configuration (When it runs) from Implementation (How it works), which is a solid architectural practice.

Give it a try! Copy the extension method above, delete those 50 lines of boilerplate in your startup file, and enjoy the clean code. ๐Ÿ›


Did you find this useful?. Let me know what you think in the comments!

Top comments (0)