Stage 7 · Master
Event-Driven Communication
The Outbox Pattern
A design specification for Meridian's future event publication path: solve the dual-write problem by recording event intent in the same transaction as the domain change, then relay it to Kafka afterward.
Why Dual Writes Fail
The moment a service must persist one business change in two different systems, it faces the dual-write problem. Suppose billing-service marks an invoice as paid in Postgres and then tries to publish invoice.paid to Kafka. If the database commit succeeds but the broker publish fails, the source of truth changed and downstream consumers never hear about it. If the publish happens first and the database commit fails afterward, consumers hear about a payment that never actually committed. Either order produces a correctness gap.
Retries reduce the probability of the gap but do not remove the category of failure. Distributed transactions are usually too operationally heavy for this style of system and still do not simplify the mental model for beginners. The outbox pattern exists because it converts one impossible 'commit to two systems at once' problem into two honest steps: commit the domain change and event intent together in one database transaction, then relay that durable intent to the broker asynchronously.
Workflow
Write: The service applies the domain mutation such as marking an invoice as paid inside Postgres.
Step 1 / 5 — Write
| Publication strategy | What looks attractive | Why Meridian should choose or reject it |
|---|---|---|
| Commit database, then publish broker | Minimal code path | Rejected because a broker failure after commit silently loses the integration event |
| Publish broker, then commit database | Consumers hear about the change quickly | Rejected because consumers can observe facts that never committed |
| Outbox row plus relay | Adds another table and worker | Chosen because durable publish intent lives in the same transaction as the domain state change |
A relay may still publish the same row more than once during retries or crash recovery. That is acceptable because the consumer side is already designed to be idempotent.
The Outbox Table Records Intent
An outbox table is not an audit log and not a trigger-generated copy of low-level table changes. It is a deliberate integration queue stored in the same database as the transactional data. Each row should identify the tenant boundary, the aggregate that changed, the event type, the serialized payload, the publication status, attempt counters, and timestamps that let operators answer what is pending or stuck.
CREATE TABLE outbox_events (
id uuid PRIMARY KEY,
tenant_id uuid NOT NULL,
aggregate_type text NOT NULL,
aggregate_id uuid NOT NULL,
event_type text NOT NULL,
payload jsonb NOT NULL,
status text NOT NULL CHECK (status IN ('pending', 'publishing', 'sent', 'failed')),
attempt_count integer NOT NULL DEFAULT 0,
locked_at timestamptz,
locked_by text,
last_error text,
created_at timestamptz NOT NULL DEFAULT NOW(),
updated_at timestamptz NOT NULL DEFAULT NOW(),
published_at timestamptz
);
CREATE INDEX idx_outbox_pending_created_at
ON outbox_events (status, created_at, id)
WHERE status IN ('pending', 'publishing');The schema answers both correctness and operations: what should be published, which tenant it belongs to, how many times publication has been attempted, and which rows are currently stuck.
Writing Domain State and Outbox Together
The critical rule is simple: if a business write should emit an integration event, the service writes the domain mutation and the outbox row inside one database transaction. This lesson uses billing-service as the concrete example because invoice.paid is easy to reason about, but the same pattern belongs in identity-service and community-service for their own events. The source service decides that a business fact occurred; the outbox makes that decision durable before the transaction commits.
apps/billing-service/internal/outbox/store.gocreateResponsibility: Insert outbox rows transactionally and expose query/update helpers for the relay.
Why now: The pattern will repeat across many event-producing operations. A focused package keeps the SQL rules consistent.
Connects to: Used by billing-service payment and invoice application services.
apps/billing-service/internal/payments/service.gomodifyResponsibility: Write invoice and payment state changes together with their outbox rows in one database transaction.
Why now: The dual-write problem is solved only when the originating domain service participates directly in the transactional insert.
Connects to: Publishes future billing events defined under libs/platform/events.
apps/billing-service/internal/outbox/relay.gocreateResponsibility: Claim pending rows, publish them to Kafka, and mark success or retry state back in Postgres.
Why now: An outbox table without a relay is only stored intent. The relay turns intent into delivered events.
Connects to: Relies on the idempotent-consumer rules from the previous lesson so safe retries are possible.
apps/community-service/internal/outbox/store.gocreateResponsibility: Apply the same transactional-outbox abstraction to complaint and resident events inside community-service.
Why now: Outbox is a cross-cutting publication pattern, not a billing-only optimization.
Connects to: Will later back events such as complaint.filed and announcement.published.
Relaying from Postgres to Kafka
A relay process runs separately from the request path. Its job is to fetch pending outbox rows, claim them safely so multiple workers do not publish the same row simultaneously, publish them to Kafka, and then mark them sent. FOR UPDATE SKIP LOCKED is a common and practical claiming strategy because it allows multiple relay workers to process the outbox concurrently without blocking one another on already-claimed rows.
Retries, Recovery, and Operations
Outbox shifts complexity into a place that is explicit and inspectable. That is a feature, not a drawback. Operators can now ask: Which tenant's events are backing up? How old is the oldest pending row? Which relay worker crashed while holding rows in publishing? Those are answerable SQL questions. The alternative—silent event loss during a direct dual write—is operationally simpler only until the first inconsistency investigation begins.
- Monitor the age of the oldest pending outbox row; growing age indicates broker or relay trouble even when request paths are still succeeding.
- Reset stale
publishingrows topendingafter a timeout so a crashed relay cannot strand them forever. - Archive or prune old
sentrows on a retention policy once they are no longer needed for audit or replay diagnostics. - Keep
tenant_idon every outbox row so backpressure, throttling, and incident analysis can remain tenant-aware.
The Decision
Meridian's future event publication path should use the outbox pattern in each transactional service. identity-service, community-service, and billing-service will record domain changes and event intent in one database transaction, then relay pending rows to Kafka asynchronously. That design does not yet exist in meridian, but it is the correct specification for avoiding silent event loss once Chapter 11 moves from scaffolding to implementation.
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.