# ADR-0022: Quartz.NET for time-triggered fleet sweeps - **Status:** Accepted - **Date:** 2026-07-23 - **Deciders:** Respellion engineering - **Slice:** S-17 (#18) · **Proposal issue:** #120 ## Context A BIG inscription is valid for a fixed term; before it lapses the zorgprofessional must herregistreren. S-17 adds a **herregistratie reminder sweep**: once a day, scan the register for inscriptions whose deadline is within the reminder window and remind each one. The Domain Service already runs periodic background work — `OpenZaakJobPump`, `BeoordelingEscalatiePump`, `RegistratieVerlopenPump`. Those are **continuous job pollers**: they drain Flowable's external-task/job queues at-least-once, picking up work as soon as it is parked, on a short poll interval. The reminder sweep is a different shape of work: **time-triggered**, once a day, over our own store — there is no queue to drain and no "as soon as possible" requirement. The PRD already names the scheduler component: "Scheduler (Quartz.NET): fleet-wide sweeps (expiry, reminders)" (§39, §94). Adding Quartz.NET is nonetheless a new dependency, so this decision is recorded before the code lands (CLAUDE.md §14). ## Decision **Use Quartz.NET for time-triggered fleet sweeps, starting with the herregistratie reminder sweep. Leave the existing pumps as `BackgroundService` job pollers.** - `HerregistratieReminderJob` (a Quartz `IJob`) is fired by a cron trigger — daily at 03:00 by default, overridable with `Quartz__Cron`. It is a thin shell: it resolves the pure `HerregistratieReminderSweep` (application layer) and logs how many reminders went out. - The sweep's rule lives in the domain: `Registration.HerregistratieReminderDue(asOf)`, which the store query and the sweep both build on. The sweep marks each reminded inscription (`HerregistratieReminderVerstuurd`), so a re-fire reminds no one twice (§8.6). Two options were rejected: 1. **A `BackgroundService` with a 24h `Task.Delay`.** No new dependency, but it drifts to process-start time, has no cron/misfire semantics, and contradicts the PRD's named component. A daily "run at 03:00" is exactly what cron scheduling is for. 2. **Migrating the three pumps onto Quartz too, for one mechanism.** Rejected: the pumps are not schedulers. Forcing a "run at time T" tool onto "drain this queue continuously" work is churn and a boundary change for negative benefit. The teachable distinction is worth keeping: **pumps drain queues; Quartz fires sweeps.** ## Consequences **Positive** - Cron scheduling with restart-stable timing and misfire handling, for free. - The reminder rule is one domain method, reused by the store query and the sweep; the scheduler owns none of the policy. - The reference app now demonstrates the intended Scheduler component. **Negative / costs** - One new dependency (`Quartz`, `Quartz.Extensions.Hosting`) in the Domain Service. - Two periodic-work mechanisms coexist (pumps + Quartz). Deliberate — they model two genuinely different concerns, documented here. **Follow-up** - The validity term (5 years) and reminder lead time (16 weeks) are domain calibration knobs; promote them to beheer config (S-15) if a demo needs them per-catalogus. - The Quartz job stores its schedule in RAM (`RAMJobStore`); a persistent/clustered store is a later concern if the Domain Service is scaled out. ## Coupling rules touched (CLAUDE.md §8) None. Quartz is internal to the Domain Service and drives an application use case over the store port. No ZGW or Flowable coupling is added; the sweep talks to no peer module.