§ learn

Complete project setup

The full project wiring guide. Pick stack, build tool, and database once with the picker below; every setup snippet adapts.

If you are still learning the framework’s model, start with the Getting started first. This page is the detailed setup checklist for wiring a real project once the core ideas make sense.

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:1.0.0-SNAPSHOT \
    --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 Ekbatan, ekbatan-flyway, your JDBC driver, and your stack’s Flyway integration. Use the snippet as-is for your selected stack, build tool, and database; the short notes after the snippets explain the few non-obvious lines.

build.gradle.kts
kotlin
1 // At the top of build.gradle.kts, outside dependencies.
2 extra["jooq.version"] = "3.20.10"
3
4 dependencies {
5 implementation("io.github.zyraz-io:ekbatan-spring-boot-starter:0.2.1")
6 implementation("io.github.zyraz-io:ekbatan-flyway:0.2.1")
7 compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.2.1")
8 annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.2.1")
9
10 implementation("org.springframework.boot:spring-boot-starter-flyway")
11 implementation("org.flywaydb:flyway-database-postgresql")
12 implementation("org.postgresql:postgresql")
13
14 jooqCodegen("org.postgresql:postgresql")
15 }
build.gradle.kts
kotlin
1 // At the top of build.gradle.kts, outside dependencies.
2 extra["jooq.version"] = "3.20.10"
3
4 dependencies {
5 implementation("io.github.zyraz-io:ekbatan-spring-boot-starter:0.2.1")
6 implementation("io.github.zyraz-io:ekbatan-flyway:0.2.1")
7 compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.2.1")
8 annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.2.1")
9
10 implementation("org.springframework.boot:spring-boot-starter-flyway")
11 implementation("org.flywaydb:flyway-mysql")
12 implementation("org.mariadb.jdbc:mariadb-java-client")
13
14 jooqCodegen("org.mariadb.jdbc:mariadb-java-client")
15 jooqCodegen("org.flywaydb:flyway-mysql:12.0.0")
16 }
build.gradle.kts
kotlin
1 // At the top of build.gradle.kts, outside dependencies.
2 extra["jooq.version"] = "3.20.10"
3
4 dependencies {
5 implementation("io.github.zyraz-io:ekbatan-spring-boot-starter:0.2.1")
6 implementation("io.github.zyraz-io:ekbatan-flyway:0.2.1")
7 compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.2.1")
8 annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.2.1")
9
10 implementation("org.springframework.boot:spring-boot-starter-flyway")
11 implementation("org.flywaydb:flyway-mysql")
12 implementation("com.mysql:mysql-connector-j")
13
14 jooqCodegen("com.mysql:mysql-connector-j")
15 jooqCodegen("org.flywaydb:flyway-mysql:12.0.0")
16 }
pom.xml
xml
1 <!-- Inside <properties>. -->
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.2.1</version>
9 </dependency>
10 <dependency>
11 <groupId>io.github.zyraz-io</groupId>
12 <artifactId>ekbatan-flyway</artifactId>
13 <version>0.2.1</version>
14 </dependency>
15 <dependency>
16 <groupId>io.github.zyraz-io</groupId>
17 <artifactId>ekbatan-annotation-processor</artifactId>
18 <version>0.2.1</version>
19 <scope>provided</scope>
20 </dependency>
21 <dependency>
22 <groupId>org.springframework.boot</groupId>
23 <artifactId>spring-boot-starter-flyway</artifactId>
24 </dependency>
25
26 <dependency>
27 <groupId>org.flywaydb</groupId>
28 <artifactId>flyway-database-postgresql</artifactId>
29 </dependency>
30 <dependency>
31 <groupId>org.postgresql</groupId>
32 <artifactId>postgresql</artifactId>
33 </dependency>
pom.xml
xml
1 <!-- Inside <properties>. -->
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.2.1</version>
9 </dependency>
10 <dependency>
11 <groupId>io.github.zyraz-io</groupId>
12 <artifactId>ekbatan-flyway</artifactId>
13 <version>0.2.1</version>
14 </dependency>
15 <dependency>
16 <groupId>io.github.zyraz-io</groupId>
17 <artifactId>ekbatan-annotation-processor</artifactId>
18 <version>0.2.1</version>
19 <scope>provided</scope>
20 </dependency>
21 <dependency>
22 <groupId>org.springframework.boot</groupId>
23 <artifactId>spring-boot-starter-flyway</artifactId>
24 </dependency>
25
26 <dependency>
27 <groupId>org.flywaydb</groupId>
28 <artifactId>flyway-mysql</artifactId>
29 </dependency>
30 <dependency>
31 <groupId>org.mariadb.jdbc</groupId>
32 <artifactId>mariadb-java-client</artifactId>
33 </dependency>
pom.xml
xml
1 <!-- Inside <properties>. -->
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.2.1</version>
9 </dependency>
10 <dependency>
11 <groupId>io.github.zyraz-io</groupId>
12 <artifactId>ekbatan-flyway</artifactId>
13 <version>0.2.1</version>
14 </dependency>
15 <dependency>
16 <groupId>io.github.zyraz-io</groupId>
17 <artifactId>ekbatan-annotation-processor</artifactId>
18 <version>0.2.1</version>
19 <scope>provided</scope>
20 </dependency>
21 <dependency>
22 <groupId>org.springframework.boot</groupId>
23 <artifactId>spring-boot-starter-flyway</artifactId>
24 </dependency>
25
26 <dependency>
27 <groupId>org.flywaydb</groupId>
28 <artifactId>flyway-mysql</artifactId>
29 </dependency>
30 <dependency>
31 <groupId>com.mysql</groupId>
32 <artifactId>mysql-connector-j</artifactId>
33 </dependency>
build.gradle.kts
kotlin
1 // At the top of build.gradle.kts, outside dependencies.
2 extra["jooq.version"] = "3.20.10"
3
4 dependencies {
5 implementation("io.github.zyraz-io:ekbatan-quarkus:0.2.1")
6 implementation("io.github.zyraz-io:ekbatan-flyway:0.2.1")
7 compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.2.1")
8 annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.2.1")
9
10 implementation("io.quarkus:quarkus-jdbc-postgresql")
11 implementation("io.quarkus:quarkus-flyway")
12 implementation("org.flywaydb:flyway-database-postgresql")
13
14 jooqCodegen("org.postgresql:postgresql")
15 }
build.gradle.kts
kotlin
1 // At the top of build.gradle.kts, outside dependencies.
2 extra["jooq.version"] = "3.20.10"
3
4 dependencies {
5 implementation("io.github.zyraz-io:ekbatan-quarkus:0.2.1")
6 implementation("io.github.zyraz-io:ekbatan-flyway:0.2.1")
7 compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.2.1")
8 annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.2.1")
9
10 implementation("io.quarkus:quarkus-jdbc-mariadb")
11 implementation("io.quarkus:quarkus-flyway")
12 implementation("org.flywaydb:flyway-mysql")
13
14 jooqCodegen("org.mariadb.jdbc:mariadb-java-client")
15 jooqCodegen("org.flywaydb:flyway-mysql:12.0.0")
16 }
build.gradle.kts
kotlin
1 // At the top of build.gradle.kts, outside dependencies.
2 extra["jooq.version"] = "3.20.10"
3
4 dependencies {
5 implementation("io.github.zyraz-io:ekbatan-quarkus:0.2.1")
6 implementation("io.github.zyraz-io:ekbatan-flyway:0.2.1")
7 compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.2.1")
8 annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.2.1")
9
10 implementation("io.quarkus:quarkus-jdbc-mysql")
11 implementation("io.quarkus:quarkus-flyway")
12 implementation("org.flywaydb:flyway-mysql")
13
14 jooqCodegen("com.mysql:mysql-connector-j")
15 jooqCodegen("org.flywaydb:flyway-mysql:12.0.0")
16 }
pom.xml
xml
1 <!-- Inside <properties>. -->
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.2.1</version>
9 </dependency>
10 <dependency>
11 <groupId>io.github.zyraz-io</groupId>
12 <artifactId>ekbatan-flyway</artifactId>
13 <version>0.2.1</version>
14 </dependency>
15 <dependency>
16 <groupId>io.github.zyraz-io</groupId>
17 <artifactId>ekbatan-annotation-processor</artifactId>
18 <version>0.2.1</version>
19 <scope>provided</scope>
20 </dependency>
21 <dependency>
22 <groupId>io.quarkus</groupId>
23 <artifactId>quarkus-jdbc-postgresql</artifactId>
24 </dependency>
25 <dependency>
26 <groupId>io.quarkus</groupId>
27 <artifactId>quarkus-flyway</artifactId>
28 </dependency>
29
30 <dependency>
31 <groupId>org.flywaydb</groupId>
32 <artifactId>flyway-database-postgresql</artifactId>
33 </dependency>
pom.xml
xml
1 <!-- Inside <properties>. -->
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.2.1</version>
9 </dependency>
10 <dependency>
11 <groupId>io.github.zyraz-io</groupId>
12 <artifactId>ekbatan-flyway</artifactId>
13 <version>0.2.1</version>
14 </dependency>
15 <dependency>
16 <groupId>io.github.zyraz-io</groupId>
17 <artifactId>ekbatan-annotation-processor</artifactId>
18 <version>0.2.1</version>
19 <scope>provided</scope>
20 </dependency>
21 <dependency>
22 <groupId>io.quarkus</groupId>
23 <artifactId>quarkus-jdbc-mariadb</artifactId>
24 </dependency>
25 <dependency>
26 <groupId>io.quarkus</groupId>
27 <artifactId>quarkus-flyway</artifactId>
28 </dependency>
29
30 <dependency>
31 <groupId>org.flywaydb</groupId>
32 <artifactId>flyway-mysql</artifactId>
33 </dependency>
pom.xml
xml
1 <!-- Inside <properties>. -->
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.2.1</version>
9 </dependency>
10 <dependency>
11 <groupId>io.github.zyraz-io</groupId>
12 <artifactId>ekbatan-flyway</artifactId>
13 <version>0.2.1</version>
14 </dependency>
15 <dependency>
16 <groupId>io.github.zyraz-io</groupId>
17 <artifactId>ekbatan-annotation-processor</artifactId>
18 <version>0.2.1</version>
19 <scope>provided</scope>
20 </dependency>
21 <dependency>
22 <groupId>io.quarkus</groupId>
23 <artifactId>quarkus-jdbc-mysql</artifactId>
24 </dependency>
25 <dependency>
26 <groupId>io.quarkus</groupId>
27 <artifactId>quarkus-flyway</artifactId>
28 </dependency>
29
30 <dependency>
31 <groupId>org.flywaydb</groupId>
32 <artifactId>flyway-mysql</artifactId>
33 </dependency>
build.gradle.kts
kotlin
1 dependencies {
2 implementation("io.github.zyraz-io:ekbatan-micronaut:0.2.1")
3 implementation("io.github.zyraz-io:ekbatan-flyway:0.2.1")
4 compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.2.1")
5 annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.2.1")
6 annotationProcessor("io.github.zyraz-io:ekbatan-micronaut:0.2.1") // required for Micronaut DI
7
8 implementation("io.micronaut.flyway:micronaut-flyway")
9 implementation("org.flywaydb:flyway-database-postgresql")
10 implementation("io.micronaut.sql:micronaut-jdbc-hikari")
11 runtimeOnly("org.postgresql:postgresql")
12
13 jooqCodegen("org.postgresql:postgresql")
14 }
build.gradle.kts
kotlin
1 dependencies {
2 implementation("io.github.zyraz-io:ekbatan-micronaut:0.2.1")
3 implementation("io.github.zyraz-io:ekbatan-flyway:0.2.1")
4 compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.2.1")
5 annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.2.1")
6 annotationProcessor("io.github.zyraz-io:ekbatan-micronaut:0.2.1") // required for Micronaut DI
7
8 implementation("io.micronaut.flyway:micronaut-flyway")
9 implementation("org.flywaydb:flyway-mysql")
10 implementation("io.micronaut.sql:micronaut-jdbc-hikari")
11 runtimeOnly("org.mariadb.jdbc:mariadb-java-client")
12
13 jooqCodegen("org.mariadb.jdbc:mariadb-java-client")
14 jooqCodegen("org.flywaydb:flyway-mysql:12.0.0")
15 }
build.gradle.kts
kotlin
1 dependencies {
2 implementation("io.github.zyraz-io:ekbatan-micronaut:0.2.1")
3 implementation("io.github.zyraz-io:ekbatan-flyway:0.2.1")
4 compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.2.1")
5 annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.2.1")
6 annotationProcessor("io.github.zyraz-io:ekbatan-micronaut:0.2.1") // required for Micronaut DI
7
8 implementation("io.micronaut.flyway:micronaut-flyway")
9 implementation("org.flywaydb:flyway-mysql")
10 implementation("io.micronaut.sql:micronaut-jdbc-hikari")
11 runtimeOnly("com.mysql:mysql-connector-j")
12
13 jooqCodegen("com.mysql:mysql-connector-j")
14 jooqCodegen("org.flywaydb:flyway-mysql:12.0.0")
15 }
pom.xml
xml
1 <!-- inside <dependencies> -->
2 <dependency>
3 <groupId>io.github.zyraz-io</groupId>
4 <artifactId>ekbatan-micronaut</artifactId>
5 <version>0.2.1</version>
6 </dependency>
7 <dependency>
8 <groupId>io.github.zyraz-io</groupId>
9 <artifactId>ekbatan-flyway</artifactId>
10 <version>0.2.1</version>
11 </dependency>
12 <dependency>
13 <groupId>io.github.zyraz-io</groupId>
14 <artifactId>ekbatan-annotation-processor</artifactId>
15 <version>0.2.1</version>
16 <scope>provided</scope>
17 </dependency>
18 <dependency>
19 <groupId>io.micronaut.flyway</groupId>
20 <artifactId>micronaut-flyway</artifactId>
21 </dependency>
22
23 <dependency>
24 <groupId>org.flywaydb</groupId>
25 <artifactId>flyway-database-postgresql</artifactId>
26 </dependency>
27 <dependency>
28 <groupId>io.micronaut.sql</groupId>
29 <artifactId>micronaut-jdbc-hikari</artifactId>
30 </dependency>
31 <dependency>
32 <groupId>org.postgresql</groupId>
33 <artifactId>postgresql</artifactId>
34 <scope>runtime</scope>
35 </dependency>
pom.xml
xml
1 <!-- inside <dependencies> -->
2 <dependency>
3 <groupId>io.github.zyraz-io</groupId>
4 <artifactId>ekbatan-micronaut</artifactId>
5 <version>0.2.1</version>
6 </dependency>
7 <dependency>
8 <groupId>io.github.zyraz-io</groupId>
9 <artifactId>ekbatan-flyway</artifactId>
10 <version>0.2.1</version>
11 </dependency>
12 <dependency>
13 <groupId>io.github.zyraz-io</groupId>
14 <artifactId>ekbatan-annotation-processor</artifactId>
15 <version>0.2.1</version>
16 <scope>provided</scope>
17 </dependency>
18 <dependency>
19 <groupId>io.micronaut.flyway</groupId>
20 <artifactId>micronaut-flyway</artifactId>
21 </dependency>
22
23 <dependency>
24 <groupId>org.flywaydb</groupId>
25 <artifactId>flyway-mysql</artifactId>
26 </dependency>
27 <dependency>
28 <groupId>io.micronaut.sql</groupId>
29 <artifactId>micronaut-jdbc-hikari</artifactId>
30 </dependency>
31 <dependency>
32 <groupId>org.mariadb.jdbc</groupId>
33 <artifactId>mariadb-java-client</artifactId>
34 <scope>runtime</scope>
35 </dependency>
pom.xml
xml
1 <!-- inside <dependencies> -->
2 <dependency>
3 <groupId>io.github.zyraz-io</groupId>
4 <artifactId>ekbatan-micronaut</artifactId>
5 <version>0.2.1</version>
6 </dependency>
7 <dependency>
8 <groupId>io.github.zyraz-io</groupId>
9 <artifactId>ekbatan-flyway</artifactId>
10 <version>0.2.1</version>
11 </dependency>
12 <dependency>
13 <groupId>io.github.zyraz-io</groupId>
14 <artifactId>ekbatan-annotation-processor</artifactId>
15 <version>0.2.1</version>
16 <scope>provided</scope>
17 </dependency>
18 <dependency>
19 <groupId>io.micronaut.flyway</groupId>
20 <artifactId>micronaut-flyway</artifactId>
21 </dependency>
22
23 <dependency>
24 <groupId>org.flywaydb</groupId>
25 <artifactId>flyway-mysql</artifactId>
26 </dependency>
27 <dependency>
28 <groupId>io.micronaut.sql</groupId>
29 <artifactId>micronaut-jdbc-hikari</artifactId>
30 </dependency>
31 <dependency>
32 <groupId>com.mysql</groupId>
33 <artifactId>mysql-connector-j</artifactId>
34 <scope>runtime</scope>
35 </dependency>
build.gradle.kts
kotlin
1 dependencies {
2 implementation("io.github.zyraz-io:ekbatan-core:0.2.1")
3 implementation("io.github.zyraz-io:ekbatan-flyway:0.2.1")
4 compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.2.1")
5 annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.2.1")
6
7 implementation("org.flywaydb:flyway-core:12.0.0")
8 implementation("org.flywaydb:flyway-database-postgresql:12.0.0")
9 implementation("org.postgresql:postgresql:42.7.4")
10
11 jooqCodegen("org.postgresql:postgresql:42.7.4")
12 }
build.gradle.kts
kotlin
1 dependencies {
2 implementation("io.github.zyraz-io:ekbatan-core:0.2.1")
3 implementation("io.github.zyraz-io:ekbatan-flyway:0.2.1")
4 compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.2.1")
5 annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.2.1")
6
7 implementation("org.flywaydb:flyway-core:12.0.0")
8 implementation("org.flywaydb:flyway-mysql:12.0.0")
9 implementation("org.mariadb.jdbc:mariadb-java-client:3.4.1")
10
11 jooqCodegen("org.mariadb.jdbc:mariadb-java-client:3.4.1")
12 jooqCodegen("org.flywaydb:flyway-mysql:12.0.0")
13 }
build.gradle.kts
kotlin
1 dependencies {
2 implementation("io.github.zyraz-io:ekbatan-core:0.2.1")
3 implementation("io.github.zyraz-io:ekbatan-flyway:0.2.1")
4 compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.2.1")
5 annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.2.1")
6
7 implementation("org.flywaydb:flyway-core:12.0.0")
8 implementation("org.flywaydb:flyway-mysql:12.0.0")
9 implementation("com.mysql:mysql-connector-j:9.1.0")
10
11 jooqCodegen("com.mysql:mysql-connector-j:9.1.0")
12 jooqCodegen("org.flywaydb:flyway-mysql:12.0.0")
13 }
pom.xml
xml
1 <!-- inside <dependencies> -->
2 <dependency>
3 <groupId>io.github.zyraz-io</groupId>
4 <artifactId>ekbatan-core</artifactId>
5 <version>0.2.1</version>
6 </dependency>
7 <dependency>
8 <groupId>io.github.zyraz-io</groupId>
9 <artifactId>ekbatan-flyway</artifactId>
10 <version>0.2.1</version>
11 </dependency>
12 <dependency>
13 <groupId>io.github.zyraz-io</groupId>
14 <artifactId>ekbatan-annotation-processor</artifactId>
15 <version>0.2.1</version>
16 <scope>provided</scope>
17 </dependency>
18 <dependency>
19 <groupId>org.flywaydb</groupId>
20 <artifactId>flyway-core</artifactId>
21 <version>12.0.0</version>
22 </dependency>
23
24 <dependency>
25 <groupId>org.flywaydb</groupId>
26 <artifactId>flyway-database-postgresql</artifactId>
27 <version>12.0.0</version>
28 </dependency>
29 <dependency>
30 <groupId>org.postgresql</groupId>
31 <artifactId>postgresql</artifactId>
32 <version>42.7.4</version>
33 </dependency>
pom.xml
xml
1 <!-- inside <dependencies> -->
2 <dependency>
3 <groupId>io.github.zyraz-io</groupId>
4 <artifactId>ekbatan-core</artifactId>
5 <version>0.2.1</version>
6 </dependency>
7 <dependency>
8 <groupId>io.github.zyraz-io</groupId>
9 <artifactId>ekbatan-flyway</artifactId>
10 <version>0.2.1</version>
11 </dependency>
12 <dependency>
13 <groupId>io.github.zyraz-io</groupId>
14 <artifactId>ekbatan-annotation-processor</artifactId>
15 <version>0.2.1</version>
16 <scope>provided</scope>
17 </dependency>
18 <dependency>
19 <groupId>org.flywaydb</groupId>
20 <artifactId>flyway-core</artifactId>
21 <version>12.0.0</version>
22 </dependency>
23
24 <dependency>
25 <groupId>org.flywaydb</groupId>
26 <artifactId>flyway-mysql</artifactId>
27 <version>12.0.0</version>
28 </dependency>
29 <dependency>
30 <groupId>org.mariadb.jdbc</groupId>
31 <artifactId>mariadb-java-client</artifactId>
32 <version>3.4.1</version>
33 </dependency>
pom.xml
xml
1 <!-- inside <dependencies> -->
2 <dependency>
3 <groupId>io.github.zyraz-io</groupId>
4 <artifactId>ekbatan-core</artifactId>
5 <version>0.2.1</version>
6 </dependency>
7 <dependency>
8 <groupId>io.github.zyraz-io</groupId>
9 <artifactId>ekbatan-flyway</artifactId>
10 <version>0.2.1</version>
11 </dependency>
12 <dependency>
13 <groupId>io.github.zyraz-io</groupId>
14 <artifactId>ekbatan-annotation-processor</artifactId>
15 <version>0.2.1</version>
16 <scope>provided</scope>
17 </dependency>
18 <dependency>
19 <groupId>org.flywaydb</groupId>
20 <artifactId>flyway-core</artifactId>
21 <version>12.0.0</version>
22 </dependency>
23
24 <dependency>
25 <groupId>org.flywaydb</groupId>
26 <artifactId>flyway-mysql</artifactId>
27 <version>12.0.0</version>
28 </dependency>
29 <dependency>
30 <groupId>com.mysql</groupId>
31 <artifactId>mysql-connector-j</artifactId>
32 <version>9.1.0</version>
33 </dependency>

