Transactional Outbox Pattern
debt(d9/e7/b7/t9)
Closest to 'silent in production until users hit it' (d9). The detection_hints field explicitly states automated detection is 'no', and the code_pattern hint (outbox|publish.*commit) is a regex grep, not a tool-based check. The dual-write gap — broker publish succeeding after DB commit fails, or vice versa — produces silent event loss that only surfaces when users notice missing events or data inconsistencies in production. No tool in the hints list catches this.
Closest to 'cross-cutting refactor across the codebase' (e7). The quick_fix describes adding an outbox table to every service that publishes events, writing to it within the same transaction as domain data, and building an idempotent relay process. This is not a single-component fix — it requires schema changes per service, transaction scope changes, a new relay/poller infrastructure component, and cleanup jobs. The scope 'applies_to: web, cli, queue-worker' confirms the cross-cutting nature.
Closest to 'strong gravitational pull' (e7). The outbox pattern applies across web, cli, and queue-worker contexts for every service that publishes events. Every new event-publishing feature must follow the outbox convention: write to outbox table in transaction, rely on relay. The relay process and outbox table schema become load-bearing infrastructure. Any service touching event publishing is shaped by this choice, though it doesn't redefine the entire system architecture.
Closest to 'catastrophic trap — the obvious way is always wrong' (t9). The misconception is stated directly: 'A DB transaction plus broker publish are atomic — they're not.' The obvious, intuitive approach (publish to broker inside or immediately after a DB transaction) is always wrong. The common_mistakes confirm this: dual-write without outbox is the default pattern developers reach for. The failure mode is silent event loss, making this exactly the kind of trap where the natural implementation is the broken one.
TL;DR
Explanation
Problem: update DB and publish message — if broker publish fails after DB commit, event is lost. Solution: write to an 'outbox' table in the same DB transaction as the domain data. A separate relay process reads the outbox and publishes to the broker, deleting rows after confirmed publish. This achieves atomic write-to-DB and guaranteed eventual publish. CDC (Change Data Capture): use Debezium to read DB transaction log and publish to Kafka — no outbox table needed. Polling relay: simpler, slight delay. Event store: Kafka as the source of truth (event sourcing). Implementations: Symfony Messenger Doctrine transport, Transactionally Outbox libraries.
Common Misconception
Why It Matters
Common Mistakes
- Dual-write without outbox — publish to broker then DB commit (or vice versa) can leave them inconsistent.
- Not cleaning up old outbox rows — table grows unboundedly.
- Relay not idempotent — duplicate publish on relay restart.
Code Examples
// Dual-write — inconsistent on failure:
$db->beginTransaction();
$db->insert('orders', $orderData);
$db->commit();
$broker->publish('order.created', $event); // Fails? Event lost.
// Outbox pattern:
$db->beginTransaction();
$db->insert('orders', $orderData);
$db->insert('outbox', [
'topic' => 'order.created',
'payload' => json_encode($event),
'created_at' => now(),
]);
$db->commit();
// Relay process reads outbox and publishes asynchronously
// Idempotent: broker publish + delete outbox row