DEV Community

sayner
sayner

Posted on

How I Shipped a Java Library to Maven Central, Solo

The dual-write problem shows up wherever a service needs to update its database and notify the outside world as one atomic step. The write succeeds and the publish fails, or the reverse, and two systems that were supposed to agree quietly stop agreeing. The usual fixes are heavyweight: two-phase commit, or a CDC pipeline like Debezium reading the database's transaction log.

I built oncebox to solve it without either. The event is written to an outbox table in the same transaction as the business write, then polled and published separately, safely, even with several instances polling the same table at once. I started on October 11, 2025. A year later it's on Maven Central at v2.0.0, with signed releases and a test suite I'm proud of. This is what went into that.

What it does

oncebox guarantees a database write and a message-broker publish either both happen or neither does, with no two-phase commit and no CDC tooling watching the binlog. You call publish() (or annotate a method with @OutboxPublish) inside your existing @Transactional method. The event is written to an outbox table in that same transaction, and a poller picks it up and publishes it to Kafka or RabbitMQ, retrying with backoff. Anything that keeps failing moves into a dead letter queue, which has its own REST API for inspecting and replaying failed events. On the receiving side there's an inbox that dedupes on the event's id, so a redelivered message doesn't get processed twice. Postgres, MySQL, and Oracle on the write side; Kafka and RabbitMQ as brokers. You pick one of each.

A year, alone

As of today it's 13 Maven modules and 602 Java files, more than half of them tests. Five released versions so far: v1.0.0, v1.1.0, v1.1.1, v1.1.2 and v2.0.0.

There was no team to split this with, which meant no one to unblock me either. Every dialect quirk, every CI failure, every "why does this pass on Postgres and hang on MySQL" moment was just me and the logs. The upside of solo is that nothing shipped that I didn't fully understand. The downside is that everything took longer than it would have with a second set of eyes to catch the mistake I was too close to see.

Shipping is its own project

The polling logic was one job. Getting it onto Maven Central as something a stranger could <dependency> into their pom.xml and trust was another: GPG signing so releases are verifiable, the Sonatype/Central publishing flow, javadoc.io rendering usable docs instead of a wall of bare method signatures, and Codecov and CodeFactor wired up so the README badges report something real.

None of that touches the outbox logic, but it's what separates a repo I pushed from a library someone can depend on in production. The config is split into oncebox.outbox.* and oncebox.inbox.*, so a service can run only the side it needs. There are ready-made Grafana dashboards and a Postman collection for the DLQ API, because "read the source to figure out the metrics" is not documentation. Each of those was a small decision on its own.

Where the engineering lived

The core trick is SELECT ... FOR UPDATE SKIP LOCKED. It lets several instances poll the same outbox table in parallel, each locking a disjoint batch of rows without blocking the others. Event IDs are UUIDv7 instead of random UUIDs, so inserts land at the tail of the primary-key index instead of scattering writes across it under load. Polling is adaptive: exponential backoff toward a max delay while the table is empty, back to a fast poll as soon as events show up, so idle instances don't hammer the database. Event types are polled independently, in parallel, on a library-managed thread pool.

Supporting three databases with one concurrency primitive turned out to be a project of its own. Postgres and Oracle behave. On MySQL, InnoDB's default REPEATABLE READ makes that same locking clause take gap locks, so concurrent pollers deadlock instead of grabbing disjoint batches, unless the outbox datasource runs at READ COMMITTED. That one deserves its own post. Oracle diverges differently: it can't express FETCH FIRST ... FOR UPDATE SKIP LOCKED the way Postgres batches it in a CTE, so the batch size is capped at the JDBC level with PreparedStatement#setMaxRows. One concurrency primitive, three implementations.

The failure paths got the same attention as the happy path. If an instance dies mid-publish, its events are left IN_PROCESS. A separate recovery worker sweeps those back to PENDING without touching the retry count, so a crash doesn't cost an event its remaining retries. Anything that exhausts its retries moves to the dead letter queue in one transaction (lock the batch, insert into the DLQ table, delete from the outbox, commit) instead of two separate writes that could disagree if the process dies in between.

v2.0.0 is a breaking release. The structure is now named by mechanism, outbox and inbox, rather than by the role of the service. Every timestamp is set by the database clock instead of the JVM, so instances in different time zones agree on it. And each captured batch now carries a lock token, which fences it against a poller that no longer owns it.

Why I went overboard on tests

With no one reviewing my PRs, tests were the only thing between me and shipping a race condition to users I'd never meet. 346 of the 602 Java files in the repo are test sources, split across tiers that each catch a different kind of failure. Unit tests cover logic, integration tests run against real databases, and a concurrent tier hammers the SKIP LOCKED polling path with multiple real threads to catch races a mock never would. A classpath-isolation job checks the library still works when only one database dialect's jar is on the classpath, catching accidental hard dependencies between modules that should be independent. End-to-end tests run across a 3-database × 2-broker matrix, and Gatling load tests run against dockerized infrastructure on every release tag.

For a library one person maintains, that's a lot of test infrastructure. It's also the only way I could say with any confidence that a release won't deadlock someone's production database.

Try it

oncebox is Apache 2.0, on Maven Central as io.github.dmitriy-iliyov:oncebox-*, and the source is at github.com/dmitriy-iliyov/oncebox. If you have a dual-write problem and don't want to stand up a CDC pipeline to solve it, I'd like to know whether this works for you. Issues, PRs, and stars all help when it's one person deciding whether to keep going.


Written 2026-10-15, against oncebox v2.0.0. Repo stats are current as of this post; check the README for anything that has moved since.

Top comments (0)