Agendando tarefas com Quartz no .NET

O Quartz.NET é uma das bibliotecas mais completas para agendar tarefas no ecossistema .NET, como envio de relatórios, notificações e integrações com sistemas externos. Quando uma aplicação exige execução programada, ela precisa de um agendamento robusto e flexível, e é exatamente isso que essa biblioteca entrega.

Neste artigo, vamos explorar o uso do Quartz.NET construindo um agendador de envio de e-mails. O objetivo é mostrar, passo a passo, como configurar, criar e executar jobs de maneira confiável.

Por que usar o Quartz.NET

O Quartz.NET é uma biblioteca open source de agendamento de tarefas, criada como uma versão para .NET do Quartz original do mundo Java. Com o tempo, o projeto ganhou vida própria e hoje mantém documentação oficial com tutoriais, pacotes de integração e referência de API.

Na prática, a biblioteca permite várias coisas importantes:

  • Executar tarefas em intervalos fixos ou com expressões CRON.
  • Persistir agendamentos em banco de dados.
  • Criar jobs com injeção de dependência.
  • Escalar horizontalmente em ambientes distribuídos com clustering.

O .NET também oferece serviços hospedados nativos, que explicamos no artigo sobre Background Services em C# e .NET. No entanto, com eles você mesmo precisa controlar horários, calendários e persistência.

Por isso, o Quartz.NET faz sentido quando a agenda é o centro do problema. Ele já sabe interpretar expressões CRON, lidar com execuções perdidas e coordenar vários servidores.

Conceitos fundamentais do Quartz.NET

Antes de escrever código, vale entender as quatro peças centrais da biblioteca. Segundo o tutorial de jobs e triggers, cada uma tem uma responsabilidade bem definida.

  • Scheduler: representado por IScheduler, coordena tudo e dispara os jobs.
  • Job: classe que implementa IJob e contém a lógica a executar.
  • Job detail: o IJobDetail descreve uma instância do job e seus dados.
  • Trigger: o ITrigger define quando o job deve rodar.

Essa separação entre job e trigger é proposital. Assim, um mesmo job pode ter vários gatilhos, e você consegue reagendar uma tarefa sem redefinir a lógica dela.

Cada job e cada trigger também recebem uma identidade, formada por nome e grupo. Dessa forma, JobKey e TriggerKey garantem unicidade e ajudam a organizar agendas em categorias.

Tipos de trigger

O SimpleTrigger atende execuções únicas ou repetidas em intervalos fixos, de forma parecida com um timer. Já o CronTrigger atende agendas de calendário, como toda sexta-feira ao meio-dia.

As versões mais recentes da série 3 também trouxeram um trigger baseado em regras de recorrência RRULE. Ainda assim, para a maioria dos cenários, os dois primeiros resolvem o problema.

Estrutura do exemplo

Vamos construir um agendador simples que envia e-mails. A aplicação terá três partes principais:

  • Um job chamado EmailJob, que simula o envio de um e-mail.
  • Uma configuração de agendamento com intervalo simples ou expressão CRON.
  • A integração com o container de injeção de dependência do .NET.

Com isso, você terá uma base pronta para trocar a simulação por um envio real. O restante do código continua igual.

Instalando os pacotes do Quartz.NET

Antes de começar, adicione os pacotes essenciais para usar o Quartz.NET com injeção de dependência. Pelo Package Manager Console do Visual Studio, os comandos são estes:

Install-Package Quartz
Install-Package Quartz.Extensions.DependencyInjection

Se preferir a CLI do .NET, o resultado é o mesmo:

dotnet add package Quartz
dotnet add package Quartz.Extensions.DependencyInjection

O pacote Quartz traz o núcleo do agendador e também a integração com serviços hospedados. Já Quartz.Extensions.DependencyInjection adiciona o método AddQuartz ao IServiceCollection.

Criando o job de e-mail

O primeiro passo prático é criar a classe que representa o job, ou seja, a tarefa que vai rodar periodicamente. No nosso exemplo, ela simula o envio de um e-mail.

Definição da classe

public class EmailJob : IJob

A classe EmailJob implementa a interface IJob do Quartz. Essa implementação é obrigatória, porque é ela que permite ao scheduler reconhecer a classe como um job válido.