Keep the jOOQ version override. Spring Boot’s BOM currently manages an older jOOQ line than Ekbatan uses. Put the override in the BOM property location shown above: extra["jooq.version"] for Gradle, <jooq.version> for Maven.

Keep the jOOQ version override. Quarkus’s BOM currently manages an older jOOQ line than Ekbatan uses. Put the override in the BOM property location shown above: extra["jooq.version"] for Gradle, <jooq.version> for Maven.

Keep the Micronaut annotation processor. The extra annotationProcessor("io.github.zyraz-io:ekbatan-micronaut:...") line makes @Ekbatan* classes visible to Micronaut DI.

Keep the jooqCodegen(...) lines. Gradle code generation runs on its own classpath, separate from the app runtime classpath. MariaDB and MySQL also include flyway-mysql there because the codegen migration step uses Flyway.

Keep the codegen plugin dependencies. Maven code generation runs on the plugin classpath, separate from the project dependencies. MariaDB and MySQL also include flyway-mysql there because the codegen migration step uses Flyway.

Keep the Flyway dialect module. Flyway core is not enough by itself. PostgreSQL uses flyway-database-postgresql; MariaDB and MySQL use flyway-mysql.

4. Sneak peek — a Model and a Repository

Define WalletState, a Wallet model, the events the wallet emits, and a WalletRepository. WalletState first because the Wallet model’s type signature names it.

