Skip to main content

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.