§ reference

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-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.

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:

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:

AnnotationQuarkus discovery
@EkbatanActionFound in the Jandex index by discoverEkbatanBeans build step → registered as @Singleton @Unremovable Arc bean → injected via @All List<Action<?, ?>> into EkbatanCoreConfiguration.ekbatanActionRegistry.
@EkbatanRepositorySame — Jandex → unremovable Arc singleton. Injected directly anywhere it’s needed and into RepositoryRegistry via @All List<AbstractRepository>.
@EkbatanEventHandlerSame path; only effective when the local-event-handler module is on the classpath.
@EkbatanDistributedJobSame 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:

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 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 below.

Native-image specifics

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

See also