ADR-0001. In-process EventBus + BullMQ for multi-merchant product sync
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-03-01 |
| Deciders | commerce-team |
| Supersedes | — |
Context
- A product created/updated for one merchant often must be replicated to additional merchants of the same organizer (chain stores share a catalog).
- The aggregate create/update is already a single DB transaction (Product + Info + identifiers + channel links + default variant). Replication touches many rows across N merchants and can be slow.
- It must not block or fail the primary aggregate response, and must survive partial failure (one merchant's copy failing should not roll back the primary).
- Commerce already runs a BullMQ Redis connection and a worker role.
Decision
Split replication into two stages connected by an in-process EventBus (eventemitter3) and a BullMQ queue:
ProductCreateService/ProductUpdateServicecommit the primary aggregate, then emitproduct.aggregate.{created,updated}on the EventBus (EVENT_EMITTERkeys insrc/common/event.ts).ProductAggregateCreatedListener/ProductAggregateUpdatedListenerhandle the event. Thecreatedlistener also marks the merchant onboardingPRODUCTstep. Both callpushJobToQueue()— but only whensyncMerchantIds.length > 0.- The job lands on
SYNC_PRODUCT_QUEUE(@nx/commerce/sync-product-queue) on the BullMQ Redis. SyncProductWorkerconsumes it (job name.createvs.update) and callsProductCreateSyncService/ProductUpdateSyncService.syncToAdditionalMerchants().
The EventBus decouples emission from enqueue; BullMQ provides durability, retry, and bounded concurrency (APP_ENV_BULLMQ_WORKER_CONCURRENCY).
Consequences
| Pros | Cons |
|---|---|
| Primary aggregate response is fast — replication is fully async | Emit happens post-commit; a crash between commit and enqueue loses the sync trigger |
| Per-merchant failures isolated, retried by BullMQ | Two infra hops (EventBus → Redis queue) to reason about |
| Onboarding step + sync share one event, registered twice | EventBus is in-process only — no cross-replica delivery |
| Worker concurrency tunable; runs only in worker role | Eventual consistency: replicated merchants lag the primary |
Alternatives Considered
| Option | Pros | Cons | Why rejected |
|---|---|---|---|
| Replicate synchronously inside the aggregate TX | Strong consistency | Slow response; one bad merchant rolls back all | UX + blast radius unacceptable |
| Kafka topic for sync fan-out | Cross-replica, durable | Adds consumer wiring; commerce is producer-only by ADR-0002 | Overkill for an internal, same-service job |
| Direct BullMQ enqueue from the service (no EventBus) | Fewer hops | Couples service to queue; onboarding-step side-effect would need a second path | EventBus cleanly multiplexes the event to both listeners |
References
src/common/event.ts(EVENT_EMITTER.PRODUCT.*)src/components/event-bus/component.ts(registry, double registration ofcreated)src/events/listeners/product-aggregate-created.listener.ts/...updated.listener.tssrc/components/queues/queue.component.ts,src/components/workers/sync-product.worker.tssrc/services/product/product-create.service.ts:225,product-update.service.ts:228