§ reference

DI · Micronaut

What you write in a Micronaut app to use Ekbatan: the integration jar plus the framework's compile-time annotation processor, four `@Ekbatan*` annotations on your own classes, and an `application.yml` tree under `ekbatan.*`. The compile-time `EkbatanStereotypeVisitor` lifts your annotated classes to `@Singleton` so Micronaut generates `BeanDefinition`s for them, and `ActionExecutor` is injectable anywhere.

For the equivalent in plain Java with no DI container, see wiring/without-di.md. For Spring Boot and Quarkus, see spring.md and quarkus.md.

What you write

1. The integration jar + annotation processor

// build.gradle.kts
dependencies {
    implementation("io.github.zyraz-io:ekbatan-micronaut:<version>")
    annotationProcessor("io.github.zyraz-io:ekbatan-micronaut:<version>")
    annotationProcessor("io.micronaut:micronaut-inject-java")
}

(Published on Maven Central under groupId io.github.zyraz-io. Java packages stay io.ekbatan.* — they don’t need to match the Maven groupId.)

The annotationProcessor line is required — without it, the EkbatanStereotypeVisitor doesn’t run during your compile, so your @Ekbatan* classes never get lifted to @Singleton and Micronaut produces no BeanDefinitions for them.

The implementation jar transitively pulls in ekbatan-core, ekbatan-events:local-event-handler, ekbatan-distributed-jobs, and the @Ekbatan* annotation jar. 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. Micronaut discovers the @Ekbatan*-annotated classes via the compile-time EkbatanStereotypeVisitor (see How the integration 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

Standard Micronaut entry point:

public class WalletsApplication {
    public static void main(String[] args) {
        Micronaut.run(WalletsApplication.class, args);
    }
}

4. The configuration

application.yml:

ekbatan:
  namespace: com.example.wallets

  # Optional — opt in to running EventHandlingJob in this process.
  local-event-handler:
    handling:
      enabled: true

  sharding:
    default-shard:
      group: 0
      member: 0

    groups:
      - group: 0
        name: default
        members:
          - member: 0
            configs:
              primary-config:
                jdbc-url: jdbc:postgresql://primary:5432/wallets
                username: wallets_app
                password: ${APP_DB_PASSWORD}
                maximum-pool-size: 20
                driver-class-name: org.postgresql.Driver
              secondary-config:
                jdbc-url: jdbc:postgresql://replica:5432/wallets
                username: wallets_app_ro
                password: ${APP_DB_PASSWORD}
                maximum-pool-size: 20
                driver-class-name: org.postgresql.Driver
              jobs-config:
                jdbc-url: jdbc:postgresql://primary:5432/wallets
                username: wallets_app
                password: ${APP_DB_PASSWORD}
                maximum-pool-size: 5
                driver-class-name: org.postgresql.Driver

driver-class-name is recommended for Micronaut. Not every Micronaut/Hikari combination discovers the JDBC Driver SPI when the JVM is started by the Gradle test worker or some launcher modes. Setting driver-class-name makes Hikari Class.forName(...) it explicitly.

Make sure snakeyaml is on the runtime classpath — Micronaut’s inspectRuntimeClasspath verifies any *.yml has a YAML parser available.

5. Use it

@Controller("/wallets")
public class WalletController {

    @Inject ActionExecutor executor;

    @Post("/{id}/deposit")
    public Wallet deposit(UUID id, @Body 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 Micronaut beans — inject any @EkbatanRepository-annotated class anywhere.


How the integration works

If you only want the tutorial above, stop here. The rest is the reference for what the compile-time visitor + factory beans are doing on your behalf — useful when discovery isn’t happening or you need to override a default.

EkbatanStereotypeVisitor (the compile-time machinery)

The integration ships a TypeElementVisitor<Object, Object> registered both as:

Both are required — Micronaut 4 reads the per-impl marker, but earlier tooling and incremental-AP builds may still consult the services file.

When the visitor sees a class bearing one of the four @Ekbatan* annotations during your compile, it calls element.annotate(Singleton.class) to lift it to @Singleton. Micronaut’s annotation processor then generates a BeanDefinition for that class. No @Singleton annotation in your source — the visitor inserts it.

Why annotationProcessor on the integration jar matters

The visitor only runs on classes being compiled with the visitor JAR on the AP classpath. If your @Ekbatan* classes live in a transitive jar that was compiled without the visitor, no BeanDefinition was ever generated for them, and Micronaut won’t find them at runtime — putting the visitor on the downstream module’s annotationProcessor path doesn’t retroactively process already-compiled classes.

Two fixes:

The three @Factory classes

In io.ekbatan.micronaut:

The four @Ekbatan* annotations

AnnotationWhat the integration does
@EkbatanActionEkbatanStereotypeVisitor lifts to @Singleton at your compile time → Micronaut generates a BeanDefinition → injected as List<Action<?, ?>> into EkbatanCoreConfiguration.ekbatanActionRegistry.
@EkbatanRepositorySame — lifted to @Singleton, BeanDefinition generated. Injected directly anywhere it’s needed and into RepositoryRegistry via List<AbstractRepository>.
@EkbatanEventHandlerSame lifting; only effective when the local-event-handler module is on the classpath (@Requires(classes = ...)).
@EkbatanDistributedJobSame lifting; only effective when ekbatan-distributed-jobs is on the classpath.

Flyway — programmatic @Context bean

Skip the micronaut-flyway auto-wiring path. It works, but it forces you to declare a flyway.datasources.default block in application.yml with ${ekbatan.sharding...} placeholder interpolation chasing back into Ekbatan’s config — duplicating the source of truth and burying a FlywayConfigurationCustomizer override on top to fix it. Cleaner: construct Flyway directly from the typed ShardingConfig in a @Context bean.

Dependencies — pull the Micronaut extension anyway (it brings the BOM-pinned flyway-core and ships substrate-VM Flyway scanning needed for native-image), but don’t add a flyway: block in YAML.

// build.gradle.kts
dependencies {
    // The Micronaut extension. Pulls flyway-core transitively at Micronaut's BOM-pinned
    // version and ships substrate-VM hints for native-image. We don't use its auto-wired
    // `Flyway` beans (no `flyway:` block in application.yml) — the @Context bean below
    // calls `Flyway.configure()...migrate()` itself.
    implementation("io.micronaut.flyway:micronaut-flyway")

    // Database-specific Flyway plugin (BOM-managed; no version needed).
    implementation("org.flywaydb:flyway-database-postgresql")   // or flyway-mysql for MariaDB/MySQL
}
<!-- pom.xml -->
<dependency>
    <groupId>io.micronaut.flyway</groupId>
    <artifactId>micronaut-flyway</artifactId>
</dependency>
<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-database-postgresql</artifactId>
</dependency>
@Context
public class EkbatanFlywayMigrator {

    public EkbatanFlywayMigrator(ShardingConfig shardingConfig) {
        var primary = shardingConfig.groups.getFirst().members.getFirst().primaryConfig();
        Flyway.configure()
                .dataSource(primary.jdbcUrl, primary.username, primary.password)
                .load()
                .migrate();
    }
}

Why this shape:

If you’d rather use the auto-wired customizer path (@Singleton @Named("default") FlywayConfigurationCustomizer bound to a flyway.datasources.default YAML block), that still works — it’s just more moving parts.

See ekbatan-examples/micronaut-wallet-rest-gradle-pg for the full runnable shape (and its -mariadb/-mysql/-native-*/-maven-* variants).

Serialization — use micronaut-serde-jackson (not micronaut-jackson-databind)

For native-image friendliness, swap the runtime Jackson integration to micronaut-serde-jackson, which generates compile-time Serializer/Deserializer beans instead of relying on Jackson Databind’s reflection.

Dependencies — pull the Micronaut Serialization Jackson bridge, NOT raw jackson-databind or micronaut-jackson-databind:

// build.gradle.kts
dependencies {
    // ✅ The Micronaut Serialization Jackson bridge. Standard Jackson annotations
    //    (@JsonProperty, @JsonCreator, @JsonAlias) are honoured; types tagged with @Serdeable
    //    get a compile-time Serializer + Deserializer generated. No runtime reflection — the
    //    native image is ~5MB smaller as a result.
    implementation("io.micronaut.serde:micronaut-serde-jackson")

    // ✅ The annotation processor that emits the compile-time serdes. REQUIRED — without
    //    it, micronaut-serde-jackson runs but finds no generated serdes and falls back to
    //    Jackson Databind, which trips reflection errors on native.
    annotationProcessor("io.micronaut.serde:micronaut-serde-processor")

    // ❌ Don't add `io.micronaut:micronaut-jackson-databind` alongside this — they conflict
    //    (both register Jackson MessageBodyReader/Writer beans). Drop jackson-databind
    //    entirely; micronaut-serde-jackson replaces it.
    //
    // ❌ Don't add `com.fasterxml.jackson.core:jackson-databind` directly either —
    //    micronaut-serde-jackson brings the right version transitively, and direct pulls
    //    bypass the AOT processor's serde generation.
}
<!-- pom.xml -->
<dependency>
    <groupId>io.micronaut.serde</groupId>
    <artifactId>micronaut-serde-jackson</artifactId>
</dependency>

<!-- Plus the annotation processor entry in maven-compiler-plugin's <annotationProcessorPaths> -->
<plugin>
    <artifactId>maven-compiler-plugin</artifactId>
    <configuration>
        <annotationProcessorPaths combine.children="append">
            <path>
                <groupId>io.micronaut.serde</groupId>
                <artifactId>micronaut-serde-processor</artifactId>
            </path>
        </annotationProcessorPaths>
    </configuration>
</plugin>

Tag your on-the-wire types with @io.micronaut.serde.annotation.Serdeable. Standard Jackson annotations (@JsonProperty, @JsonCreator, @JsonAlias) are still honoured:

@Controller("/wallets")
public class WalletController {

    @Serdeable
    public record CreateRequest(UUID ownerId, String currency, BigDecimal initialBalance) {}

    @Serdeable
    public record WalletResponse(UUID id, UUID ownerId, String currency, BigDecimal balance, String state, Long version) {}

    @Post
    public HttpResponse<WalletResponse> create(@Body CreateRequest body) { ... }
}

Records work out of the box (canonical constructor). For non-record DTOs, add @JsonCreator to the constructor you want used. The reward: no reflection on the HTTP path → smaller, faster native image.

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/Quarkus. Both kebab-case and camelCase keys are accepted before binding. 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").

What’s deliberately not bridged

See also