Skip to content

7. Jobs

Books get borrowed. Due dates pass. Someone needs to flag the overdue ones. That's a background job — a scheduled task that runs on a cron expression and does its work through the same effect pipeline as everything else.

Define the Job

src/Library.Lending/Domain/Jobs/CheckOverdueLoans.cs
[BackgroundJob(Schedule = "0 2 * * *")]
public record CheckOverdueLoansJob;

That's a cron expression for "2 AM every day." The [BackgroundJob] attribute also supports MaxRetries, RetryDelayMs, TimeoutMs, and Priority — but the defaults are fine for a nightly check.

The Handler

src/Library.Lending/Domain/Jobs/CheckOverdueLoans.cs (continued)
public static class CheckOverdueLoansHandler
{
    [JobHandler]
    public static Eff<LendingRuntime, Unit> Handle(CheckOverdueLoansJob _) =>
        from overdueLoans in LendingStore.Loans.QueryPage<LendingRuntime>(
            1, 1000,
            filter: l => l.ReturnedAt == null && l.DueAt < DateTime.UtcNow && !l.IsOverdue)
        from __ in overdueLoans.Data.ForEachEff<LendingRuntime, Loan>(loan =>
            LendingStore.Loans.Save<LendingRuntime>(loan with { IsOverdue = true }))
        select unit;
}

Query for unreturned, past-due, not-yet-flagged loans. For each one, save it with IsOverdue = true. ForEachEff processes each item sequentially — if one fails, the rest still run (it doesn't short-circuit on individual items).

The with expression creates a new Loan record — the original is never mutated. Immutability all the way down.

What the Generator Produces

The [BackgroundJob] + [JobHandler] combination generates:

  • A DeepstagingJobWorker hosted service that processes queued jobs
  • A cron registry mapping job types to their schedules
  • A CronSchedulerService that enqueues jobs on their cron schedule

Remember the [Uses(typeof(JobsModule))] on LendingRuntime from Chapter 5? That's what makes the job scheduler available as a runtime capability.

Test It

Job handlers are just effect methods — test them directly:

[Test]
public async Task CheckOverdueLoans_FlagsOverdueLoans()
{
    var runtime = TestLendingRuntime.Create();

    runtime.LoanStore.Seed(new Loan
    {
        Id = LoanId.New(),
        PatronId = PatronId.New(),
        BookId = BookId.New(),
        BorrowedAt = DateTime.UtcNow.AddDays(-30),
        DueAt = DateTime.UtcNow.AddDays(-16),
        IsOverdue = false
    });

    var fin = await CheckOverdueLoansHandler
        .Handle(new CheckOverdueLoansJob())
        .RunAsync(runtime);

    await Assert.That(fin).IsSucc();
    await Assert.That(runtime.LoanStore.SaveCalls).HasCount().EqualTo(1);
    await Assert.That(runtime.LoanStore.SaveCalls[0].IsOverdue).IsTrue();
}

Create() gives you the test runtime with .Seed() for direct data setup and .SaveCalls for verifying what was saved. No database, no scheduler — just the handler logic.

Next → Integration Events