Files
register-referentie/docs/architecture/adr-0022-quartz-scheduler.md
not 4fe9915816
CI / verify-stack (push) Successful in 8m14s
CI / lint (push) Successful in 1m20s
CI / build (push) Successful in 59s
CI / unit (push) Successful in 1m16s
CI / frontend (push) Successful in 2m38s
CI / mutation (push) Successful in 5m53s
feat(domain): herregistratie reminder sweep on a Quartz cron (S-17, closes #18) (#121)
## What & why

S-17: a BIG inscription is valid for a fixed term; before it lapses the zorgprofessional must herregistreren. This adds a **daily herregistratie reminder sweep**.

- **Domain:** `Approve(ingeschrevenOp)` now stamps the inscription moment; `HerregistratieVoor` derives the deadline (inscription + 5-year validity); `HerregistratieReminderDue(asOf)` is the single rule (inside the 90-day window, inscribed, not yet reminded); `MarkHerregistratieReminderVerstuurd()` is idempotent.
- **Store:** `FindDueForHerregistratieReminderAsync(asOf)` — the sweep's candidate set, filtered on the aggregate's own rule (no duplicated policy).
- **Application:** `HerregistratieReminderSweep` — pure over the store + an injected `TimeProvider`; flags + persists each due inscription, returns the reminded ids.
- **Infra/API:** `HerregistratieReminderJob` (Quartz `IJob`) fires the sweep on a daily cron (03:00, overridable via `Quartz__Cron`) and logs the count. `GET /registrations/{id}` surfaces `herregistratieVoor` + `herregistratieReminderVerstuurd`.

**Decisions (both raised with you before coding):** use Quartz.NET as the PRD names it — a genuine cron concern, distinct from the queue-draining pumps, which stay as-is (**ADR-0022**, proposal #120); and the reminder's observable effect is a flag on the aggregate + a log line (no outbound notification infra in v1). No coupling rule (§8) is touched — Quartz is internal to the Domain Service.

Closes #18
Closes #120

## Definition of Done

- [x] Linked Gitea issue (above).
- [x] Failing test committed before the implementation (red→green per layer: domain rule, store query, sweep).
- [x] Implementation makes the test pass; refactor commit for the 90-day knob.
- [x] Conventional Commits referencing the issue (`refs #18`).
- [ ] CI green — awaiting Gitea Actions.
- [ ] `docker compose up` reaches green health checks within 3 minutes — API boots locally with Quartz initialised; verified in CI compose smoke.
- [x] Docs updated — ADR-0022, demo-script, BACKLOG.
- [x] ADR added — `docs/architecture/adr-0022-quartz-scheduler.md`.
- [x] Demo note in `docs/demo-script.md`.

## Notes for reviewers

- **Ripple:** `Approve()` gained the inscription moment, so the two approving handlers (`ApproveRegistration`, `BeoordeelRegistratie`) now take an injected `TimeProvider`; existing tests pass a fixed clock. All three `IRegistrationStore` implementers (prod, unit fake, acceptance) got the new query.
- **Calibration knobs:** validity (5y) and reminder lead time (90d) are domain constants marked with `ponytail:` comments; promotion path to beheer config (S-15) noted in the ADR.
- **Mutation:** the Quartz job shell is excluded from Stryker, mirroring the pumps; all rule/sweep/query logic is covered.
- Local: 152 domain unit tests green; API boots with the Quartz scheduler and `/health` green.

Reviewed-on: #121
2026-07-23 10:31:56 +00:00

3.5 KiB

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.