DI · 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. For Spring Boot and Micronaut, see spring.md and micronaut.md.
What you write
1. The extension dependency
// build.gradle.kts
dependencies {
implementation("io.github.zyraz-io:ekbatan-quarkus:<version>")
}
(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 below); the source itself is unchanged.
Wallet — the Model (@AutoBuilder)
@AutoBuilder
public final class Wallet extends Model<Wallet, Id<Wallet>, 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.
@EkbatanAction
public class WalletDepositAction extends Action<WalletDepositAction.Params, Wallet> {
public record Params(Id<Wallet> 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.
@EkbatanRepository
public class WalletRepository extends ModelRepository<Wallet, WalletsRecord, Wallets, UUID> {
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.
@EkbatanEventHandler
public class WalletMoneyDepositedEventHandler implements EventHandler<WalletMoneyDepositedEvent> {
private final NotificationService notificationService;
public WalletMoneyDepositedEventHandler(NotificationService notificationService) {
this.notificationService = notificationService;
}
@Override public String name() { return "wallet-deposit-notification"; }
@Override public Class<WalletMoneyDepositedEvent> eventType() { return WalletMoneyDepositedEvent.class; }
@Override
public void handle(EventEnvelope<WalletMoneyDepositedEvent> 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.
@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.
3. The application bootstrap
Just a Quarkus app. Nothing Ekbatan-specific.
@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:
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-nameis explicitly required for Quarkus. SmallRye Config + the Quarkus runtime classloader don’t always discover the JDBCDriverSPI during the Arc producer phase the way a vanilla JVM’sDriverManagerdoes. Settingdriver-class-namemakes HikariClass.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.
5. Use it
@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@Singletonfactory classes that produce the framework’s beans (ActionExecutor,DatabaseRegistry,EventHandlerRegistry,JobRegistry, etc.) using CDI’s@Produces. TheActionExecutorproducer takesInstance<EventPersister>: if the application defines its own@Produces EventPersister, it replaces the executor’s defaultSingleTableJsonEventPersister. Otherwise the default is used — and that default already writesdelivered=false, so the local-event-handler fan-out picks events up automatically. - Deployment —
EkbatanProcessorwith several@BuildStepmethods that:- Add the
Configurationclasses themselves as unremovable Arc beans (string class names, notClass<?>, to avoid loading runtime types into the deployment classloader). - Register
EkbatanPropertiesas aConfigMappingBuildItem. - Disable strict SmallRye mapping validation (the
ekbatan.sharding.*subtree is bound by Jackson, not by@ConfigMapping, so SmallRye must not reject those keys). - Walk the Jandex combined index for
@EkbatanAction/@EkbatanRepository/@EkbatanEventHandler/@EkbatanDistributedJob, collect their class names, and register each as an unremovable singleton. - Conditionally register the local-event-handler / distributed-jobs producer classes only if their corresponding modules are present at runtime — checked via
QuarkusClassLoader.isClassPresentAtRuntime(...), neverClass.forName.
- Add the
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<Action<?, ?>> into EkbatanCoreConfiguration.ekbatanActionRegistry. |
@EkbatanRepository | Same — Jandex → unremovable Arc singleton. Injected directly anywhere it’s needed and into RepositoryRegistry via @All List<AbstractRepository>. |
@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:
quarkus.index-dependency.<alias>.group-id=com.your.group
quarkus.index-dependency.<alias>.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:
// 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
}
<!-- pom.xml -->
<dependency>
<groupId>io.github.zyraz-io</groupId>
<artifactId>ekbatan-flyway</artifactId>
<version>0.2.1</version>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-flyway</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-postgresql</artifactId>
</dependency>
No Quarkus datasource block is required:
# No Quarkus datasource properties
# No Quarkus Flyway startup properties
Startup migrator:
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 parallelquarkus.datasource.*tree to keep in sync. - Same code for one shard or many.
FlywayMigrator.migrate(shardingConfig)iterates every configured shard member’sprimaryConfig. - Quarkus still helps native-image.
quarkus-jdbc-*contributes driver metadata, andquarkus-flywaykeeps 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 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:
// 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 byekbatan-coreand is unrelated to your app’s HTTP-layer Jackson 2 setup; the two coexist.ekbatan-native’sJackson3RecordsFeatureregisters your records under Jackson 3 — see 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. Copy that file into your app’sMETA-INF/native-image/com.zaxxer/HikariCP/. (ekbatan-nativeships the same file under its own coordinate, so depending onekbatan-nativeis the cheapest way to pull it in.) - Jackson 3 records — the
ekbatan-nativemodule’sJackson3RecordsFeaturepicks up the framework’s records automatically. For your own records /@AutoBuilderbuilders, setquarkus.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.
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").
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 — what the deployment build steps + runtime producers are doing for you
- Wiring with Spring Boot / Wiring with Micronaut — same end state in the other DI frameworks
- Actions, ActionPlan, ActionExecutor — what
executor.execute(...)runs on your behalf - Listen-to-yourself: in-process event handlers — what
@EkbatanEventHandlerconsumes - Distributed background jobs — what
@EkbatanDistributedJobschedules - GraalVM native-image — Quarkus + native specifics
- The runnable reference:
ekbatan-integration-tests/di/quarkus