Skip to content

Getting Started with Pragmatic.Jobs

This guide walks you through creating your first background job, from package installation to seeing it execute. By the end, you will have a recurring cleanup job running on a cron schedule with retry, timeout, and EF Core persistence.

  • .NET 10.0+
  • Pragmatic.Jobs package
  • Pragmatic.SourceGenerator analyzer reference
  • Pragmatic.Temporal (comes transitively)

Your project file should include:

<ItemGroup>
<PackageReference Include="Pragmatic.Jobs" />
<ProjectReference Include="..\Pragmatic.SourceGenerator\...\Pragmatic.SourceGenerator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>

Create a class that implements IJob and decorate it with [RecurringJob]. The class must be partial so the source generator can emit the invoker.

using Pragmatic.Jobs;
using Pragmatic.Jobs.Attributes;
namespace MyApp.Infrastructure.Jobs;
/// <summary>
/// Cleans up expired user sessions every day at 3 AM.
/// </summary>
[RecurringJob("0 3 * * *", Id = "cleanup-expired-sessions", TimeZone = "Europe/Rome")]
[Retry(MaxAttempts = 2, Strategy = BackoffStrategy.Exponential, BaseDelayMs = 2000)]
[Timeout(TimeoutSeconds = 120)]
public sealed partial class CleanupExpiredSessionsJob(
AppDbContext db,
ILogger<CleanupExpiredSessionsJob> logger) : IJob
{
public async Task ExecuteAsync(JobContext context, CancellationToken ct)
{
var cutoff = context.ScheduledAt.AddDays(-30);
var deleted = await db.Sessions
.Where(s => s.ExpiresAt < cutoff)
.ExecuteDeleteAsync(ct);
logger.LogInformation(
"Cleaned up {Count} expired sessions older than {Cutoff}",
deleted, cutoff);
}
}

Key points:

  • partial is mandatory — diagnostic PRAG2502 warns if missing.
  • IJob is the parameterless interface. Use IJob<T> if the job needs input data.
  • JobContext.ScheduledAt gives you the scheduled execution time, not DateTime.UtcNow.
  • Primary constructor parameters are resolved from DI (the SG handles registration).
ExpressionSchedule
0 3 * * *Daily at 3:00 AM
0 0 * * 0Every Sunday at midnight
*/15 * * * *Every 15 minutes
0 9 1 * *First day of every month at 9:00 AM
0 0 * * 1-5Every weekday at midnight

await PragmaticApp.RunAsync(args, app =>
{
app.UseJobs(jobs =>
{
jobs.WithWorkerCount(2); // 2 concurrent worker tasks
jobs.WithPollingInterval(5); // Poll every 5 seconds
});
});

The SG auto-discovers your job classes and generates all registration code. No manual AddTransient<CleanupExpiredSessionsJob>() needed.

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddPragmaticJobs(jobs =>
{
jobs.WithWorkerCount(2);
jobs.WithPollingInterval(5);
});
// Register the background processing services
builder.Services.AddJobProcessingServices();
var app = builder.Build();
app.Run();

Build the project. The source generator produces five files in obj/GeneratedFiles/Pragmatic.SourceGenerator/:

CleanupExpiredSessionsJob.Invoker.g.cs # Retry loop + timeout + telemetry
_Infra.Jobs.Registration.g.cs # DI registration
_Infra.Jobs.TypeRegistry.g.cs # AOT-safe type mapping
_Infra.Jobs.RecurringJobs.g.cs # Cron definition registration
_Metadata.Jobs.g.cs # Host aggregation metadata

In Visual Studio, expand Dependencies > Analyzers > Pragmatic.SourceGenerator in Solution Explorer to inspect these files.

In Rider, look under Dependencies > Source Generators > Pragmatic.SourceGenerator.

If these files are missing, check that the <ProjectReference> has OutputItemType="Analyzer".


Not every job runs on a cron schedule. For jobs triggered by business events, use [Job] and schedule them programmatically.

using Pragmatic.Jobs;
using Pragmatic.Jobs.Attributes;
namespace MyApp.Infrastructure.Jobs;
public record SendReminderParams(Guid ReservationId, Guid GuestId, string GuestEmail, DateTimeOffset CheckIn);
[Job]
[Retry(MaxAttempts = 3, Strategy = BackoffStrategy.ExponentialWithJitter, BaseDelayMs = 500)]
public sealed partial class SendCheckInReminderJob(
IEmailService email,
ILogger<SendCheckInReminderJob> logger) : IJob<SendReminderParams>
{
public async Task ExecuteAsync(SendReminderParams p, JobContext context, CancellationToken ct)
{
logger.LogInformation(
"Sending check-in reminder to {Email} for reservation {Id}",
p.GuestEmail, p.ReservationId);
await email.SendAsync(
p.GuestEmail,
$"Reminder: your check-in is at {p.CheckIn:g}",
$"Dear guest, your reservation {p.ReservationId} check-in is tomorrow.",
ct);
}
}

