Core architectural decision: skip the full extension model
Quarkus integrations are conventionally built as extensions (a -deployment module doing build-time @buildstep processing + a runtime module). Okapi doesn't need that. The deployment/runtime split exists to let Quarkus resolve bean wiring and reflection metadata before native-image compilation. Okapi has no reflection-based serialization (DeliveryInfo.serialize() is app-implemented), no annotation-driven object mapping, and no build-time codegen need. The Spring autoconfig's @ConditionalOnClass tricks map onto CDI's own build-time-processed annotations (@IfBuildProperty/@UnlessBuildProperty) which work in a plain CDI bean archive — no extension required.
Recommendation: ship okapi-quarkus as an ordinary Gradle module (same kotlin-jvm + publish convention plugins as every other okapi module) containing CDI producers, with a META-INF/beans.xml (bean-discovery-mode="annotated") so it's recognized as an implicit bean archive. This avoids introducing Maven-oriented extension tooling (quarkus-extension-maven-plugin, deployment/runtime split) that has no precedent anywhere in this repo's buildSrc. Trade-off: no build-time ReflectiveClassBuildItem registration — acceptable here since okapi doesn't need it, and native-image users get JDBC/Liquibase reflection config for free from quarkus-jdbc-postgresql/quarkus-liquibase already.
Rather than replicating Spring's "auto-detect which store is on the classpath" @ConditionalOnClass dance (OutboxAutoConfiguration.kt:250-279), publish the store choice as separate thin artifacts — okapi-quarkus (core CDI wiring, config, scheduler lifecycle) plus consumers add okapi-postgres/okapi-mysql directly and a producer keyed off which OutboxStore impl class is present. This is more in the spirit of Quarkus's "many small explicit extensions" culture than Spring's "one fat autoconfig with classpath sniffing."
Mapping okapi-core interfaces to Quarkus/CDI
| Interface |
Quarkus adapter |
| TransactionRunner |
QuarkusTransactionRunner — runInTransaction = QuarkusTransaction.requiringNew().call(block) (Narayana JTA under the hood) |
| ConnectionProvider |
AgroalConnectionProvider(dataSource: AgroalDataSource) — withConnection = dataSource.connection.use(block); Agroal auto-enlists the connection in the active JTA tx |
| TransactionContextValidator |
QuarkusTransactionContextValidator — checks jakarta.transaction.TransactionManager.getStatus() == Status.STATUS_ACTIVE.Note: JTA has no "read-only transaction" concept, so the read-only half of Spring's check (SpringTransactionContextValidator) doesn't translate — worth flagging in docs rather than silently dropping |
| OutboxProcessorListener, Micrometer classes |
Reused unchanged — already framework-agnostic |
| Scheduler lifecycle |
CDI @observes StartupEvent / @observes ShutdownEvent wrapping OutboxScheduler/OutboxPurger.start()/stop(), explicit ordering in one wiring class (CDI doesn't have Spring's SmartLifecycle phase system, so replicate the "processor starts before purger, stops after" ordering by sequencing calls directly rather than relying on event priority) |
Multiple datasources and transaction managers — the part that doesn't map 1:1
This is the biggest conceptual gap versus Spring, worth being explicit about:
Spring model: N independent DataSource beans, each with its own PlatformTransactionManager, local (non-XA) transactions per resource. Okapi's Spring module resolves both via qualifiers and cross-checks they're bound to the same resource (validatePtmDataSourceMatch, OutboxAutoConfiguration.kt:363-454) because a mismatch silently degrades FOR UPDATE SKIP LOCKED to autocommit.
Quarkus model: JTA is inherently a single global TransactionManager (Narayana) — there's no "multiple transaction manager beans" concept. What varies is the datasource, not the transaction manager:
- Multiple named datasources via quarkus.datasource."".*, each an AgroalDataSource CDI bean qualified @io.quarkus.agroal.DataSource("name").
- Any Agroal-managed connection obtained inside an active JTA tx auto-enlists (as long as quarkus.datasource."name".jdbc.transactions is enlist — the default — or xa), regardless of which datasources the rest of the business logic touched. This is actually safer by default than Spring's world for the common case: no per-transaction-manager bean mismatch is possible.
- The equivalent failure mode to Spring's mismatch bug is a datasource configured with quarkus.datasource."name".jdbc.transactions=disabled — a non-transactional pool. If okapi's store is wired to that datasource, writes silently autocommit outside the caller's transaction, identical failure shape to the Spring bug. So okapi-quarkus still needs a startup safety check, just a simpler one: assert the resolved outbox datasource's JTA integration mode isn't disabled.
Design: replace Spring's okapi.datasource-qualifier / okapi.transaction-manager-qualifier pair with a single okapi.datasource-qualifier (Agroal @Datasource("name") value) — there is no second qualifier to resolve since the transaction manager is ambient/global. Document clearly that this is a deliberate simplification versus Spring, not a missing feature.
The one place Quarkus users do get true per-datasource transaction independence is Hibernate ORM persistence units configured transaction-type=RESOURCE_LOCAL instead of JTA — but that's a Hibernate-specific escape hatch, not something plain Agroal/JDBC okapi should chase for v1.
Liquibase
quarkus-liquibase assumes one changelog per named datasource at build-time config — it can't cleanly host "the app's own migrations" and "okapi's migrations" as two independent changelog runs against the same datasource the way Spring lets you declare two separate SpringLiquibase beans (OkapiLiquibaseAutoConfiguration.kt). Two options:
- Include-based: app adds into their own changelog. Simple, but couples okapi's migration timing to the app's own Liquibase run and loses the independent okapi_databasechangelog/okapi_databasechangeloglock tracking-table isolation Spring gives you.
- Self-contained (recommended, matches Spring's behavior): don't use the quarkus-liquibase extension at all for okapi's own schema. Ship a small CDI bean that runs plain liquibase.Liquibase Java API directly against the resolved AgroalDataSource's connection at startup (@observes StartupEvent, gated by okapi.liquibase.enabled), using okapi's own changelog resource and tracking-table names exactly as today. This keeps the module self-contained and independent of whether/how the app uses quarkus-liquibase for its own schema.
Config
Quarkus's @ConfigMapping (interface-based, build-time validated) maps cleanly onto the existing OkapiProperties/OutboxProcessorProperties/OutboxPurgerProperties/OkapiMetricsProperties shapes — same prefix structure (okapi.processor., okapi.purger., okapi.metrics.*) is directly portable, just as Kotlin interfaces instead of @ConfigurationProperties data classes.
Micrometer
MicrometerOutboxListener/MicrometerOutboxMetrics/OutboxMetricsRefresher are already framework-agnostic (only depend on okapi-core + micrometer-core) — reuse verbatim. Wire via a producer injecting Instance and checking .isResolvable() for optionality (CDI's equivalent of @ConditionalOnBean(MeterRegistry::class)), since quarkus-micrometer may or may not be present.
Build/test tooling
- Add quarkusBom/quarkus to libs.versions.toml; okapi-quarkus/build.gradle.kts follows the existing flat-module template (kotlin-jvm + publish convention plugins, api(project(":okapi-core")), compileOnly Quarkus artifacts so consumers pull their own Quarkus BOM-managed versions).
- Follow the existing -PspringBootVersion=/-PkafkaVersion= precedent: add -PquarkusVersion= override for cross-version testing.
- Testing: Quarkus's Dev Services (auto Testcontainers Postgres/MySQL) makes an okapi-quarkus integration-tests setup lighter than the existing okapi-integration-tests module — worth a dedicated test module using @QuarkusTest + Dev Services rather than hand-rolled Testcontainers.
- Add okapi-quarkus to okapi-bom's constraints once published.
Suggested phasing
- Phase 1 — single datasource, global JTA, Postgres+MySQL via existing store modules, self-contained Liquibase bean, scheduler lifecycle wiring, config mapping. Directly parallels the Spring module's baseline.
- Phase 2 — named-datasource qualifier support (@Datasource("name")), startup safety check for JTA-disabled datasources.
- Phase 3 — Micrometer wiring, Dev Services-based test module, native-image smoke test in CI.
Core architectural decision: skip the full extension model
Quarkus integrations are conventionally built as extensions (a -deployment module doing build-time @buildstep processing + a runtime module). Okapi doesn't need that. The deployment/runtime split exists to let Quarkus resolve bean wiring and reflection metadata before native-image compilation. Okapi has no reflection-based serialization (DeliveryInfo.serialize() is app-implemented), no annotation-driven object mapping, and no build-time codegen need. The Spring autoconfig's @ConditionalOnClass tricks map onto CDI's own build-time-processed annotations (@IfBuildProperty/@UnlessBuildProperty) which work in a plain CDI bean archive — no extension required.
Recommendation: ship okapi-quarkus as an ordinary Gradle module (same kotlin-jvm + publish convention plugins as every other okapi module) containing CDI producers, with a META-INF/beans.xml (bean-discovery-mode="annotated") so it's recognized as an implicit bean archive. This avoids introducing Maven-oriented extension tooling (quarkus-extension-maven-plugin, deployment/runtime split) that has no precedent anywhere in this repo's buildSrc. Trade-off: no build-time ReflectiveClassBuildItem registration — acceptable here since okapi doesn't need it, and native-image users get JDBC/Liquibase reflection config for free from quarkus-jdbc-postgresql/quarkus-liquibase already.
Rather than replicating Spring's "auto-detect which store is on the classpath" @ConditionalOnClass dance (OutboxAutoConfiguration.kt:250-279), publish the store choice as separate thin artifacts — okapi-quarkus (core CDI wiring, config, scheduler lifecycle) plus consumers add okapi-postgres/okapi-mysql directly and a producer keyed off which OutboxStore impl class is present. This is more in the spirit of Quarkus's "many small explicit extensions" culture than Spring's "one fat autoconfig with classpath sniffing."
Mapping okapi-core interfaces to Quarkus/CDI
Multiple datasources and transaction managers — the part that doesn't map 1:1
This is the biggest conceptual gap versus Spring, worth being explicit about:
Spring model: N independent DataSource beans, each with its own PlatformTransactionManager, local (non-XA) transactions per resource. Okapi's Spring module resolves both via qualifiers and cross-checks they're bound to the same resource (validatePtmDataSourceMatch, OutboxAutoConfiguration.kt:363-454) because a mismatch silently degrades FOR UPDATE SKIP LOCKED to autocommit.
Quarkus model: JTA is inherently a single global TransactionManager (Narayana) — there's no "multiple transaction manager beans" concept. What varies is the datasource, not the transaction manager:
Design: replace Spring's okapi.datasource-qualifier / okapi.transaction-manager-qualifier pair with a single okapi.datasource-qualifier (Agroal @Datasource("name") value) — there is no second qualifier to resolve since the transaction manager is ambient/global. Document clearly that this is a deliberate simplification versus Spring, not a missing feature.
The one place Quarkus users do get true per-datasource transaction independence is Hibernate ORM persistence units configured transaction-type=RESOURCE_LOCAL instead of JTA — but that's a Hibernate-specific escape hatch, not something plain Agroal/JDBC okapi should chase for v1.
Liquibase
quarkus-liquibase assumes one changelog per named datasource at build-time config — it can't cleanly host "the app's own migrations" and "okapi's migrations" as two independent changelog runs against the same datasource the way Spring lets you declare two separate SpringLiquibase beans (OkapiLiquibaseAutoConfiguration.kt). Two options:
Config
Quarkus's @ConfigMapping (interface-based, build-time validated) maps cleanly onto the existing OkapiProperties/OutboxProcessorProperties/OutboxPurgerProperties/OkapiMetricsProperties shapes — same prefix structure (okapi.processor., okapi.purger., okapi.metrics.*) is directly portable, just as Kotlin interfaces instead of @ConfigurationProperties data classes.
Micrometer
MicrometerOutboxListener/MicrometerOutboxMetrics/OutboxMetricsRefresher are already framework-agnostic (only depend on okapi-core + micrometer-core) — reuse verbatim. Wire via a producer injecting Instance and checking .isResolvable() for optionality (CDI's equivalent of @ConditionalOnBean(MeterRegistry::class)), since quarkus-micrometer may or may not be present.
Build/test tooling
Suggested phasing