Integration pattern
Reviewed 14 October 2026
Save the order and the event in one transaction.transactional outbox pattern
A service saves an order, then publishes an event. If it stops between the two, nobody is told. An outbox closes that gap.
No demo shows an outbox. The injector below is the demo.
Use it when
- A change in your database must always cause a message elsewhere: orders, stock, payments.
- The database and the broker cannot share one transaction, which is the usual case.
- You can make consumers handle a repeat, or you will.
Skip it when
- Only your own database needs the change: a consumer can read the table, and no broker is involved.
- Losing the odd event is harmless, or a reconciliation job would find it anyway.
- Nobody will run and watch a relay. A relay that stops is a silent failure.
Try it
Failure injector
Pick how the service saves and publishes, then stop the process after any step or take the broker down. See what the database, the broker and the consumer end up with. Sample data. Not a measurement.
How the service saves and publishes
What goes wrong
Off: it acts on every copy it receives.
- 1Database
- 2App
- 3Broker
- 4Consumer
The steps
Save the order (transaction open)Complete
CommitWhere it stops
Publish “order saved”
The consumer acts
Where things stand
| Where | Right after | After the restart |
|---|---|---|
| Order in the database | 1 (saved) | 1 (saved) |
| Unsent rows in the outbox | — | — |
| Events in the broker | 0 | 0 |
| Times the consumer acted | 0 | 0 |
To repair it. Only a comparison with the database finds this: a reconciliation job.
The worst moment to stop in each design
| Design | What is left |
|---|---|
| Save, commit, then publish | The order is saved. Nobody was told. Nothing remembers that an event was owed. |
| Publish, then commit | The consumer was told about an order that does not exist. The save never committed. |
| Outbox: save the event with the order | The order was saved at once. The event went out after the restart, once. Late, not lost. |
What is left after the restart, when the process stops right after the commit (publish-then-commit: right after the publish).
What you give up, and how it fails.
What you give up
| What you get | What it costs |
|---|---|
| The event exists if and only if the change committed. | A second table to write, read and clean up. Rows pile up when the relay is down. |
| A broker outage no longer stops orders. | The event is late by the relay's interval: a scheduler tick or a polling period. |
| No two-phase commit, and any broker works. | Delivery is at least once. The consumer must cope with a repeat. |
| Order per record can be kept by sequence. | More than one relay can reorder, and one relay is one more thing to watch. |
How it fails
| What happens | Why | Evidence |
|---|---|---|
| The relay publishes twice. | It can stop after publishing and before it records that it did; on restart it publishes again. The consumer has to recognise the event id. | |
| The relay never runs, and nothing says so. | By default Frappe's Email Queue is sent only when the scheduler flushes it. With the scheduler paused or disabled, rows wait. An unsent row's age is the only sign, so alert on it. | |
| A relay that dies mid-send leaves a row stuck. | Frappe marks a row Sending before it sends, and puts rows stuck for 15 minutes back in line, up to a retry limit of three. Past it the row becomes Error and a person has to look. | |
| Frappe's Webhook is not an outbox. | The pending send waits in the request's memory until the commit, then a job is queued in Redis. The first database row is written after an attempt. A process that stops between the commit and the queue leaves no record that a webhook was owed. | Follows from the designRead from:Frappe v15 · webhook queueFrappe v15 · enqueueFrappe v15 · webhook send |
| Medusa v2 stages events in Redis, with a timer. | Events from a workflow wait in a Redis list and go to the queue when the workflow ends. By default the list is set to expire 10 minutes after the last event was staged. The code's own comment says a long-running workflow would need a much higher TTL, or none. If the release fails, Medusa cancels the workflow. Redis event jobs make one attempt unless you set attempts. | |
| Events can arrive out of order. | Several relays, or a relay that retries one row, can deliver in a different order than the writes. AWS and microservices.io both list order as an issue. Keep a sequence per record. | DocumentedAWS outboxmicroservices.io outbox |
Each failure is Documented, with the maker's own page or code, or it Follows from the design. None is a result from a MithTech client project.
In ERPNext and Frappe, and Medusa.
Read from Frappe v15.123.0 and v16.52.0, ERPNext v15.122.0, Frappe's Ecommerce Integrations app (version-15, commit 3dc8eb81) and Medusa v2.21.2 on 14 October 2026, with the file and line. One Medusa row was read at v1.20.11, for history only. Other systems' names and settings were not checked here.
Where each part lives
| Feature | Where | What it does |
|---|---|---|
| An outbox you already run | For an ordinary message, frappe.sendmail saves a row in your transaction (delayed is the default). The scheduler sends rows that are Not Sent or Partially Sent, 500 at a time, locking each one. It marks Sending with a commit, records each recipient as sent, then marks Sent. An error puts the row back with retry plus one until the limit (3 by default), then Error.Frappe v15 · sendmailFrappe v15 · Email Queue flushFrappe v15 · Email Queue | |
| The relay's recovery | Rows stuck in Sending for more than 15 minutes go back to Not Sent or Partially Sent, or to Error after the limit.Frappe v15 · Email Queue flush | |
| When the relay runs | scheduler event | flush and retry_sending_emails run on every All tick: four minutes by default. Nothing runs while the scheduler is off.Frappe v15 · scheduler hooksFrappe · scheduled job typeFrappe v15 · scheduler |
| Not an outbox | Pending sends sit in the request's memory, then a background job is queued in Redis after the commit. The first database row, Webhook Request Log, is written after an attempt.Frappe v15 · webhook queueFrappe v15 · webhook sendFrappe v15 · enqueue | |
| Queue after the commit | Registers the enqueue to run after the commit. The queue is Redis, a different store from the database.Frappe v15 · enqueueFrappe v15 · commit hooks | |
| An inbox | The Shopify receiver saves the payload, commits, then queues the job.Ecommerce Integrations · Shopify receiverEcommerce Integrations · log | |
| Medusa v2 | events from workflows | Staged in Redis, released when the workflow ends, cleared if it fails. The staging list expires 10 minutes after the last event was staged, by default. The Local Event Module is for development; Redis is the production module.Medusa v2 · Redis event busMedusa v2 · release eventsMedusa v2 · emitEventStepMedusa Event Module |
| Medusa v1 did this | table | Events were saved in the caller's transaction. An enqueuer read the table every 3 seconds, published, then deleted the rows. Version 2 has no such table.Medusa v1 · staged jobs |
How to build it
List what must never be lost: the events whose absence a person would notice later.
Save the event in the same transaction as the change, in a table next to the data, with an id, a type, the payload and a created time.
Run a relay: read the oldest unsent rows, publish them, mark them sent. Poll the table (simple) or read the database log (lower delay, more to run).
Make the consumer idempotent: keep the ids you have seen, in the same transaction as the work.
Watch the age of the oldest unsent row. An alert on it is the only way to hear that the relay stopped. Clear sent rows on a schedule.