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:

commit DB ✓ ──crash──▶ publish ✗ → state changed, nobody was told (lost event) publish ✓ ──crash──▶ commit DB ✗ → event sent, state never changed (phantom event)

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

The pattern (Chris Richardson) Don't publish directly. Instead, insert the event into an 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:

internal/golibs/outboxevents/outbox_events.go:89-104
// 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

The message relay = CDC (your CDC course) This repo doesn't poll the outbox table with a Go worker. The relay is Debezium: an 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:

internal/eureka/v2/modules/assessment/usecase/complete_assessment_session.go:311,392,411
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

Read this next

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?

DB and broker are separate systems with no shared transaction — a crash between the two steps loses an event or invents one.

Q2. How does the outbox pattern fix it?

One atomic commit — the state change and the outbox row together — then a relay ships the row. No dual write.

Q3. What relays the repo's outbox rows to Kafka?

Debezium tails the outbox_events table and routes each row to its topic — the CDC you learned earlier is the message relay here.
Recall: the dual-write problem, the outbox, and the repo's relay.
problem + pattern + repo + scope, then reveal
Dual-write problem: DB write + broker publish are two systems, no shared tx → a crash between them loses an event or invents a phantom. Outbox pattern (Richardson): INSERT the event into an outbox table in the same tx as the business write → one atomic commit; a message relay ships it later. Repo: golibs/outboxeventsInsert(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).
🎯 Interview one-liner "Explain the transactional outbox." → "You can't atomically write your DB and publish to a broker — two systems, no shared transaction. So you insert the event into an outbox table in the same transaction as the state change; one commit, both or neither. A relay then ships it — we use Debezium CDC to tail the outbox table and route rows to Kafka, so there's no poller and no lost events."
Last in Part 2: the honest part — your own services don't use the outbox. Next we see what they do instead, and when that trade-off is fine. Ask me how this ties back to the CDC course — it's the same machinery.

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).