Outbox Pattern skill for Codex
Publish if and only if the transaction committed. Codex hunts down dual writes where code saves to the database and then publishes to a broker, replaces them with an outbox row in the same transaction, and adds a relay: a polling publisher with FOR UPDATE SKIP LOCKED by default, or the Debezium outbox event router when Kafka Connect exists. It preserves per-aggregate ordering, adds an inbox for idempotent consumers, and wires cleanup, lag alerts and a replay procedure.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- outbox-pattern Installs to ~/.agents/skills/outbox-pattern/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install outbox-pattern The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project outbox-pattern Installs to .agents/skills/outbox-pattern/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/outbox-pattern.zipunzip -o outbox-pattern.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) outbox-pattern PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $outbox-pattern, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Implements the transactional outbox and inbox patterns so database changes and published messages never diverge - outbox table written in the same transaction, a polling relay with FOR UPDATE SKIP LOCKED or CDC with the Debezium outbox event router, per-aggregate ordering, cleanup, and an inbox table for idempotent consumers. Use when code writes to a database and then publishes to Kafka, RabbitMQ, SQS or webhooks, when events are lost or sent for rolled-back data, or when designing reliable messaging between services.
Use it when
- Code writes to a database and then publishes a message or webhook
- Events go missing after crashes, or are sent for rolled-back data
- Consumers apply the same message twice after redelivery
- Designing reliable integration between services that own separate databases
Use something else when
- You need the overall event and broker design rather than delivery mechanics: use event-driven-architecture
- You need full Debezium connector setup and operations: use cdc-debezium
How to check the result
Killing the relay between publish and mark causes a republish that consumers apply once, a rolled-back transaction leaves no outbox row, and events for one aggregate arrive in creation order.
The mistake it prevents
Running several polling relay workers with SKIP LOCKED and no sharding, which lets later events for the same aggregate overtake earlier ones.
Done well: a message is published if and only if the business transaction committed, at least once, in per-aggregate order; consumers apply each message exactly once in effect; and the outbox stays small, monitored and replayable.
The bug this fixes
# Dual write: two systems, no atomicity
db.save(order) # commits
broker.publish(order_placed) # process dies here -> event lost forever
Reversing the order is no better: publish then crash before commit announces an order
that does not exist. Retrying the publish in a finally does not survive a crash.
Search for this shape in the codebase first:
rg -n "publish|send_message|producer\.send|sns\.publish|sqs\.send_message|channel\.basic_publish" -g '!*test*' | head -40
Any publish next to a database write, outside the database transaction, is a candidate.
The pattern
service transaction: UPDATE orders ...; INSERT INTO outbox (...) -- one COMMIT
relay (separate): read outbox -> publish to broker -> mark published
consumer: INSERT INTO inbox (message_id) ON CONFLICT DO NOTHING; apply; COMMIT
Outbox table (PostgreSQL)
CREATE TABLE outbox (
id uuid PRIMARY KEY, -- also the message id consumers dedupe on
aggregate_type text NOT NULL, -- 'order' -> routes to a topic
aggregate_id text NOT NULL, -- partition/ordering key
event_type text NOT NULL, -- 'OrderPlaced'
payload jsonb NOT NULL,
headers jsonb NOT NULL DEFAULT '{}', -- traceparent, schema version
created_at timestamptz NOT NULL DEFAULT now(),
published_at timestamptz
);
CREATE INDEX outbox_unpublished ON outbox (created_at) WHERE published_at IS NULL;
Write it in the same transaction as the business change:
with db.transaction() as tx:
tx.execute("UPDATE orders SET status = 'placed' WHERE id = %s", (order.id,))
tx.execute(
"INSERT INTO outbox (id, aggregate_type, aggregate_id, event_type, payload, headers) "
"VALUES (%s, 'order', %s, 'OrderPlaced', %s, %s)",
(uuid7(), order.id, json.dumps(payload), json.dumps({"traceparent": current_traceparent()})),
)
Use time-ordered IDs (UUIDv7, ULID) so id order roughly matches creation order.
Relay: choose one
| Relay | Pick when | Notes |
|---|---|---|
| Polling publisher | Default; any SQL database; low to moderate volume (hundreds/s) | Simple; adds polling latency (100 ms - 1 s) and DB load |
| CDC with Debezium outbox event router | Kafka already in place; high volume; low latency | Reads the WAL; no polling; needs Kafka Connect and replication slot care |
| Managed CDC (DynamoDB Streams, Firestore triggers, Mongo change streams) | That datastore | Outbox is often the item itself or a sibling item in the same transaction |
Polling publisher
-- One batch, inside a transaction held only for the batch
SELECT id, aggregate_type, aggregate_id, event_type, payload, headers
FROM outbox
WHERE published_at IS NULL
ORDER BY created_at, id
LIMIT 100
FOR UPDATE SKIP LOCKED;
-- publish each (await broker acks), then:
UPDATE outbox SET published_at = now() WHERE id = ANY(%(ids)s);
- Publish, wait for the broker acknowledgement, then mark published. A crash between the two republishes the batch: that is the at-least-once guarantee, handled by the inbox.
- Ordering:
SKIP LOCKEDwith several relay workers lets a later event for the same aggregate overtake an earlier one. If per-aggregate order matters, run one active relay (leader election or an advisory lock:SELECT pg_try_advisory_lock(42)), or shard workers byhashtext(aggregate_id) % N. - Reduce latency without hammering the DB:
NOTIFY outboxin the transaction andLISTENin the relay as a wake-up signal, with polling as the fallback. - Set the broker key to
aggregate_idso ordering holds through the broker partitions.
Debezium outbox event router (Kafka Connect)
connector.class=io.debezium.connector.postgresql.PostgresConnector
plugin.name=pgoutput
table.include.list=public.outbox
transforms=outbox
transforms.outbox.type=io.debezium.transforms.outbox.EventRouter
transforms.outbox.table.field.event.id=id
transforms.outbox.table.field.event.key=aggregate_id
transforms.outbox.table.field.event.payload=payload
transforms.outbox.route.by.field=aggregate_type
transforms.outbox.route.topic.replacement=${routedByValue}.events
Field names above match the table in this skill; Debezium’s defaults are aggregateid,
aggregatetype and type, so map them if you rename. With CDC the relay never updates
rows; delete outbox rows soon after insert (even in the same transaction) since the WAL
already carries them. Monitor replication slot lag: a stalled connector makes Postgres
retain WAL until the disk fills. Connector setup details belong to $cdc-debezium.
Inbox (consumer side)
CREATE TABLE inbox (
consumer text NOT NULL,
message_id uuid NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (consumer, message_id)
);
def handle(msg):
with db.transaction() as tx:
fresh = tx.execute(
"INSERT INTO inbox (consumer, message_id) VALUES ('shipping', %s) ON CONFLICT DO NOTHING",
(msg.id,),
).rowcount == 1
if fresh:
create_shipment(tx, msg.payload)
msg.ack() # only after commit; a crash before ack redelivers -> deduped
When the consumer’s effect is naturally idempotent (an upsert keyed by entity with a
version check), the inbox table is optional. When the effect is an external call (email,
payment), pass msg.id as the downstream idempotency key as well.
Housekeeping
- Delete or archive published rows on a schedule (
DELETE ... WHERE published_at < now() - interval '7 days'in batches). Keep enough history to replay recent incidents. - Prune inbox rows older than the broker’s maximum redelivery window.
- Metrics: count and age of the oldest unpublished row (alert when age > 1 minute), relay publish errors, consumer DLQ depth.
- Replay: re-publishing is
UPDATE outbox SET published_at = NULL WHERE .... Confirm with the user before replaying to production consumers; downstream side effects repeat for any consumer that is not idempotent.
Workflow
- [ ] 1. Read AGENTS.md; find every dual write (rg above) and the broker client in use
- [ ] 2. Add the outbox migration; write outbox rows inside existing transactions
- [ ] 3. Choose relay (polling by default, Debezium if Kafka Connect exists)
- [ ] 4. Preserve per-aggregate order: single relay or sharded workers, broker key = aggregate_id
- [ ] 5. Add inbox or idempotent handling to every consumer
- [ ] 6. Add cleanup job, oldest-unpublished-age alert and a replay procedure
- [ ] 7. Remove the old direct publish calls
Gotchas
- Publishing inside the transaction (before commit) still announces rolled-back data. Only the relay publishes.
- ORMs that flush late can insert the outbox row in a different transaction than the aggregate change if you open a new session. Use the same session/unit of work.
- The payload must be built from committed state in the transaction, not re-read later by the relay, or it may describe a newer version.
- Large payloads bloat the table and WAL; store a reference to blob storage for big data.
- A relay that marks rows published before the broker acks loses messages on broker errors.
Verification
- Kill the relay between publish and mark: on restart the message is published again and consumers apply it once (inbox row count equals distinct message IDs).
- Roll back a business transaction in a test: no outbox row, no message.
- Send several events for one aggregate rapidly with multiple relay workers: consumers see them in creation order.
- The oldest-unpublished-age alert fires when the relay is stopped.
Output contract
**Dual writes replaced:** <file:line list>
**Outbox:** migration <path>; written in <n> transactions
**Relay:** polling (single leader via advisory lock) | Debezium EventRouter -> <topics>
**Ordering key:** aggregate_id; broker key set
**Consumers:** inbox table in <services>; idempotency key passed to <external APIs>
**Ops:** cleanup <schedule>, alert on oldest unpublished > 60 s, replay procedure <doc> What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Outbox Pattern"
short_description: "Publish if and only if the transaction committed"
default_prompt: "$outbox-pattern We save the order and then publish to Kafka - sometimes the event is lost. Fix it with an outbox" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.