src/main/java/io/example/wallet/model/ WalletState.java
java
1 package io.example.wallet.model;
2
3 public enum WalletState {
4 OPENED,
5 CLOSED,
6 DELETED
7 }
src/main/java/io/example/wallet/model/ Wallet.java
java
1 package io.example.wallet.model;
2
3 import io.ekbatan.core.domain.Id;
4 import io.ekbatan.core.domain.Model;
5 import io.ekbatan.core.processor.AutoBuilder;
6 import io.example.wallet.model.events.WalletClosedEvent;
7 import io.example.wallet.model.events.WalletCreatedEvent;
8 import io.example.wallet.model.events.WalletMoneyDepositedEvent;
9 import java.math.BigDecimal;
10 import java.time.Instant;
11 import java.util.Currency;
12 import java.util.Objects;
13 import java.util.UUID;
14
15 @AutoBuilder
16 public final class Wallet extends Model<Wallet, Id<Wallet>, WalletState> {
17
18 public final UUID ownerId;
19 public final Currency currency;
20 public final BigDecimal balance;
21
22 Wallet(WalletBuilder builder) {
23 super(builder);
24 this.ownerId = Objects.requireNonNull(builder.ownerId, "ownerId cannot be null");
25 this.currency = Objects.requireNonNull(builder.currency, "currency cannot be null");
26 this.balance = Objects.requireNonNull(builder.balance, "balance cannot be null");
27 }
28
29 public static WalletBuilder createWallet(
30 UUID ownerId, Currency currency, BigDecimal initialBalance, Instant createdDate) {
31 final var id = Id.random(Wallet.class);
32 return WalletBuilder.wallet()
33 .id(id)
34 .state(WalletState.OPENED)
35 .ownerId(ownerId)
36 .currency(currency)
37 .balance(initialBalance)
38 .createdDate(createdDate)
39 .withInitialVersion()
40 .withEvent(new WalletCreatedEvent(id, ownerId, currency, initialBalance));
41 }
42
43 @Override
44 public WalletBuilder copy() {
45 return WalletBuilder.wallet()
46 .copyBase(this)
47 .ownerId(ownerId)
48 .currency(currency)
49 .balance(balance);
50 }
51
52 public Wallet deposit(BigDecimal amount) {
53 Objects.requireNonNull(amount, "amount cannot be null");
54 if (amount.compareTo(BigDecimal.ZERO) <= 0) {
55 throw new IllegalArgumentException("Deposit amount must be positive");
56 }
57
58 final var newBalance = balance.add(amount);
59 return copy()
60 .withEvent(new WalletMoneyDepositedEvent(id, amount, newBalance))
61 .balance(newBalance)
62 .build();
63 }
64
65 public Wallet close() {
66 if (state.equals(WalletState.CLOSED)) {
67 return this;
68 }
69 return copy()
70 .withEvent(new WalletClosedEvent(id))
71 .state(WalletState.CLOSED)
72 .build();
73 }
74
75 @Override
76 public boolean equals(Object o) {
77 if (this == o) return true;
78 if (o == null || getClass() != o.getClass()) return false;
79 if (!super.equals(o)) return false;
80 Wallet wallet = (Wallet) o;
81 return ownerId.equals(wallet.ownerId)
82 && currency.equals(wallet.currency)
83 && balance.compareTo(wallet.balance) == 0;
84 }
85 }

