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 BeanDefinitions 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.
Ekbatan works with Micronaut 4 and Micronaut 5, and is tested on both. On Micronaut 5, leave each @JsonCreator constructor package-private, not private: Micronaut 5 refuses to compile a private one (see models and entities).
What you write
1. The integration jar + annotation processor
// build.gradle.kts
dependencies {
implementation("io.github.ekbatan-io:ekbatan-micronaut:<version>")
annotationProcessor("io.github.ekbatan-io:ekbatan-micronaut:<version>")
annotationProcessor("io.micronaut:micronaut-inject-java")
}
(Published on Maven Central under groupId io.github.ekbatan-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.
Extra driver settings go under data-source-properties, each name exactly as the driver spells it - a client key’s password, say, or the application name the database shows for each connection. They reach the driver beside the URL, never inside it, for the pools and for migrations alike:
primary-config:
jdbc-url: jdbc:postgresql://primary:5432/wallets
username: wallets_app
data-source-properties:
sslpassword: ${DB_KEY_PASSWORD}
ApplicationName: wallet-service
Every value there is treated as a secret and never printed, and username and password may be left out when the login needs none. Every field, the rules, and each way to log in: Connecting to the database.
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: 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.
// 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.ekbatan-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
}
<!-- pom.xml -->
<dependency>
<groupId>io.github.ekbatan-io</groupId>
<artifactId>ekbatan-flyway</artifactId>
<version>${ekbatan.version}</version>
</dependency>
<dependency>
<groupId>io.micronaut.flyway</groupId>
<artifactId>micronaut-flyway</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
import io.ekbatan.flyway.FlywayMigrator;
@Context
public class EkbatanFlywayMigrator {
public EkbatanFlywayMigrator(ShardingConfig shardingConfig) {
FlywayMigrator.migrate(shardingConfig);
}
}
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. - Same shape for one shard or many.
FlywayMigrator.migrate(shardingConfig)runs the configured migration locations on every member’sprimaryConfig, sequentially. With a single member, that is just one migration run. - Native works with the same application code. In a native image,
FlywayMigratorinstalls an internal resource scanner so migrations are still found, whether packed inside the binary (classpath:) or in a folder on disk (filesystem:). See GraalVM native-image for the stack-by-stack details.
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.
- Leave the metadata repository’s version alone on Micronaut 5: its Gradle plugin brings native-build-tools 1.1.x, which picks a repository it can read and refuses the old 0.3.x ones. On Micronaut 4 it is the other way round: its plugin (4.6.x) brings native-build-tools 0.11.x, which needs the old format, so pin the repository to
0.3.35, the last 0.x release:graalvmNative { metadataRepository { version.set("0.3.35") } }. - 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. - On Micronaut 4 with the
0.3.35repository only, add init-at-build-time hints for the JUnit and Logback classes that repository doesn’t cover. Leave them out on Micronaut 5: with its newer repository, Logback initialized at build time fails the build on its XML parser state (Cannot read instance field of a class that is initialized at run time: org.xml.sax.helpers.LocatorImpl).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