| name | event-driven-patterns |
| description | Message queue patterns with BullMQ, Kafka, RabbitMQ - saga, outbox, dead letter queue, exactly-once semantics. |
Event-Driven Patterns
Message queue and event bus patterns for decoupled, reliable async processing.
BullMQ Setup (Producer + Consumer)
import { Queue, Worker, QueueEvents } from 'bullmq'
import Redis from 'ioredis'
const connection = new Redis(process.env.REDIS_URL!, { maxRetriesPerRequest: null })
const emailQueue = new Queue('email', { connection })
const marketQueue = new Queue('market-resolution', { connection })
await emailQueue.add(
'send-welcome',
{ userId: 'abc', email: 'user@example.com' },
{
attempts: 3,
backoff: { type: 'exponential', delay: 1000 },
removeOnComplete: { count: 1000 },
removeOnFail: { count: 5000 }
}
)
await emailQueue.add('send-reminder', { userId: 'abc' }, { delay: 3_600_000 })
const emailWorker = new Worker(
'email',
async (job) => {
if (job.name === 'send-welcome') {
await sendWelcomeEmail(job.data.email)
} else if (job.name === 'send-reminder') {
await sendReminderEmail(job.data.userId)
}
return { sent: true, at: new Date().toISOString() }
},
{
connection,
concurrency: 10
}
)
emailWorker.on('completed', (job, result) => {
console.log(`Job ${job.id} completed:`, result)
})
emailWorker.on('failed', (job, err) => {
console.error(`Job ${job?.id} failed after ${job?.attemptsMade} attempts:`, err.message)
})
Retry Policies and Dead Letter Queue
import { Queue, Worker, QueueEvents } from 'bullmq'
const dlqQueue = new Queue('dead-letter', { connection })
const processingWorker = new Worker(
'orders',
async (job) => {
await processOrder(job.data)
},
{
connection,
concurrency: 5
}
)
processingWorker.on('failed', async (job, err) => {
if (!job) return
const isExhausted = job.attemptsMade >= (job.opts.attempts || 1)
if (isExhausted) {
await dlqQueue.add('order-failed', {
originalJob: job.name,
data: job.data,
error: err.message,
failedAt: new Date().toISOString(),
attempts: job.attemptsMade
})
console.error()
}
})
dlqWorker = (, (job) => {
({
: ,
: job.
})
}, { connection })
Transactional Outbox Pattern
async function createMarketWithOutbox(data: CreateMarketDto): Promise<Market> {
return db.$transaction(async (tx) => {
const market = await tx.market.create({ data })
await tx.outbox.create({
data: {
aggregateType: 'Market',
aggregateId: market.id,
eventType: 'MarketCreated',
payload: { marketId: market.id, name: market.name, createdAt: market.createdAt }
}
})
market
})
}
(): <> {
unpublished = db..({
: { : },
: { : },
:
})
( event unpublished) {
{
(event., event.)
db..({
: { : event. },
: { : () }
})
} (err) {
.(, err)
}
}
}
(outboxRelay, )
Saga Pattern (Orchestration)
interface SagaStep<T> {
name: string
execute: (ctx: T) => Promise<Partial<T>>
compensate: (ctx: T) => Promise<void>
}
class SagaOrchestrator<T extends Record<string, unknown>> {
constructor(private steps: SagaStep<T>[]) {}
async run(initialContext: T): Promise<T> {
const ctx = { ...initialContext }
const completed: SagaStep<T>[] = []
for (const step of this.steps) {
try {
const result = await step.execute(ctx)
Object.assign(ctx, result)
completed.push(step)
console.log(`Saga step '${step.name}' succeeded`)
} (err) {
.()
( done completed.()) {
{
done.(ctx)
.()
} (compensateErr) {
.(, compensateErr)
}
}
err
}
}
ctx
}
}
{
:
:
:
?:
?:
}
orderSaga = <>([
{
: ,
: (ctx) => {
reservationId = inventory.(ctx.)
{ reservationId }
},
: (ctx) => {
(ctx.) inventory.(ctx.)
}
},
{
: ,
: (ctx) => {
paymentId = payments.(ctx., ctx.)
{ paymentId }
},
: (ctx) => {
(ctx.) payments.(ctx.)
}
},
{
: ,
: (ctx) => {
orders.(ctx.)
{}
},
: (ctx) => {
orders.(ctx.)
}
}
])
Idempotent Consumers (Exactly-Once Semantics)
async function processEventIdempotent(
eventId: string,
handler: () => Promise<void>
): Promise<void> {
const key = `processed:${eventId}`
const isNew = await redis.set(key, '1', 'EX', 86_400, 'NX')
if (!isNew) {
console.log(`Event ${eventId} already processed, skipping`)
return
}
try {
await handler()
} catch (err) {
await redis.del(key)
throw err
}
}
const worker = new Worker('payments', async (job) => {
await processEventIdempotent(job.id!, async () => {
await processPayment(job.data)
})
}, { connection })
Fan-Out Pattern
const eventBus = new Queue('events', { connection })
async function publishMarketResolved(marketId: string, outcome: string): Promise<void> {
const event = { marketId, outcome, resolvedAt: new Date().toISOString() }
await Promise.all([
notificationQueue.add('market-resolved', event),
payoutQueue.add('process-payouts', event),
analyticsQueue.add('track-resolution', event),
feedQueue.add('update-feed', event)
])
}
Priority Queue
await criticalQueue.add('urgent-payout', data, { priority: 1 })
await normalQueue.add('regular-email', data, { priority: 10 })
await batchQueue.add('report-generation', data, { priority: 100 })
Queue Monitoring
import { QueueEvents } from 'bullmq'
const queueEvents = new QueueEvents('email', { connection })
queueEvents.on('waiting', ({ jobId }) => metrics.increment('jobs.waiting'))
queueEvents.on('active', ({ jobId }) => metrics.increment('jobs.active'))
queueEvents.on('completed', ({ jobId }) => metrics.increment('jobs.completed'))
queueEvents.on('failed', ({ jobId, failedReason }) => {
metrics.increment('jobs.failed')
console.error(`Job ${jobId} failed: ${failedReason}`)
})
queueEvents.on('stalled', ({ jobId }) => {
metrics.increment('jobs.stalled')
console.warn(`Job ${jobId} stalled — worker may have crashed`)
})
(): <> {
counts = queue.(, , , )
(counts. > ) {
({ : queue., : counts. })
}
(counts. > ) {
({ : queue., : counts. })
}
}
( (emailQueue), )
Remember: Use the outbox pattern whenever a DB write and an event publish must be atomic. Never publish directly inside a transaction — the broker call can fail after the DB commits, causing lost events.