The three events the wallet emits. Each extends ModelEvent<Wallet> and carries the data that gets written to the outbox row when the action commits. (For Jackson deserialization when consuming events from the outbox, each event also needs a @JsonCreator constructor — see the example projects for that full pattern.)

src/main/java/io/example/wallet/model/events/ WalletCreatedEvent.java
java
1 package io.example.wallet.model.events;
2
3 import io.ekbatan.core.domain.Id;
4 import io.ekbatan.core.domain.ModelEvent;
5 import io.example.wallet.model.Wallet;
6 import java.math.BigDecimal;
7 import java.util.Currency;
8 import java.util.UUID;
9
10 public class WalletCreatedEvent extends ModelEvent<Wallet> {
11
12 public final UUID ownerId;
13 public final String currency;
14 public final BigDecimal initialBalance;
15
16 public WalletCreatedEvent(Id<Wallet> walletId, UUID ownerId, Currency currency, BigDecimal initialBalance) {
17 super(walletId.getValue().toString(), Wallet.class);
18 this.ownerId = ownerId;
19 this.currency = currency.getCurrencyCode();
20 this.initialBalance = initialBalance;
21 }
22 }
src/main/java/io/example/wallet/model/events/ WalletMoneyDepositedEvent.java
java
1 package io.example.wallet.model.events;
2
3 import io.ekbatan.core.domain.Id;
4 import io.ekbatan.core.domain.ModelEvent;
5 import io.example.wallet.model.Wallet;
6 import java.math.BigDecimal;
7
8 public class WalletMoneyDepositedEvent extends ModelEvent<Wallet> {
9
10 public final BigDecimal amount;
11 public final BigDecimal newBalance;
12
13 public WalletMoneyDepositedEvent(Id<Wallet> walletId, BigDecimal amount, BigDecimal newBalance) {
14 super(walletId.getValue().toString(), Wallet.class);
15 this.amount = amount;
16 this.newBalance = newBalance;
17 }
18 }
src/main/java/io/example/wallet/model/events/ WalletClosedEvent.java
java
1 package io.example.wallet.model.events;
2
3 import io.ekbatan.core.domain.Id;
4 import io.ekbatan.core.domain.ModelEvent;
5 import io.example.wallet.model.Wallet;
6
7 public class WalletClosedEvent extends ModelEvent<Wallet> {
8
9 public WalletClosedEvent(Id<Wallet> walletId) {
10 super(walletId.getValue().toString(), Wallet.class);
11 }
12 }

