Normal-path cost
Writing and indexing an Outbox row for every order increases database work in the order transaction.
Kafka Outbox Fallback · Visual Companion
The normal path writes only orders and publishes directly to Kafka. A publish failure stores a replayable event_publications row; if that write also fails, reconciliation rebuilds publication data from the order.
Transactional outbox stores the order and publication data in one transaction. Kafka-first fallback reduces the normal-path database writes, but must recover timeouts and fallback-store failures separately.
Writing and indexing an Outbox row for every order increases database work in the order transaction.
Kafka may have stored the event while only its response was delayed. The application cannot declare non-delivery.
When both Kafka publication and the event_publications write fail, only the order remains.
Relay or reconciliation can publish an eventId that Kafka already accepted.
A second durable system would obscure the core PostgreSQL and Kafka trade-off in the first example.
This retains the strongest storage semantics but does not reduce normal-order database writes.
This reuses the current Exposed and PostgreSQL patterns while separating normal and recovery costs.
This handles duplicate publication caused by timeouts and reconciliation.
Switching the approach changes database writes, Kafka waiting, and the recovery reference together. The write count is a structural fact, not a benchmark result.
The architecture view shows responsibilities and data access, not detailed timing. Use the recovery simulator below for ordered behavior.
OrderController → PlaceOrderUseCaseorder requestPlaceOrderUseCase → TransactionalOrderWriter → ordersorder transactionPlaceOrderUseCase → OrderEventPublisher → Kafkadirect publish after commitOrderEventPublisher → EventPublicationRepositorystore after failureEventPublicationRelay → event_publications → Kafkaclaim and republishPublicationReconciler → orders → event_publicationsreconstruct missing rowsAdvancing the scenario changes the active layer and cumulative state. A Kafka timeout uses unknown, not zero, for the received-event count.
A claim prevents two workers from processing the same row at the same time. Duplicate publication is still possible if the process stops after Kafka confirms but before PostgreSQL records PUBLISHED.
Select a state to inspect its entry condition and next action. CLAIMED is not an EventPublicationStatus enum value.
The class view includes only public methods and use relationships that map to the scenarios.
Order writes, eventId upserts, claims, and SQL anti-joins run through different repository methods.
TransactionalOrderWriter.saveOrder() changes only orders. Kafka and event_publications run after commit.
OrderPlacedEvent.from(order) uses order-placed:{orderId}:v1. The unique event_id upsert collapses retries and reconstruction into one row.
claimNextBatch() applies state, nextAttemptAt, claim expiry, ordering, and batch limits in the database.
findOrdersWithoutPublicationsCreatedOnOrBefore() uses a cutoff and anti-join to find orders without publication data.
The application uses PostgreSQL and Kafka. Demo admin endpoints are disabled by default.
| Meaning | KafkaOutboxFallbackFlowTest |
|---|---|
| order transaction writes only orders | transactional writer stores only order row |
| direct success without a fallback row | placeOrder stores only order row and returns PUBLISHED_DIRECT when direct Kafka publish succeeds |
| NOT_PUBLISHED after three failures | direct publish retries three times then stores NOT_PUBLISHED fallback row |
| bounded response time and fallback storage | direct publish timeout stores NOT_PUBLISHED fallback row |
| visible FALLBACK_STORE_FAILED result | fallback insert failure returns FALLBACK_STORE_FAILED and records safe metric and log |
| PUBLISHED after relay | relay publishes fallback row and marks it PUBLISHED |
| DEAD_LETTER after the third failure | relay failure increments retry and moves to DEAD_LETTER |
| one claim winner under concurrency | concurrent relay calls cannot claim the same row twice |
| expired claims become eligible | stale relay claim becomes eligible after claim ttl |
| ordering and batch limits in SQL | claimNextBatch applies SQL eligibility ordering and limit |
| stable eventId reconstruction | reconciler reconstructs deterministic fallback row and documents duplicate risk |
| SQL cutoff and anti-join | reconciler uses SQL cutoff and anti join for missing publications |
| no raw payload or sensitive error text | publication endpoint never exposes raw payload or raw exception text |
| direct, fallback, relay, and reconciler metrics | metrics record direct failure fallback relay and reconciler outcomes |
The normal path writes only orders and does not persist publication data.
A relay claims NOT_PUBLISHED rows and republishes them to Kafka.
Reconciliation rebuilds publication data from orders after the grace period.
The application cannot determine whether Kafka stored the event.
Claims and stable eventIds do not provide exactly-once delivery.
Consumers must prevent duplicate business effects for the same eventId.