Adding a scheduler to a web application should not scatter “has it started?” checks across controllers. Startup and shutdown need a clear owner. Hosted-service structures in my archive provide the context for attaching Quartz lifetime to the .NET Generic Host.
Registration and startup are different responsibilities
AddQuartz registers job definitions and triggers. AddQuartzHostedService connects scheduler startup and shutdown to the host. The heartbeat below is scheduled immediately and then at roughly ten-second intervals. The code targets the Quartz.Extensions.Hosting 3.x integration pattern.
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Quartz;
var builder=Host.CreateApplicationBuilder();
builder.Services.AddQuartz(options=>{
var key=new JobKey("heartbeat");
options.AddJob<Heartbeat>(job=>job.WithIdentity(key));
options.AddTrigger(t=>t.ForJob(key).StartNow()
.WithSimpleSchedule(s=>s.WithIntervalInSeconds(10).RepeatForever()));
});
builder.Services.AddQuartzHostedService(options=>options.WaitForJobsToComplete=true);
await builder.Build().RunAsync();
public class Heartbeat:IJob
{
public Task Execute(IJobExecutionContext context)
{ context.CancellationToken.ThrowIfCancellationRequested(); return Task.CompletedTask; }
}
Heartbeat only checks cancellation and returns. It does not write a heartbeat log or persist a timestamp. Observing executions therefore requires a testable operation or controlled counter. No console output is not evidence that the scheduler never started.
Give the long-lived process a long-lived owner
RunAsync manages the application lifetime, including shutdown signals. Starting a scheduler inside a request can create duplicates or lose the object responsible for stopping it. A hosted service makes it explicit that scheduling is not owned by one HTTP request.
Dependency lifetimes matter alongside that ownership. A long-lived object should not capture a scoped DbContext and reuse it indefinitely. Job dependencies need an appropriate execution scope, with DI behavior verified against the selected Quartz version and job factory configuration.
Waiting for completion does not replace cancellation design
WaitForJobsToComplete=true expresses an intention to wait for running work during shutdown. ThrowIfCancellationRequested at the start only checks that instant. If a later HTTP or SQL call does not receive the token, the work can continue for much longer. Cancellation must flow through the asynchronous I/O chain.
Normal execution duration, operation timeouts and the host's shutdown budget should agree. A process remaining alive indefinitely is not fixed merely by describing it as waiting for completion. Work may need interruptible steps and persisted progress so it can recover safely after termination.
Restarting is not automatic recovery
This sample does not configure a durable Quartz store. In-memory scheduling is not a ledger of work missed while the application is down. A persistent trigger also does not automatically record which business period was completed. Job progress and trigger schedules describe different state.
Likewise, running two web instances does not automatically establish one safe execution of each business operation. Clustering, durable stores and data-level idempotency are options to choose against the requirement. A small application should not acquire that infrastructure merely to resemble a larger enterprise system.
Test by stopping and restarting the host
Start the host, observe an execution and request shutdown. Verify that new executions stop, active work follows the chosen completion/cancellation policy and resources are released. Restart and confirm that definitions remain intentional and completed work is not accidentally duplicated. A successful registration call alone cannot establish those lifecycle behaviors.
Quartz hosted-service integration; .NET Generic Host lifetime.