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-nameis recommended for Micronaut. Not every Micronaut/Hikari combination discovers the JDBCDriverSPI when the JVM is started by the Gradle test worker or some launcher modes. Settingdriver-class-namemakes HikariClass.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:
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
annotationProcessorpath, so itsBeanDefinitions are generated up-front. - Alternative: see the recipe in
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— producesShardingConfig,JobsConfig,LocalEventHandlerConfig,DatabaseRegistry,Clock,JsonMapper,RepositoryRegistry,ActionRegistry,ActionExecutor. The executor’s factory takesOptional<EventPersister>: if the application declares its ownEventPersister@Bean, 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.EkbatanLocalEventHandlerConfiguration—@Requires(classes = EventHandlerRegistry.class). ProducesEventHandlerRegistry,EventFanoutJob, and conditionallyEventHandlingJob(gated on@Requires(property = "ekbatan.local-event-handler.handling.enabled", value = "true")).EkbatanDistributedJobsConfiguration—@Requires(classes = JobRegistry.class). ProducesConnectionProvider(from thejobs-config/jobsConfigslot) andJobRegistry. A nestedLifecycleclass implementsApplicationEventListener<StartupEvent>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<Action<?, ?>> into EkbatanCoreConfiguration.ekbatanActionRegistry. |
@EkbatanRepository | Same — lifted to @Singleton, BeanDefinition generated. Injected directly anywhere it’s needed and into RepositoryRegistry via List<AbstractRepository>. |
@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: 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:
@Contextis eager. Micronaut instantiates@Contextbeans during application startup, before lazy@Singletonbeans (including Ekbatan’sDatabaseRegistry). 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 YAMLflyway:block, no placeholder interpolation, noFlywayConfigurationCustomizeroverride to maintain. - Native works. Keeping
micronaut-flywayon the classpath preserves its substrate-VM Flyway scanning hints. For the native-image variants of the example projects, the migrator delegates toFlywayHelper.migrate(...)instead — see GraalVM native-image for why.
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
- 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 theirgraalvmNative { metadataRepository { version.set("0.3.35") } }block. - For JUnit
nativeTest, ship a minimalsrc/main/resources/logback.xmlthat pinscom.zaxxer.hikarito INFO. Hikari’sHikariConfig.logConfiguration()at DEBUG reflects over every JavaBean property, which trips a missing-reflection-metadata error at runtime; raising the level short-circuits the gatingif (LOGGER.isDebugEnabled()) logConfiguration();. - For Testcontainers init scripts (the
mariadb_init.sql/mysql_init.sqlthat grants the test user cross-DB privileges), replaceMountableFile.forClasspathResource(...)withTransferable.of(<inline-bytes>.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:
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 /
@AutoBuilderbuilders /@JsonCreatormixins / jOOQ-generated classes that live outsideio.ekbatan, extend the scan roots: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.
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 — what the visitor + factory beans are doing for you
- Wiring with Spring Boot / Wiring with Quarkus — 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 — Micronaut + native specifics
- The runnable reference:
ekbatan-integration-tests/di/micronaut