Tandem is a Java library implementing the Transactional Outbox Pattern: reliable, strictly-ordered event delivery from PostgreSQL or MySQL to Apache Kafka.
No CDC. No Kafka Connect. No two-phase commit. Just your relational database and your broker — with the correctness traps already handled.
The whole idea
The classic double write — write to the database, then publish to Kafka as two non-atomic steps — diverges permanently on partial failure. Tandem removes it: the domain change and the event are one commit, guaranteed by the database you already trust.
If the relay crashes after publishing but before marking the row done, it republishes: a duplicate, which your consumer can handle — never a divergence, which nobody can.
BEGIN TX UPDATE aggregate SET version = version + 1 WHERE id = ? FOR UPDATE INSERT INTO tandem_outbox (aggregate_id, type, seq, payload, ...) COMMIT TX ← both or neither, guaranteed by the DB
Quickstart
tandem-sample is a self-contained tutorial. It starts a real PostgreSQL and
a real Kafka via Testcontainers, inserts five events for two interleaved orders, and
verifies that they arrive in per-aggregate sequence order.
Requires Java 17+ and Docker. Nothing is fetched from Maven Central — it builds and runs the modules in the repository. Containers stay up until you press ENTER, and the script prints the JDBC and Kafka details so you can poke at them yourself.
git clone https://github.com/alirux/tandem.git cd tandem ./tandem-sample/run.sh
On Spring Boot?
./tandem-sample-spring/run.sh boots an application that writes through the
@TransactionalOutbox tiers and leaves the Admin API running to curl.
Operating it
tandem-cli outbox summary --watch — the outbox, redrawing in
place. A Go binary over the same Admin API contract, never a second control path.
What you get
Every aggregate hashes into one of a fixed set of buckets, and each bucket has exactly one owning worker — ordering is structural, not a per-aggregate lock that can be lost to a race. Full parallelism across aggregates, the Kafka partition-key model, enforced end to end.
Sharded SKIP LOCKED polling, lease-based failover, exponential backoff,
and poison-message isolation: a stuck event blocks only its own aggregate.
Messages are published in the CNCF CloudEvents envelope (binary mode), so the wider ecosystem can consume them without a bespoke reader.
Backlog depth, oldest-waiting age, permanently-failed count, blocked-behind-a-failure count, live workers. A pluggable port with a Micrometer adapter — a no-op default costs nothing.
A consumed event can be traced back to the domain transaction that produced it: the trace context rides on the row at insert, and the relay emits a publish span at the real send instant. Off by default; the correlation id alone needs no tracing library at all.
An optional REST module (off by default) to search the outbox, replay, discard, and pause or resume the relay — API-first, audit-logged, with a Go CLI over the same contract.
The relay runs inside your application or in a process of its own, and coordinates one
or many instances through the database alone: SINGLE owns every bucket at
zero cost, LEASE partitions ownership across a horizontally-scaled
deployment.
The part your application imports is the outbox INSERT. tandem-core has
zero runtime dependencies; Kafka, CloudEvents and tracing live on the relay side.
Install
dependencies {
implementation(platform("com.codingful:tandem-bom:x.y.z"))
implementation("com.codingful:tandem-jdbc")
implementation("com.codingful:tandem-kafka")
testImplementation("com.codingful:tandem-test")
}
Published to Maven Central under the com.codingful group. Import the BOM
and declare modules without a version; take the current one from
Maven
Central.
The write side (tandem-jdbc) pulls no Kafka dependency — add
tandem-kafka only where the relay runs. On Spring Boot, take
tandem-spring-producer where you write and
tandem-spring-relay where the relay runs; one artifact serves Boot 3.x and
4.x alike.
Read on
Usage, configuration, Spring tiers, known limitations.
The architecture, the data model, and why each decision went the way it did.
Javadoc for every published module, straight from Maven Central.
Every RFC 9457 error the Admin API can return, and what to do about it.