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
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.
-- 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
outboxtable 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.
Keep reading
ULIDs over UUIDs: standardizing identifiers across 40 services
An implicit decision left unenforced becomes 40 services split 60/40 between two incompatible identifier shapes. Here's the standard I wrote to close the door — and why ULID won.
- Architecture
- PostgreSQL
- Platform engineering
- DDD
Stop calling new Date() in your services
Time and randomness are infrastructure. When every service rolls its own clock port, you get six file paths and three interface names for one concern. The fix is one platform port — and the testability it unlocks.
- Testability
- Hexagonal architecture
- DDD
- TypeScript
AI-first is a posture, not a feature
Most platforms bolt AI onto a non-AI core. Treating it as foundational architecture — a single governed gateway, provenance on every artifact — changes what you can promise.
- AI-first
- Architecture
- Governance
- DDD