Injeção de dependência no job

Aqui usamos injeção de dependência para obter uma instância de ILogger. Com ela, registramos logs durante cada execução.

private readonly ILogger<EmailJob> _logger;

public EmailJob(ILogger<EmailJob> logger)
{
    _logger = logger;
}

O Quartz.NET se integra ao container de DI do .NET e injeta automaticamente as dependências registradas. Além disso, cada execução recebe o próprio escopo, então serviços scoped, como um DbContext, funcionam sem truques.

Método Execute

O método Execute roda toda vez que o trigger dispara. O parâmetro IJobExecutionContext traz o contexto da execução, como dados do job e horários, e pode servir para parâmetros ou identificação.

public Task Execute(IJobExecutionContext context)
{
    _logger.LogInformation("Sending email at: {Date}", DateTime.Now);

    // Simulação do envio
    Console.WriteLine("[EmailJob] Email sent successfully!");

    return Task.CompletedTask;
}

Dentro do método, primeiro registramos um log com a data e a hora. Em seguida, simulamos o envio com Console.WriteLine e retornamos uma Task já concluída, pois não há nenhuma operação assíncrona real.

Essa estrutura básica basta para o scheduler reconhecer, instanciar e executar o job. A classe completa fica assim:

public class EmailJob : IJob
{
    private readonly ILogger<EmailJob> _logger;

    public EmailJob(ILogger<EmailJob> logger)
    {
        _logger = logger;
    }

    public Task Execute(IJobExecutionContext context)
    {
        _logger.LogInformation("Sending email at: {Date}", DateTime.Now);

        // Simulação do envio
        Console.WriteLine("[EmailJob] Email sent successfully!");

        return Task.CompletedTask;
    }
}

Configurando o Quartz.NET na aplicação

A configuração acontece no Program.cs ou no Startup.cs, dependendo do tipo de projeto. O método AddQuartz registra jobs, triggers e demais opções no sistema de injeção de dependência.

Criação do builder

O primeiro passo é inicializar o builder da aplicação ASP.NET Core. É por meio dele que adicionamos serviços e configuramos o pipeline.

var builder = WebApplication.CreateBuilder(args);

Registrando job e trigger

Dentro de AddQuartz, criamos primeiro uma chave única para identificar o job. Essa chave é útil especialmente quando você quer associar vários triggers ao mesmo job.

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

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

Depois, registramos o EmailJob no scheduler, associado à chave criada. Por fim, adicionamos um trigger que está ligado ao EmailJob, tem a identidade EmailJob-trigger e usa uma expressão CRON para rodar a cada 30 segundos.

    q.AddTrigger(opts => opts
        .ForJob(jobKey)
        .WithIdentity("EmailJob-trigger")
        .WithCronSchedule("0/30 * * * * ?"));
});

Em versões antigas do Quartz.NET, era comum chamar q.UseMicrosoftDependencyInjectionJobFactory() nesse ponto. Segundo a documentação de integração com o Microsoft DI, desde a versão 3.3.2 a fábrica padrão já resolve os jobs pelo container, e esse método ficou obsoleto.

Registrando o serviço hospedado

Agora só falta adicionar o serviço que executa os jobs em background. A opção WaitForJobsToComplete faz o encerramento da aplicação aguardar os jobs em andamento.

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

Sem essa opção, cujo padrão é false, a aplicação pode terminar no meio de um envio. Outra opção, AwaitApplicationStarted, já vem ligada e faz o scheduler iniciar apenas depois que a aplicação termina de subir.

O código final da configuração fica assim:

var builder = WebApplication.CreateBuilder(args);

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

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

    q.AddTrigger(opts => opts
        .ForJob(jobKey)
        .WithIdentity("EmailJob-trigger")
        .WithCronSchedule("0/30 * * * * ?")); // a cada 30 segundos
});

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

var app = builder.Build();

app.Run();

Entendendo as expressões CRON do Quartz.NET

As expressões CRON do Quartz.NET diferem um pouco do cron tradicional do Linux. Segundo o tutorial de CronTrigger, elas têm seis campos obrigatórios e um sétimo opcional.

  • Segundos, de 0 a 59.
  • Minutos, de 0 a 59.
  • Horas, de 0 a 23.
  • Dia do mês, de 1 a 31.
  • Mês, de 1 a 12 ou de JAN a DEC.
  • Dia da semana, de 1 a 7 ou de SUN a SAT.
  • Ano, campo opcional.

