# Wiring with Quarkus What you write in a Quarkus app to use Ekbatan: the extension dependency, the Flyway migrator dependency, four `@Ekbatan*` annotations on your own classes, and an `application.properties` tree under `ekbatan.*`. The extension's deployment-time build steps wire every framework bean, and `ActionExecutor` is injectable anywhere. For the equivalent in plain Java with no DI container, see [wiring/without-di.md](without-di.md). For Spring Boot and Micronaut, see [spring.md](spring.md) and [micronaut.md](micronaut.md). ## What you write ### 1. The extension dependency ```kotlin // build.gradle.kts dependencies { implementation("io.github.zyraz-io:ekbatan-quarkus:") } ``` (Published on Maven Central under groupId `io.github.zyraz-io`. Java packages stay `io.ekbatan.*` — they don't need to match the Maven groupId.) That one runtime artifact transitively pulls in `ekbatan-core`, `ekbatan-events:local-event-handler`, `ekbatan-distributed-jobs`, and the `@Ekbatan*` annotation jar. Quarkus resolves the matching deployment module automatically at build time. Add `ekbatan-flyway` for `FlywayMigrator`, and add `ekbatan-keyed-lock-redis` separately if you want the Redis-backed lock provider. ### 2. Your domain classes — annotated This is what you actually write. Five domain classes carry five annotations — `@AutoBuilder` on the `Model`, and the four `@Ekbatan*` markers on the action, repository, event handler, and job. They're framework-agnostic: the same source compiles and runs identically against Spring Boot, Quarkus, and Micronaut. The four `@Ekbatan*` annotations are pure markers; `@AutoBuilder` is an independent compile-time builder generator. Quarkus discovers the `@Ekbatan*`-annotated classes via Jandex at deployment time (see [How the extension works](#how-the-extension-works) below); the source itself is unchanged. #### Wallet — the Model (`@AutoBuilder`) ```java @AutoBuilder public final class Wallet extends Model, WalletState> { public final UUID ownerId; public final Currency currency; public final BigDecimal balance; Wallet(WalletBuilder builder) { super(builder); this.ownerId = Validate.notNull(builder.ownerId, "ownerId cannot be null"); this.currency = Validate.notNull(builder.currency, "currency cannot be null"); this.balance = Validate.notNull(builder.balance, "balance cannot be null"); } public Wallet deposit(BigDecimal amount) { Validate.isTrue(amount.compareTo(BigDecimal.ZERO) > 0, "Deposit amount must be positive"); final var newBalance = balance.add(amount); return copy() .withEvent(new WalletMoneyDepositedEvent(id, amount, newBalance)) .balance(newBalance) .build(); } @Override public WalletBuilder copy() { return WalletBuilder.wallet().copyBase(this).ownerId(ownerId).currency(currency).balance(balance); } } ``` #### WalletDepositAction — the Action (`@EkbatanAction`) Discovered and registered into `ActionRegistry` so `ActionExecutor.execute(...)` can find it. Constructor params are resolved by the DI container. ```java @EkbatanAction public class WalletDepositAction extends Action { public record Params(Id walletId, BigDecimal amount) {} private final WalletRepository walletRepository; public WalletDepositAction(Clock clock, WalletRepository walletRepository) { super(clock); this.walletRepository = walletRepository; } @Override protected Wallet perform(Principal principal, Params params) { var wallet = walletRepository.getById(params.walletId().getValue()); var updated = wallet.deposit(params.amount()); return plan().update(updated); } } ``` #### WalletRepository — the Repository (`@EkbatanRepository`) Registered as a managed DI bean and into `RepositoryRegistry`. Inject it by its concrete class anywhere. ```java @EkbatanRepository public class WalletRepository extends ModelRepository { public WalletRepository(DatabaseRegistry databaseRegistry) { super(Wallet.class, WALLETS, WALLETS.ID, databaseRegistry); } @Override public Wallet fromRecord(WalletsRecord r) { return WalletBuilder.wallet() .id(Id.of(Wallet.class, r.getId())) .version(r.getVersion()) .state(WalletState.valueOf(r.getState())) .ownerId(r.getOwnerId()) .currency(Currency.getInstance(r.getCurrency())) .balance(r.getBalance()) .createdDate(r.getCreatedDate()) .updatedDate(r.getUpdatedDate()) .build(); } @Override public WalletsRecord toRecord(Wallet w) { return new WalletsRecord( w.id.getValue(), w.version, w.state.name(), w.ownerId, w.currency.getCurrencyCode(), w.balance, w.createdDate, w.updatedDate); } } ``` #### WalletMoneyDepositedEventHandler — the EventHandler (`@EkbatanEventHandler`) Registered with `EventHandlerRegistry`. Only effective when the local-event-handler module is on the classpath. ```java @EkbatanEventHandler public class WalletMoneyDepositedEventHandler implements EventHandler { private final NotificationService notificationService; public WalletMoneyDepositedEventHandler(NotificationService notificationService) { this.notificationService = notificationService; } @Override public String name() { return "wallet-deposit-notification"; } @Override public Class eventType() { return WalletMoneyDepositedEvent.class; } @Override public void handle(EventEnvelope envelope) { notificationService.notifyDeposit(envelope.event.modelId, envelope.event.amount); } } ``` #### DailyWalletReportJob — the DistributedJob (`@EkbatanDistributedJob`) Registered with `JobRegistry`. Only effective when the distributed-jobs module is on the classpath. ```java @EkbatanDistributedJob public class DailyWalletReportJob extends DistributedJob { private final ReportService reportService; public DailyWalletReportJob(ReportService reportService) { this.reportService = reportService; } @Override public String name() { return "daily-wallet-report"; } @Override public Schedule schedule() { return Schedules.daily(LocalTime.of(2, 0)); } @Override public void execute(ExecutionContext ctx) { reportService.generateAndSend(); } } ``` For the annotation reference table and the full rationale on why `Action` instances are *not* exposed as DI beans, see [annotations.md](annotations.md). ### 3. The application bootstrap Just a Quarkus app. Nothing Ekbatan-specific. ```java @QuarkusMain public class WalletsApplication { public static void main(String[] args) { Quarkus.run(args); } } ``` (Or if you have a `@Path`-annotated REST resource you don't even need a `main`; Quarkus assembles the application around it.) ### 4. The configuration `application.properties`: ```properties ekbatan.namespace=com.example.wallets # Build-time gate for EventHandlingJob — see "Build-time vs runtime" below. ekbatan.local-event-handler.handling.enabled=true # Sharding — single shard pointing at PG. ekbatan.sharding.default-shard.group=0 ekbatan.sharding.default-shard.member=0 ekbatan.sharding.groups[0].group=0 ekbatan.sharding.groups[0].name=default ekbatan.sharding.groups[0].members[0].member=0 ekbatan.sharding.groups[0].members[0].configs.primary-config.jdbc-url=jdbc:postgresql://primary:5432/wallets ekbatan.sharding.groups[0].members[0].configs.primary-config.username=wallets_app ekbatan.sharding.groups[0].members[0].configs.primary-config.password=${APP_DB_PASSWORD} ekbatan.sharding.groups[0].members[0].configs.primary-config.maximum-pool-size=20 ekbatan.sharding.groups[0].members[0].configs.primary-config.driver-class-name=org.postgresql.Driver ekbatan.sharding.groups[0].members[0].configs.secondary-config.jdbc-url=jdbc:postgresql://replica:5432/wallets ekbatan.sharding.groups[0].members[0].configs.secondary-config.username=wallets_app_ro ekbatan.sharding.groups[0].members[0].configs.secondary-config.password=${APP_DB_PASSWORD} ekbatan.sharding.groups[0].members[0].configs.secondary-config.maximum-pool-size=20 ekbatan.sharding.groups[0].members[0].configs.secondary-config.driver-class-name=org.postgresql.Driver ekbatan.sharding.groups[0].members[0].configs.jobs-config.jdbc-url=jdbc:postgresql://primary:5432/wallets ekbatan.sharding.groups[0].members[0].configs.jobs-config.username=wallets_app ekbatan.sharding.groups[0].members[0].configs.jobs-config.password=${APP_DB_PASSWORD} ekbatan.sharding.groups[0].members[0].configs.jobs-config.maximum-pool-size=5 ekbatan.sharding.groups[0].members[0].configs.jobs-config.driver-class-name=org.postgresql.Driver ``` > **`driver-class-name` is explicitly required for Quarkus.** SmallRye Config + the Quarkus runtime classloader don't always discover the JDBC `Driver` SPI during the Arc producer phase the way a vanilla JVM's `DriverManager` does. Setting `driver-class-name` makes Hikari `Class.forName(...)` the driver explicitly. (Spring Boot and Micronaut don't usually need this.) The `ekbatan.sharding.*` subtree mirrors the structure described in [docs/database/sharding.md](../database/sharding.md). ### 5. Use it ```java @Path("/wallets") public class WalletResource { @Inject ActionExecutor executor; @POST @Path("/{id}/deposit") public Wallet deposit(@PathParam("id") UUID id, DepositRequest req) throws Exception { return executor.execute( () -> "alice", WalletDepositAction.class, new WalletDepositAction.Params(Id.of(Wallet.class, id), req.amount())); } public record DepositRequest(BigDecimal amount) {} } ``` Repositories are also CDI beans — inject any `@EkbatanRepository`-annotated class anywhere. --- ## How the extension works If you only want the tutorial above, stop here. The rest is the reference for what the deployment module is doing on your behalf — useful when something doesn't auto-discover or you need to override a default. ### Runtime + deployment split Quarkus extensions are two modules: `runtime` (loaded into your app classpath) and `deployment` (only used during the Quarkus build). Ekbatan's split: - **Runtime** — the three `Configuration` `@Singleton` factory classes that produce the framework's beans (`ActionExecutor`, `DatabaseRegistry`, `EventHandlerRegistry`, `JobRegistry`, etc.) using CDI's `@Produces`. The `ActionExecutor` producer takes `Instance`: if the application defines its own `@Produces EventPersister`, it replaces the executor's default `SingleTableJsonEventPersister`. Otherwise the default is used — and that default already writes `delivered=false`, so the local-event-handler fan-out picks events up automatically. - **Deployment** — `EkbatanProcessor` with several `@BuildStep` methods that: 1. Add the `Configuration` classes themselves as unremovable Arc beans (string class names, not `Class`, to avoid loading runtime types into the deployment classloader). 2. Register `EkbatanProperties` as a `ConfigMappingBuildItem`. 3. Disable strict SmallRye mapping validation (the `ekbatan.sharding.*` subtree is bound by Jackson, not by `@ConfigMapping`, so SmallRye must not reject those keys). 4. Walk the Jandex combined index for `@EkbatanAction` / `@EkbatanRepository` / `@EkbatanEventHandler` / `@EkbatanDistributedJob`, collect their class names, and register each as an unremovable singleton. 5. Conditionally register the local-event-handler / distributed-jobs producer classes only if their corresponding modules are present at runtime — checked via `QuarkusClassLoader.isClassPresentAtRuntime(...)`, **never** `Class.forName`. That last point is critical: `Class.forName` would load a runtime class into the deployment classloader, which then conflicts with the runtime classloader at boot. `QuarkusClassLoader.isClassPresentAtRuntime` returns a boolean without loading anything. ### The four `@Ekbatan*` annotations Same set as Spring/Micronaut. Discovery mechanism differs: | Annotation | Quarkus discovery | |---|---| | `@EkbatanAction` | Found in the Jandex index by `discoverEkbatanBeans` build step → registered as `@Singleton @Unremovable` Arc bean → injected via `@All List>` into `EkbatanCoreConfiguration.ekbatanActionRegistry`. | | `@EkbatanRepository` | Same — Jandex → unremovable Arc singleton. Injected directly anywhere it's needed and into `RepositoryRegistry` via `@All List`. | | `@EkbatanEventHandler` | Same path; only effective when the local-event-handler module is on the classpath. | | `@EkbatanDistributedJob` | Same path; only effective when ekbatan-distributed-jobs is on the classpath. | ### Indexing transitive jars If your `@Ekbatan*` classes live in a transitive jar (e.g. a shared module across multiple Quarkus apps), Jandex won't see them by default. Tell Quarkus to walk that jar's index too: ```properties quarkus.index-dependency..group-id=com.your.group quarkus.index-dependency..artifact-id=your-shared-artifact ``` Without this, the deployment processor's scan returns nothing for that jar and Arc fails at boot with `UnsatisfiedResolutionException` for the missing repository / handler / job. ### Build-time vs runtime gates `@IfBuildProperty(name = "ekbatan.local-event-handler.handling.enabled", stringValue = "true", enableIfMissing = false)` on `EkbatanLocalEventHandlerConfiguration.ekbatanEventHandlingJob` is evaluated **at jar assembly time**, not at runtime. Set the flag in `application.properties`; runtime overrides won't flip it. This matches Spring's `@ConditionalOnProperty(havingValue="true")` "default-off" semantic. The reason it's build-time-only on Quarkus is so the `EventHandlingJob` bean simply doesn't exist in the closed-world image — useful for native builds where every bean adds reflection metadata. ### Flyway — use `ekbatan-flyway` + a startup migrator Ekbatan's Quarkus examples do not use a Quarkus-managed datasource for migrations. Ekbatan already has the database coordinates under `ekbatan.sharding.*`, and `FlywayMigrator.migrate(shardingConfig)` can run the same migration set against one shard or many shards. Keep `quarkus-flyway` on the classpath for Flyway/Quarkus/native-image integration, and keep the matching `quarkus-jdbc-*` extension for driver registration. Do **not** add `quarkus.datasource.*` or `quarkus.flyway.*` properties for this setup; the Quarkus Flyway startup runner is not the migration owner. **Dependencies** — pull `ekbatan-flyway`, the Quarkus Flyway extension, and the dialect pieces: ```kotlin // build.gradle.kts dependencies { implementation("io.github.zyraz-io:ekbatan-flyway:0.2.1") // Quarkus integration for Flyway and native-image support. implementation("io.quarkus:quarkus-flyway") // Database-specific Flyway plugin. implementation("org.flywaydb:flyway-database-postgresql") // or flyway-mysql for MariaDB/MySQL // Matching JDBC driver extension. This registers the driver class for native-image. implementation("io.quarkus:quarkus-jdbc-postgresql") // or quarkus-jdbc-mariadb / quarkus-jdbc-mysql } ``` ```xml io.github.zyraz-io ekbatan-flyway 0.2.1 io.quarkus quarkus-flyway org.flywaydb flyway-database-postgresql io.quarkus quarkus-jdbc-postgresql ``` No Quarkus datasource block is required: ```properties # No Quarkus datasource properties # No Quarkus Flyway startup properties ``` Startup migrator: ```java import io.ekbatan.core.config.ShardingConfig; import io.ekbatan.flyway.FlywayMigrator; import io.quarkus.runtime.StartupEvent; import jakarta.annotation.Priority; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.event.Observes; import jakarta.interceptor.Interceptor; @ApplicationScoped public class EkbatanShardFlywayMigrator { private final ShardingConfig shardingConfig; public EkbatanShardFlywayMigrator(ShardingConfig shardingConfig) { this.shardingConfig = shardingConfig; } void migrate(@Observes @Priority(Interceptor.Priority.PLATFORM_BEFORE) StartupEvent event) { FlywayMigrator.migrate(shardingConfig); } } ``` Why this shape: - **Single source of truth.** Connection coordinates live under `ekbatan.sharding.*`; there is no parallel `quarkus.datasource.*` tree to keep in sync. - **Same code for one shard or many.** `FlywayMigrator.migrate(shardingConfig)` iterates every configured shard member's `primaryConfig`. - **Quarkus still helps native-image.** `quarkus-jdbc-*` contributes driver metadata, and `quarkus-flyway` keeps Flyway aligned with the Quarkus platform. - **Do not turn on Quarkus Flyway startup migration for this pattern.** If Quarkus runs its own Flyway startup path, it uses datasource-based configuration that is separate from Ekbatan's sharding config. See [`ekbatan-examples/quarkus-wallet-rest-gradle-pg`](../../ekbatan-examples/quarkus-wallet-rest-gradle-pg) for the full runnable shape (and its `-mariadb`/`-mysql`/`-native-*`/`-maven-*` variants for the other dialects, build tools, and native). ### Jackson — use `quarkus-rest-jackson` (which pulls `quarkus-jackson`) **Dependencies** — pull the Quarkus REST-Jackson bridge, NOT raw `jackson-databind`: ```kotlin // build.gradle.kts dependencies { // ✅ The Quarkus JAX-RS Jackson integration. Pulls `quarkus-jackson` (the underlying // ObjectMapper-providing extension) transitively. Together they: // - wire `ObjectMapper` into request/response (de)serialization for @POST / @GET handlers // - register reflection metadata for Jackson 2 types reachable from JAX-RS resources in // native-image // - integrate with `quarkus.jackson.*` configuration keys (date format, fail-on-unknown) implementation("io.quarkus:quarkus-rest-jackson") // ❌ Don't add `com.fasterxml.jackson.core:jackson-databind` directly. It bypasses // the JAX-RS integration (no MessageBodyReader/Writer wiring), bypasses Quarkus's // native-image reflection registration, and pins a version that may not match // what Quarkus' BOM expects for related modules (jackson-annotations, jackson-core, // jackson-datatype-jsr310). } ``` To customize the mapper (date formats, naming strategy, modules), implement `io.quarkus.jackson.ObjectMapperCustomizer` and `@ApplicationScoped` it. The wallet examples don't need any customization — defaults are fine. > **Ekbatan internals use Jackson 3** (`tools.jackson.databind.*`) for event serialization, not Jackson 2. That dependency is pulled transitively by `ekbatan-core` and is unrelated to your app's HTTP-layer Jackson 2 setup; the two coexist. `ekbatan-native`'s `Jackson3RecordsFeature` registers your records under Jackson 3 — see [Native-image specifics](#native-image-specifics) below. ### Native-image specifics - **JDBC drivers** come via `quarkus-jdbc-postgresql` / `quarkus-jdbc-mysql` / `quarkus-jdbc-mariadb`. Add the matching extension to your build; Quarkus registers the driver class for native automatically. - **HikariCP** is *not* covered by any Quarkus extension (Quarkus blesses Agroal). The framework integration tests vendor the upstream GraalVM Reachability Metadata Repository entry at [`ekbatan-integration-tests/di/quarkus/src/main/resources/META-INF/native-image/com.zaxxer/HikariCP/reachability-metadata.json`](../../ekbatan-integration-tests/di/quarkus). Copy that file into your app's `META-INF/native-image/com.zaxxer/HikariCP/`. (`ekbatan-native` ships the same file under its own coordinate, so depending on `ekbatan-native` is the cheapest way to pull it in.) - **Jackson 3 records** — the `ekbatan-native` module's `Jackson3RecordsFeature` picks up the framework's records automatically. For your own records / `@AutoBuilder` builders, set `quarkus.native.additional-build-args=-Dio.ekbatan.graalvm.scan.packages=io.ekbatan\,com.your.package` (the comma in the value must be escaped — Quarkus uses `,` to separate multiple build args). For broader native-image considerations, see [docs/runtime/native-image.md](../runtime/native-image.md). ### Optional knobs Same `ekbatan.namespace` / `ekbatan.local-event-handler.*` / `ekbatan.jobs.*` properties as Spring. Both kebab-case and camelCase keys are accepted; the extension normalizes keys before binding them to Ekbatan's typed config classes. This includes root names (`local-event-handler` / `localEventHandler`), leaf names (`fanout-poll-delay` / `fanoutPollDelay`, `polling-interval` / `pollingInterval`), and shard datasource slots (`jobs-config` / `jobsConfig`, `lock-config` / `lockConfig`). Java lookups through `configFor(...)` must use camelCase: `configFor("jobsConfig")`, `configFor("lockConfig")`. ```properties ekbatan.local-event-handler.fanout-poll-delay=200ms ekbatan.local-event-handler.handling-poll-delay=200ms ekbatan.jobs.polling-interval=1s ekbatan.jobs.shutdown-max-wait=5s ``` ## What's deliberately *not* bridged - **Quarkus' Agroal datasource** — Ekbatan uses HikariCP via its own `ConnectionProvider`. Don't try to pass an Agroal datasource into Ekbatan; declare a separate sharding config block instead. - **Quarkus Hibernate Panache** — can coexist with Ekbatan in the same app (different concerns, different datasources or even the same one) but the framework does not integrate with Panache repositories. ## See also - [Wiring without DI](without-di.md) — what the deployment build steps + runtime producers are doing for you - [Wiring with Spring Boot](spring.md) / [Wiring with Micronaut](micronaut.md) — same end state in the other DI frameworks - [Actions, ActionPlan, ActionExecutor](../concepts/actions.md) — what `executor.execute(...)` runs on your behalf - [Listen-to-yourself: in-process event handlers](../events/local-event-handler.md) — what `@EkbatanEventHandler` consumes - [Distributed background jobs](../jobs/distributed-jobs.md) — what `@EkbatanDistributedJob` schedules - [GraalVM native-image](../runtime/native-image.md) — Quarkus + native specifics - The runnable reference: [`ekbatan-integration-tests/di/quarkus`](../../ekbatan-integration-tests/di/quarkus)