§ learn

Getting started

Set your stack, build tool, and database once with the picker below. Every snippet adapts — and your choice carries to every other lesson under /learn/.

1. Scaffold a project

Skip if you already have a project; otherwise start one.

start.spring.io → pick Java 25, your build tool, and add Web + JDBC API + Flyway Migration + your DB driver. Or via CLI:

curl https://start.spring.io/starter.zip \
    -d type=gradle-project-kotlin -d language=java -d javaVersion=25 \
    -d artifactId=wallet -d groupId=io.example \
    -d dependencies=web,jdbc,flyway,postgresql \
    -o wallet.zip && unzip wallet.zip -d wallet && cd wallet
# Install: brew install quarkusio/tap/quarkus
quarkus create app io.example:wallet:0.1.0 \
    --extension=rest-jackson,jdbc-postgresql,flyway,jooq
cd wallet

Or via code.quarkus.io with the same extensions.

# Install: brew install --cask micronaut-projects/tap/mn
mn create-app io.example.wallet --jdk=25 --build=gradle \
    --features=postgres,flyway,data-jdbc,serialization-jackson
cd wallet

Or via micronaut.io/launch with the same features.

mkdir wallet && cd wallet
gradle init --type basic --dsl kotlin
mkdir -p src/main/java/io/example/wallet
mkdir -p src/main/resources/db/migration

No scaffolder — you build the project structure manually. The framework works fine without a DI container; see Plain Java wiring.

2. Start a database

Drop a compose.yaml in the project root. docker compose up -d wallet-db and you have a database in ~10 seconds.

compose.yaml
yaml
1 services:
2 wallet-db:
3 image: postgres:17
4 ports: ["5432:5432"]
5 environment:
6 POSTGRES_USER: wallet
7 POSTGRES_PASSWORD: wallet
8 POSTGRES_DB: wallet
compose.yaml
yaml
1 services:
2 wallet-db:
3 image: mariadb:11
4 ports: ["3306:3306"]
5 environment:
6 MARIADB_USER: wallet
7 MARIADB_PASSWORD: wallet
8 MARIADB_DATABASE: wallet
9 MARIADB_ROOT_PASSWORD: root
10 volumes:
11 # Runs as root on first init — grants the wallet user cross-database access so
12 # V0000 below can CREATE DATABASE eventlog.
13 - "./src/main/resources/mariadb_init.sql:/docker-entrypoint-initdb.d/mariadb_init.sql:ro"
compose.yaml
yaml
1 services:
2 wallet-db:
3 image: mysql:9
4 ports: ["3306:3306"]
5 environment:
6 MYSQL_USER: wallet
7 MYSQL_PASSWORD: wallet
8 MYSQL_DATABASE: wallet
9 MYSQL_ROOT_PASSWORD: root
10 volumes:
11 - "./src/main/resources/mysql_init.sql:/docker-entrypoint-initdb.d/mysql_init.sql:ro"

3. Add dependencies

Add the framework, your JDBC driver, and the framework-native Flyway extension.

build.gradle.kts
kotlin
1 // At the top of build.gradle.kts, outside dependencies. See "(1) Override jOOQ version" below.
2 extra["jooq.version"] = "3.20.10"
3
4 dependencies {
5 implementation("io.github.zyraz-io:ekbatan-spring-boot-starter:0.1.0")
6 compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
7 annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
8
9 implementation("org.springframework.boot:spring-boot-starter-flyway")
10 implementation("org.postgresql:postgresql")
11
12 jooqCodegen("org.postgresql:postgresql") // see "(6) jOOQ codegen needs its own classpath" below
13 }
build.gradle.kts
kotlin
1 // At the top of build.gradle.kts, outside dependencies. See "(1) Override jOOQ version" below.
2 extra["jooq.version"] = "3.20.10"
3
4 dependencies {
5 implementation("io.github.zyraz-io:ekbatan-spring-boot-starter:0.1.0")
6 compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
7 annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
8
9 implementation("org.springframework.boot:spring-boot-starter-flyway")
10 implementation("org.mariadb.jdbc:mariadb-java-client")
11
12 jooqCodegen("org.mariadb.jdbc:mariadb-java-client") // see "(6) jOOQ codegen needs its own classpath" below
13 jooqCodegen("org.flywaydb:flyway-mysql:11.20.0") // (6) flyway-mysql also covers MariaDB
14 }
build.gradle.kts
kotlin
1 // At the top of build.gradle.kts, outside dependencies. See "(1) Override jOOQ version" below.
2 extra["jooq.version"] = "3.20.10"
3
4 dependencies {
5 implementation("io.github.zyraz-io:ekbatan-spring-boot-starter:0.1.0")
6 compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
7 annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
8
9 implementation("org.springframework.boot:spring-boot-starter-flyway")
10 implementation("com.mysql:mysql-connector-j")
11
12 jooqCodegen("com.mysql:mysql-connector-j") // see "(6) jOOQ codegen needs its own classpath" below
13 jooqCodegen("org.flywaydb:flyway-mysql:11.20.0") // (6) flyway-mysql is required for MySQL too
14 }
pom.xml
xml
1 <!-- (1) Override BOM-managed jOOQ - inside <properties>. See footnote (1) below. -->
2 <jooq.version>3.20.10</jooq.version>
3
4 <!-- inside <dependencies> -->
5 <dependency>
6 <groupId>io.github.zyraz-io</groupId>
7 <artifactId>ekbatan-spring-boot-starter</artifactId>
8 <version>0.1.0</version>
9 </dependency>
10 <dependency>
11 <groupId>io.github.zyraz-io</groupId>
12 <artifactId>ekbatan-annotation-processor</artifactId>
13 <version>0.1.0</version>
14 <scope>provided</scope>
15 </dependency>
16 <dependency>
17 <groupId>org.springframework.boot</groupId>
18 <artifactId>spring-boot-starter-flyway</artifactId>
19 </dependency>
20 <dependency>
21 <groupId>org.postgresql</groupId>
22 <artifactId>postgresql</artifactId>
23 </dependency>
pom.xml
xml
1 <!-- (1) Override BOM-managed jOOQ - inside <properties>. See footnote (1) below. -->
2 <jooq.version>3.20.10</jooq.version>
3
4 <!-- inside <dependencies> -->
5 <dependency>
6 <groupId>io.github.zyraz-io</groupId>
7 <artifactId>ekbatan-spring-boot-starter</artifactId>
8 <version>0.1.0</version>
9 </dependency>
10 <dependency>
11 <groupId>io.github.zyraz-io</groupId>
12 <artifactId>ekbatan-annotation-processor</artifactId>
13 <version>0.1.0</version>
14 <scope>provided</scope>
15 </dependency>
16 <dependency>
17 <groupId>org.springframework.boot</groupId>
18 <artifactId>spring-boot-starter-flyway</artifactId>
19 </dependency>
20 <dependency>
21 <groupId>org.mariadb.jdbc</groupId>
22 <artifactId>mariadb-java-client</artifactId>
23 </dependency>
pom.xml
xml
1 <!-- (1) Override BOM-managed jOOQ - inside <properties>. See footnote (1) below. -->
2 <jooq.version>3.20.10</jooq.version>
3
4 <!-- inside <dependencies> -->
5 <dependency>
6 <groupId>io.github.zyraz-io</groupId>
7 <artifactId>ekbatan-spring-boot-starter</artifactId>
8 <version>0.1.0</version>
9 </dependency>
10 <dependency>
11 <groupId>io.github.zyraz-io</groupId>
12 <artifactId>ekbatan-annotation-processor</artifactId>
13 <version>0.1.0</version>
14 <scope>provided</scope>
15 </dependency>
16 <dependency>
17 <groupId>org.springframework.boot</groupId>
18 <artifactId>spring-boot-starter-flyway</artifactId>
19 </dependency>
20 <dependency>
21 <groupId>com.mysql</groupId>
22 <artifactId>mysql-connector-j</artifactId>
23 </dependency>
build.gradle.kts
kotlin
1 // At the top of build.gradle.kts, outside dependencies. See "(1) Override jOOQ version" below.
2 extra["jooq.version"] = "3.20.10"
3
4 dependencies {
5 implementation("io.github.zyraz-io:ekbatan-quarkus:0.1.0")
6 compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
7 annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
8
9 implementation("io.quarkus:quarkus-jdbc-postgresql")
10 implementation("io.quarkus:quarkus-flyway")
11
12 jooqCodegen("org.postgresql:postgresql") // see "(6) jOOQ codegen needs its own classpath" below
13 }
build.gradle.kts
kotlin
1 // At the top of build.gradle.kts, outside dependencies. See "(1) Override jOOQ version" below.
2 extra["jooq.version"] = "3.20.10"
3
4 dependencies {
5 implementation("io.github.zyraz-io:ekbatan-quarkus:0.1.0")
6 compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
7 annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
8
9 implementation("io.quarkus:quarkus-jdbc-mariadb")
10 implementation("io.quarkus:quarkus-flyway")
11
12 jooqCodegen("org.mariadb.jdbc:mariadb-java-client") // see "(6) jOOQ codegen needs its own classpath" below
13 jooqCodegen("org.flywaydb:flyway-mysql:11.20.0") // (6) flyway-mysql also covers MariaDB
14 }
build.gradle.kts
kotlin
1 // At the top of build.gradle.kts, outside dependencies. See "(1) Override jOOQ version" below.
2 extra["jooq.version"] = "3.20.10"
3
4 dependencies {
5 implementation("io.github.zyraz-io:ekbatan-quarkus:0.1.0")
6 compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
7 annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
8
9 implementation("io.quarkus:quarkus-jdbc-mysql")
10 implementation("io.quarkus:quarkus-flyway")
11
12 jooqCodegen("com.mysql:mysql-connector-j") // see "(6) jOOQ codegen needs its own classpath" below
13 jooqCodegen("org.flywaydb:flyway-mysql:11.20.0") // (6) flyway-mysql is required for MySQL too
14 }
pom.xml
xml
1 <!-- (1) Override BOM-managed jOOQ - inside <properties>. See footnote (1) below. -->
2 <jooq.version>3.20.10</jooq.version>
3
4 <!-- inside <dependencies> -->
5 <dependency>
6 <groupId>io.github.zyraz-io</groupId>
7 <artifactId>ekbatan-quarkus</artifactId>
8 <version>0.1.0</version>
9 </dependency>
10 <dependency>
11 <groupId>io.github.zyraz-io</groupId>
12 <artifactId>ekbatan-annotation-processor</artifactId>
13 <version>0.1.0</version>
14 <scope>provided</scope>
15 </dependency>
16 <dependency>
17 <groupId>io.quarkus</groupId>
18 <artifactId>quarkus-jdbc-postgresql</artifactId>
19 </dependency>
20 <dependency>
21 <groupId>io.quarkus</groupId>
22 <artifactId>quarkus-flyway</artifactId>
23 </dependency>
pom.xml
xml
1 <!-- (1) Override BOM-managed jOOQ - inside <properties>. See footnote (1) below. -->
2 <jooq.version>3.20.10</jooq.version>
3
4 <!-- inside <dependencies> -->
5 <dependency>
6 <groupId>io.github.zyraz-io</groupId>
7 <artifactId>ekbatan-quarkus</artifactId>
8 <version>0.1.0</version>
9 </dependency>
10 <dependency>
11 <groupId>io.github.zyraz-io</groupId>
12 <artifactId>ekbatan-annotation-processor</artifactId>
13 <version>0.1.0</version>
14 <scope>provided</scope>
15 </dependency>
16 <dependency>
17 <groupId>io.quarkus</groupId>
18 <artifactId>quarkus-jdbc-mariadb</artifactId>
19 </dependency>
20 <dependency>
21 <groupId>io.quarkus</groupId>
22 <artifactId>quarkus-flyway</artifactId>
23 </dependency>
pom.xml
xml
1 <!-- (1) Override BOM-managed jOOQ - inside <properties>. See footnote (1) below. -->
2 <jooq.version>3.20.10</jooq.version>
3
4 <!-- inside <dependencies> -->
5 <dependency>
6 <groupId>io.github.zyraz-io</groupId>
7 <artifactId>ekbatan-quarkus</artifactId>
8 <version>0.1.0</version>
9 </dependency>
10 <dependency>
11 <groupId>io.github.zyraz-io</groupId>
12 <artifactId>ekbatan-annotation-processor</artifactId>
13 <version>0.1.0</version>
14 <scope>provided</scope>
15 </dependency>
16 <dependency>
17 <groupId>io.quarkus</groupId>
18 <artifactId>quarkus-jdbc-mysql</artifactId>
19 </dependency>
20 <dependency>
21 <groupId>io.quarkus</groupId>
22 <artifactId>quarkus-flyway</artifactId>
23 </dependency>
build.gradle.kts
kotlin
1 dependencies {
2 implementation("io.github.zyraz-io:ekbatan-micronaut:0.1.0")
3 compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
4 annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
5 annotationProcessor("io.github.zyraz-io:ekbatan-micronaut:0.1.0") // CRITICAL — see "(5) Micronaut needs the AP line"
6
7 implementation("io.micronaut.flyway:micronaut-flyway")
8 implementation("io.micronaut.sql:micronaut-jdbc-hikari")
9 runtimeOnly("org.postgresql:postgresql")
10
11 jooqCodegen("org.postgresql:postgresql") // see "(6) jOOQ codegen needs its own classpath" below
12 }
build.gradle.kts
kotlin
1 dependencies {
2 implementation("io.github.zyraz-io:ekbatan-micronaut:0.1.0")
3 compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
4 annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
5 annotationProcessor("io.github.zyraz-io:ekbatan-micronaut:0.1.0") // CRITICAL — see "(5) Micronaut needs the AP line"
6
7 implementation("io.micronaut.flyway:micronaut-flyway")
8 implementation("io.micronaut.sql:micronaut-jdbc-hikari")
9 runtimeOnly("org.mariadb.jdbc:mariadb-java-client")
10
11 jooqCodegen("org.mariadb.jdbc:mariadb-java-client") // see "(6) jOOQ codegen needs its own classpath" below
12 jooqCodegen("org.flywaydb:flyway-mysql:11.20.0") // (6) flyway-mysql also covers MariaDB
13 }
build.gradle.kts
kotlin
1 dependencies {
2 implementation("io.github.zyraz-io:ekbatan-micronaut:0.1.0")
3 compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
4 annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
5 annotationProcessor("io.github.zyraz-io:ekbatan-micronaut:0.1.0") // CRITICAL — see "(5) Micronaut needs the AP line"
6
7 implementation("io.micronaut.flyway:micronaut-flyway")
8 implementation("io.micronaut.sql:micronaut-jdbc-hikari")
9 runtimeOnly("com.mysql:mysql-connector-j")
10
11 jooqCodegen("com.mysql:mysql-connector-j") // see "(6) jOOQ codegen needs its own classpath" below
12 jooqCodegen("org.flywaydb:flyway-mysql:11.20.0") // (6) flyway-mysql is required for MySQL too
13 }
pom.xml
xml
1 <!-- inside <dependencies> -->
2 <dependency>
3 <groupId>io.github.zyraz-io</groupId>
4 <artifactId>ekbatan-micronaut</artifactId>
5 <version>0.1.0</version>
6 </dependency>
7 <dependency>
8 <groupId>io.github.zyraz-io</groupId>
9 <artifactId>ekbatan-annotation-processor</artifactId>
10 <version>0.1.0</version>
11 <scope>provided</scope>
12 </dependency>
13 <dependency>
14 <groupId>io.micronaut.flyway</groupId>
15 <artifactId>micronaut-flyway</artifactId>
16 </dependency>
17 <dependency>
18 <groupId>io.micronaut.sql</groupId>
19 <artifactId>micronaut-jdbc-hikari</artifactId>
20 </dependency>
21 <dependency>
22 <groupId>org.postgresql</groupId>
23 <artifactId>postgresql</artifactId>
24 <scope>runtime</scope>
25 </dependency>
pom.xml
xml
1 <!-- inside <dependencies> -->
2 <dependency>
3 <groupId>io.github.zyraz-io</groupId>
4 <artifactId>ekbatan-micronaut</artifactId>
5 <version>0.1.0</version>
6 </dependency>
7 <dependency>
8 <groupId>io.github.zyraz-io</groupId>
9 <artifactId>ekbatan-annotation-processor</artifactId>
10 <version>0.1.0</version>
11 <scope>provided</scope>
12 </dependency>
13 <dependency>
14 <groupId>io.micronaut.flyway</groupId>
15 <artifactId>micronaut-flyway</artifactId>
16 </dependency>
17 <dependency>
18 <groupId>io.micronaut.sql</groupId>
19 <artifactId>micronaut-jdbc-hikari</artifactId>
20 </dependency>
21 <dependency>
22 <groupId>org.mariadb.jdbc</groupId>
23 <artifactId>mariadb-java-client</artifactId>
24 <scope>runtime</scope>
25 </dependency>
pom.xml
xml
1 <!-- inside <dependencies> -->
2 <dependency>
3 <groupId>io.github.zyraz-io</groupId>
4 <artifactId>ekbatan-micronaut</artifactId>
5 <version>0.1.0</version>
6 </dependency>
7 <dependency>
8 <groupId>io.github.zyraz-io</groupId>
9 <artifactId>ekbatan-annotation-processor</artifactId>
10 <version>0.1.0</version>
11 <scope>provided</scope>
12 </dependency>
13 <dependency>
14 <groupId>io.micronaut.flyway</groupId>
15 <artifactId>micronaut-flyway</artifactId>
16 </dependency>
17 <dependency>
18 <groupId>io.micronaut.sql</groupId>
19 <artifactId>micronaut-jdbc-hikari</artifactId>
20 </dependency>
21 <dependency>
22 <groupId>com.mysql</groupId>
23 <artifactId>mysql-connector-j</artifactId>
24 <scope>runtime</scope>
25 </dependency>
build.gradle.kts
kotlin
1 dependencies {
2 implementation("io.github.zyraz-io:ekbatan-core:0.1.0")
3 compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
4 annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
5
6 implementation("org.flywaydb:flyway-core:11.0.0")
7 implementation("org.postgresql:postgresql:42.7.4")
8
9 jooqCodegen("org.postgresql:postgresql:42.7.4") // see "(6) jOOQ codegen needs its own classpath" below
10 }
build.gradle.kts
kotlin
1 dependencies {
2 implementation("io.github.zyraz-io:ekbatan-core:0.1.0")
3 compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
4 annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
5
6 implementation("org.flywaydb:flyway-core:11.0.0")
7 implementation("org.mariadb.jdbc:mariadb-java-client:3.4.1")
8
9 jooqCodegen("org.mariadb.jdbc:mariadb-java-client:3.4.1") // see "(6) jOOQ codegen needs its own classpath" below
10 jooqCodegen("org.flywaydb:flyway-mysql:11.20.0") // (6) flyway-mysql also covers MariaDB
11 }
build.gradle.kts
kotlin
1 dependencies {
2 implementation("io.github.zyraz-io:ekbatan-core:0.1.0")
3 compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
4 annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
5
6 implementation("org.flywaydb:flyway-core:11.0.0")
7 implementation("com.mysql:mysql-connector-j:9.1.0")
8
9 jooqCodegen("com.mysql:mysql-connector-j:9.1.0") // see "(6) jOOQ codegen needs its own classpath" below
10 jooqCodegen("org.flywaydb:flyway-mysql:11.20.0") // (6) flyway-mysql is required for MySQL too
11 }
pom.xml
xml
1 <!-- inside <dependencies> -->
2 <dependency>
3 <groupId>io.github.zyraz-io</groupId>
4 <artifactId>ekbatan-core</artifactId>
5 <version>0.1.0</version>
6 </dependency>
7 <dependency>
8 <groupId>io.github.zyraz-io</groupId>
9 <artifactId>ekbatan-annotation-processor</artifactId>
10 <version>0.1.0</version>
11 <scope>provided</scope>
12 </dependency>
13 <dependency>
14 <groupId>org.flywaydb</groupId>
15 <artifactId>flyway-core</artifactId>
16 <version>11.0.0</version>
17 </dependency>
18 <dependency>
19 <groupId>org.postgresql</groupId>
20 <artifactId>postgresql</artifactId>
21 <version>42.7.4</version>
22 </dependency>
pom.xml
xml
1 <!-- inside <dependencies> -->
2 <dependency>
3 <groupId>io.github.zyraz-io</groupId>
4 <artifactId>ekbatan-core</artifactId>
5 <version>0.1.0</version>
6 </dependency>
7 <dependency>
8 <groupId>io.github.zyraz-io</groupId>
9 <artifactId>ekbatan-annotation-processor</artifactId>
10 <version>0.1.0</version>
11 <scope>provided</scope>
12 </dependency>
13 <dependency>
14 <groupId>org.flywaydb</groupId>
15 <artifactId>flyway-core</artifactId>
16 <version>11.0.0</version>
17 </dependency>
18 <dependency>
19 <groupId>org.mariadb.jdbc</groupId>
20 <artifactId>mariadb-java-client</artifactId>
21 <version>3.4.1</version>
22 </dependency>
pom.xml
xml
1 <!-- inside <dependencies> -->
2 <dependency>
3 <groupId>io.github.zyraz-io</groupId>
4 <artifactId>ekbatan-core</artifactId>
5 <version>0.1.0</version>
6 </dependency>
7 <dependency>
8 <groupId>io.github.zyraz-io</groupId>
9 <artifactId>ekbatan-annotation-processor</artifactId>
10 <version>0.1.0</version>
11 <scope>provided</scope>
12 </dependency>
13 <dependency>
14 <groupId>org.flywaydb</groupId>
15 <artifactId>flyway-core</artifactId>
16 <version>11.0.0</version>
17 </dependency>
18 <dependency>
19 <groupId>com.mysql</groupId>
20 <artifactId>mysql-connector-j</artifactId>
21 <version>9.1.0</version>
22 </dependency>

