Tandem is a Java library implementing the Transactional Outbox Pattern: the domain change and the event commit together in PostgreSQL, and a relay publishes them to Apache Kafka in per-aggregate order.
No CDC. No Kafka Connect. No two-phase commit. Just your relational database and your broker — with the correctness traps already handled.
Zoomed in
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.
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
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.
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-transactional-outbox-kafka.git tandem 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.
The relay polls fast while a bucket is receiving events and slows down when it goes quiet. Low delivery latency under traffic, without a tight poll running all day against an idle database.
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. Nothing
on that path holds a lock, so it runs unchanged on virtual threads.
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.
Task-oriented chapters, from a first setup to running Tandem in production: getting started, consuming events, observability, reliability, the Admin API and the CLI, configuration and troubleshooting.
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.
The message-flow diagram above, on its own page — pause it, step through each stage.
On the name. A tandem is a bicycle whose riders share one frame and one drivetrain: they cannot pedal off to different destinations, and neither of them arrives without the other. The domain change and the event announcing it ride the same way — one transaction, committed or rolled back together.
The word is Latin for at length, borrowed into English as a pun about horses harnessed one behind the other rather than side by side. That sense is in here too: events for one aggregate leave single file, in the order they were committed.