Skip to content
Asmar.
← Blog
ADRMay 24, 20262 min read

ADR: Outbox over dual-write for cross-context events

Why every state-changing event in the Ghasi suite goes through a transactional outbox instead of a direct publish — and what that costs.

Status
Accepted
Deciders
Asmar Momand (lead architect)
Date
May 24, 2026
ShareLinkedInX

This is a worked example of how I write ADRs for the Ghasi platforms — short context, a single decision, honest consequences. The status and deciders are tracked in the post's metadata.

Context#

Bounded contexts in the suite communicate through domain events on an event bus. The naive implementation is a dual-write: inside a request, the service commits its database transaction and publishes to the broker.

The failure mode is well known. The two writes are not atomic. The process can commit the row and then crash before the publish — or publish and then fail the commit. Either way, the database and the event stream disagree, and there is no transaction spanning both that would have saved us.

For an intake platform handling visas, claims, and permits, "the record says approved but the downstream never heard about it" is not an acceptable class of bug.

Decision#

Every state-changing event is written to a per-context outbox table inside the same transaction as the aggregate change. A separate relay process reads the outbox and publishes to the bus, marking rows as dispatched.

sql
-- Same transaction as the aggregate write
INSERT INTO outbox (id, aggregate_id, type, payload, tenant_id, created_at)
VALUES (gen_random_uuid(), $1, $2, $3, current_setting('app.tenant')::uuid, now());

The relay is idempotent on the consumer side via the event id, so re-delivery after a relay crash is safe.

Consequences#

What it costs:

  • An outbox table per bounded context, plus a relay process to operate and monitor.
  • Events are eventually consistent — consumers see them milliseconds-to-seconds later, not synchronously.
  • Relay lag is now a thing you must alert on.

What it wins:

  • Exactly-once-effective delivery without a distributed transaction or a two-phase commit.
  • The database is the single source of truth; the event stream is derived from it and can be rebuilt.
  • Tenant context travels on every envelope, set from the same transaction that wrote the row.

The pattern is old and unglamorous. That's exactly why it's load-bearing.

ShareLinkedInX

Keep reading

Stay in the loop

New posts, in your inbox.

When I publish on architecture, AI engineering, or the systems I'm building, you'll be the first to know.

New posts only. No spam; unsubscribe anytime.