@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 package io.example.wallet.model;
2
3 import io.ekbatan.core.domain.Id;
4 import io.ekbatan.core.domain.Model;
5 import io.ekbatan.core.processor.AutoBuilder;
6 import io.example.wallet.model.events.WalletClosedEvent;
7 import io.example.wallet.model.events.WalletCreatedEvent;
8 import io.example.wallet.model.events.WalletMoneyDepositedEvent;
9 import java.math.BigDecimal;
10 import java.time.Instant;
11 import java.util.Currency;
12 import java.util.Objects;
13 import java.util.UUID;
14
15 @AutoBuilder
16 public final class Wallet extends Model<Wallet, Id<Wallet>, WalletState> {
17
18 public final UUID ownerId;
19 public final Currency currency;
20 public final BigDecimal balance;
21
22 Wallet(WalletBuilder builder) {
23 super(builder);
24 this.ownerId = Objects.requireNonNull(builder.ownerId, "ownerId cannot be null");
25 this.currency = Objects.requireNonNull(builder.currency, "currency cannot be null");
26 this.balance = Objects.requireNonNull(builder.balance, "balance cannot be null");
27 }
28
29 public static WalletBuilder createWallet(
30 UUID ownerId, Currency currency, BigDecimal initialBalance, Instant createdDate) {
31 final var id = Id.random(Wallet.class);
32 return WalletBuilder.wallet()
33 .id(id)
34 .state(WalletState.OPENED)
35 .ownerId(ownerId)
36 .currency(currency)
37 .balance(initialBalance)
38 .createdDate(createdDate)
39 .withInitialVersion()
40 .withEvent(new WalletCreatedEvent(id, ownerId, currency, initialBalance));
41 }
42
43 @Override
44 public WalletBuilder copy() {
45 return WalletBuilder.wallet()
46 .copyBase(this)
47 .ownerId(ownerId)
48 .currency(currency)
49 .balance(balance);
50 }
51
52 public Wallet deposit(BigDecimal amount) {
53 Objects.requireNonNull(amount, "amount cannot be null");
54 if (amount.compareTo(BigDecimal.ZERO) <= 0) {
55 throw new IllegalArgumentException("Deposit amount must be positive");
56 }
57
58 final var newBalance = balance.add(amount);
59 return copy()
60 .withEvent(new WalletMoneyDepositedEvent(id, amount, newBalance))
61 .balance(newBalance)
62 .build();
63 }
64
65 public Wallet close() {
66 if (state.equals(WalletState.CLOSED)) {
67 return this;
68 }
69 return copy()
70 .withEvent(new WalletClosedEvent(id))
71 .state(WalletState.CLOSED)
72 .build();
73 }
74
75 @Override
76 public boolean equals(Object o) {
77 if (this == o) return true;
78 if (o == null || getClass() != o.getClass()) return false;
79 if (!super.equals(o)) return false;
80 Wallet wallet = (Wallet) o;
81 return ownerId.equals(wallet.ownerId)
82 && currency.equals(wallet.currency)
83 && balance.compareTo(wallet.balance) == 0;
84 }
85 }

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