(1) Override the BOM-managed jOOQ version. Spring Boot 4.x’s BOM pins jOOQ to 3.19.x; Ekbatan needs 3.20.x. The codegen plugin generates classes against 3.20.x APIs (Constants.VERSION_3_20, resetTouchedOnNotNull, etc.), so leaving the BOM-managed version in place fails the compile step. Override it once and the override applies to every classpath (compile + runtime):

  • Gradle: extra["jooq.version"] = "3.20.10" at the top of build.gradle.kts, outside the dependencies block. The dependency-management plugin reads the property and rewrites the BOM-managed version. A runtimeOnly("org.jooq:jooq:3.20.10") line does NOT work here - it only patches the runtime classpath, leaving the compile classpath on 3.19.x.
  • Maven: <jooq.version>3.20.10</jooq.version> inside the project’s <properties>. The spring-boot-dependencies BOM reads this property when resolving its managed jOOQ version.

(1) Override the BOM-managed jOOQ version. Quarkus’s BOM pins jOOQ to 3.19.x; Ekbatan needs 3.20.x. The codegen plugin generates classes against 3.20.x APIs (Constants.VERSION_3_20, resetTouchedOnNotNull, etc.), so leaving the BOM-managed version in place fails the compile step. Override it once:

  • Gradle: extra["jooq.version"] = "3.20.10" at the top of build.gradle.kts, outside the dependencies block.
  • Maven: <jooq.version>3.20.10</jooq.version> inside the project’s <properties>.

(5) Micronaut needs the annotation-processor line. The ekbatan-micronaut jar ships an EkbatanStereotypeVisitor that runs at compile time and lifts your @Ekbatan*-annotated classes to @Singleton. Without the annotationProcessor(...) line above, the build succeeds but at runtime Micronaut finds no BeanDefinition for your actions / repositories / handlers.

(6) jOOQ codegen needs the driver on its own classpath. The dev.monosoul.jooq-docker plugin (wired in step 6 below) runs its codegen step on a separate Gradle configuration named jooqCodegen, not on your application classpath. The implementation / runtimeOnly line puts the driver on the application’s classpath; jooqCodegen puts it on the codegen plugin’s classpath. Same coordinate, two configurations. Forgetting the jooqCodegen line gives Driver class not found during build, not at app boot. For MariaDB and MySQL, the codegen also needs flyway-mysql (it covers both dialects despite the name) so Flyway can recognize the JDBC URL inside the codegen container.

(6) jOOQ codegen needs the driver on its own classpath. The jooq-codegen-maven plugin (wired in step 6 below) runs on the plugin classpath, isolated from your project <dependencies>. The driver and (for MariaDB / MySQL) flyway-mysql therefore go inside the plugin’s own <dependencies> block, not at the project level. Same coordinate, two places. Forgetting the inner <dependency> gives Driver class not found during mvn generate-sources, not at app boot.

4. Sneak peek — a Model and a Repository

Define a Wallet model and a WalletRepository.

src/main/java/io/example/wallet/model/ Wallet.java
java
1 @AutoBuilder
2 public final class Wallet extends Model<Wallet, Id<Wallet>, WalletState> {
3
4 public final UUID ownerId;
5 public final Currency currency;
6 public final BigDecimal balance;
7
8 Wallet(WalletBuilder builder) {
9 super(builder);
10 this.ownerId = Validate.notNull(builder.ownerId, "ownerId");
11 this.currency = Validate.notNull(builder.currency, "currency");
12 this.balance = Validate.notNull(builder.balance, "balance");
13 }
14
15 @Override
16 public WalletBuilder copy() {
17 return WalletBuilder.wallet().copyBase(this)
18 .ownerId(ownerId).currency(currency).balance(balance);
19 }
20 }

@AutoBuilder (used on Wallet above) is the compile-time processor from ekbatan-annotation-processor. It generates WalletBuilder next to Wallet at compile time — that is what WalletBuilder.wallet(), the fluent setters, and .build() resolve to in the code above. You do not need to write the builder by hand, although you can if you want to.

The processor runs as part of normal compilation. There is no separate code-generation command — whatever Gradle or Maven task triggers javac also runs the processor.

