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 + CDC | Celeriant |
|---|---|
| State table as the source of truth | The event log is the source of truth; state is a projection |
| Outbox table + Debezium | One conditional event write |
| Partition key for ordering | The aggregate: strict order per aggregate, plus a version you can condition on |
| Consumer group reading a topic | A watch that says which aggregates changed, then a read of the new events |
| Idempotent producer | Idempotent writes keyed on aggregate, client id and client sequence, checked by the server |
| Schema Registry; broker-side validation is a Confluent commercial feature | Schema validation on the server, included |
| Dead-letter queue for poison messages | An 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.