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
| Column | Purpose |
|---|---|
id | Message ID. Receivers can use it to drop duplicates. |
tenant_id | The tenant. Outbox rows are tenant-scoped like all other data. |
event_type | What happened, for example booking_confirmed. |
aggregate_type, aggregate_id | The record the event is about, for example a booking and its ID. |
payload | The event data, as JSON. |
destination_type | Where it goes: email, webhook, push, an analytics destination, or internal (a handler inside the API). |
destination_config | Destination details, for example the webhook endpoint. |
status | See below. |
priority | urgent, high, normal (default) or low. Higher runs first. |
retries, max_retries | Attempts so far, and the limit (default 3). |
next_attempt_at | Earliest time for the next attempt. Also used to delay a message. |
last_error, error_details | Why the last attempt failed. |
sent_at, dlq_at | When it was sent, or moved to the dead-letter queue. |
Status
| Status | Meaning |
|---|---|
pending | Waiting to be sent. |
processing | A worker has claimed it. |
sent | Delivered. |
failed | The last attempt failed. It is retried after next_attempt_at. |
DLQ | Retries are used up. It waits in the dead-letter queue for a person. |
cancelled | Withdrawn before sending. |
discarded | Removed from the dead-letter queue without sending. |
The worker
The worker runs on a short interval. Each run it:
- Selects due messages (
pendingorfailed,next_attempt_atin the past, retries left), highest priority first. Rows are locked withFOR UPDATE SKIP LOCKED, so several workers never take the same message. - Marks them
processing. - Hands each one to the adapter for its
destination_type. - Marks it
sent, orfailedwith a laternext_attempt_at. The delay grows with each attempt, up to about an hour or two. - 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
idoreventIdto recognise a repeat. - Use
delaySecondsto 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:
| Method | Path | Description |
|---|---|---|
GET | /admin/messaging/dlq | List 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-bulk | Retry several messages. |
GET | /admin/messaging/health | Queue health and counts. |
A growing dead-letter queue usually means a destination is down or misconfigured. Fix the cause first, then retry.