# Wiring with 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](without-di.md). For Spring Boot and Quarkus, see [spring.md](spring.md) and [quarkus.md](quarkus.md). ## What you write ### 1. The integration jar + annotation processor ```kotlin // build.gradle.kts dependencies { implementation("io.github.zyraz-io:ekbatan-micronaut:") annotationProcessor("io.github.zyraz-io:ekbatan-micronaut:") 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 `BeanDefinition`s 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](#how-the-integration-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 Standard Micronaut entry point: ```java public class WalletsApplication { public static void main(String[] args) { Micronaut.run(WalletsApplication.class, args); } } ``` ### 4. The configuration `application.yml`: ```yaml 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 ```java @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` registered both as: - `META-INF/services/io.micronaut.inject.visitor.TypeElementVisitor` (legacy SoftServiceLoader format) - `META-INF/micronaut/io.micronaut.inject.visitor.TypeElementVisitor/io.ekbatan.micronaut.internal.EkbatanStereotypeVisitor` (Micronaut 4.x per-impl marker file) 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: - **Recommended**: ensure the upstream module also includes the visitor on its `annotationProcessor` path, so its `BeanDefinition`s are generated up-front. - **Alternative**: see the recipe in [`ekbatan-integration-tests/di/shared/build.gradle.kts`](../../ekbatan-integration-tests/di/shared/build.gradle.kts), which pre-generates the BeanDefinitions in the shared jar. ### The three `@Factory` classes In `io.ekbatan.micronaut`: - **`EkbatanCoreConfiguration`** — produces `ShardingConfig`, `JobsConfig`, `LocalEventHandlerConfig`, `DatabaseRegistry`, `Clock`, `JsonMapper`, `RepositoryRegistry`, `ActionRegistry`, `ActionExecutor`. The executor's factory takes `Optional`: if the application declares its own `EventPersister` `@Bean`, 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. - **`EkbatanLocalEventHandlerConfiguration`** — `@Requires(classes = EventHandlerRegistry.class)`. Produces `EventHandlerRegistry`, `EventFanoutJob`, and conditionally `EventHandlingJob` (gated on `@Requires(property = "ekbatan.local-event-handler.handling.enabled", value = "true")`). - **`EkbatanDistributedJobsConfiguration`** — `@Requires(classes = JobRegistry.class)`. Produces `ConnectionProvider` (from the `jobs-config` / `jobsConfig` slot) and `JobRegistry`. A nested `Lifecycle` class implements `ApplicationEventListener` for start, and a separate `@EventListener void onShutdown(ShutdownEvent)` handles graceful stop. ### The four `@Ekbatan*` annotations | Annotation | What the integration does | |---|---| | `@EkbatanAction` | `EkbatanStereotypeVisitor` lifts to `@Singleton` at *your* compile time → Micronaut generates a `BeanDefinition` → injected as `List>` into `EkbatanCoreConfiguration.ekbatanActionRegistry`. | | `@EkbatanRepository` | Same — lifted to `@Singleton`, BeanDefinition generated. Injected directly anywhere it's needed and into `RepositoryRegistry` via `List`. | | `@EkbatanEventHandler` | Same lifting; only effective when the local-event-handler module is on the classpath (`@Requires(classes = ...)`). | | `@EkbatanDistributedJob` | Same 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: call `FlywayMigrator` from a `@Context` bean and pass the typed `ShardingConfig`. **Dependencies** — pull the Micronaut extension anyway (it brings the BOM-pinned flyway-core and native-image support), but don't add a `flyway:` block in YAML. ```kotlin // build.gradle.kts dependencies { // Ekbatan's programmatic migrator. Runs one datasource or every primary shard // from ShardingConfig, and is native-image-aware when used in native binaries. implementation("io.github.zyraz-io:ekbatan-flyway:$ekbatanVersion") // The Micronaut extension. Pulls flyway-core transitively at Micronaut's BOM-pinned // version and ships native-image support. We don't use its auto-wired // `Flyway` beans (no `flyway:` block in application.yml) — the @Context bean below // calls FlywayMigrator 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 } ``` ```xml io.github.zyraz-io ekbatan-flyway ${ekbatan.version} io.micronaut.flyway micronaut-flyway org.flywaydb flyway-database-postgresql ``` ```java import io.ekbatan.flyway.FlywayMigrator; @Context public class EkbatanFlywayMigrator { public EkbatanFlywayMigrator(ShardingConfig shardingConfig) { FlywayMigrator.migrate(shardingConfig); } } ``` Why this shape: - **`@Context` is eager.** Micronaut instantiates `@Context` beans during application startup, before lazy `@Singleton` beans (including Ekbatan's `DatabaseRegistry`). The constructor calls `.migrate()` synchronously — so by the time anything else touches the database, the schema is in place. - **Single source of truth.** Connection coordinates live only in `ekbatan.sharding.*`. No YAML `flyway:` block, no placeholder interpolation, no `FlywayConfigurationCustomizer` override to maintain. - **Same shape for one shard or many.** `FlywayMigrator.migrate(shardingConfig)` runs the configured migration locations on every member's `primaryConfig`, sequentially. With a single member, that is just one migration run. - **Native works with the same application code.** In a native image, `FlywayMigrator` installs an internal classpath resource scanner so migrations can still be discovered inside the binary. 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`](../../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`: ```kotlin // 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. } ``` ```xml io.micronaut.serde micronaut-serde-jackson maven-compiler-plugin io.micronaut.serde micronaut-serde-processor ``` Tag your on-the-wire types with `@io.micronaut.serde.annotation.Serdeable`. Standard Jackson annotations (`@JsonProperty`, `@JsonCreator`, `@JsonAlias`) are still honoured: ```java @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 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 - The GraalVM Build Tools plugin auto-pulls the GraalVM Reachability Metadata Repository, which already covers HikariCP and the major JDBC drivers. Nothing extra to vendor. - The auto-applied native-build-tools plugin (0.11.x, brought by Micronaut 4.6.x) expects an old metadata-repo format; pin the repo to `0.3.35` (last 0.x release). The wallet examples show this in their `graalvmNative { metadataRepository { version.set("0.3.35") } }` block. - For JUnit `nativeTest`, ship a minimal `src/main/resources/logback.xml` that pins `com.zaxxer.hikari` to INFO. Hikari's `HikariConfig.logConfiguration()` at DEBUG reflects over every JavaBean property, which trips a missing-reflection-metadata error at runtime; raising the level short-circuits the gating `if (LOGGER.isDebugEnabled()) logConfiguration();`. - For Testcontainers init scripts (the `mariadb_init.sql` / `mysql_init.sql` that grants the test user cross-DB privileges), replace `MountableFile.forClasspathResource(...)` with `Transferable.of(.getBytes(UTF_8))`. Classpath-resource resolution doesn't work reliably inside an in-process native test image; inlining the bytes does. - Add init-at-build-time hints for the JUnit and Logback classes the bundled 0.3.35 metadata-repo doesn't cover: ```kotlin buildArgs.add("--initialize-at-build-time=org.junit.platform.commons.logging.LoggerFactory") buildArgs.add("--initialize-at-build-time=org.junit.platform.commons.logging.LoggerFactory\$DelegatingLogger") buildArgs.add("--initialize-at-build-time=ch.qos.logback") buildArgs.add("--initialize-at-build-time=org.slf4j") ``` - For your own records / `@AutoBuilder` builders / `@JsonCreator` mixins / jOOQ-generated classes that live outside `io.ekbatan`, extend the scan roots: ```kotlin graalvmNative { binaries.all { buildArgs.add("-Dio.ekbatan.graalvm.scan.packages=io.ekbatan,com.your.package") } } ``` 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/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 - **Micronaut Data** — can coexist with Ekbatan in the same app (different concerns, different datasources or the same one) but the framework does not integrate with Micronaut Data repositories. - **Micronaut's own transactional annotations** — Ekbatan owns its own `TransactionManager`. Code outside an Action that needs database transactions should use the host framework's facilities directly on its own datasource. ## See also - [Wiring without DI](without-di.md) — what the visitor + factory beans are doing for you - [Wiring with Spring Boot](spring.md) / [Wiring with Quarkus](quarkus.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) — Micronaut + native specifics - The runnable reference: [`ekbatan-integration-tests/di/micronaut`](../../ekbatan-integration-tests/di/micronaut)