Run ./gradlew compileJava (or any task that depends on it, like build or test) to compile and trigger the processor. WalletBuilder.java lands under build/generated/sources/annotationProcessor/java/main/io/example/wallet/model/.

Run ./mvnw compile (or package, install, test) to trigger the processor as part of compilation. WalletBuilder.java lands under target/generated-sources/annotations/io/example/wallet/model/.

The processor emits WalletBuilder, and the Wallet compiles cleanly — no more red squiggles:

src/main/java/io/example/wallet/model/ Wallet.java
java
1 @AutoBuilder
2 public final class Wallet extends Model<Wallet, Id<Wallet>, WalletState> {
3
4 public final UUID ownerId;
5 public final Currency currency;
6 public final BigDecimal balance;
7
8 Wallet(WalletBuilder builder) {
9 super(builder);
10 this.ownerId = Validate.notNull(builder.ownerId, "ownerId");
11 this.currency = Validate.notNull(builder.currency, "currency");
12 this.balance = Validate.notNull(builder.balance, "balance");
13 }
14
15 @Override
16 public WalletBuilder copy() {
17 return WalletBuilder.wallet().copyBase(this)
18 .ownerId(ownerId).currency(currency).balance(balance);
19 }
20 }

Now the repository. There’s a catch — see the red squiggles.

src/main/java/io/example/wallet/repository/ WalletRepository.java
java
1 @EkbatanRepository
2 public class WalletRepository extends ModelRepository<Wallet, WalletsRecord, Wallets, UUID> {
3
4 public WalletRepository(DatabaseRegistry databaseRegistry) {
5 super(Wallet.class, WALLETS, WALLETS.ID, databaseRegistry);
6 }
7
8 @Override
9 public Wallet fromRecord(WalletsRecord r) {
10 return WalletBuilder.wallet()
11 .id(Id.of(Wallet.class, r.getId()))
12 .version(r.getVersion())
13 .state(WalletState.valueOf(r.getState()))
14 .ownerId(r.getOwnerId())
15 .currency(Currency.getInstance(r.getCurrency()))
16 .balance(r.getBalance())
17 .createdDate(r.getCreatedDate())
18 .updatedDate(r.getUpdatedDate())
19 .build();
20 }
21
22 @Override
23 public WalletsRecord toRecord(Wallet w) {
24 return new WalletsRecord(
25 w.id.getValue(), w.version, w.state.name(),
26 w.ownerId, w.currency.getCurrencyCode(), w.balance,
27 w.createdDate, w.updatedDate);
28 }
29 }

The repository references WALLETS, Wallets, and WalletsRecord — jOOQ-generated classes that don’t exist yet. Build fails. Three steps to fix that: add a migration, wire jOOQ codegen, run it. Then the repository compiles.

5. Add a Flyway migration