Inject IJobScheduler and schedule when a reservation is confirmed:

public class ReservationConfirmedHandler(IJobScheduler scheduler)
{
public async Task HandleAsync(ReservationConfirmed evt, CancellationToken ct)
{
// Schedule reminder 24 hours before check-in
var delay = evt.CheckIn - DateTimeOffset.UtcNow - TimeSpan.FromHours(24);
if (delay > TimeSpan.Zero)
{
await scheduler.ScheduleAsync<SendCheckInReminderJob, SendReminderParams>(
new SendReminderParams(evt.ReservationId, evt.GuestId, evt.GuestEmail, evt.CheckIn),
delay: delay,
correlationId: $"reservation-{evt.ReservationId}",
ct: ct);
}
}
}

Scheduling methods:

MethodWhen the job runs
ScheduleAsync<TJob>()Immediately (fire-and-forget)
ScheduleAsync<TJob>(delay: ...)After the specified delay
ScheduleAtAsync<TJob>(scheduledFor: ...)At the specified time
CancelAsync(jobId)Cancels a pending job

Step 5: Add EF Core Persistence for Production

Section titled “Step 5: Add EF Core Persistence for Production”

By default, jobs are stored in memory — fine for development, but lost on restart. For production, switch to EF Core:

Terminal window
dotnet add package Pragmatic.Jobs.EFCore
await PragmaticApp.RunAsync(args, app =>
{
app.UseJobs(jobs =>
{
jobs.WithWorkerCount(4);
jobs.WithPollingInterval(5);
jobs.WithLeaseTime(300); // 5 minute lease
jobs.WithBatchSize(10); // Fetch 10 jobs per poll
jobs.UseEfCore(); // Use database instead of memory
});
});

This replaces:

  • InMemoryJobStore with EfCoreJobStore
  • InMemoryRecurringJobStore with EfCoreRecurringJobStore

EF Core creates two tables:

TablePurpose
__JobsJob instances with status, lease, retry tracking
__RecurringJobsRecurring definitions with next-run timestamps

With EF Core, multiple app instances can safely process jobs. The lease mechanism uses optimistic SQL — only one worker acquires each job. If a worker crashes, the lease expires and another worker picks it up.


When one job must run after another succeeds, declare it with [Continuation<TNextJob>]:

[Job]
[Retry(MaxAttempts = 3)]
[Continuation<SendInvoiceEmailJob>]
public sealed partial class GenerateInvoiceJob(
IBillingService billing) : IJob<InvoiceParams>
{
public async Task ExecuteAsync(InvoiceParams p, JobContext context, CancellationToken ct)
=> await billing.GenerateAsync(p.ReservationId, p.Amount, p.Currency, ct);
// On success, SendInvoiceEmailJob is automatically enqueued
}
[Job]
[Retry(MaxAttempts = 2)]
public sealed partial class SendInvoiceEmailJob(
IEmailService email) : IJob
{
public async Task ExecuteAsync(JobContext context, CancellationToken ct)
=> await email.SendInvoiceAsync(context.CorrelationId!, ct);
// Uses CorrelationId to find the invoice
}

The SG validates at compile time:

  • PRAG2505 if the continuation type does not implement IJob or IJob<T>.
  • PRAG2506 if the chain creates a cycle (A->B->A).

Here is the lifecycle for the recurring CleanupExpiredSessionsJob:

  1. Startup: The SG-generated RegisterRecurringJobs() upserts the cron definition into IRecurringJobStore.
  2. Every 5 seconds: RecurringJobSchedulerService polls for due recurring jobs.
  3. At 3:00 AM Rome time: The cron expression matches. A JobInstance is enqueued with Status = Pending and ScheduledFor = now.
  4. Next poll cycle: JobProcessorService finds the pending job. A worker acquires the lease.
  5. Execution: The generated invoker wraps ExecuteAsync with a 120-second timeout and up to 2 retry attempts with exponential backoff.
  6. Success: The job is marked Completed. Metrics are emitted (pragmatic.jobs.completed, pragmatic.jobs.duration).
  7. Failure: If all retries are exhausted, the job is marked Failed. The pragmatic.jobs.failed counter increments.

For the delayed SendCheckInReminderJob:

  1. Business event: A reservation is confirmed. Your handler calls scheduler.ScheduleAsync(...).
  2. Job created: A JobInstance is stored with ScheduledFor = now + delay and Status = Pending.
  3. When due: JobProcessorService picks it up (it only fetches jobs where ScheduledFor <= now).
  4. Execution: The generated invoker deserializes SendReminderParams from JSON, calls ExecuteAsync, and handles retry/telemetry.

  • Read Architecture and Core Concepts for the full pipeline details and decision tree.
  • Read Common Mistakes to avoid the most frequent issues.
  • Read Troubleshooting for diagnostic IDs and debugging guidance.
  • Explore the Showcase examples: NoShowDetectionJob and SendCheckInReminderJob in examples/showcase/src/Showcase.Booking/Infrastructure/Jobs/.