Generic fanout abstraction (D15)
Current state
The platform has one cron-tickle + SQS fanout consumer: BookingJobsService at libs/api/services-api/src/lib/scheduler/booking/booking.jobs-service.ts. The pattern is described in Current state → Multi-instance runtime → Cron + SQS fanout and in Cluster coordination pattern → Cron-tickle + SQS fanout.
The cron-handler logic is hand-rolled inside BookingJobsService: it holds the orchestration lock, scans for eligible records, enqueues per-record SQS messages with MessageDeduplicationId, and consumes them through a typed dispatch loop.
The deferred alternative
Extract the pattern into a sixth coordination primitive — a generic CronFanoutModule (or similar) with:
- A registry interface for consumers to declare
{ jobType, source, handler } - Built-in lock acquisition, fanout enqueue, dedup, and consumer dispatch
- Configurable concurrency, retry policy, and DLQ binding per consumer
Why deferred — the Rule of Three
The platform deliberately follows the Rule of Three for new abstractions: a sixth coordination primitive is a long-term commitment to maintain, document, and onboard engineers to. The cost compounds. We extract when the second call site makes the abstraction's shape obvious — until then, the existing five primitives + the per-callsite pattern carry less ambiguity than a premature abstraction.
With one consumer (BookingJobsService), the abstraction's shape is a guess. With two consumers, the shape is informed by what they share. Extracting now risks codifying booking-specific concerns into the generic interface.
Candidates for the second consumer
Four request-triggered candidates have been identified:
- Notification dispatch (per-recipient fanout)
- Bulk-operation routes that today hold an HTTP connection while iterating records
- Per-record reconciliation jobs that may be needed when a second integration vendor lands
When any of these become real (i.e. an actual ticket, not a hypothetical), the second consumer surfaces the shape and the extraction becomes informed.
What's already abstraction-friendly today
BookingJobsService.tryEnqueueOrgJob deliberately keeps zero booking-domain knowledge in the pull/dispatch loop. The loop maps jobType → method and dispatches. When the eventual lift happens, the work is mechanical — rename the directory, parameterize the type-to-method map, drop booking-specific imports.
Revisit trigger
When the second consumer materializes. Specifically, when an actual feature PR introduces a second cron-fanout: lock key + SQS queue pair, the extraction becomes the natural next step.
Related
- OpenSpec:
openspec/current-decisions-for-future-tracking.md §D15 - Current state → Cluster coordination pattern
- Current state → Multi-instance runtime