Kotlin library implementing the transactional outbox pattern — reliable message delivery alongside local database operations.
Messages are stored in a database table within the same transaction as your business operation, then asynchronously delivered to external transports (HTTP webhooks, Kafka). This guarantees at-least-once delivery without distributed transactions.
Add dependencies using the BOM for version alignment:
dependencies {
implementation(platform("com.softwaremill.okapi:okapi-bom:$okapiVersion"))
implementation("com.softwaremill.okapi:okapi-core")
implementation("com.softwaremill.okapi:okapi-postgres")
implementation("com.softwaremill.okapi:okapi-http")
implementation("com.softwaremill.okapi:okapi-spring-boot")
}Provide a MessageDeliverer bean — this tells okapi how to deliver messages.
ServiceUrlResolver maps the logical service name (set per message) to a base URL:
@Bean
fun httpDeliverer(): HttpMessageDeliverer =
HttpMessageDeliverer(ServiceUrlResolver { serviceName ->
when (serviceName) {
"notification-service" -> "https://notifications.example.com"
else -> error("Unknown service: $serviceName")
}
})Publish inside any @Transactional method — inject SpringOutboxPublisher via constructor:
@Service
class OrderService(
private val orderRepository: OrderRepository,
private val springOutboxPublisher: SpringOutboxPublisher
) {
@Transactional
fun placeOrder(order: Order) {
orderRepository.save(order)
springOutboxPublisher.publish(
OutboxMessage("order.created", order.toJson()),
httpDeliveryInfo {
serviceName = "notification-service"
endpointPath = "/webhooks/orders"
}
)
}
}Autoconfiguration handles scheduling, retries, and delivery automatically. For Micrometer metrics, also add okapi-micrometer — see Observability.
Using Kafka instead of HTTP? Swap the deliverer bean and delivery info:
@Bean
fun kafkaDeliverer(producer: KafkaProducer<String, String>): KafkaMessageDeliverer =
KafkaMessageDeliverer(producer)springOutboxPublisher.publish(
OutboxMessage("order.created", order.toJson()),
kafkaDeliveryInfo { topic = "order-events" }
)Using MySQL instead of PostgreSQL? Replace okapi-postgres with okapi-mysql in your dependencies — no code changes needed.
Note: Spring and Kafka versions are not forced by okapi — you control them. Okapi uses plain JDBC internally — it works with any
PlatformTransactionManager(JPA, JDBC, jOOQ, Exposed, etc.).
Okapi implements the transactional outbox pattern (see also: microservices.io description):
- Your application writes an
OutboxMessageto the outbox table in the same database transaction as your business operation - A background
OutboxSchedulerpolls for pending messages and delivers them to the configured transport (HTTP, Kafka) - Failed deliveries are retried according to a configurable
RetryPolicy(max attempts, backoff)
Delivery guarantees:
- At-least-once delivery — okapi guarantees every message will be delivered, but duplicates are possible (e.g., after a crash between delivery and status update). Consumers should handle idempotency, for example by checking the
OutboxIdreturned bypublish(). - Concurrent processing — multiple processors can run in parallel using
FOR UPDATE SKIP LOCKED, so messages are never processed twice simultaneously. - Delivery result classification — each transport classifies errors as
Success,RetriableFailure, orPermanentFailure. For example, HTTP 429 is retriable while HTTP 400 is permanent.
Okapi ships Liquibase changelogs that create the outbox table and its indexes:
classpath:com/softwaremill/okapi/db/changelog.xml— PostgreSQL (fromokapi-postgres)classpath:com/softwaremill/okapi/db/mysql/changelog.xml— MySQL (fromokapi-mysql)
When okapi-spring-boot is on the classpath, these run automatically against the configured DataSource on application startup. Without Spring Boot, point your own Liquibase setup at the paths above and pass an outboxTable change-log parameter (see below).
Okapi's table names are fixed under the okapi_ prefix so its schema stays out of the way of any pre-existing tables in the host application (outbox, databasechangelog, etc.):
| Table | Purpose |
|---|---|
okapi_outbox |
Domain table holding outbox entries (created by the bundled Liquibase changesets, queried by PostgresOutboxStore / MysqlOutboxStore). |
okapi_databasechangelog |
Liquibase changeset history for okapi (configurable). |
okapi_databasechangeloglock |
Liquibase concurrency lock for okapi (configurable). |
The Liquibase tracking-table names are configurable in case the host application wants to share them with its own Liquibase setup:
| Property | Default | Description |
|---|---|---|
okapi.liquibase.changelog-table |
okapi_databasechangelog |
Liquibase changeset history for okapi |
okapi.liquibase.changelog-lock-table |
okapi_databasechangeloglock |
Liquibase concurrency lock for okapi |
These properties affect the autoconfigured okapiPostgresLiquibase / okapiMysqlLiquibase beans only. If you run Liquibase yourself, configure the table names there directly. The domain table name (okapi_outbox) is fixed.
Releases up to 0.2.x wrote to shared tables databasechangelog / databasechangeloglock and the domain table outbox. From 0.3.0 these are renamed to okapi_*. Two upgrade paths:
Stay on the existing changelog tables (simplest for the Liquibase tracking pair, zero-downtime) — opt out of the new defaults:
okapi:
liquibase:
changelog-table: databasechangelog
changelog-lock-table: databasechangeloglockThe domain table outbox cannot be opted out via configuration — see the migration steps below.
Migrate to dedicated tables — run before the first 0.3.0 startup (PostgreSQL syntax shown):
-- Outbox domain table: rename in place. Indexes follow the table.
ALTER TABLE outbox RENAME TO okapi_outbox;
ALTER INDEX idx_outbox_status_last_attempt RENAME TO idx_okapi_outbox_status_last_attempt;
ALTER INDEX idx_outbox_status_created_at RENAME TO idx_okapi_outbox_status_created_at;
-- Liquibase tracking: split okapi rows into the new tables.
CREATE TABLE okapi_databasechangelog (LIKE databasechangelog INCLUDING ALL);
CREATE TABLE okapi_databasechangeloglock (LIKE databasechangeloglock INCLUDING ALL);
INSERT INTO okapi_databasechangelog
SELECT * FROM databasechangelog WHERE filename LIKE '%com/softwaremill/okapi/%';
INSERT INTO okapi_databasechangeloglock SELECT * FROM databasechangeloglock;
DELETE FROM databasechangelog WHERE filename LIKE '%com/softwaremill/okapi/%';Without one of these steps, Liquibase will see an empty changelog table on the first 0.3.0 startup and try to re-run okapi's migrations — which fails if rows already exist under the legacy outbox table while okapi now writes to okapi_outbox.
Full release history: CHANGELOG.md.
Add okapi-micrometer alongside okapi-spring-boot (from the Quick Start above) to get Micrometer metrics:
implementation("com.softwaremill.okapi:okapi-micrometer")With Spring Boot Actuator and a Prometheus registry (micrometer-registry-prometheus) on the classpath, metrics are automatically exposed on /actuator/prometheus. They are also visible via /actuator/metrics.
| Metric | Type | Description |
|---|---|---|
okapi.entries.delivered |
Counter | Successfully delivered entries |
okapi.entries.retry.scheduled |
Counter | Failed attempts rescheduled for retry |
okapi.entries.failed |
Counter | Permanently failed entries |
okapi.batch.duration |
Timer | Processing time per batch |
okapi.entries.count |
Gauge | Current entry count (tag: status=pending|delivered|failed) |
okapi.entries.lag.seconds |
Gauge | Age of oldest entry in seconds (tag: status) |
| Property | Default | Description |
|---|---|---|
okapi.metrics.refresh-interval |
PT15S (15s) |
How often gauge metrics poll the outbox store. Each refresh runs one transaction with two queries. |
Counters and timers (okapi.entries.delivered, okapi.entries.retry.scheduled, okapi.entries.failed, okapi.batch.duration) report work performed by each instance — aggregate with sum:
sum(rate(okapi_entries_delivered_total[5m]))
Gauges (okapi.entries.count, okapi.entries.lag.seconds) reflect the shared outbox state and are reported identically by every instance. Aggregate with max by (status), not sum:
max by (status) (okapi_entries_count)
Polling cost per instance is 2 queries / okapi.metrics.refresh-interval (default 2 queries / 15s).
okapi-micrometer has no Spring dependency. Construct the beans manually and pass a MeterRegistry. MicrometerOutboxMetrics requires a TransactionRunner for Exposed-backed stores — see the class KDoc.
For periodic gauge refresh, use the framework-agnostic OutboxMetricsRefresher (single daemon thread):
val listener = MicrometerOutboxListener(meterRegistry)
val metrics = MicrometerOutboxMetrics(store, meterRegistry, transactionRunner)
val refresher = OutboxMetricsRefresher(metrics, Duration.ofSeconds(15))
refresher.start()
// on application shutdown:
refresher.close()Or call metrics.refresh() from your own scheduler (Ktor coroutine, ScheduledExecutorService, etc.) — refresh() is thread-safe.
Implement OutboxProcessorListener to react to delivery events (logging, alerting, custom metrics). OutboxProcessor accepts a single listener; to combine multiple, implement a composite that delegates to each.
graph BT
PG[okapi-postgres] --> CORE[okapi-core]
MY[okapi-mysql] --> CORE
HTTP[okapi-http] --> CORE
KAFKA[okapi-kafka] --> CORE
MICRO[okapi-micrometer] --> CORE
EXP[okapi-exposed] --> CORE
SPRING[okapi-spring-boot] --> CORE
SPRING -.->|compileOnly| PG
SPRING -.->|compileOnly| MY
SPRING -.->|compileOnly| MICRO
BOM[okapi-bom]
style CORE fill:#4a9eff,color:#fff
style BOM fill:#888,color:#fff
| Module | Purpose |
|---|---|
okapi-core |
Transport/storage-agnostic orchestration, scheduling, retry policy, ConnectionProvider interface |
okapi-exposed |
Exposed ORM integration — ExposedConnectionProvider, ExposedTransactionRunner, ExposedTransactionContextValidator |
okapi-postgres |
PostgreSQL storage via plain JDBC (FOR UPDATE SKIP LOCKED) |
okapi-mysql |
MySQL 8+ storage via plain JDBC |
okapi-http |
HTTP webhook delivery (JDK HttpClient) |
okapi-kafka |
Kafka topic publishing |
okapi-micrometer |
Micrometer metrics (counters, timers, gauges) |
okapi-spring-boot |
Spring Boot autoconfiguration (auto-detects store, transports, and metrics) |
okapi-bom |
Bill of Materials for version alignment |
| Dependency | Supported Versions | Notes |
|---|---|---|
| Java | 21+ | Required |
| Spring Boot | 3.5.x, 4.0.x | okapi-spring-boot module |
| Kafka Clients | 3.9.x, 4.x | okapi-kafka — you provide kafka-clients |
| Exposed | 1.x | okapi-exposed module — for Ktor/standalone apps |
Throughput baseline (single instance, sync sequential delivery, MacBook M3 Max, JDK 25 LTS, April 2026):
| Transport | batchSize=10 | batchSize=100 |
|---|---|---|
Kafka (acks=all, localhost broker) |
~110 msg/s | ~115 msg/s |
| HTTP @ webhook latency 20 ms | ~33 msg/s | ~36 msg/s |
| HTTP @ webhook latency 100 ms | ~9 msg/s | ~9 msg/s |
These numbers reflect the current sync-sequential delivery model. Throughput is bounded by per-message round-trip time × batch size. Performance work to lift these limits (async batch delivery, multi-threaded scheduler) is tracked under the KOJAK-14 epic.
Full methodology, raw JMH results, and reproduction instructions: benchmarks/.
./gradlew build # Build all modules
./gradlew test # Run tests (Docker required — Testcontainers)
./gradlew ktlintFormat # Format code
./gradlew :okapi-benchmarks:jmh # Run JMH benchmarks (~30 min, see benchmarks/README.md)Requires JDK 21.
All suggestions welcome :)
To compile and test, run:
./gradlew build
./gradlew ktlintFormat # Mandatory before committingSee the list of issues and pick one! Or report your own.
If you are having doubts on the why or how something works, don't hesitate to ask a question on Discourse or via GitHub. This probably means that the documentation or code is unclear and can be improved for the benefit of all.
Tests use Testcontainers — Docker must be running.
When you have a PR ready, take a look at our "How to prepare a good PR" guide. Thanks! :)
We offer commercial development services. Contact us to learn more about us!
Copyright (C) 2026 SoftwareMill https://softwaremill.com.