Portanto, a expressão 0/30 * * * * ? significa a cada 30 segundos, começando no segundo zero. O asterisco aceita qualquer valor, enquanto a barra define incrementos.

O ponto de interrogação merece atenção especial. Ele indica nenhum valor específico e aparece no dia do mês ou no dia da semana, porque você não pode definir os dois ao mesmo tempo.

Alguns exemplos úteis para o agendador de e-mails:

  • Todos os dias ao meio-dia: 0 0 12 * * ?.
  • De segunda a sexta, às 10h15: 0 15 10 ? * MON-FRI.
  • No último dia de cada mês, às 10h15: 0 15 10 L * ?.

Fuso horário nas expressões CRON

Por padrão, o CronTrigger usa o fuso horário local do servidor. Isso pode surpreender quando a aplicação roda em um container configurado em UTC e o e-mail precisa sair às 8h de Brasília.

Nesse caso, informe o fuso horário diretamente na configuração do trigger:

q.AddTrigger(opts => opts
    .ForJob(jobKey)
    .WithIdentity("EmailJob-morning-trigger")
    .WithCronSchedule("0 0 8 * * ?", x => x
        .InTimeZone(TimeZoneInfo.FindSystemTimeZoneById("America/Sao_Paulo"))));

Assim, o disparo acontece sempre às 8h no horário de São Paulo, independentemente do fuso do servidor. Além disso, a intenção fica documentada no próprio código.

Agendando com intervalos simples

Nem toda agenda precisa de CRON. Quando você quer apenas repetir uma tarefa em um intervalo fixo, o agendamento simples é mais legível.

q.AddTrigger(opts => opts
    .ForJob(jobKey)
    .WithIdentity("EmailJob-simple-trigger")
    .StartNow()
    .WithSimpleSchedule(x => x
        .WithIntervalInSeconds(30)
        .RepeatForever()));

Nesse caso, o trigger dispara imediatamente e depois repete a cada 30 segundos. Ou seja, o efeito é parecido com o do exemplo anterior, mas sem depender da sintaxe CRON.

Passando dados para o job

Um agendador de e-mails real precisa saber para quem enviar. Para isso, o Quartz.NET oferece o JobDataMap, um dicionário associado ao job ou ao trigger.

q.AddJob<EmailJob>(opts => opts
    .WithIdentity(jobKey)
    .UsingJobData("recipient", "admin@site.com"));

Dentro do job, você lê o valor pelo contexto da execução:

var recipient = context.MergedJobDataMap.GetString("recipient");

O MergedJobDataMap combina os dados do job e do trigger. Assim, o mesmo job pode enviar para destinatários diferentes, conforme o gatilho que disparou.

Disparando um job do Quartz.NET pela API

Às vezes, o time precisa reenviar um e-mail fora da agenda, por exemplo, depois de corrigir um template. Para isso, você pode disparar o job manualmente a partir de um endpoint.

app.MapPost("/emails/send-now", async (ISchedulerFactory schedulerFactory) =>
{
    // Obtém o scheduler registrado pelo AddQuartz
    var scheduler = await schedulerFactory.GetScheduler();

    await scheduler.TriggerJob(new JobKey("EmailJob"));

    return Results.Accepted();
});

O ISchedulerFactory já fica disponível no container depois de AddQuartz. Então o endpoint apenas obtém o scheduler e chama TriggerJob com a mesma chave usada no registro.

Esse disparo não altera a agenda existente. Ou seja, o trigger CRON continua funcionando normalmente, e a execução manual acontece apenas uma vez.

Executando a aplicação

Ao rodar a aplicação, o job executa automaticamente conforme a agenda definida.

dotnet run

Você verá logs no console parecidos com estes:

[INFO] Sending email at: 07/05/2025 18:00:00
[EmailJob] Email sent successfully!

A cada 30 segundos, novas linhas aparecem. Então, se nada surgir, revise a expressão CRON e confirme que o serviço hospedado está registrado.

Quartz.NET em produção

