Lesson 7 · Keep it correct — reliability
The transactional outbox
You can't update a database and publish a message atomically — two systems, no shared transaction. The outbox pattern is the standard fix, and this repo relays it in a way you already know: CDC.
Your win: state the dual-write problem, explain how the outbox solves it, and describe the repo's eureka-only implementation — including what actually ships the message.
The dual-write problem
A service does two things on one request: write to its database, and publish an event to Kafka. These are two separate systems — there's no transaction spanning both. So whatever order you pick, a crash in between breaks consistency:
You can't 2-phase-commit across Postgres and Kafka in practice. This is the dual-write problem, and it's the reason the outbox pattern exists.1
The outbox: make it one write
outbox table in the
same database transaction as the business write. Now there's only one atomic
commit — the state change and the event succeed or fail together. A separate
message relay reads the outbox table afterwards and publishes to the broker. No
dual write, no lost or phantom events.1
The repo's outbox — and its doc-comment says it best
The implementation is a golib whose own comment is the clearest statement of the guarantee:
// Insert MUST be called inside the same database transaction as the business write it
// accompanies. … the event row is committed atomically with the domain change, so
// Debezium can never miss it even if the process crashes after the commit.
database.ExecInTx(ctx, db, func(ctx, tx pgx.Tx) error {
if err := repo.Create(ctx, tx, entity); err != nil { return err }
return outboxEvents.Insert(ctx, tx, OutboxEventInsert{
AggregateType: constants.MyTopic, // → Kafka topic
AggregateID: &entity.ID, // → partition key
Payload: myEventPayload, // → JSON body
})
})
The domain write (repo.Create) and the event (outboxEvents.Insert) share
the same tx — one commit, both or neither.2
The relay is Debezium — you already know this
outbox_events table added to the debezium_publication, a source
connector (hephaestus/connectors/source/eureka_outbox.json — the only
*_outbox.json), and Debezium's EventRouter turns each inserted row into a
Kafka message on the topic named by aggregate_type. So the outbox is
"insert a row"; CDC does the shipping. There is no outbox poller goroutine to look
for.2
It's used in exactly one place — and it's flagged
Despite being a shared golib, the outbox is eureka-only and gated behind an Unleash feature flag. The real caller is the assessment usecase:
useOutbox, _ := UnleashClient.IsFeatureEnabled(constants.UnleashArchitectOutboxEvents, …) // :311
// … inside ExecInTx, alongside the domain writes:
a.OutboxEvents.Insert(ctx, tx, …) // :392, :411
So the "correct" reliable-publish pattern exists, is well-documented, and is used by one service behind a flag. What everyone else does — including your own services — is the subject of Lesson 8.2
Transactional Outbox — Chris Richardson
The canonical write-up of the dual-write problem and the outbox solution — the reference every interviewer means when they ask.
→ microservices.io — Transactional outbox
→ in-repo internal/golibs/outboxevents/outbox_events.go,
deployments/docs/outbox_pattern.md, migrations/eureka/1614_migrate.up.sql
Check yourself (from memory)
Q1. What is the dual-write problem?
Q2. How does the outbox pattern fix it?
Q3. What relays the repo's outbox rows to Kafka?
outbox_events table and routes each
row to its topic — the CDC you learned earlier is the message relay here.
outbox table in the same tx
as the business write → one atomic commit; a message relay ships it later.
Repo: golibs/outboxevents — Insert(ctx, tx, {AggregateType→topic,
AggregateID→key, Payload}) inside ExecInTx; the relay is Debezium CDC
(EventRouter, eureka_outbox.json), NOT a Go poller. Scope: eureka-only,
behind an Unleash flag (complete_assessment_session.go:311,392,411). Everyone else does
something different (Lesson 8).1. Chris Richardson — Transactional outbox.
2. In-repo (verified): internal/golibs/outboxevents/outbox_events.go:55,64-104; deployments/docs/outbox_pattern.md; deployments/helm/backend/hephaestus/connectors/source/eureka_outbox.json; .../assessment/usecase/complete_assessment_session.go:311,392,411 (flagged).