How to create the job using Quartz.NET and set up Entity Framework Core

Introduction
Every application eventually needs something to run on a schedule. Trim old rows. Send a digest email. Recalculate a cache. Retry failed webhooks. The naive answer is a Timer or a while (true) loop with Task.Delay, and it works right up until you need a real cron expression, overlap protection, or the ability to inject a scoped DbContext into the thing doing the work.
Quartz.NET is the mature answer to that problem. It gives you named jobs, cron-based triggers, misfire handling, and clean integration with the .NET generic host and dependency injection. In this video you build a Worker Service that runs a single recurring job: deleting log entries older than a configured date from a SQL Server database through Entity Framework Core.
š¬ Watch the full video here:
Why a Worker Service and not a Web API
The demo uses the Worker Service template rather than a web project. That is the right default for a process whose entire job is running background work. There is no HTTP pipeline, no controllers, just Host.CreateDefaultBuilder, configuration, logging, and the DI container. It can be published as a Windows Service, a Linux systemd unit, or a container, and it participates in graceful shutdown like any other hosted service.
If your scheduled work lives inside an existing API instead, everything below still applies - you register Quartz on the same IServiceCollection. The only real difference is process lifetime.
Registering Quartz with the host
Quartz plugs into the host through two packages: Quartz and Quartz.Extensions.Hosting. Registration happens in ConfigureServices and has three moving parts:
AddQuartz(...)configures the scheduler itself and registers jobs and triggers.AddQuartzHostedService(...)starts and stops the scheduler with the host.UseMicrosoftDependencyInjectionJobFactory()tells Quartz to resolve job instances from the .NET container, which is what makes constructor injection into a job work.
A job is identified by a JobKey - a name, optionally within a group. You register the job type against that key, then register one or more triggers that reference the same key:
var jobKey = new JobKey("DeleteLogsJob");
q.AddJob<DeleteLogsJob>(opts => opts.WithIdentity(jobKey));
q.AddTrigger(opts => opts
.ForJob(jobKey)
.WithIdentity($"{jobKey}-trigger")
.WithCronSchedule(cronFromConfig ?? "0/5 * * * * ?"));
Keeping the cron expression in configuration rather than hardcoded is the small decision that pays off later - you change the cadence per environment without a rebuild.
Cron expressions in Quartz have seven fields, not five
This trips people up coming from Linux cron. Quartz cron is seconds minutes hours day-of-month month day-of-week [year]. The demo's 0/5 * * * * ? means "every 5 seconds", which is deliberately aggressive so you can watch it fire on camera. In production you would use something like 0 0 3 * * ? for "every day at 3am". The ? in the day-of-week or day-of-month position means "no specific value" and is required because those two fields overlap.
WaitForJobsToComplete matters for data jobs
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);
With this set, when the host is asked to shut down, Quartz lets an in-flight job finish before the process exits instead of tearing it down mid-execution. For a job that is halfway through a SaveChangesAsync against your database, that is the difference between a clean stop and a partial write. Leave it on for anything that touches persistent state.
Injecting a scoped DbContext into a job
A Quartz job is transient - a fresh instance is created for every trigger fire. That instance is resolved from the container, so its constructor can take whatever it needs:
public DeleteLogsJob(
ILogRepository logRepository,
IOptions<DeleteLogsJobOptions> options,
ILogger<DeleteLogsJob> logger)
Because UseMicrosoftDependencyInjectionJobFactory() creates a scope per job execution, a scoped service like an EF Core DbContext (or a repository that wraps one) is safe to inject directly. You do not need to create a scope by hand inside Execute. The scope opens when the job starts and disposes when it finishes, exactly like a request scope in a web app.
The actual work is a single method on the repository - filter the Logs set by date, RemoveRange, SaveChangesAsync. Nothing Quartz-specific leaks into the data layer.
DisallowConcurrentExecution
[DisallowConcurrentExecution]
public class DeleteLogsJob : IJob
This attribute stops Quartz from starting a second instance of the job while one is still running. If your cleanup job normally takes 2 seconds but one night the table is huge and it takes 90, you do not want a second, third, and fourth copy piling up behind it and fighting over the same rows. With the attribute, Quartz simply skips the fire (or queues it, depending on misfire policy) until the current run completes. For almost any job that mutates shared data, you want this.
Binding job settings with the Options pattern
The delete cutoff date and the cron schedule both come from a bound options class rather than raw IConfiguration lookups inside the job:
public class DeleteLogsJobOptions
{
public const string DeleteLogsJob = "DeleteLogsJob";
public DateTime? DeleteAfterDate { get; set; }
}
The job takes IOptions<DeleteLogsJobOptions> and fails fast in its constructor if the required value is missing. That keeps the "what does this job need to be configured" contract in one typed place instead of scattered string keys.
Common pitfalls
- Forgetting
AddQuartzHostedService. You will register jobs and triggers, run the app, and nothing fires - because the scheduler was never started. - Five-field cron expressions. They will either throw or behave nothing like you expect. Always account for the leading seconds field.
- Doing heavy async work without awaiting it properly.
Executereturns aTask; if you fire-and-forget inside it,DisallowConcurrentExecutionandWaitForJobsToCompletelose track of the real work. - Assuming triggers persist. This setup uses the in-memory job store, so schedules are re-created from code on every startup. That is fine here. If you need durable, clustered scheduling that survives restarts and coordinates across multiple instances, Quartz supports a database-backed job store - a bigger topic.
Key Takeaways
- Quartz.NET integrates with the .NET generic host through
AddQuartz,AddQuartzHostedService, and the Microsoft DI job factory. - Jobs are transient and resolved from the container, so constructor injection - including a scoped
DbContext- just works, with a scope created per execution. - Quartz cron has seven fields; the leading one is seconds.
[DisallowConcurrentExecution]prevents overlapping runs of the same job - essential for anything that writes shared data.WaitForJobsToComplete = truelets in-flight jobs finish on shutdown instead of being killed mid-write.- Keep the schedule and job parameters in configuration, bound via the Options pattern, so cadence changes never need a rebuild.
Get the Full Source Code
The complete runnable solution - the Worker Service, the Quartz registration, the EF Core DbContext and migration, the seed data, and the cleanup job wired end to end against SQL Server - is available to Patreon supporters. If you want to run it and watch the job fire against a real database instead of rebuilding it from the walkthrough above, you can find it on Patreon.