Tandem

Events reliably delivered

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.

Tandem architecture: your application writes the domain change and the outbox row in one transaction to PostgreSQL; the Tandem relay polls tandem_outbox, publishes to Apache Kafka keyed by aggregate_id, and marks the row done.
The write and the outbox row commit together. A separate relay polls, publishes, and marks the row done — at-least-once, in per-aggregate order.

Zoomed in

Same flow, message by message

The whole idea

One transaction, no dual write

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

See it work before you read another word

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

A stuck outbox is visible, and actionable

tandem-cli outbox summary --watch — a live terminal dashboard with colour-coded bar charts for pending, in-flight and failed message counts.
tandem-cli outbox summary --watch — the outbox, redrawing in place. A Go binary over the same Admin API contract, never a second control path.
Tandem relay metrics on a live Grafana dashboard, showing the backlog and the blocked-versus-claimable split during a failing aggregate.
The relay's own signals on Grafana during a failing aggregate — including how many events are blocked behind a poison message, reported separately from a relay that is merely falling behind.

What you get

Built for the 80% case, on purpose

Per-aggregate ordering

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.

At-least-once relay

Sharded SKIP LOCKED polling, lease-based failover, exponential backoff, and poison-message isolation: a stuck event blocks only its own aggregate.

A poll interval that adapts

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.

CloudEvents by default

Messages are published in the CNCF CloudEvents envelope (binary mode), so the wider ecosystem can consume them without a bespoke reader.

Metrics you can alert on

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.

Traces that survive the outbox

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.

Admin API and CLI

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.

Embedded or standalone

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.

A client that stays light

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

Add it to your build

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

README

Usage, configuration, Spring tiers, known limitations.

User guide

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.

Design documents

The architecture, the data model, and why each decision went the way it did.

API reference

Javadoc for every published module, straight from Maven Central.

Problem types

Every RFC 9457 error the Admin API can return, and what to do about it.

How it works

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.