Skip to main content

Outbox pattern

Sessiq sends emails, webhooks, push notifications and analytics events as a side effect of booking changes. Those sends must not be lost, and they must not happen for a change that was rolled back. The outbox pattern gives both.

The problem​

If the code confirms a booking and then calls an email provider directly, two things can go wrong:

  • the database commit succeeds, the send fails, and the customer never hears about the booking
  • the send succeeds, the commit fails, and the customer gets an email for a booking that does not exist

The solution​

Instead of sending, the code writes a row to the message_outbox table in the same database transaction as the booking change. Either both are saved or neither is.

A background worker then reads pending rows, delivers them, and records the result. Delivery is at least once: a message can be sent twice if the worker stops between sending and recording, so handlers and receivers must be idempotent.

Booking change + outbox row   ──commit──►   message_outbox (pending)
│
worker picks it up
│
┌─────────────┴─────────────┐
success failure
│ │
sent failed, retry later
│
retries used up → DLQ

The outbox table​

ColumnPurpose
idMessage ID. Receivers can use it to drop duplicates.
tenant_idThe tenant. Outbox rows are tenant-scoped like all other data.
event_typeWhat happened, for example booking_confirmed.
aggregate_type, aggregate_idThe record the event is about, for example a booking and its ID.
payloadThe event data, as JSON.
destination_typeWhere it goes: email, webhook, push, an analytics destination, or internal (a handler inside the API).
destination_configDestination details, for example the webhook endpoint.
statusSee below.
priorityurgent, high, normal (default) or low. Higher runs first.
retries, max_retriesAttempts so far, and the limit (default 3).
next_attempt_atEarliest time for the next attempt. Also used to delay a message.
last_error, error_detailsWhy the last attempt failed.
sent_at, dlq_atWhen it was sent, or moved to the dead-letter queue.

Status​

StatusMeaning
pendingWaiting to be sent.
processingA worker has claimed it.
sentDelivered.
failedThe last attempt failed. It is retried after next_attempt_at.
DLQRetries are used up. It waits in the dead-letter queue for a person.
cancelledWithdrawn before sending.
discardedRemoved from the dead-letter queue without sending.

The worker​

The worker runs on a short interval. Each run it:

  1. Selects due messages (pending or failed, next_attempt_at in the past, retries left), highest priority first. Rows are locked with FOR UPDATE SKIP LOCKED, so several workers never take the same message.
  2. Marks them processing.
  3. Hands each one to the adapter for its destination_type.
  4. Marks it sent, or failed with a later next_attempt_at. The delay grows with each attempt, up to about an hour or two.
  5. After the last retry, moves it to the dead-letter queue.

Writing a message​

Write the outbox row inside the same tenant transaction as the change it describes. In the API, use OutboxService.createMessage() from the notifications module. It runs in a reusable tenant transaction, so when you call it inside your own reusable withTenant block, both writes commit together.

import { withTenant } from '@booking/db/rls-context';

await withTenant(
tenantId,
async (tx) => {
await tx.booking.update({
where: { id: bookingId },
data: { lifecycleStatus: 'confirmed' },
});

await this.outbox.createMessage({
tenantId,
eventType: 'booking_confirmed',
aggregateType: 'booking',
aggregateId: bookingId,
destinationType: 'email',
payload: { bookingId },
eventId: `booking_confirmed:${bookingId}`,
});
},
{ reusable: true },
);

Guidelines:

  • Never send inside the transaction. Write the outbox row; let the worker send.
  • Keep payloads small and stable. Put IDs in the payload and let the handler load current data where that makes sense.
  • Make handlers idempotent. Use the message id or eventId to recognise a repeat.
  • Use delaySeconds to schedule a message for later instead of building your own timer.

Dead-letter queue​

Staff with the admin:write permission can inspect and replay dead-lettered messages:

MethodPathDescription
GET/admin/messaging/dlqList dead-lettered messages.
GET/admin/messaging/dlq/{id}One message, with its error.
POST/admin/messaging/retry/{id}Send it to the queue again.
POST/admin/messaging/retry-bulkRetry several messages.
GET/admin/messaging/healthQueue health and counts.

A growing dead-letter queue usually means a destination is down or misconfigured. Fix the cause first, then retry.