DI · Quarkus
What you write in a Quarkus app to use Ekbatan: the extension 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-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 quarkus-flyway + a FlywayConfigurationCustomizer
Don’t run Flyway by hand from a @Observes StartupEvent (that’s what the framework’s own tests at ekbatan-integration-tests/di/quarkus/PostgresTestResource do via FlywayHelper, because they use raw Flyway with no framework extension wrapping it — see GraalVM native-image § two patterns). For a real Quarkus app, use quarkus-flyway and let an EkbatanShardFlywayCustomizer (a CDI bean) override the dataSource from ekbatan.sharding.*.
Dependencies — pull the Quarkus extension, NOT raw flyway-core:
// build.gradle.kts
dependencies {
// ✅ The Quarkus extension. Pulls flyway-core transitively at Quarkus's BOM-pinned version,
// ships substrate-VM Flyway resource scanning for native, integrates with @ConfigMapping.
implementation("io.quarkus:quarkus-flyway")
// ✅ Database-specific Flyway plugin (also BOM-managed; no version needed).
implementation("org.flywaydb:flyway-database-postgresql") // or flyway-mysql for MariaDB/MySQL
// ✅ Matching JDBC driver extension — registers the driver class for native automatically.
implementation("io.quarkus:quarkus-jdbc-postgresql") // or quarkus-jdbc-mariadb / quarkus-jdbc-mysql
// ❌ Don't add `org.flywaydb:flyway-core` directly. quarkus-flyway brings the right version
// and configures the CDI bean for you; pulling flyway-core independently can pin a
// version that doesn't match what Quarkus's @ConfigMapping expects, and even when it
// does, the native build won't find migrations at runtime (no substrate-VM scanner).
}
<!-- pom.xml -->
<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>
# application.properties — minimum needed for FlywayAutoConfiguration to wake up.
# We don't set quarkus.datasource.url/username/password — the customizer below overrides it.
quarkus.datasource.db-kind=postgresql
quarkus.flyway.migrate-at-start=true
@ApplicationScoped
public class EkbatanShardFlywayCustomizer implements FlywayConfigurationCustomizer {
private final ShardingConfig shardingConfig;
public EkbatanShardFlywayCustomizer(ShardingConfig shardingConfig) {
this.shardingConfig = shardingConfig;
}
@Override
public void customize(FluentConfiguration configuration) {
var primary = shardingConfig.groups.getFirst().members.getFirst().primaryConfig();
configuration.dataSource(primary.jdbcUrl, primary.username, primary.password);
}
}
Why this shape:
- Single source of truth. Connection coordinates live under
ekbatan.sharding.*; no parallelquarkus.datasource.url/username/passwordtree to keep in sync. - Native works out of the box.
quarkus-flywayships substrate-VM-aware Flyway resource scanning; no need forFlywayHelper. - Migrations run before the StartupEvent fires, so
ekbatan-jobs’scheduled_taskstable polling sees the schema already in place.
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