Tandem

Your database already committed it. Kafka should have it too.

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.

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.

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.

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

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.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

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.

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.

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.

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.