src/main/java/io/example/wallet/repository/ WalletRepository.java
java
1 package io.example.wallet.repository;
2
3 import static io.example.wallet.generated.jooq.public_schema.tables.Wallets.WALLETS;
4
5 import io.ekbatan.core.domain.Id;
6 import io.ekbatan.core.repository.ModelRepository;
7 import io.ekbatan.core.shard.DatabaseRegistry;
8 import io.ekbatan.di.EkbatanRepository;
9 import io.example.wallet.generated.jooq.public_schema.tables.Wallets;
10 import io.example.wallet.generated.jooq.public_schema.tables.records.WalletsRecord;
11 import io.example.wallet.model.Wallet;
12 import io.example.wallet.model.WalletBuilder;
13 import io.example.wallet.model.WalletState;
14 import java.util.Currency;
15 import java.util.UUID;
16
17 @EkbatanRepository
18 public class WalletRepository extends ModelRepository<Wallet, WalletsRecord, Wallets, UUID> {
19
20 public WalletRepository(DatabaseRegistry databaseRegistry) {
21 super(Wallet.class, WALLETS, WALLETS.ID, databaseRegistry);
22 }
23
24 @Override
25 public Wallet fromRecord(WalletsRecord r) {
26 return WalletBuilder.wallet()
27 .id(Id.of(Wallet.class, r.getId()))
28 .version(r.getVersion())
29 .state(WalletState.valueOf(r.getState()))
30 .ownerId(r.getOwnerId())
31 .currency(Currency.getInstance(r.getCurrency()))
32 .balance(r.getBalance())
33 .createdDate(r.getCreatedDate())
34 .updatedDate(r.getUpdatedDate())
35 .build();
36 }
37
38 @Override
39 public WalletsRecord toRecord(Wallet w) {
40 return new WalletsRecord(
41 w.id.getValue(), w.version, w.state.name(),
42 w.ownerId, w.currency.getCurrencyCode(), w.balance,
43 w.createdDate, w.updatedDate);
44 }
45 }

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

