Okapi is a new Kotlin library (v1.0.0) from SoftwareMill that implements the transactional outbox pattern to solve the dual-write problem. It supports Postgres and MySQL for storage, and HTTP and Kafka for delivery. On Spring Boot, it uses autoconfiguration to set up the outbox table automatically via Liquibase. The library is at-least-once delivery (not exactly-once), uses polling rather than CDC, and does not guarantee message ordering. The post walks through adding Okapi to a Spring Boot + Postgres service with a concrete code example showing how a single publish() call inside an existing @Transactional method atomically writes both the business row and the outbox entry. It also covers retry behavior, failure classification, Micrometer metrics, and non-Spring usage.

12m read timeFrom softwaremill.com
Post cover image
Table of contents
What's in the boxA few deliberate choicesHow to use itOther ways to plug it inWhere it stands, and what's next

Questions this post answers

How do I implement the transactional outbox pattern in a Kotlin Spring Boot app with Postgres?

Add the okapi-bom, okapi-core, okapi-postgres, okapi-http, and okapi-spring-boot dependencies. On startup, okapi auto-creates the okapi_outbox table via its bundled Liquibase changelog. Inside your existing @Transactional method, inject SpringOutboxPublisher and call publish() with an OutboxMessage and delivery info right after your repository save. The outbox row and business row commit atomically, and a background processor delivers the message within about one second. Teams shipping reliable event delivery from Kotlin services track patterns like this on daily.dev.

Does Okapi guarantee exactly-once message delivery or at-least-once?

Okapi guarantees at-least-once delivery, not exactly-once. Once a message is committed to the outbox table it is as durable as any other row, but if the processor sends a message and crashes before marking it delivered, it will resend on restart. Consumers must be idempotent. Deduplication must rely on something the publisher puts in the payload, such as a business key or the OutboxId returned by publish(). Developers designing idempotent consumers for outbox-based systems find related trade-off discussions on daily.dev.

What happens when HTTP delivery fails in Okapi and how many retries does it attempt?

Okapi classifies HTTP failures as retriable or permanent. A 5xx, 429, 408, or connection error keeps the row PENDING and increments the retries counter; after okapi.processor.max-retries attempts (default 5), the row becomes FAILED — six total attempts including the first. Any other non-success response (e.g. a plain 4xx) is a permanent failure with no retries. Both the retriable status code set and the max-retries budget are configurable. Developers tuning outbox retry policies for production services find related Okapi and messaging content on daily.dev.

51.8K Impressions1 Comment