Flyway runs every V*.sql in src/main/resources/db/migration/ on first boot — in numeric order — and the jOOQ codegen plugin (step 6) replays them against a throwaway container to discover the schema. Ekbatan does NOT bundle V0001/V0002 inside its jars — you write them, or copy verbatim from any ekbatan-examples/*-{pg,mariadb,mysql}/src/main/resources/db/migration/.

Where the framework’s outbox tables live differs by dialect. PostgreSQL has true schemas inside a database, so V0001 just does CREATE SCHEMA eventlog at the top — no separate database-creation migration is needed. MariaDB and MySQL treat “schema” and “database” as synonyms, so the eventlog namespace must be its own database, created by V0000__create_eventlog_database.sql before V0001 writes into it.

Files Flyway will run on first boot:

FileWhat it is
V0001__eventlog.sqlthe framework’s outbox tables — eventlog.events, eventlog.event_notifications (starts with CREATE SCHEMA eventlog)
V0002__scheduled_tasks.sqldb-scheduler’s required table; the local-event-handler module depends on it
V0003__wallets.sqlyour domain tables
FileWhat it is
V0000__create_eventlog_database.sqlmakes the eventlog database exist before V0001 writes into it
V0001__eventlog.sqlthe framework’s outbox tables — eventlog.events, eventlog.event_notifications
V0002__scheduled_tasks.sqldb-scheduler’s required table; the local-event-handler module depends on it
V0003__wallets.sqlyour domain tables
FileWhat it is
V0000__create_eventlog_database.sqlmakes the eventlog database exist before V0001 writes into it
V0001__eventlog.sqlthe framework’s outbox tables — eventlog.events, eventlog.event_notifications
V0002__scheduled_tasks.sqldb-scheduler’s required table; the local-event-handler module depends on it
V0003__wallets.sqlyour domain tables

5a. V0000__create_eventlog_database.sql (MariaDB / MySQL only)

Skip this — Postgres uses a schema (created inside V0001) instead of a separate database.

src/main/resources/db/migration/ V0000__create_eventlog_database.sql
sql
1 -- MariaDB / MySQL: "schema" and "database" are synonyms here. Ekbatan's
2 -- eventlog tables live in their own database; create it explicitly. This works
3 -- only because the connecting user has cross-database GRANTs from the
4 -- mariadb_init.sql / mysql_init.sql in step 2.
5 CREATE DATABASE IF NOT EXISTS eventlog;
src/main/resources/db/migration/ V0000__create_eventlog_database.sql
sql
1 -- MariaDB / MySQL: "schema" and "database" are synonyms here. Ekbatan's
2 -- eventlog tables live in their own database; create it explicitly. This works
3 -- only because the connecting user has cross-database GRANTs from the
4 -- mariadb_init.sql / mysql_init.sql in step 2.
5 CREATE DATABASE IF NOT EXISTS eventlog;

5b. V0001__eventlog.sql — Ekbatan’s outbox tables

The framework reads/writes two tables: eventlog.events (the outbox itself) and eventlog.event_notifications (per-handler delivery rows used by the local-event-handler module).

src/main/resources/db/migration/ V0001__eventlog.sql
sql
1 CREATE SCHEMA IF NOT EXISTS eventlog;
2
3 CREATE TABLE eventlog.events (
4 id UUID PRIMARY KEY,
5 namespace VARCHAR(255) NOT NULL,
6 action_id UUID NOT NULL,
7 action_name VARCHAR(255) NOT NULL,
8 action_params JSONB NOT NULL,
9 started_date TIMESTAMP NOT NULL,
10 completion_date TIMESTAMP NOT NULL,
11 model_id VARCHAR(255),
12 model_type VARCHAR(255),
13 event_type VARCHAR(255),
14 payload JSONB,
15 event_date TIMESTAMP NOT NULL,
16 delivered BOOLEAN NOT NULL
17 );
18
19 CREATE INDEX idx_events_action_id ON eventlog.events(action_id);
20
21 CREATE INDEX events_undelivered
22 ON eventlog.events (event_type, event_date)
23 WHERE delivered = FALSE;
24
25 CREATE TABLE eventlog.event_notifications (
26 id UUID PRIMARY KEY,
27 event_id UUID NOT NULL,
28 handler_name VARCHAR(255) NOT NULL,
29 namespace VARCHAR(255) NOT NULL,
30 action_id UUID NOT NULL,
31 action_name VARCHAR(255) NOT NULL,
32 action_params JSONB NOT NULL,
33 started_date TIMESTAMP NOT NULL,
34 completion_date TIMESTAMP NOT NULL,
35 model_id VARCHAR(255),
36 model_type VARCHAR(255),
37 event_type VARCHAR(255) NOT NULL,
38 payload JSONB,
39 event_date TIMESTAMP NOT NULL,
40 state VARCHAR(24) NOT NULL,
41 attempts INT NOT NULL DEFAULT 0,
42 next_retry_at TIMESTAMP NOT NULL,
43 created_date TIMESTAMP NOT NULL,
44 updated_date TIMESTAMP NOT NULL,
45 UNIQUE (event_id, handler_name)
46 );
47
48 CREATE INDEX event_notifications_due
49 ON eventlog.event_notifications (next_retry_at)
50 WHERE state IN ('PENDING', 'FAILED');
src/main/resources/db/migration/ V0001__eventlog.sql
sql
1 CREATE TABLE eventlog.events (
2 id UUID PRIMARY KEY,
3 namespace VARCHAR(255) NOT NULL,
4 action_id UUID NOT NULL,
5 action_name VARCHAR(255) NOT NULL,
6 action_params JSON NOT NULL,
7 started_date DATETIME(6) NOT NULL,
8 completion_date DATETIME(6) NOT NULL,
9 model_id VARCHAR(255),
10 model_type VARCHAR(255),
11 event_type VARCHAR(255),
12 payload JSON,
13 event_date DATETIME(6) NOT NULL,
14 delivered BOOLEAN NOT NULL
15 );
16
17 CREATE INDEX idx_events_action_id ON eventlog.events(action_id);
18 -- MariaDB doesn't support partial indexes; the polling query filters
19 -- delivered = FALSE at the predicate level instead.
20 CREATE INDEX events_pending_fanout ON eventlog.events (delivered, event_type, event_date);
21
22 CREATE TABLE eventlog.event_notifications (
23 id UUID PRIMARY KEY,
24 event_id UUID NOT NULL,
25 handler_name VARCHAR(255) NOT NULL,
26 namespace VARCHAR(255) NOT NULL,
27 action_id UUID NOT NULL,
28 action_name VARCHAR(255) NOT NULL,
29 action_params JSON NOT NULL,
30 started_date DATETIME(6) NOT NULL,
31 completion_date DATETIME(6) NOT NULL,
32 model_id VARCHAR(255),
33 model_type VARCHAR(255),
34 event_type VARCHAR(255) NOT NULL,
35 payload JSON,
36 event_date DATETIME(6) NOT NULL,
37 state VARCHAR(24) NOT NULL,
38 attempts INT NOT NULL DEFAULT 0,
39 next_retry_at DATETIME(6) NOT NULL,
40 created_date DATETIME(6) NOT NULL,
41 updated_date DATETIME(6) NOT NULL,
42 UNIQUE (event_id, handler_name)
43 );
44
45 CREATE INDEX event_notifications_due ON eventlog.event_notifications (next_retry_at);
src/main/resources/db/migration/ V0001__eventlog.sql
sql
1 CREATE TABLE eventlog.events (
2 id CHAR(36) CHARACTER SET ascii NOT NULL,
3 namespace VARCHAR(255) NOT NULL,
4 action_id CHAR(36) CHARACTER SET ascii NOT NULL,
5 action_name VARCHAR(255) NOT NULL,
6 action_params JSON NOT NULL,
7 started_date DATETIME(6) NOT NULL,
8 completion_date DATETIME(6) NOT NULL,
9 model_id VARCHAR(255),
10 model_type VARCHAR(255),
11 event_type VARCHAR(255),
12 payload JSON,
13 event_date DATETIME(6) NOT NULL,
14 delivered BOOLEAN NOT NULL,
15 PRIMARY KEY (id)
16 );
17
18 CREATE INDEX idx_events_action_id ON eventlog.events(action_id);
19 -- MySQL doesn't support partial indexes; the polling query filters
20 -- delivered = FALSE at the predicate level instead.
21 CREATE INDEX events_pending_fanout ON eventlog.events (delivered, event_type, event_date);
22
23 CREATE TABLE eventlog.event_notifications (
24 id CHAR(36) CHARACTER SET ascii NOT NULL,
25 event_id CHAR(36) CHARACTER SET ascii NOT NULL,
26 handler_name VARCHAR(255) NOT NULL,
27 namespace VARCHAR(255) NOT NULL,
28 action_id CHAR(36) CHARACTER SET ascii NOT NULL,
29 action_name VARCHAR(255) NOT NULL,
30 action_params JSON NOT NULL,
31 started_date DATETIME(6) NOT NULL,
32 completion_date DATETIME(6) NOT NULL,
33 model_id VARCHAR(255),
34 model_type VARCHAR(255),
35 event_type VARCHAR(255) NOT NULL,
36 payload JSON,
37 event_date DATETIME(6) NOT NULL,
38 state VARCHAR(24) NOT NULL,
39 attempts INT NOT NULL DEFAULT 0,
40 next_retry_at DATETIME(6) NOT NULL,
41 created_date DATETIME(6) NOT NULL,
42 updated_date DATETIME(6) NOT NULL,
43 PRIMARY KEY (id),
44 UNIQUE (event_id, handler_name)
45 );
46
47 CREATE INDEX event_notifications_due ON eventlog.event_notifications (next_retry_at);

5c. V0002__scheduled_tasks.sql — db-scheduler’s table

Required when ekbatan-distributed-jobs is on the classpath. The Spring Boot starter pulls it transitively; on Quarkus/Micronaut/Plain Java it’s opt-in.

src/main/resources/db/migration/ V0002__scheduled_tasks.sql
sql
1 -- db-scheduler's required schema (verbatim from its repo). Required when
2 -- ekbatan-distributed-jobs is on the classpath — which the Ekbatan Spring
3 -- Boot starter pulls transitively, and which the local-event-handler module
4 -- depends on. Ekbatan intentionally steps off its always-TIMESTAMP rule
5 -- here because db-scheduler owns this table's schema.
6 create table scheduled_tasks (
7 task_name text not null,
8 task_instance text not null,
9 task_data bytea,
10 execution_time timestamp with time zone not null,
11 picked BOOLEAN not null,
12 picked_by text,
13 last_success timestamp with time zone,
14 last_failure timestamp with time zone,
15 consecutive_failures INT,
16 last_heartbeat timestamp with time zone,
17 version BIGINT not null,
18 priority SMALLINT,
19 PRIMARY KEY (task_name, task_instance)
20 );
21
22 CREATE INDEX execution_time_idx ON scheduled_tasks (execution_time);
23 CREATE INDEX last_heartbeat_idx ON scheduled_tasks (last_heartbeat);
24 CREATE INDEX priority_execution_time_idx ON scheduled_tasks (priority desc, execution_time asc);
src/main/resources/db/migration/ V0002__scheduled_tasks.sql
sql
1 -- db-scheduler's required schema (verbatim from its repo). Required when
2 -- ekbatan-distributed-jobs is on the classpath. db-scheduler owns this
3 -- table's schema; the type choices are db-scheduler's, not Ekbatan's.
4 create table scheduled_tasks (
5 task_name VARCHAR(100) NOT NULL,
6 task_instance VARCHAR(100) NOT NULL,
7 task_data BLOB,
8 execution_time TIMESTAMP(6) NOT NULL,
9 picked BOOLEAN NOT NULL,
10 picked_by VARCHAR(50),
11 last_success TIMESTAMP(6) NULL,
12 last_failure TIMESTAMP(6) NULL,
13 consecutive_failures INT,
14 last_heartbeat TIMESTAMP(6) NULL,
15 version BIGINT NOT NULL,
16 priority SMALLINT,
17 PRIMARY KEY (task_name, task_instance),
18 INDEX execution_time_idx (execution_time),
19 INDEX last_heartbeat_idx (last_heartbeat),
20 INDEX priority_execution_time_idx (priority, execution_time)
21 );
src/main/resources/db/migration/ V0002__scheduled_tasks.sql
sql
1 -- db-scheduler's required schema (verbatim from its repo). Required when
2 -- ekbatan-distributed-jobs is on the classpath. db-scheduler owns this
3 -- table's schema; the type choices are db-scheduler's, not Ekbatan's.
4 create table scheduled_tasks (
5 task_name VARCHAR(100) NOT NULL,
6 task_instance VARCHAR(100) NOT NULL,
7 task_data BLOB,
8 execution_time TIMESTAMP(6) NOT NULL,
9 picked BOOLEAN NOT NULL,
10 picked_by VARCHAR(50),
11 last_success TIMESTAMP(6) NULL,
12 last_failure TIMESTAMP(6) NULL,
13 consecutive_failures INT,
14 last_heartbeat TIMESTAMP(6) NULL,
15 version BIGINT NOT NULL,
16 priority SMALLINT,
17 PRIMARY KEY (task_name, task_instance),
18 INDEX execution_time_idx (execution_time),
19 INDEX last_heartbeat_idx (last_heartbeat),
20 INDEX priority_execution_time_idx (priority, execution_time)
21 );

5d. V0003__wallets.sql — your domain tables

src/main/resources/db/migration/ V0003__wallets.sql
sql
1 CREATE TABLE wallets (
2 id UUID PRIMARY KEY,
3 version BIGINT NOT NULL,
4 state VARCHAR(24) NOT NULL,
5 owner_id UUID NOT NULL,
6 currency CHAR(3) NOT NULL,
7 balance NUMERIC(20,4) NOT NULL,
8 created_date TIMESTAMP NOT NULL,
9 updated_date TIMESTAMP NOT NULL
10 );
11 CREATE INDEX wallets_owner_id ON wallets (owner_id);
src/main/resources/db/migration/ V0003__wallets.sql
sql
1 CREATE TABLE wallets (
2 id UUID PRIMARY KEY,
3 version BIGINT NOT NULL,
4 state VARCHAR(24) NOT NULL,
5 owner_id UUID NOT NULL,
6 currency CHAR(3) NOT NULL,
7 balance DECIMAL(20,4) NOT NULL,
8 created_date DATETIME(6) NOT NULL,
9 updated_date DATETIME(6) NOT NULL
10 );
11 CREATE INDEX wallets_owner_id ON wallets (owner_id);
src/main/resources/db/migration/ V0003__wallets.sql
sql
1 CREATE TABLE wallets (
2 id CHAR(36) PRIMARY KEY,
3 version BIGINT NOT NULL,
4 state VARCHAR(24) NOT NULL,
5 owner_id CHAR(36) NOT NULL,
6 currency CHAR(3) NOT NULL,
7 balance DECIMAL(20,4) NOT NULL,
8 created_date DATETIME(6) NOT NULL,
9 updated_date DATETIME(6) NOT NULL
10 );
11 CREATE INDEX wallets_owner_id ON wallets (owner_id);

On first boot Flyway runs them in order: V0000 (MariaDB/MySQL only) → V0001 (eventlog) → V0002 (scheduled_tasks) → V0003 (wallets). The same set is what jOOQ codegen (step 6) reads against its throwaway container — so what your code references and what the database has are guaranteed to match.

6. Wire jOOQ codegen

Every repository references generated classes (the WALLETS constant, the WalletsRecord POJO, the Wallets table type). jOOQ generates them at build time by reading a real DB schema.

build.gradle.kts
kotlin
1 import org.jooq.meta.jaxb.ForcedType
2
3 plugins {
4 id("dev.monosoul.jooq-docker") version "8.0.9"
5 }
6
7 tasks {
8 generateJooqClasses {
9 // public = your domain tables; eventlog = framework outbox (generated so app code
10 // can query it with type safety — backfill, debugging, custom monitoring).
11 schemas.set(listOf("public", "eventlog"))
12 basePackageName.set("io.example.wallet.generated.jooq")
13 migrationLocations.setFromFilesystem("src/main/resources/db/migration")
14 outputDirectory.set(project.layout.buildDirectory.dir("generated-jooq"))
15 // Defensive: stops Flyway from interpreting literal ${...} in CHECK/DEFAULT
16 // clauses as missing placeholders during the codegen migration pass.
17 flywayProperties.put("flyway.placeholderReplacement", "false")
18 // Skip generating a record for Flyway's own bookkeeping table.
19 includeFlywayTable.set(false)
20 // 'public' is the default schema → call sites read Tables.WALLETS, not Public.WALLETS.
21 outputSchemaToDefault.add("public")
22 // 'public' is a Java keyword — rename the generated subpackages.
23 schemaToPackageMapping.put("public", "public_schema")
24 schemaToPackageMapping.put("eventlog", "eventlog_schema")
25 usingJavaConfig {
26 // jOOQ defaults: TIMESTAMP→LocalDateTime, JSONB→String. Model.createdDate is
27 // Instant and outbox payloads are ObjectNode — without these, step 7 won't compile.
28 database.withForcedTypes(
29 ForcedType()
30 .withUserType("java.time.Instant")
31 .withConverter("io.ekbatan.core.persistence.jooq.converter.InstantConverter")
32 .withIncludeTypes("TIMESTAMP")
33 .withIncludeExpression(".*"),
34 ForcedType()
35 .withUserType("tools.jackson.databind.node.ObjectNode")
36 .withConverter("io.ekbatan.core.persistence.jooq.converter.JSONBObjectNodeConverter")
37 .withIncludeTypes("JSONB")
38 .withIncludeExpression(".*"),
39 )
40 }
41 }
42 }
43
44 sourceSets {
45 main {
46 java {
47 srcDir(tasks.generateJooqClasses.flatMap { it.outputDirectory })
48 }
49 }
50 }
build.gradle.kts
kotlin
1 import org.jooq.meta.jaxb.ForcedType
2
3 plugins {
4 id("dev.monosoul.jooq-docker") version "8.0.9"
5 }
6
7 // Codegen container: MariaDB connected as root because V0000 needs CREATE DATABASE privilege.
8 // The wallet user only has access to its own DB; the runtime mariadb_init.sql grants
9 // cross-DB rights at runtime, but the codegen container doesn't run that script.
10 jooq {
11 withContainer {
12 image {
13 name = "mariadb:11.8"
14 envVars = mapOf(
15 "MARIADB_ROOT_PASSWORD" to "root",
16 "MARIADB_DATABASE" to "wallet",
17 )
18 }
19 db {
20 username = "root"
21 password = "root"
22 name = "wallet"
23 port = 3306
24 jdbc {
25 schema = "jdbc:mariadb"
26 driver-class-name = "org.mariadb.jdbc.Driver"
27 }
28 }
29 }
30 }
31
32 tasks {
33 generateJooqClasses {
34 // MariaDB has no schemas (schema = database). Only the 'wallet' DB is generated;
35 // the eventlog tables are accessed via the framework's own field constants.
36 schemas.set(listOf("wallet"))
37 basePackageName.set("io.example.wallet.generated.jooq")
38 migrationLocations.setFromFilesystem("src/main/resources/db/migration")
39 outputDirectory.set(project.layout.buildDirectory.dir("generated-jooq"))
40 flywayProperties.put("flyway.placeholderReplacement", "false")
41 includeFlywayTable.set(false)
42 // 'wallet' is the default schema → call sites read Tables.WALLETS, not Wallet.WALLETS.
43 outputSchemaToDefault.add("wallet")
44 usingJavaConfig {
45 // jOOQ defaults: DATETIME/TIMESTAMP→LocalDateTime, JSON→String. Model.createdDate
46 // is Instant and outbox payloads are ObjectNode — without these, step 7 won't compile.
47 database.withForcedTypes(
48 ForcedType()
49 .withUserType("java.time.Instant")
50 .withConverter("io.ekbatan.core.persistence.jooq.converter.InstantConverter")
51 .withIncludeTypes("(?i:DATETIME|TIMESTAMP)")
52 .withIncludeExpression(".*"),
53 // Note: no 'B' in JSONObjectNodeConverter (vs. Postgres's JSONBObjectNodeConverter).
54 ForcedType()
55 .withUserType("tools.jackson.databind.node.ObjectNode")
56 .withConverter("io.ekbatan.core.persistence.jooq.converter.JSONObjectNodeConverter")
57 .withIncludeTypes("(?i:JSON)")
58 .withIncludeExpression(".*"),
59 // No UUID converter — MariaDB 10.7+ has a native UUID type that jOOQ maps
60 // to java.util.UUID directly. Contrast with MySQL below.
61 )
62 }
63 }
64 }
65
66 sourceSets {
67 main {
68 java {
69 srcDir(tasks.generateJooqClasses.flatMap { it.outputDirectory })
70 }
71 }
72 }
build.gradle.kts
kotlin
1 import org.jooq.meta.jaxb.ForcedType
2
3 plugins {
4 id("dev.monosoul.jooq-docker") version "8.0.9"
5 }
6
7 // Codegen container: MySQL connected as root because V0000 needs CREATE DATABASE privilege.
8 // The wallet user only has access to its own DB; the runtime mysql_init.sql grants
9 // cross-DB rights at runtime, but the codegen container doesn't run that script.
10 jooq {
11 withContainer {
12 image {
13 name = "mysql:9.4.0"
14 envVars = mapOf(
15 "MYSQL_ROOT_PASSWORD" to "root",
16 "MYSQL_DATABASE" to "wallet",
17 )
18 }
19 db {
20 username = "root"
21 password = "root"
22 name = "wallet"
23 port = 3306
24 jdbc {
25 schema = "jdbc:mysql"
26 driver-class-name = "com.mysql.cj.jdbc.Driver"
27 }
28 }
29 }
30 }
31
32 tasks {
33 generateJooqClasses {
34 // MySQL has no schemas (schema = database). Only the 'wallet' DB is generated;
35 // the eventlog tables are accessed via the framework's own field constants.
36 schemas.set(listOf("wallet"))
37 basePackageName.set("io.example.wallet.generated.jooq")
38 migrationLocations.setFromFilesystem("src/main/resources/db/migration")
39 outputDirectory.set(project.layout.buildDirectory.dir("generated-jooq"))
40 flywayProperties.put("flyway.placeholderReplacement", "false")
41 includeFlywayTable.set(false)
42 outputSchemaToDefault.add("wallet")
43 usingJavaConfig {
44 database.withForcedTypes(
45 ForcedType()
46 .withUserType("java.time.Instant")
47 .withConverter("io.ekbatan.core.persistence.jooq.converter.InstantConverter")
48 .withIncludeTypes("(?i:DATETIME|TIMESTAMP)")
49 .withIncludeExpression(".*"),
50 ForcedType()
51 .withUserType("tools.jackson.databind.node.ObjectNode")
52 .withConverter("io.ekbatan.core.persistence.jooq.converter.JSONObjectNodeConverter")
53 .withIncludeTypes("(?i:JSON)")
54 .withIncludeExpression(".*"),
55 // MySQL has no native UUID type — UUID columns are CHAR(36) ASCII. The converter
56 // maps them back to java.util.UUID so app code stays dialect-agnostic. Scope to
57 // 'id' / '*_id' columns so unrelated CHAR(36) columns aren't bound to UUID.
58 ForcedType()
59 .withUserType("java.util.UUID")
60 .withConverter("io.ekbatan.core.persistence.jooq.converter.mysql.UuidStringConverter")
61 .withIncludeTypes("CHAR\\(36\\)")
62 .withIncludeExpression(".*\\.id|.*_id"),
63 )
64 }
65 }
66 }
67
68 sourceSets {
69 main {
70 java {
71 srcDir(tasks.generateJooqClasses.flatMap { it.outputDirectory })
72 }
73 }
74 }

Maven’s codegen path uses three plugins (the Gradle plugin bundles all three into one). The chain is: docker-maven-plugin starts a throwaway Postgres in `initialize`, flyway-maven-plugin runs your migrations against it in the same phase, then jooq-codegen-maven introspects the migrated schema in `generate-sources`. `prepare-package` tears the container down.

pom.xml
xml
1 <!-- 1. Inside <properties>: plugin versions + codegen-container coordinates,
2 used as a single source of truth by the three plugins below. -->
3 <flyway-version>12.0.0</flyway-version>
4 <postgresql.version>42.7.10</postgresql.version>
5 <fabric8-docker-plugin-version>0.48.1</fabric8-docker-plugin-version>
6 <flyway-maven-plugin-version>12.0.0</flyway-maven-plugin-version>
7 <jooq-codegen-maven-plugin-version>3.20.10</jooq-codegen-maven-plugin-version>
8
9 <codegen.db.port>15432</codegen.db.port>
10 <codegen.db.name>wallet_codegen</codegen.db.name>
11 <codegen.db.user>codegen</codegen.db.user>
12 <codegen.db.password>codegen</codegen.db.password>
13 <codegen.db.url>jdbc:postgresql://localhost:${codegen.db.port}/${codegen.db.name}</codegen.db.url>
pom.xml
xml
1 <!-- 2. Inside <build><plugins>, in this exact declaration order — Maven runs
2 executions of the same lifecycle phase in declaration order. -->
3
4 <!-- (a) Start / stop the codegen Postgres container. -->
5 <plugin>
6 <groupId>io.fabric8</groupId>
7 <artifactId>docker-maven-plugin</artifactId>
8 <version>${fabric8-docker-plugin-version}</version>
9 <configuration>
10 <images>
11 <image>
12 <name>postgres:16</name>
13 <alias>postgres-codegen</alias>
14 <run>
15 <ports>
16 <port>codegen.db.port:5432</port>
17 </ports>
18 <env>
19 <POSTGRES_DB>${codegen.db.name}</POSTGRES_DB>
20 <POSTGRES_USER>${codegen.db.user}</POSTGRES_USER>
21 <POSTGRES_PASSWORD>${codegen.db.password}</POSTGRES_PASSWORD>
22 <TZ>UTC</TZ>
23 </env>
24 <!-- Postgres logs "ready to accept connections" twice during init:
25 once before the init script runs, once when the real server
26 starts. Match both so Flyway connects to the real one. -->
27 <wait>
28 <log>(?s)database system is ready to accept connections.*database system is ready to accept connections</log>
29 <time>30000</time>
30 </wait>
31 </run>
32 </image>
33 </images>
34 </configuration>
35 <executions>
36 <execution>
37 <id>start-codegen-postgres</id>
38 <phase>initialize</phase>
39 <goals><goal>start</goal></goals>
40 </execution>
41 <execution>
42 <id>stop-codegen-postgres</id>
43 <phase>prepare-package</phase>
44 <goals><goal>stop</goal></goals>
45 </execution>
46 </executions>
47 </plugin>
48
49 <!-- (b) Run Flyway migrations against the freshly-started container. -->
50 <plugin>
51 <groupId>org.flywaydb</groupId>
52 <artifactId>flyway-maven-plugin</artifactId>
53 <version>${flyway-maven-plugin-version}</version>
54 <configuration>
55 <url>${codegen.db.url}</url>
56 <user>${codegen.db.user}</user>
57 <password>${codegen.db.password}</password>
58 <schemas>
59 <schema>public</schema>
60 <schema>eventlog</schema>
61 </schemas>
62 <locations>
63 <location>filesystem:src/main/resources/db/migration</location>
64 </locations>
65 <placeholderReplacement>false</placeholderReplacement>
66 </configuration>
67 <!-- Flyway 10+ requires the per-dialect plugin module on the build classpath. -->
68 <dependencies>
69 <dependency>
70 <groupId>org.flywaydb</groupId>
71 <artifactId>flyway-database-postgresql</artifactId>
72 <version>${flyway-version}</version>
73 </dependency>
74 </dependencies>
75 <executions>
76 <execution>
77 <id>codegen-migrate</id>
78 <phase>initialize</phase>
79 <goals><goal>migrate</goal></goals>
80 </execution>
81 </executions>
82 </plugin>
83
84 <!-- (c) Introspect the migrated schema and emit jOOQ classes. -->
85 <plugin>
86 <groupId>org.jooq</groupId>
87 <artifactId>jooq-codegen-maven</artifactId>
88 <version>${jooq-codegen-maven-plugin-version}</version>
89 <!-- (6) jOOQ codegen needs the driver on its own classpath - see footnote after step 3. -->
90 <dependencies>
91 <dependency>
92 <groupId>org.postgresql</groupId>
93 <artifactId>postgresql</artifactId>
94 <version>${postgresql.version}</version>
95 </dependency>
96 </dependencies>
97 <executions>
98 <execution>
99 <id>jooq-codegen</id>
100 <phase>generate-sources</phase>
101 <goals><goal>generate</goal></goals>
102 <configuration>
103 <jdbc>
104 <driver>org.postgresql.Driver</driver>
105 <url>${codegen.db.url}</url>
106 <user>${codegen.db.user}</user>
107 <password>${codegen.db.password}</password>
108 </jdbc>
109 <generator>
110 <database>
111 <name>org.jooq.meta.postgres.PostgresDatabase</name>
112 <includes>.*</includes>
113 <excludes>flyway_schema_history</excludes>
114 <!-- public = your domain tables; eventlog = framework outbox.
115 outputSchemaToDefault keeps SQL refs unqualified (INSERT INTO
116 wallets, not public_schema.wallets). 'public' is renamed
117 because it's a Java keyword and can't be a package name. -->
118 <schemata>
119 <schema>
120 <inputSchema>public</inputSchema>
121 <outputSchema>public_schema</outputSchema>
122 <outputSchemaToDefault>true</outputSchemaToDefault>
123 </schema>
124 <schema>
125 <inputSchema>eventlog</inputSchema>
126 <outputSchema>eventlog_schema</outputSchema>
127 </schema>
128 </schemata>
129 <!-- jOOQ defaults: TIMESTAMP→LocalDateTime, JSONB→String.
130 Model.createdDate is Instant and outbox payloads are
131 ObjectNode — without these, step 7 won't compile. -->
132 <forcedTypes>
133 <forcedType>
134 <userType>java.time.Instant</userType>
135 <converter>io.ekbatan.core.persistence.jooq.converter.InstantConverter</converter>
136 <includeTypes>TIMESTAMP</includeTypes>
137 <includeExpression>.*</includeExpression>
138 </forcedType>
139 <forcedType>
140 <userType>tools.jackson.databind.node.ObjectNode</userType>
141 <converter>io.ekbatan.core.persistence.jooq.converter.JSONBObjectNodeConverter</converter>
142 <includeTypes>JSONB</includeTypes>
143 <includeExpression>.*</includeExpression>
144 </forcedType>
145 </forcedTypes>
146 </database>
147 <target>
148 <packageName>io.example.wallet.generated.jooq</packageName>
149 </target>
150 </generator>
151 </configuration>
152 </execution>
153 </executions>
154 </plugin>

Maven’s codegen path uses three plugins in <build><plugins>. docker-maven-plugin starts a throwaway MariaDB in initialize, flyway-maven-plugin runs your migrations against it (also initialize), and jooq-codegen-maven introspects the schema in generate-sources. prepare-package tears the container down. The codegen container connects as root so V0000__create_eventlog_database.sql can CREATE DATABASE; the runtime / test containers use a less-privileged user.

pom.xml
xml
1 <!-- 1. Inside <properties>: plugin versions + codegen-container coordinates. -->
2 <flyway-version>12.0.0</flyway-version>
3 <mariadb-driver-version>3.5.7</mariadb-driver-version>
4 <fabric8-docker-plugin-version>0.48.1</fabric8-docker-plugin-version>
5 <flyway-maven-plugin-version>12.0.0</flyway-maven-plugin-version>
6 <jooq-codegen-maven-plugin-version>3.20.10</jooq-codegen-maven-plugin-version>
7
8 <codegen.db.port>13306</codegen.db.port>
9 <codegen.db.name>wallet</codegen.db.name>
10 <codegen.db.user>root</codegen.db.user>
11 <codegen.db.password>root</codegen.db.password>
12 <codegen.db.url>jdbc:mariadb://localhost:${codegen.db.port}/${codegen.db.name}</codegen.db.url>
pom.xml
xml
1 <!-- 2. Inside <build><plugins>, in this exact declaration order. -->
2
3 <!-- (a) Start / stop the codegen MariaDB container. -->
4 <plugin>
5 <groupId>io.fabric8</groupId>
6 <artifactId>docker-maven-plugin</artifactId>
7 <version>${fabric8-docker-plugin-version}</version>
8 <configuration>
9 <images>
10 <image>
11 <name>mariadb:11.8</name>
12 <alias>mariadb-codegen</alias>
13 <run>
14 <ports>
15 <port>codegen.db.port:3306</port>
16 </ports>
17 <env>
18 <!-- root user has cross-database GRANTs that V0000's CREATE
19 DATABASE eventlog requires. -->
20 <MARIADB_ROOT_PASSWORD>${codegen.db.password}</MARIADB_ROOT_PASSWORD>
21 <MARIADB_DATABASE>${codegen.db.name}</MARIADB_DATABASE>
22 <TZ>UTC</TZ>
23 </env>
24 <!-- MariaDB logs "ready for connections" twice during init — once
25 for the temp init server, once when the real server starts.
26 Match the second so Flyway connects to the real one. -->
27 <wait>
28 <log>(?s)ready for connections.*ready for connections</log>
29 <time>60000</time>
30 </wait>
31 </run>
32 </image>
33 </images>
34 </configuration>
35 <executions>
36 <execution>
37 <id>start-codegen-mariadb</id>
38 <phase>initialize</phase>
39 <goals><goal>start</goal></goals>
40 </execution>
41 <execution>
42 <id>stop-codegen-mariadb</id>
43 <phase>prepare-package</phase>
44 <goals><goal>stop</goal></goals>
45 </execution>
46 </executions>
47 </plugin>
48
49 <!-- (b) Run Flyway migrations against the codegen container. -->
50 <plugin>
51 <groupId>org.flywaydb</groupId>
52 <artifactId>flyway-maven-plugin</artifactId>
53 <version>${flyway-maven-plugin-version}</version>
54 <configuration>
55 <url>${codegen.db.url}</url>
56 <user>${codegen.db.user}</user>
57 <password>${codegen.db.password}</password>
58 <!-- "schema" = "database" on MariaDB/MySQL; both namespaces are databases. -->
59 <schemas>
60 <schema>wallet</schema>
61 <schema>eventlog</schema>
62 </schemas>
63 <locations>
64 <location>filesystem:src/main/resources/db/migration</location>
65 </locations>
66 <placeholderReplacement>false</placeholderReplacement>
67 </configuration>
68 <dependencies>
69 <!-- flyway-mysql covers MariaDB JDBC URLs - same module for both dialects. -->
70 <dependency>
71 <groupId>org.flywaydb</groupId>
72 <artifactId>flyway-mysql</artifactId>
73 <version>${flyway-version}</version>
74 </dependency>
75 </dependencies>
76 <executions>
77 <execution>
78 <id>codegen-migrate</id>
79 <phase>initialize</phase>
80 <goals><goal>migrate</goal></goals>
81 </execution>
82 </executions>
83 </plugin>
84
85 <!-- (c) Introspect the migrated schema and emit jOOQ classes. -->
86 <plugin>
87 <groupId>org.jooq</groupId>
88 <artifactId>jooq-codegen-maven</artifactId>
89 <version>${jooq-codegen-maven-plugin-version}</version>
90 <!-- (6) jOOQ codegen needs the driver on its own classpath - see footnote after step 3. -->
91 <dependencies>
92 <dependency>
93 <groupId>org.mariadb.jdbc</groupId>
94 <artifactId>mariadb-java-client</artifactId>
95 <version>${mariadb-driver-version}</version>
96 </dependency>
97 </dependencies>
98 <executions>
99 <execution>
100 <id>jooq-codegen</id>
101 <phase>generate-sources</phase>
102 <goals><goal>generate</goal></goals>
103 <configuration>
104 <jdbc>
105 <driver>org.mariadb.jdbc.Driver</driver>
106 <url>${codegen.db.url}</url>
107 <user>${codegen.db.user}</user>
108 <password>${codegen.db.password}</password>
109 </jdbc>
110 <generator>
111 <database>
112 <name>org.jooq.meta.mariadb.MariaDBDatabase</name>
113 <includes>.*</includes>
114 <!-- Skip Flyway's bookkeeping table and db-scheduler's table. -->
115 <excludes>flyway_schema_history|scheduled_tasks</excludes>
116 <!-- MariaDB has no schemas (schema = database). Single-database
117 codegen on 'wallet'; outputSchemaToDefault keeps SQL refs
118 unqualified. -->
119 <inputSchema>wallet</inputSchema>
120 <outputSchemaToDefault>true</outputSchemaToDefault>
121 <!-- jOOQ defaults: DATETIME/TIMESTAMP→LocalDateTime, JSON→String.
122 Note: JSONObjectNodeConverter has no 'B' (vs. Postgres's JSONB).
123 No UUID converter — MariaDB 10.7+ has a native UUID type. -->
124 <forcedTypes>
125 <forcedType>
126 <userType>java.time.Instant</userType>
127 <converter>io.ekbatan.core.persistence.jooq.converter.InstantConverter</converter>
128 <includeTypes>(?i:DATETIME|TIMESTAMP)</includeTypes>
129 <includeExpression>.*</includeExpression>
130 </forcedType>
131 <forcedType>
132 <userType>tools.jackson.databind.node.ObjectNode</userType>
133 <converter>io.ekbatan.core.persistence.jooq.converter.JSONObjectNodeConverter</converter>
134 <includeTypes>(?i:JSON)</includeTypes>
135 <includeExpression>.*</includeExpression>
136 </forcedType>
137 </forcedTypes>
138 </database>
139 <target>
140 <packageName>io.example.wallet.generated.jooq</packageName>
141 </target>
142 </generator>
143 </configuration>
144 </execution>
145 </executions>
146 </plugin>

Maven’s codegen path uses three plugins in <build><plugins>. docker-maven-plugin starts a throwaway MySQL in initialize, flyway-maven-plugin runs your migrations against it (also initialize), and jooq-codegen-maven introspects the schema in generate-sources. prepare-package tears the container down. The codegen container connects as root so V0000__create_eventlog_database.sql can CREATE DATABASE; the runtime / test containers use a less-privileged user.

pom.xml
xml
1 <!-- 1. Inside <properties>: plugin versions + codegen-container coordinates. -->
2 <flyway-version>12.0.0</flyway-version>
3 <mysql-driver-version>9.4.0</mysql-driver-version>
4 <fabric8-docker-plugin-version>0.48.1</fabric8-docker-plugin-version>
5 <flyway-maven-plugin-version>12.0.0</flyway-maven-plugin-version>
6 <jooq-codegen-maven-plugin-version>3.20.10</jooq-codegen-maven-plugin-version>
7
8 <codegen.db.port>13307</codegen.db.port>
9 <codegen.db.name>wallet</codegen.db.name>
10 <codegen.db.user>root</codegen.db.user>
11 <codegen.db.password>root</codegen.db.password>
12 <codegen.db.url>jdbc:mysql://localhost:${codegen.db.port}/${codegen.db.name}</codegen.db.url>
pom.xml
xml
1 <!-- 2. Inside <build><plugins>, in this exact declaration order. -->
2
3 <!-- (a) Start / stop the codegen MySQL container. -->
4 <plugin>
5 <groupId>io.fabric8</groupId>
6 <artifactId>docker-maven-plugin</artifactId>
7 <version>${fabric8-docker-plugin-version}</version>
8 <configuration>
9 <images>
10 <image>
11 <name>mysql:9.4.0</name>
12 <alias>mysql-codegen</alias>
13 <run>
14 <ports>
15 <port>codegen.db.port:3306</port>
16 </ports>
17 <env>
18 <!-- root user has cross-database GRANTs that V0000's CREATE
19 DATABASE eventlog requires. -->
20 <MYSQL_ROOT_PASSWORD>${codegen.db.password}</MYSQL_ROOT_PASSWORD>
21 <MYSQL_DATABASE>${codegen.db.name}</MYSQL_DATABASE>
22 <TZ>UTC</TZ>
23 </env>
24 <!-- MySQL logs "ready for connections" twice during init — once for
25 the temp init server, once when the real server starts. Match
26 the second so Flyway connects to the real one. -->
27 <wait>
28 <log>(?s)ready for connections.*ready for connections</log>
29 <time>60000</time>
30 </wait>
31 </run>
32 </image>
33 </images>
34 </configuration>
35 <executions>
36 <execution>
37 <id>start-codegen-mysql</id>
38 <phase>initialize</phase>
39 <goals><goal>start</goal></goals>
40 </execution>
41 <execution>
42 <id>stop-codegen-mysql</id>
43 <phase>prepare-package</phase>
44 <goals><goal>stop</goal></goals>
45 </execution>
46 </executions>
47 </plugin>
48
49 <!-- (b) Run Flyway migrations against the codegen container. -->
50 <plugin>
51 <groupId>org.flywaydb</groupId>
52 <artifactId>flyway-maven-plugin</artifactId>
53 <version>${flyway-maven-plugin-version}</version>
54 <configuration>
55 <url>${codegen.db.url}</url>
56 <user>${codegen.db.user}</user>
57 <password>${codegen.db.password}</password>
58 <schemas>
59 <schema>wallet</schema>
60 <schema>eventlog</schema>
61 </schemas>
62 <locations>
63 <location>filesystem:src/main/resources/db/migration</location>
64 </locations>
65 <placeholderReplacement>false</placeholderReplacement>
66 </configuration>
67 <dependencies>
68 <dependency>
69 <groupId>org.flywaydb</groupId>
70 <artifactId>flyway-mysql</artifactId>
71 <version>${flyway-version}</version>
72 </dependency>
73 </dependencies>
74 <executions>
75 <execution>
76 <id>codegen-migrate</id>
77 <phase>initialize</phase>
78 <goals><goal>migrate</goal></goals>
79 </execution>
80 </executions>
81 </plugin>
82
83 <!-- (c) Introspect the migrated schema and emit jOOQ classes. -->
84 <plugin>
85 <groupId>org.jooq</groupId>
86 <artifactId>jooq-codegen-maven</artifactId>
87 <version>${jooq-codegen-maven-plugin-version}</version>
88 <!-- (6) jOOQ codegen needs the driver on its own classpath - see footnote after step 3. -->
89 <dependencies>
90 <dependency>
91 <groupId>com.mysql</groupId>
92 <artifactId>mysql-connector-j</artifactId>
93 <version>${mysql-driver-version}</version>
94 </dependency>
95 </dependencies>
96 <executions>
97 <execution>
98 <id>jooq-codegen</id>
99 <phase>generate-sources</phase>
100 <goals><goal>generate</goal></goals>
101 <configuration>
102 <jdbc>
103 <driver>com.mysql.cj.jdbc.Driver</driver>
104 <url>${codegen.db.url}</url>
105 <user>${codegen.db.user}</user>
106 <password>${codegen.db.password}</password>
107 </jdbc>
108 <generator>
109 <database>
110 <name>org.jooq.meta.mysql.MySQLDatabase</name>
111 <includes>.*</includes>
112 <excludes>flyway_schema_history|scheduled_tasks</excludes>
113 <inputSchema>wallet</inputSchema>
114 <outputSchemaToDefault>true</outputSchemaToDefault>
115 <forcedTypes>
116 <forcedType>
117 <userType>java.time.Instant</userType>
118 <converter>io.ekbatan.core.persistence.jooq.converter.InstantConverter</converter>
119 <includeTypes>(?i:DATETIME|TIMESTAMP)</includeTypes>
120 <includeExpression>.*</includeExpression>
121 </forcedType>
122 <forcedType>
123 <userType>tools.jackson.databind.node.ObjectNode</userType>
124 <converter>io.ekbatan.core.persistence.jooq.converter.JSONObjectNodeConverter</converter>
125 <includeTypes>(?i:JSON)</includeTypes>
126 <includeExpression>.*</includeExpression>
127 </forcedType>
128 <!-- MySQL has no native UUID type — every UUID column is
129 CHAR(36) ASCII. UuidStringConverter maps back to
130 java.util.UUID. Scope to id / *_id columns so unrelated
131 CHAR(36) columns aren't bound to UUID. -->
132 <forcedType>
133 <userType>java.util.UUID</userType>
134 <converter>io.ekbatan.core.persistence.jooq.converter.mysql.UuidStringConverter</converter>
135 <includeTypes>CHAR\(36\)</includeTypes>
136 <includeExpression>.*\.id|.*_id</includeExpression>
137 </forcedType>
138 </forcedTypes>
139 </database>
140 <target>
141 <packageName>io.example.wallet.generated.jooq</packageName>
142 </target>
143 </generator>
144 </configuration>
145 </execution>
146 </executions>
147 </plugin>

Run it once: ./gradlew generateJooqClasses (Gradle) or ./mvnw generate-sources (Maven). The generated classes appear under build/generated/sources/jooq/ (Gradle) or target/generated-sources/jooq/ (Maven), with package io.example.wallet.generated.jooq.*.

7. The repository compiles now

Same file as before — but WALLETS, Wallets, and WalletsRecord now exist:

src/main/java/io/example/wallet/repository/ WalletRepository.java
java
1 @EkbatanRepository
2 public class WalletRepository extends ModelRepository<Wallet, WalletsRecord, Wallets, UUID> {
3
4 public WalletRepository(DatabaseRegistry databaseRegistry) {
5 super(Wallet.class, WALLETS, WALLETS.ID, databaseRegistry);
6 }
7
8 @Override
9 public Wallet fromRecord(WalletsRecord r) {
10 return WalletBuilder.wallet()
11 .id(Id.of(Wallet.class, r.getId()))
12 .version(r.getVersion())
13 .state(WalletState.valueOf(r.getState()))
14 .ownerId(r.getOwnerId())
15 .currency(Currency.getInstance(r.getCurrency()))
16 .balance(r.getBalance())
17 .createdDate(r.getCreatedDate())
18 .updatedDate(r.getUpdatedDate())
19 .build();
20 }
21
22 @Override
23 public WalletsRecord toRecord(Wallet w) {
24 return new WalletsRecord(
25 w.id.getValue(), w.version, w.state.name(),
26 w.ownerId, w.currency.getCurrencyCode(), w.balance,
27 w.createdDate, w.updatedDate);
28 }
29 }

Build (./gradlew compileJava / ./mvnw compile) succeeds. No more squiggles.

8. Configure the DB connection

The framework reads connection coordinates from ekbatan.sharding.*. Each shard member (a physical database) carries three pools:

default-shard is required — the framework routes there when no sharding strategy commits. ekbatan.namespace is required too; it scopes events/notifications when multiple Ekbatan apps share a database.

Casing. The snippets below use kebab-case — the canonical convention for Spring, Quarkus, and Micronaut. Ekbatan also accepts camelCase (jdbc-url / jdbcUrl, default-shard / defaultShard, primary-config / primaryConfig, jobs-config / jobsConfig, lock-config / lockConfig) and you can mix the two within a single file; keys are normalised to a canonical form before binding. Whichever style you write, the Java code that reads user-defined datasource slots uses the camelCase form internally: member.configFor("jobsConfig") and member.configFor("lockConfig"), not jobs-config or lock-config. The local-event-handler config follows the same rule: local-event-handler / localEventHandler, fanout-poll-delay / fanoutPollDelay, handling-poll-delay / handlingPollDelay, etc.

Note what’s NOT here: no spring.datasource.*, no datasources.default, no quarkus.datasource.jdbc.url. The framework owns its pools directly; your stack’s datasource auto-config is intentionally not used — so Flyway and Ekbatan don’t argue over connection coords. Step 10 bridges Flyway to the same ekbatan.sharding.* block.

src/main/resources/ application.yml
yaml
1 ekbatan:
2 namespace: example.wallet
3 sharding:
4 default-shard:
5 group: 0
6 member: 0
7 groups:
8 - group: 0
9 name: default
10 members:
11 - member: 0
12 name: default
13 configs:
14 primary-config:
15 jdbc-url: jdbc:postgresql://localhost:5432/wallet
16 username: wallet
17 password: wallet
18 driver-class-name: org.postgresql.Driver
19 maximum-pool-size: 20
20 jobs-config:
21 jdbc-url: jdbc:postgresql://localhost:5432/wallet
22 username: wallet
23 password: wallet
24 driver-class-name: org.postgresql.Driver
25 maximum-pool-size: 5
26 lock-config:
27 jdbc-url: jdbc:postgresql://localhost:5432/wallet
28 username: wallet
29 password: wallet
30 driver-class-name: org.postgresql.Driver
31 maximum-pool-size: 30
32 leak-detection-threshold: 0
33 local-event-handler:
34 handling:
35 enabled: true
src/main/resources/ application.yml
yaml
1
2 ekbatan:
3 namespace: example.wallet
4 sharding:
5 default-shard:
6 group: 0
7 member: 0
8 groups:
9 - group: 0
10 name: default
11 members:
12 - member: 0
13 name: default
14 configs:
15 primary-config:
16 jdbc-url: jdbc:mariadb://localhost:3306/wallet
17 username: wallet
18 password: wallet
19 driver-class-name: org.mariadb.jdbc.Driver
20 maximum-pool-size: 20
21 jobs-config:
22 jdbc-url: jdbc:mariadb://localhost:3306/wallet
23 username: wallet
24 password: wallet
25 driver-class-name: org.mariadb.jdbc.Driver
26 maximum-pool-size: 5
27 lock-config:
28 jdbc-url: jdbc:mariadb://localhost:3306/wallet
29 username: wallet
30 password: wallet
31 driver-class-name: org.mariadb.jdbc.Driver
32 maximum-pool-size: 30
33 leak-detection-threshold: 0
34 local-event-handler:
35 handling:
36 enabled: true
src/main/resources/ application.yml
yaml
1
2 ekbatan:
3 namespace: example.wallet
4 sharding:
5 default-shard:
6 group: 0
7 member: 0
8 groups:
9 - group: 0
10 name: default
11 members:
12 - member: 0
13 name: default
14 configs:
15 # MySQL: serverTimezone=UTC keeps DATETIME round-tripping consistent with
16 # Ekbatan's UTC-everywhere contract (driver default would shift by host TZ).
17 primary-config:
18 jdbc-url: jdbc:mysql://localhost:3306/wallet?serverTimezone=UTC
19 username: wallet
20 password: wallet
21 driver-class-name: com.mysql.cj.jdbc.Driver
22 maximum-pool-size: 20
23 jobs-config:
24 jdbc-url: jdbc:mysql://localhost:3306/wallet?serverTimezone=UTC
25 username: wallet
26 password: wallet
27 driver-class-name: com.mysql.cj.jdbc.Driver
28 maximum-pool-size: 5
29 lock-config:
30 jdbc-url: jdbc:mysql://localhost:3306/wallet?serverTimezone=UTC
31 username: wallet
32 password: wallet
33 driver-class-name: com.mysql.cj.jdbc.Driver
34 maximum-pool-size: 30
35 leak-detection-threshold: 0
36 local-event-handler:
37 handling:
38 enabled: true
src/main/resources/ application.properties
properties
1 ekbatan.namespace=example.wallet
2 ekbatan.local-event-handler.handling.enabled=true
3
4 ekbatan.sharding.default-shard.group=0
5 ekbatan.sharding.default-shard.member=0
6 ekbatan.sharding.groups[0].group=0
7 ekbatan.sharding.groups[0].name=default
8 ekbatan.sharding.groups[0].members[0].member=0
9 ekbatan.sharding.groups[0].members[0].name=default
10
11 ekbatan.sharding.groups[0].members[0].configs.primary-config.jdbc-url=jdbc:postgresql://localhost:5432/wallet
12 ekbatan.sharding.groups[0].members[0].configs.primary-config.username=wallet
13 ekbatan.sharding.groups[0].members[0].configs.primary-config.password=wallet
14 ekbatan.sharding.groups[0].members[0].configs.primary-config.driver-class-name=org.postgresql.Driver
15 ekbatan.sharding.groups[0].members[0].configs.primary-config.maximum-pool-size=20
16
17 ekbatan.sharding.groups[0].members[0].configs.jobs-config.jdbc-url=jdbc:postgresql://localhost:5432/wallet
18 ekbatan.sharding.groups[0].members[0].configs.jobs-config.username=wallet
19 ekbatan.sharding.groups[0].members[0].configs.jobs-config.password=wallet
20 ekbatan.sharding.groups[0].members[0].configs.jobs-config.driver-class-name=org.postgresql.Driver
21 ekbatan.sharding.groups[0].members[0].configs.jobs-config.maximum-pool-size=5
22
23 ekbatan.sharding.groups[0].members[0].configs.lock-config.jdbc-url=jdbc:postgresql://localhost:5432/wallet
24 ekbatan.sharding.groups[0].members[0].configs.lock-config.username=wallet
25 ekbatan.sharding.groups[0].members[0].configs.lock-config.password=wallet
26 ekbatan.sharding.groups[0].members[0].configs.lock-config.driver-class-name=org.postgresql.Driver
27 ekbatan.sharding.groups[0].members[0].configs.lock-config.maximum-pool-size=30
28 ekbatan.sharding.groups[0].members[0].configs.lock-config.leak-detection-threshold=0
29
30 # Quarkus needs db-kind to bind a Flyway bean. URL/user/pass are intentionally NOT set —
31 # EkbatanShardFlywayCustomizer (step 9) overrides them from ekbatan.sharding.* at startup.
32 quarkus.datasource.db-kind=postgresql
33 quarkus.flyway.migrate-at-start=true
src/main/resources/ application.properties
properties
1 ekbatan.namespace=example.wallet
2 ekbatan.local-event-handler.handling.enabled=true
3
4 ekbatan.sharding.default-shard.group=0
5 ekbatan.sharding.default-shard.member=0
6 ekbatan.sharding.groups[0].group=0
7 ekbatan.sharding.groups[0].name=default
8 ekbatan.sharding.groups[0].members[0].member=0
9 ekbatan.sharding.groups[0].members[0].name=default
10
11 ekbatan.sharding.groups[0].members[0].configs.primary-config.jdbc-url=jdbc:mariadb://localhost:3306/wallet
12 ekbatan.sharding.groups[0].members[0].configs.primary-config.username=wallet
13 ekbatan.sharding.groups[0].members[0].configs.primary-config.password=wallet
14 ekbatan.sharding.groups[0].members[0].configs.primary-config.driver-class-name=org.mariadb.jdbc.Driver
15 ekbatan.sharding.groups[0].members[0].configs.primary-config.maximum-pool-size=20
16
17 ekbatan.sharding.groups[0].members[0].configs.jobs-config.jdbc-url=jdbc:mariadb://localhost:3306/wallet
18 ekbatan.sharding.groups[0].members[0].configs.jobs-config.username=wallet
19 ekbatan.sharding.groups[0].members[0].configs.jobs-config.password=wallet
20 ekbatan.sharding.groups[0].members[0].configs.jobs-config.driver-class-name=org.mariadb.jdbc.Driver
21 ekbatan.sharding.groups[0].members[0].configs.jobs-config.maximum-pool-size=5
22
23 ekbatan.sharding.groups[0].members[0].configs.lock-config.jdbc-url=jdbc:mariadb://localhost:3306/wallet
24 ekbatan.sharding.groups[0].members[0].configs.lock-config.username=wallet
25 ekbatan.sharding.groups[0].members[0].configs.lock-config.password=wallet
26 ekbatan.sharding.groups[0].members[0].configs.lock-config.driver-class-name=org.mariadb.jdbc.Driver
27 ekbatan.sharding.groups[0].members[0].configs.lock-config.maximum-pool-size=30
28 ekbatan.sharding.groups[0].members[0].configs.lock-config.leak-detection-threshold=0
29
30 # Quarkus needs db-kind to bind a Flyway bean. URL/user/pass are intentionally NOT set —
31 # EkbatanShardFlywayCustomizer (step 9) overrides them from ekbatan.sharding.* at startup.
32 quarkus.datasource.db-kind=mariadb
33 quarkus.flyway.migrate-at-start=true
src/main/resources/ application.properties
properties
1 ekbatan.namespace=example.wallet
2 ekbatan.local-event-handler.handling.enabled=true
3
4 ekbatan.sharding.default-shard.group=0
5 ekbatan.sharding.default-shard.member=0
6 ekbatan.sharding.groups[0].group=0
7 ekbatan.sharding.groups[0].name=default
8 ekbatan.sharding.groups[0].members[0].member=0
9 ekbatan.sharding.groups[0].members[0].name=default
10
11 # MySQL: serverTimezone=UTC keeps DATETIME round-tripping consistent with
12 # Ekbatan's UTC-everywhere contract (driver default would shift by host TZ).
13 ekbatan.sharding.groups[0].members[0].configs.primary-config.jdbc-url=jdbc:mysql://localhost:3306/wallet?serverTimezone=UTC
14 ekbatan.sharding.groups[0].members[0].configs.primary-config.username=wallet
15 ekbatan.sharding.groups[0].members[0].configs.primary-config.password=wallet
16 ekbatan.sharding.groups[0].members[0].configs.primary-config.driver-class-name=com.mysql.cj.jdbc.Driver
17 ekbatan.sharding.groups[0].members[0].configs.primary-config.maximum-pool-size=20
18
19 ekbatan.sharding.groups[0].members[0].configs.jobs-config.jdbc-url=jdbc:mysql://localhost:3306/wallet?serverTimezone=UTC
20 ekbatan.sharding.groups[0].members[0].configs.jobs-config.username=wallet
21 ekbatan.sharding.groups[0].members[0].configs.jobs-config.password=wallet
22 ekbatan.sharding.groups[0].members[0].configs.jobs-config.driver-class-name=com.mysql.cj.jdbc.Driver
23 ekbatan.sharding.groups[0].members[0].configs.jobs-config.maximum-pool-size=5
24
25 ekbatan.sharding.groups[0].members[0].configs.lock-config.jdbc-url=jdbc:mysql://localhost:3306/wallet?serverTimezone=UTC
26 ekbatan.sharding.groups[0].members[0].configs.lock-config.username=wallet
27 ekbatan.sharding.groups[0].members[0].configs.lock-config.password=wallet
28 ekbatan.sharding.groups[0].members[0].configs.lock-config.driver-class-name=com.mysql.cj.jdbc.Driver
29 ekbatan.sharding.groups[0].members[0].configs.lock-config.maximum-pool-size=30
30 ekbatan.sharding.groups[0].members[0].configs.lock-config.leak-detection-threshold=0
31
32 # Quarkus needs db-kind to bind a Flyway bean. URL/user/pass are intentionally NOT set —
33 # EkbatanShardFlywayCustomizer (step 9) overrides them from ekbatan.sharding.* at startup.
34 quarkus.datasource.db-kind=mysql
35 quarkus.flyway.migrate-at-start=true
src/main/resources/ application.yml
yaml
1
2 ekbatan:
3 namespace: example.wallet
4 sharding:
5 default-shard:
6 group: 0
7 member: 0
8 groups:
9 - group: 0
10 name: default
11 members:
12 - member: 0
13 name: default
14 configs:
15 primary-config:
16 jdbc-url: jdbc:postgresql://localhost:5432/wallet
17 username: wallet
18 password: wallet
19 # Declare driver-class-name explicitly: Micronaut's Hikari init doesn't always
20 # discover the JDBC Driver via the SPI when the JVM is started by Gradle's worker.
21 driver-class-name: org.postgresql.Driver
22 maximum-pool-size: 20
23 jobs-config:
24 jdbc-url: jdbc:postgresql://localhost:5432/wallet
25 username: wallet
26 password: wallet
27 driver-class-name: org.postgresql.Driver
28 maximum-pool-size: 5
29 lock-config:
30 jdbc-url: jdbc:postgresql://localhost:5432/wallet
31 username: wallet
32 password: wallet
33 driver-class-name: org.postgresql.Driver
34 maximum-pool-size: 30
35 leak-detection-threshold: 0
36 local-event-handler:
37 handling:
38 enabled: true
39
src/main/resources/ application.yml
yaml
1
2 ekbatan:
3 namespace: example.wallet
4 sharding:
5 default-shard:
6 group: 0
7 member: 0
8 groups:
9 - group: 0
10 name: default
11 members:
12 - member: 0
13 name: default
14 configs:
15 primary-config:
16 jdbc-url: jdbc:mariadb://localhost:3306/wallet
17 username: wallet
18 password: wallet
19 driver-class-name: org.mariadb.jdbc.Driver
20 maximum-pool-size: 20
21 jobs-config:
22 jdbc-url: jdbc:mariadb://localhost:3306/wallet
23 username: wallet
24 password: wallet
25 driver-class-name: org.mariadb.jdbc.Driver
26 maximum-pool-size: 5
27 lock-config:
28 jdbc-url: jdbc:mariadb://localhost:3306/wallet
29 username: wallet
30 password: wallet
31 driver-class-name: org.mariadb.jdbc.Driver
32 maximum-pool-size: 30
33 leak-detection-threshold: 0
34 local-event-handler:
35 handling:
36 enabled: true
37
src/main/resources/ application.yml
yaml
1
2 ekbatan:
3 namespace: example.wallet
4 sharding:
5 default-shard:
6 group: 0
7 member: 0
8 groups:
9 - group: 0
10 name: default
11 members:
12 - member: 0
13 name: default
14 configs:
15 # MySQL: serverTimezone=UTC keeps DATETIME round-tripping consistent with
16 # Ekbatan's UTC-everywhere contract (driver default would shift by host TZ).
17 primary-config:
18 jdbc-url: jdbc:mysql://localhost:3306/wallet?serverTimezone=UTC
19 username: wallet
20 password: wallet
21 driver-class-name: com.mysql.cj.jdbc.Driver
22 maximum-pool-size: 20
23 jobs-config:
24 jdbc-url: jdbc:mysql://localhost:3306/wallet?serverTimezone=UTC
25 username: wallet
26 password: wallet
27 driver-class-name: com.mysql.cj.jdbc.Driver
28 maximum-pool-size: 5
29 lock-config:
30 jdbc-url: jdbc:mysql://localhost:3306/wallet?serverTimezone=UTC
31 username: wallet
32 password: wallet
33 driver-class-name: com.mysql.cj.jdbc.Driver
34 maximum-pool-size: 30
35 leak-detection-threshold: 0
36 local-event-handler:
37 handling:
38 enabled: true
39

Configure the DatabaseRegistry programmatically — there’s no DI container to read application config from. See Plain Java wiring for the full builder.

9. Wire Flyway to the Ekbatan sharding config

The framework reads connection coordinates from ekbatan.sharding.*. Flyway, when auto-configured by your stack, would otherwise read them from its own keys (spring.datasource.*, quarkus.datasource.*, flyway.datasources.default.*). Without a bridge, you’d duplicate the connection coords in two places. Each stack has its own way to point Flyway at the shard config Ekbatan already owns — a typed bean (Spring/Micronaut) or a CDI customizer (Quarkus).

Single-shard only. The pattern below works for a single-database setup (one group, one member) and migrates groups.getFirst().members.getFirst().primaryConfig(). It does not cover a sharded configuration with multiple groups or members — each shard would need its own migration run and a dependsOn edge into the framework’s job registry so db-scheduler does not start before every shard’s schema is in place. A dedicated page for the sharded-migration pattern is coming soon.

Spring Boot’s FlywayAutoConfiguration is gated on @ConditionalOnBean(DataSource.class). Produce the DataSource programmatically from ShardingConfig AND scope it to Flyway only via @FlywayDataSource — Spring won’t pick it up as your app’s main DataSource (Ekbatan keeps owning that), and you don’t write a separate customizer (the bean IS the customization).

src/main/java/io/example/wallet/startup/ EkbatanShardFlywayDataSource.java
java
1 import com.zaxxer.hikari.HikariDataSource;
2 import io.ekbatan.core.config.ShardingConfig;
3 import javax.sql.DataSource;
4 import org.springframework.boot.flyway.autoconfigure.FlywayDataSource;
5 import org.springframework.context.annotation.Bean;
6 import org.springframework.context.annotation.Configuration;
7
8 @Configuration
9 public class EkbatanShardFlywayDataSource {
10
11 @Bean
12 @FlywayDataSource
13 public DataSource flywayDataSource(ShardingConfig shardingConfig) {
14 var primary = shardingConfig.groups.getFirst().members.getFirst().primaryConfig();
15 var ds = new HikariDataSource();
16 ds.setJdbcUrl(primary.jdbcUrl);
17 ds.setUsername(primary.username);
18 ds.setPassword(primary.password);
19 ds.setMaximumPoolSize(2);
20 ds.setPoolName("flyway-migrations");
21 return ds;
22 }
23 }

Quarkus discovers any CDI bean implementing FlywayConfigurationCustomizer and calls customize(...) after applying quarkus.flyway.* / quarkus.datasource.* but before building the Flyway instance. The customizer overrides whatever datasource Quarkus prepared.

src/main/java/io/example/wallet/startup/ EkbatanShardFlywayCustomizer.java
java
1 import io.ekbatan.core.config.ShardingConfig;
2 import io.quarkus.flyway.FlywayConfigurationCustomizer;
3 import jakarta.enterprise.context.ApplicationScoped;
4 import org.flywaydb.core.api.configuration.FluentConfiguration;
5
6 @ApplicationScoped
7 public class EkbatanShardFlywayCustomizer implements FlywayConfigurationCustomizer {
8
9 private final ShardingConfig shardingConfig;
10
11 public EkbatanShardFlywayCustomizer(ShardingConfig shardingConfig) {
12 this.shardingConfig = shardingConfig;
13 }
14
15 @Override
16 public void customize(FluentConfiguration configuration) {
17 var primary = shardingConfig.groups.getFirst().members.getFirst().primaryConfig();
18 configuration.dataSource(primary.jdbcUrl, primary.username, primary.password);
19 }
20 }

The path via micronaut-flyway’s FlywayConfigurationCustomizer works but requires a flyway.datasources.default block in application.yml with ${ekbatan.sharding...} placeholder interpolation — duplicating the source of truth. A cleaner option: skip the YAML block entirely and construct Flyway directly from the typed ShardingConfig in a @Context bean.

@Context makes Micronaut instantiate it eagerly at startup — before lazy @Singleton beans (like Ekbatan’s DatabaseRegistry) ever touch the database. The constructor calls .migrate() synchronously, guaranteeing migrations are applied first.

src/main/java/io/example/wallet/startup/ EkbatanFlywayMigrator.java
java
1 import io.ekbatan.core.config.ShardingConfig;
2 import io.micronaut.context.annotation.Context;
3 import org.flywaydb.core.Flyway;
4
5 @Context
6 public class EkbatanFlywayMigrator {
7
8 public EkbatanFlywayMigrator(ShardingConfig shardingConfig) {
9 var primary = shardingConfig.groups.getFirst().members.getFirst().primaryConfig();
10 Flyway.configure()
11 .dataSource(primary.jdbcUrl, primary.username, primary.password)
12 .load()
13 .migrate();
14 }
15 }

No DI container, no customizer hook. Configure Flyway programmatically with the same ShardingConfig primary you’ll hand to DatabaseRegistry. See Plain Java wiring for the full bootstrap.

10. Example Action

An Action is Ekbatan’s unit of work — it encapsulates a state change and the domain events that describe it, in a single atomic transaction. The row update and the event row commit together (or neither does). This is where the framework’s dual-write guarantee originates.

src/main/java/io/example/wallet/action/ WalletDepositMoneyAction.java
java
1
2 package io.example.wallet.action;
3
4 import io.ekbatan.core.action.Action;
5 import io.ekbatan.core.domain.Id;
6 import io.ekbatan.di.EkbatanAction;
7 import io.example.wallet.model.Wallet;
8 import io.example.wallet.repository.WalletRepository;
9 import java.math.BigDecimal;
10 import java.security.Principal;
11 import java.time.Clock;
12
13 @EkbatanAction
14 public class WalletDepositMoneyAction extends Action<WalletDepositMoneyAction.Params, Wallet> {
15
16 public record Params(Id<Wallet> walletId, BigDecimal amount, String recipient) {}
17
18 private final WalletRepository walletRepository;
19
20 public WalletDepositMoneyAction(Clock clock, WalletRepository walletRepository) {
21 super(clock);
22 this.walletRepository = walletRepository;
23 }
24
25 @Override
26 protected Wallet perform(Principal principal, Params params) {
27 final var wallet = walletRepository.getById(params.walletId().getValue());
28 final var updated = wallet.deposit(params.amount());
29 return plan().update(updated);
30 }
31 }

What’s happening inside perform:

The recipient parameter rides on the Action’s Params, not the domain event payload — that keeps notification-routing concerns out of the event so downstream consumers (CDC streams, projections) don’t carry knowledge they don’t need. Event handlers read it back from EventEnvelope.actionParams (see the next section).

11. Example Event Handler

With ekbatan-local-event-handler on the classpath (the Spring Boot starter pulls it transitively; for Quarkus/Micronaut it’s an explicit implementation(...)), the framework polls the outbox, materializes per-handler delivery rows in event_notifications, and invokes your @EkbatanEventHandler beans on a cluster-exclusive worker.

src/main/java/io/example/wallet/handler/ WalletMoneyDepositedEventHandler.java
java
1
2 package io.example.wallet.handler;
3
4 import io.ekbatan.core.action.ActionExecutor;
5 import io.ekbatan.di.EkbatanEventHandler;
6 import io.ekbatan.events.localeventhandler.EventEnvelope;
7 import io.ekbatan.events.localeventhandler.EventHandler;
8 import io.example.wallet.action.CreateNotificationAction;
9 import io.example.wallet.model.NotificationKind;
10 import io.example.wallet.model.events.WalletMoneyDepositedEvent;
11 import java.util.UUID;
12
13 @EkbatanEventHandler
14 public class WalletMoneyDepositedEventHandler implements EventHandler<WalletMoneyDepositedEvent> {
15
16 private final ActionExecutor executor;
17
18 public WalletMoneyDepositedEventHandler(ActionExecutor executor) {
19 this.executor = executor;
20 }
21
22 @Override
23 public String name() {
24 // Cluster-stable identifier; stored in event_notifications.handler_name.
25 return "wallet-money-deposited-notification";
26 }
27
28 @Override
29 public Class<WalletMoneyDepositedEvent> eventType() {
30 return WalletMoneyDepositedEvent.class;
31 }
32
33 @Override
34 public void handle(EventEnvelope<WalletMoneyDepositedEvent> envelope) {
35 final var walletId = UUID.fromString(envelope.event.modelId);
36 final var recipient = envelope.actionParams.get("recipient").asText();
37 final var message = "Deposit of " + envelope.event.amount
38 + " received. New balance: " + envelope.event.newBalance + ".";
39
40 executor.execute(
41 () -> "wallet-handler",
42 CreateNotificationAction.class,
43 new CreateNotificationAction.Params(
44 walletId, NotificationKind.MONEY_DEPOSITED, recipient, message));
45 }
46 }

Three required methods:

Calling executor.execute(...) dispatches another Action — its update + event commit atomically in their own transaction (the listen-to-yourself pattern: side-effects from a handler are themselves recorded in the same outbox).

12. Example DistributedJob

A DistributedJob is periodic, cluster-exclusive work — runs on exactly one cluster member per schedule slot. The ekbatan-distributed-jobs module wraps db-scheduler under a small opinionated facade: name() + schedule() + execute().

src/main/java/io/example/wallet/job/ WalletStipendJob.java
java
1
2 package io.example.wallet.job;
3
4 import com.github.kagkarlsson.scheduler.task.ExecutionContext;
5 import com.github.kagkarlsson.scheduler.task.schedule.FixedDelay;
6 import com.github.kagkarlsson.scheduler.task.schedule.Schedule;
7 import io.ekbatan.core.action.ActionExecutor;
8 import io.ekbatan.di.EkbatanDistributedJob;
9 import io.ekbatan.distributedjobs.DistributedJob;
10 import io.example.wallet.action.WalletDepositMoneyAction;
11 import io.example.wallet.repository.WalletRepository;
12 import java.math.BigDecimal;
13
14 @EkbatanDistributedJob
15 public class WalletStipendJob extends DistributedJob {
16
17 private static final BigDecimal THRESHOLD = new BigDecimal("100.00");
18 private static final BigDecimal STIPEND = new BigDecimal("10.00");
19
20 private final ActionExecutor executor;
21 private final WalletRepository walletRepository;
22
23 public WalletStipendJob(ActionExecutor executor, WalletRepository walletRepository) {
24 this.executor = executor;
25 this.walletRepository = walletRepository;
26 }
27
28 @Override
29 public String name() {
30 // Cluster-wide unique. Persisted in scheduled_tasks.task_name; db-scheduler
31 // uses it to coordinate at-most-once-per-slot across worker instances.
32 return "wallet-stipend";
33 }
34
35 @Override
36 public Schedule schedule() {
37 return FixedDelay.ofSeconds(2);
38 }
39
40 @Override
41 public void execute(ExecutionContext ctx) {
42 final var underFunded = walletRepository.findOpenWithBalanceBelow(THRESHOLD);
43 for (var wallet : underFunded) {
44 executor.execute(
45 () -> "wallet-stipend-job",
46 WalletDepositMoneyAction.class,
47 new WalletDepositMoneyAction.Params(
48 wallet.id, STIPEND, "stipend@system.local"));
49 }
50 }
51 }

The shape of a job:

The headline pattern is job invokes action: the job is just a scheduling trigger, the business logic lives in the Action. Same Action a REST endpoint would call; same eventlog.events rows produced; same downstream handlers fire. The job doesn’t need to know about the events or the handlers — it just dispatches the Action and moves on.

13. Run + verify

./gradlew bootRun
./mvnw spring-boot:run
./gradlew quarkusDev
./mvnw quarkus:dev
./gradlew run
./mvnw mn:run
./gradlew run
./mvnw exec:java

After the app boots, verify the framework wired up by checking that its outbox tables exist:

docker compose exec wallet-db psql -U wallet -c "\dt eventlog.*"

You should see eventlog.events and eventlog.event_notifications.

docker compose exec wallet-db mariadb -uwallet -pwallet -e "SHOW TABLES IN eventlog"
docker compose exec wallet-db mysql -uwallet -pwallet -e "SHOW TABLES IN eventlog"

If you see the eventlog tables, Flyway ran the framework’s bundled migrations and Ekbatan is fully wired.

14. Use the example projects as your reference

Every snippet on this page came from ekbatan-examples/ — 39 runnable wallet projects covering Spring Boot, Quarkus, and Micronaut, each on PostgreSQL/MariaDB/MySQL, in Gradle and Maven, with native-image siblings. The picker’s EXAMPLE row links straight to the one matching your picks.

A few sibling projects show patterns this tutorial doesn’t:

AI assistants benefit from the same matrix — point them at ekbatan-examples/ so they pattern-match against verified code instead of guessing from training data.

Next

Your first Action — write WalletDepositAction and watch a row land in eventlog.events after the commit.

See also