At startup, the small migrator class later in this guide calls Flyway for every V*.sql in src/main/resources/db/migration/ — in numeric order — and the jOOQ codegen plugin (step 6) replays the same migrations 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);

At startup, the migrator calls Flyway and 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 <!-- Codegen plugin dependencies: driver plus Flyway dialect module for MariaDB/MySQL. -->
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 <!-- Codegen plugin dependencies: driver plus Flyway dialect module for MariaDB/MySQL. -->
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 <!-- Codegen plugin dependencies: driver plus Flyway dialect module for MariaDB/MySQL. -->
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 package io.example.wallet.repository;
2
3 import static io.example.wallet.generated.jooq.public_schema.tables.Wallets.WALLETS;
4
5 import io.ekbatan.core.domain.Id;
6 import io.ekbatan.core.repository.ModelRepository;
7 import io.ekbatan.core.shard.DatabaseRegistry;
8 import io.ekbatan.di.EkbatanRepository;
9 import io.example.wallet.generated.jooq.public_schema.tables.Wallets;
10 import io.example.wallet.generated.jooq.public_schema.tables.records.WalletsRecord;
11 import io.example.wallet.model.Wallet;
12 import io.example.wallet.model.WalletBuilder;
13 import io.example.wallet.model.WalletState;
14 import java.util.Currency;
15 import java.util.UUID;
16
17 @EkbatanRepository
18 public class WalletRepository extends ModelRepository<Wallet, WalletsRecord, Wallets, UUID> {
19
20 public WalletRepository(DatabaseRegistry databaseRegistry) {
21 super(Wallet.class, WALLETS, WALLETS.ID, databaseRegistry);
22 }
23
24 @Override
25 public Wallet fromRecord(WalletsRecord r) {
26 return WalletBuilder.wallet()
27 .id(Id.of(Wallet.class, r.getId()))
28 .version(r.getVersion())
29 .state(WalletState.valueOf(r.getState()))
30 .ownerId(r.getOwnerId())
31 .currency(Currency.getInstance(r.getCurrency()))
32 .balance(r.getBalance())
33 .createdDate(r.getCreatedDate())
34 .updatedDate(r.getUpdatedDate())
35 .build();
36 }
37
38 @Override
39 public WalletsRecord toRecord(Wallet w) {
40 return new WalletsRecord(
41 w.id.getValue(), w.version, w.state.name(),
42 w.ownerId, w.currency.getCurrencyCode(), w.balance,
43 w.createdDate, w.updatedDate);
44 }
45 }

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.*. The framework owns its pools directly. The Spring Boot starter filters Boot’s default single-datasource DataSource/Flyway auto-configuration, and the Quarkus/Micronaut examples do not configure a framework datasource. Step 9 runs Flyway from 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
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
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
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. Run Flyway from the Ekbatan sharding config

Ekbatan already has the database connection coordinates under ekbatan.sharding.*. Use FlywayMigrator from ekbatan-flyway to run migrations from that same config instead of copying the same JDBC URL, username, and password into a second framework-specific datasource block.

This is the same shape for one database or many databases. In this project setup page there is one configured shard, so FlywayMigrator.migrate(shardingConfig) runs Flyway once. In a sharded app, the same call runs the same migrations against every configured shard member’s primary-config, sequentially and fail-fast. By default it uses classpath:db/migration; pass locations explicitly if you keep migrations elsewhere.

Spring Boot, Quarkus, and Micronaut still keep their Flyway integration dependencies from step 3. Those dependencies provide Flyway itself, the database-specific Flyway module, and native-image/framework integration where the stack needs it. The actual target selection is done by the small startup class below, using Ekbatan’s typed ShardingConfig.

The Ekbatan Spring Boot starter filters Spring Boot’s default single-datasource DataSource/Flyway auto-configuration, so you do not need spring.datasource.*, spring.autoconfigure.exclude, or spring.flyway.enabled=false. This bean runs Flyway against every Ekbatan shard first, then adds an explicit dependency so Ekbatan’s distributed job registry cannot start before migrations complete.

