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

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:

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

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