Skip to main content

Coming from Kafka

You are probably not event sourcing yet, even with a lot of events flying around. The common shape: each service owns a Postgres database and writes its state there with ordinary CRUD. To tell other services what happened, it writes a row to an outbox table in the same transaction. Debezium tails the WAL and publishes those rows to Kafka; other services consume them and update their own state tables. Events exist, but they are a side effect of state changes, not the source of truth.

The dual-write problem

The outbox papers over one flaw: you cannot atomically update your state and publish an event to two different systems. The outbox makes the event part of the local transaction, and CDC makes the publish eventually happen. It works. It is also a pile of infrastructure (outbox tables, Debezium, Kafka Connect, a dead-letter queue) whose entire job is keeping your state and your events from drifting apart.

And it still does not enforce invariants

The outbox keeps state and events aligned inside one service. Across services it does nothing. A service validates its next event against state built from topics it consumes, and those topics lag. Two services validate against stale views of each other, both pass, both writes land, and the log now holds a state no validator would have allowed.

No outbox tuning fixes this. Kafka appends unconditionally; there is no "append only if this key is still at offset N", and the request for one has been open since 2015.

Celeriant flips the order

The event is the write. You append it to Celeriant as the source of truth, with a conditional write that enforces your invariant, and project state from the log afterward into the Postgres you already run. There is no second write to keep in sync, so the outbox table, the Debezium deployment and the DLQ go in the bin. The dual-write problem does not exist when there is only one write.

What maps to what

Kafka + outbox + CDCCeleriant
State table as the source of truthThe event log is the source of truth; state is a projection
Outbox table + DebeziumOne conditional event write
Partition key for orderingThe aggregate: strict order per aggregate, plus a version you can condition on
Consumer group reading a topicA watch that says which aggregates changed, then a read of the new events
Idempotent producerIdempotent writes keyed on aggregate, client id and client sequence, checked by the server
Schema Registry; broker-side validation is a Confluent commercial featureSchema validation on the server, included
Dead-letter queue for poison messagesAn event that fails its registered schema is rejected at write time and never enters the log

Durability and throughput

acks=all acknowledges once the in-sync replicas hold the record in page cache. Nothing is fsync'd. Correlated power loss across the ISR loses acknowledged writes. Celeriant acknowledges after fdatasync on both nodes.

On identical hardware, one write per request and no client batching, Kafka peaked at 24,162 writes a second on three brokers. Celeriant did 446,667 on two nodes, with the stronger durability. Kafka is built for batched streaming, and with batching on it moves far more records; that is the workload to keep it for. See Performance.

A model shift, not a library swap

You stop thinking state-first and start thinking events-first: the event is what happened, state is a view you rebuild from events. If your team has not event-sourced before, read Event sourcing and CQRS first.

What stays on Kafka

High-volume streaming and fan-out where nothing depends on shared state and nobody needs a conditional write. Kafka still moves streams between systems well. Use both. See When not to use Celeriant.