Escape hatch: set ekbatan.spring.allow-boot-database-auto-configuration=true only if you intentionally want Spring Boot to create or migrate its own separate conventional datasource. In normal Ekbatan applications, leave it unset.

src/main/java/io/example/wallet/startup/ EkbatanShardFlywayMigrator.java
java
1 package io.example.wallet.startup;
2
3 import io.ekbatan.core.config.ShardingConfig;
4 import io.ekbatan.flyway.FlywayMigrator;
5 import java.util.Arrays;
6 import java.util.stream.Stream;
7 import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
8 import org.springframework.context.annotation.Bean;
9 import org.springframework.context.annotation.Configuration;
10
11 @Configuration
12 public class EkbatanShardFlywayMigrator {
13
14 @Bean
15 public FlywayMigration flywayMigration(ShardingConfig shardingConfig) {
16 FlywayMigrator.migrate(shardingConfig);
17 return new FlywayMigration();
18 }
19
20 @Bean
21 public static BeanFactoryPostProcessor jobRegistryDependsOnFlyway() {
22 return beanFactory -> {
23 if (!beanFactory.containsBeanDefinition("ekbatanJobRegistry")) {
24 return;
25 }
26 var bd = beanFactory.getBeanDefinition("ekbatanJobRegistry");
27 var existing = bd.getDependsOn();
28 bd.setDependsOn(existing == null
29 ? new String[] {"flywayMigration"}
30 : Stream.concat(Arrays.stream(existing), Stream.of("flywayMigration"))
31 .toArray(String[]::new));
32 };
33 }
34
35 public static final class FlywayMigration {}
36 }

FlywayMigration is just a marker bean. Creating it runs the migration, and its bean name gives Spring something concrete that ekbatanJobRegistry can depend on before jobs start polling.

Quarkus’s own Flyway startup runner is not the migration owner in this setup. The observer below owns migration startup and runs Flyway against every Ekbatan shard before the rest of the application starts.

src/main/java/io/example/wallet/startup/ EkbatanShardFlywayMigrator.java
java
1 package io.example.wallet.startup;
2
3 import io.ekbatan.core.config.ShardingConfig;
4 import io.ekbatan.flyway.FlywayMigrator;
5 import io.quarkus.runtime.StartupEvent;
6 import jakarta.annotation.Priority;
7 import jakarta.enterprise.context.ApplicationScoped;
8 import jakarta.enterprise.event.Observes;
9 import jakarta.interceptor.Interceptor;
10
11 @ApplicationScoped
12 public class EkbatanShardFlywayMigrator {
13
14 private final ShardingConfig shardingConfig;
15
16 public EkbatanShardFlywayMigrator(ShardingConfig shardingConfig) {
17 this.shardingConfig = shardingConfig;
18 }
19
20 void migrate(@Observes @Priority(Interceptor.Priority.PLATFORM_BEFORE) StartupEvent event) {
21 FlywayMigrator.migrate(shardingConfig);
22 }
23 }

@Context makes Micronaut instantiate this bean eagerly at startup, before lazy @Singleton beans such as Ekbatan’s DatabaseRegistry touch the database. The constructor runs migrations synchronously from ShardingConfig.

src/main/java/io/example/wallet/startup/ EkbatanFlywayMigrator.java
java
1 package io.example.wallet.startup;
2
3 import io.ekbatan.core.config.ShardingConfig;
4 import io.ekbatan.flyway.FlywayMigrator;
5 import io.micronaut.context.annotation.Context;
6
7 @Context
8 public class EkbatanFlywayMigrator {
9
10 public EkbatanFlywayMigrator(ShardingConfig shardingConfig) {
11 FlywayMigrator.migrate(shardingConfig);
12 }
13 }

No DI container, no startup hook. Call FlywayMigrator.migrate(shardingConfig) before you build the DatabaseRegistry and repositories. See Plain Java wiring for the full bootstrap.

10. Example Actions

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.

Two example actions on the wallet: WalletCreateAction creates a fresh wallet (a plan().add(...)), and WalletDepositMoneyAction adds money to an existing one (a plan().update(...)).

src/main/java/io/example/wallet/action/ WalletCreateAction.java
java
1
2 package io.example.wallet.action;
3
4 import io.ekbatan.core.action.Action;
5 import io.ekbatan.di.EkbatanAction;
6 import io.example.wallet.model.Wallet;
7 import java.math.BigDecimal;
8 import java.security.Principal;
9 import java.time.Clock;
10 import java.util.Currency;
11 import java.util.UUID;
12
13 @EkbatanAction
14 public class WalletCreateAction extends Action<WalletCreateAction.Params, Wallet> {
15
16 public record Params(UUID ownerId, Currency currency, BigDecimal initialBalance) {}
17
18 public WalletCreateAction(Clock clock) {
19 super(clock);
20 }
21
22 @Override
23 protected Wallet perform(Principal principal, Params params) {
24 final var wallet = Wallet.createWallet(
25 params.ownerId(), params.currency(), params.initialBalance(), clock.instant())
26 .build();
27 return plan().add(wallet);
28 }
29 }
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, plus one optional rename helper:

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 migration files in src/main/resources/db/migration, including the Ekbatan eventlog schema, and the app is fully wired.

14. Use the example projects as your reference

Every snippet on this page came from ekbatan-examples/ — 38 runnable wallet REST projects covering Spring Boot, Quarkus, and Micronaut, each on PostgreSQL/MariaDB/MySQL, in Gradle and Maven, with native-image siblings and sharded Spring Boot variants. The picker’s EXAMPLE row links straight to the matching framework example; plain Java points to the manual wiring reference.

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.

After setup

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

See also