O exemplo usa o armazenamento em memória, que é o padrão. Em produção, porém, você provavelmente vai querer que os agendamentos sobrevivam a reinicializações.

Para isso, o Quartz.NET oferece o AdoJobStore, que grava jobs e triggers em um banco relacional. Pela integração com o DI, a configuração usa UsePersistentStore:

q.UsePersistentStore(s =>
{
    s.UseProperties = true;
    s.UseSqlServer(sqlServer =>
    {
        sqlServer.ConnectionString = "your connection string";
        sqlServer.TablePrefix = "QRTZ_";
    });
    s.UseClustering(c =>
    {
        c.CheckinInterval = TimeSpan.FromSeconds(10);
    });
});

O método UseClustering permite que várias instâncias compartilhem o mesmo banco. Dessa forma, cada disparo roda em apenas um nó, e você escala a aplicação horizontalmente.

Com armazenamento persistente, nomeie sempre jobs e triggers de forma explícita. Afinal, é pelo nome que o scheduler reconhece no banco os registros criados em execuções anteriores.

Também vale planejar o que acontece com disparos perdidos, os chamados misfires. Eles ocorrem quando o scheduler está desligado ou sem threads livres no horário marcado.

Para triggers CRON, você escolhe o comportamento na própria configuração. Por exemplo, WithMisfireHandlingInstructionFireAndProceed executa uma vez assim que possível, enquanto WithMisfireHandlingInstructionDoNothing simplesmente aguarda o próximo horário.

.WithCronSchedule("0 0 8 * * ?", x => x
    .WithMisfireHandlingInstructionDoNothing())

No agendador de e-mails, essa escolha importa bastante. Afinal, enviar um resumo diário atrasado pode ser aceitável, mas enviar um lembrete de evento que já passou não faz sentido.

Outro recurso útil é o atributo [DisallowConcurrentExecution]. Aplicado à classe do job, ele impede duas execuções simultâneas da mesma definição, o que evita e-mails duplicados quando um envio demora mais que o intervalo.

Quartz.NET ou Hangfire

Outra biblioteca popular para tarefas em segundo plano é o Hangfire. Mostramos como usá-la no artigo Agendando e-mails no .NET com HangFire.

Em geral, o Hangfire brilha quando a aplicação dispara jobs a partir do código, como um envio após um cadastro. Já o Quartz.NET se destaca quando a agenda em si é complexa, com calendários, CRON com segundos e vários gatilhos por job.

Considerações finais

O Quartz.NET é uma solução madura para agendar tarefas no .NET. Neste exemplo, criamos um job de envio de e-mails configurado com CRON e integrado à injeção de dependência.

Em cenários reais, esse job pode se integrar a serviços como SendGrid ou SMTP e ter seus agendamentos persistidos em banco. Além disso, vale explorar o disparo manual de jobs via API e o escalonamento em cluster.

Antes de ir para produção, revise três pontos: o armazenamento persistente, o fuso horário de cada trigger e a política de misfire. Com isso resolvido, o agendador se comporta de forma previsível mesmo após falhas e reinicializações.

Com o Quartz.NET, seu sistema ganha automação, escalabilidade e robustez na execução de tarefas agendadas. Por isso, ele continua sendo uma escolha sólida para qualquer aplicação que dependa de horários bem definidos.

FAQ

O Quartz.NET é gratuito?

Sim, o Quartz.NET é open source e pode ser usado em projetos comerciais. Os pacotes ficam disponíveis no NuGet.

Preciso chamar UseMicrosoftDependencyInjectionJobFactory?

Não nas versões a partir da 3.3.2. A fábrica padrão já resolve os jobs pelo container de DI, e o método ficou obsoleto.

Qual a diferença entre o CRON do Quartz.NET e o do Linux?

O formato do Quartz.NET inclui um campo de segundos no início e aceita o caractere ?. Por isso, uma expressão do Linux não funciona sem ajustes.

Como evitar que o mesmo job rode duas vezes ao mesmo tempo?

Aplique o atributo [DisallowConcurrentExecution] na classe do job. Assim, o scheduler espera a execução atual terminar.

Os agendamentos sobrevivem a uma reinicialização?

Somente com um armazenamento persistente, como o AdoJobStore. No modo em memória, que é o padrão, eles se perdem.