Getting started
Set your stack, build tool, and database once with the picker below. Every snippet adapts — and your choice carries to every other lesson under /learn/.
1. Scaffold a project
Skip if you already have a project; otherwise start one.
start.spring.io → pick Java 25, your build tool, and add Web + JDBC API + Flyway Migration + your DB driver. Or via CLI:
curl https://start.spring.io/starter.zip \
-d type=gradle-project-kotlin -d language=java -d javaVersion=25 \
-d artifactId=wallet -d groupId=io.example \
-d dependencies=web,jdbc,flyway,postgresql \
-o wallet.zip && unzip wallet.zip -d wallet && cd wallet# Install: brew install quarkusio/tap/quarkus
quarkus create app io.example:wallet:0.1.0 \
--extension=rest-jackson,jdbc-postgresql,flyway,jooq
cd walletOr 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 walletOr 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/migrationNo 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.
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
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"
1
services:
2
wallet-db:
3
image: mysql:9
4
ports: ["3306:3306"]
5
environment:
6
MYSQL_USER: wallet
7
MYSQL_PASSWORD: wallet
8
MYSQL_DATABASE: wallet
9
MYSQL_ROOT_PASSWORD: root
10
volumes:
11
- "./src/main/resources/mysql_init.sql:/docker-entrypoint-initdb.d/mysql_init.sql:ro"
3. Add dependencies
Add the framework, your JDBC driver, and the framework-native Flyway extension.
1
// At the top of build.gradle.kts, outside dependencies. See "(1) Override jOOQ version" below.
2
extra["jooq.version"] = "3.20.10"
3
4
dependencies {
5
implementation("io.github.zyraz-io:ekbatan-spring-boot-starter:0.1.0")
6
compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
7
annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
8
9
implementation("org.springframework.boot:spring-boot-starter-flyway")
10
implementation("org.postgresql:postgresql")
11
12
jooqCodegen("org.postgresql:postgresql") // see "(6) jOOQ codegen needs its own classpath" below
13
}
1
// At the top of build.gradle.kts, outside dependencies. See "(1) Override jOOQ version" below.
2
extra["jooq.version"] = "3.20.10"
3
4
dependencies {
5
implementation("io.github.zyraz-io:ekbatan-spring-boot-starter:0.1.0")
6
compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
7
annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
8
9
implementation("org.springframework.boot:spring-boot-starter-flyway")
10
implementation("org.mariadb.jdbc:mariadb-java-client")
11
12
jooqCodegen("org.mariadb.jdbc:mariadb-java-client") // see "(6) jOOQ codegen needs its own classpath" below
13
jooqCodegen("org.flywaydb:flyway-mysql:11.20.0") // (6) flyway-mysql also covers MariaDB
14
}
1
// At the top of build.gradle.kts, outside dependencies. See "(1) Override jOOQ version" below.
2
extra["jooq.version"] = "3.20.10"
3
4
dependencies {
5
implementation("io.github.zyraz-io:ekbatan-spring-boot-starter:0.1.0")
6
compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
7
annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
8
9
implementation("org.springframework.boot:spring-boot-starter-flyway")
10
implementation("com.mysql:mysql-connector-j")
11
12
jooqCodegen("com.mysql:mysql-connector-j") // see "(6) jOOQ codegen needs its own classpath" below
13
jooqCodegen("org.flywaydb:flyway-mysql:11.20.0") // (6) flyway-mysql is required for MySQL too
14
}
1
<!-- (1) Override BOM-managed jOOQ - inside <properties>. See footnote (1) below. -->
2
<jooq.version>3.20.10</jooq.version>
3
4
<!-- inside <dependencies> -->
5
<dependency>
6
<groupId>io.github.zyraz-io</groupId>
7
<artifactId>ekbatan-spring-boot-starter</artifactId>
8
<version>0.1.0</version>
9
</dependency>
10
<dependency>
11
<groupId>io.github.zyraz-io</groupId>
12
<artifactId>ekbatan-annotation-processor</artifactId>
13
<version>0.1.0</version>
14
<scope>provided</scope>
15
</dependency>
16
<dependency>
17
<groupId>org.springframework.boot</groupId>
18
<artifactId>spring-boot-starter-flyway</artifactId>
19
</dependency>
20
<dependency>
21
<groupId>org.postgresql</groupId>
22
<artifactId>postgresql</artifactId>
23
</dependency>
1
<!-- (1) Override BOM-managed jOOQ - inside <properties>. See footnote (1) below. -->
2
<jooq.version>3.20.10</jooq.version>
3
4
<!-- inside <dependencies> -->
5
<dependency>
6
<groupId>io.github.zyraz-io</groupId>
7
<artifactId>ekbatan-spring-boot-starter</artifactId>
8
<version>0.1.0</version>
9
</dependency>
10
<dependency>
11
<groupId>io.github.zyraz-io</groupId>
12
<artifactId>ekbatan-annotation-processor</artifactId>
13
<version>0.1.0</version>
14
<scope>provided</scope>
15
</dependency>
16
<dependency>
17
<groupId>org.springframework.boot</groupId>
18
<artifactId>spring-boot-starter-flyway</artifactId>
19
</dependency>
20
<dependency>
21
<groupId>org.mariadb.jdbc</groupId>
22
<artifactId>mariadb-java-client</artifactId>
23
</dependency>
1
<!-- (1) Override BOM-managed jOOQ - inside <properties>. See footnote (1) below. -->
2
<jooq.version>3.20.10</jooq.version>
3
4
<!-- inside <dependencies> -->
5
<dependency>
6
<groupId>io.github.zyraz-io</groupId>
7
<artifactId>ekbatan-spring-boot-starter</artifactId>
8
<version>0.1.0</version>
9
</dependency>
10
<dependency>
11
<groupId>io.github.zyraz-io</groupId>
12
<artifactId>ekbatan-annotation-processor</artifactId>
13
<version>0.1.0</version>
14
<scope>provided</scope>
15
</dependency>
16
<dependency>
17
<groupId>org.springframework.boot</groupId>
18
<artifactId>spring-boot-starter-flyway</artifactId>
19
</dependency>
20
<dependency>
21
<groupId>com.mysql</groupId>
22
<artifactId>mysql-connector-j</artifactId>
23
</dependency>
1
// At the top of build.gradle.kts, outside dependencies. See "(1) Override jOOQ version" below.
2
extra["jooq.version"] = "3.20.10"
3
4
dependencies {
5
implementation("io.github.zyraz-io:ekbatan-quarkus:0.1.0")
6
compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
7
annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
8
9
implementation("io.quarkus:quarkus-jdbc-postgresql")
10
implementation("io.quarkus:quarkus-flyway")
11
12
jooqCodegen("org.postgresql:postgresql") // see "(6) jOOQ codegen needs its own classpath" below
13
}
1
// At the top of build.gradle.kts, outside dependencies. See "(1) Override jOOQ version" below.
2
extra["jooq.version"] = "3.20.10"
3
4
dependencies {
5
implementation("io.github.zyraz-io:ekbatan-quarkus:0.1.0")
6
compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
7
annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
8
9
implementation("io.quarkus:quarkus-jdbc-mariadb")
10
implementation("io.quarkus:quarkus-flyway")
11
12
jooqCodegen("org.mariadb.jdbc:mariadb-java-client") // see "(6) jOOQ codegen needs its own classpath" below
13
jooqCodegen("org.flywaydb:flyway-mysql:11.20.0") // (6) flyway-mysql also covers MariaDB
14
}
1
// At the top of build.gradle.kts, outside dependencies. See "(1) Override jOOQ version" below.
2
extra["jooq.version"] = "3.20.10"
3
4
dependencies {
5
implementation("io.github.zyraz-io:ekbatan-quarkus:0.1.0")
6
compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
7
annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
8
9
implementation("io.quarkus:quarkus-jdbc-mysql")
10
implementation("io.quarkus:quarkus-flyway")
11
12
jooqCodegen("com.mysql:mysql-connector-j") // see "(6) jOOQ codegen needs its own classpath" below
13
jooqCodegen("org.flywaydb:flyway-mysql:11.20.0") // (6) flyway-mysql is required for MySQL too
14
}
1
<!-- (1) Override BOM-managed jOOQ - inside <properties>. See footnote (1) below. -->
2
<jooq.version>3.20.10</jooq.version>
3
4
<!-- inside <dependencies> -->
5
<dependency>
6
<groupId>io.github.zyraz-io</groupId>
7
<artifactId>ekbatan-quarkus</artifactId>
8
<version>0.1.0</version>
9
</dependency>
10
<dependency>
11
<groupId>io.github.zyraz-io</groupId>
12
<artifactId>ekbatan-annotation-processor</artifactId>
13
<version>0.1.0</version>
14
<scope>provided</scope>
15
</dependency>
16
<dependency>
17
<groupId>io.quarkus</groupId>
18
<artifactId>quarkus-jdbc-postgresql</artifactId>
19
</dependency>
20
<dependency>
21
<groupId>io.quarkus</groupId>
22
<artifactId>quarkus-flyway</artifactId>
23
</dependency>
1
<!-- (1) Override BOM-managed jOOQ - inside <properties>. See footnote (1) below. -->
2
<jooq.version>3.20.10</jooq.version>
3
4
<!-- inside <dependencies> -->
5
<dependency>
6
<groupId>io.github.zyraz-io</groupId>
7
<artifactId>ekbatan-quarkus</artifactId>
8
<version>0.1.0</version>
9
</dependency>
10
<dependency>
11
<groupId>io.github.zyraz-io</groupId>
12
<artifactId>ekbatan-annotation-processor</artifactId>
13
<version>0.1.0</version>
14
<scope>provided</scope>
15
</dependency>
16
<dependency>
17
<groupId>io.quarkus</groupId>
18
<artifactId>quarkus-jdbc-mariadb</artifactId>
19
</dependency>
20
<dependency>
21
<groupId>io.quarkus</groupId>
22
<artifactId>quarkus-flyway</artifactId>
23
</dependency>
1
<!-- (1) Override BOM-managed jOOQ - inside <properties>. See footnote (1) below. -->
2
<jooq.version>3.20.10</jooq.version>
3
4
<!-- inside <dependencies> -->
5
<dependency>
6
<groupId>io.github.zyraz-io</groupId>
7
<artifactId>ekbatan-quarkus</artifactId>
8
<version>0.1.0</version>
9
</dependency>
10
<dependency>
11
<groupId>io.github.zyraz-io</groupId>
12
<artifactId>ekbatan-annotation-processor</artifactId>
13
<version>0.1.0</version>
14
<scope>provided</scope>
15
</dependency>
16
<dependency>
17
<groupId>io.quarkus</groupId>
18
<artifactId>quarkus-jdbc-mysql</artifactId>
19
</dependency>
20
<dependency>
21
<groupId>io.quarkus</groupId>
22
<artifactId>quarkus-flyway</artifactId>
23
</dependency>
1
dependencies {
2
implementation("io.github.zyraz-io:ekbatan-micronaut:0.1.0")
3
compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
4
annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
5
annotationProcessor("io.github.zyraz-io:ekbatan-micronaut:0.1.0") // CRITICAL — see "(5) Micronaut needs the AP line"
6
7
implementation("io.micronaut.flyway:micronaut-flyway")
8
implementation("io.micronaut.sql:micronaut-jdbc-hikari")
9
runtimeOnly("org.postgresql:postgresql")
10
11
jooqCodegen("org.postgresql:postgresql") // see "(6) jOOQ codegen needs its own classpath" below
12
}
1
dependencies {
2
implementation("io.github.zyraz-io:ekbatan-micronaut:0.1.0")
3
compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
4
annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
5
annotationProcessor("io.github.zyraz-io:ekbatan-micronaut:0.1.0") // CRITICAL — see "(5) Micronaut needs the AP line"
6
7
implementation("io.micronaut.flyway:micronaut-flyway")
8
implementation("io.micronaut.sql:micronaut-jdbc-hikari")
9
runtimeOnly("org.mariadb.jdbc:mariadb-java-client")
10
11
jooqCodegen("org.mariadb.jdbc:mariadb-java-client") // see "(6) jOOQ codegen needs its own classpath" below
12
jooqCodegen("org.flywaydb:flyway-mysql:11.20.0") // (6) flyway-mysql also covers MariaDB
13
}
1
dependencies {
2
implementation("io.github.zyraz-io:ekbatan-micronaut:0.1.0")
3
compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
4
annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
5
annotationProcessor("io.github.zyraz-io:ekbatan-micronaut:0.1.0") // CRITICAL — see "(5) Micronaut needs the AP line"
6
7
implementation("io.micronaut.flyway:micronaut-flyway")
8
implementation("io.micronaut.sql:micronaut-jdbc-hikari")
9
runtimeOnly("com.mysql:mysql-connector-j")
10
11
jooqCodegen("com.mysql:mysql-connector-j") // see "(6) jOOQ codegen needs its own classpath" below
12
jooqCodegen("org.flywaydb:flyway-mysql:11.20.0") // (6) flyway-mysql is required for MySQL too
13
}
1
<!-- inside <dependencies> -->
2
<dependency>
3
<groupId>io.github.zyraz-io</groupId>
4
<artifactId>ekbatan-micronaut</artifactId>
5
<version>0.1.0</version>
6
</dependency>
7
<dependency>
8
<groupId>io.github.zyraz-io</groupId>
9
<artifactId>ekbatan-annotation-processor</artifactId>
10
<version>0.1.0</version>
11
<scope>provided</scope>
12
</dependency>
13
<dependency>
14
<groupId>io.micronaut.flyway</groupId>
15
<artifactId>micronaut-flyway</artifactId>
16
</dependency>
17
<dependency>
18
<groupId>io.micronaut.sql</groupId>
19
<artifactId>micronaut-jdbc-hikari</artifactId>
20
</dependency>
21
<dependency>
22
<groupId>org.postgresql</groupId>
23
<artifactId>postgresql</artifactId>
24
<scope>runtime</scope>
25
</dependency>
1
<!-- inside <dependencies> -->
2
<dependency>
3
<groupId>io.github.zyraz-io</groupId>
4
<artifactId>ekbatan-micronaut</artifactId>
5
<version>0.1.0</version>
6
</dependency>
7
<dependency>
8
<groupId>io.github.zyraz-io</groupId>
9
<artifactId>ekbatan-annotation-processor</artifactId>
10
<version>0.1.0</version>
11
<scope>provided</scope>
12
</dependency>
13
<dependency>
14
<groupId>io.micronaut.flyway</groupId>
15
<artifactId>micronaut-flyway</artifactId>
16
</dependency>
17
<dependency>
18
<groupId>io.micronaut.sql</groupId>
19
<artifactId>micronaut-jdbc-hikari</artifactId>
20
</dependency>
21
<dependency>
22
<groupId>org.mariadb.jdbc</groupId>
23
<artifactId>mariadb-java-client</artifactId>
24
<scope>runtime</scope>
25
</dependency>
1
<!-- inside <dependencies> -->
2
<dependency>
3
<groupId>io.github.zyraz-io</groupId>
4
<artifactId>ekbatan-micronaut</artifactId>
5
<version>0.1.0</version>
6
</dependency>
7
<dependency>
8
<groupId>io.github.zyraz-io</groupId>
9
<artifactId>ekbatan-annotation-processor</artifactId>
10
<version>0.1.0</version>
11
<scope>provided</scope>
12
</dependency>
13
<dependency>
14
<groupId>io.micronaut.flyway</groupId>
15
<artifactId>micronaut-flyway</artifactId>
16
</dependency>
17
<dependency>
18
<groupId>io.micronaut.sql</groupId>
19
<artifactId>micronaut-jdbc-hikari</artifactId>
20
</dependency>
21
<dependency>
22
<groupId>com.mysql</groupId>
23
<artifactId>mysql-connector-j</artifactId>
24
<scope>runtime</scope>
25
</dependency>
1
dependencies {
2
implementation("io.github.zyraz-io:ekbatan-core:0.1.0")
3
compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
4
annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
5
6
implementation("org.flywaydb:flyway-core:11.0.0")
7
implementation("org.postgresql:postgresql:42.7.4")
8
9
jooqCodegen("org.postgresql:postgresql:42.7.4") // see "(6) jOOQ codegen needs its own classpath" below
10
}
1
dependencies {
2
implementation("io.github.zyraz-io:ekbatan-core:0.1.0")
3
compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
4
annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
5
6
implementation("org.flywaydb:flyway-core:11.0.0")
7
implementation("org.mariadb.jdbc:mariadb-java-client:3.4.1")
8
9
jooqCodegen("org.mariadb.jdbc:mariadb-java-client:3.4.1") // see "(6) jOOQ codegen needs its own classpath" below
10
jooqCodegen("org.flywaydb:flyway-mysql:11.20.0") // (6) flyway-mysql also covers MariaDB
11
}
1
dependencies {
2
implementation("io.github.zyraz-io:ekbatan-core:0.1.0")
3
compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
4
annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.1.0")
5
6
implementation("org.flywaydb:flyway-core:11.0.0")
7
implementation("com.mysql:mysql-connector-j:9.1.0")
8
9
jooqCodegen("com.mysql:mysql-connector-j:9.1.0") // see "(6) jOOQ codegen needs its own classpath" below
10
jooqCodegen("org.flywaydb:flyway-mysql:11.20.0") // (6) flyway-mysql is required for MySQL too
11
}
1
<!-- inside <dependencies> -->
2
<dependency>
3
<groupId>io.github.zyraz-io</groupId>
4
<artifactId>ekbatan-core</artifactId>
5
<version>0.1.0</version>
6
</dependency>
7
<dependency>
8
<groupId>io.github.zyraz-io</groupId>
9
<artifactId>ekbatan-annotation-processor</artifactId>
10
<version>0.1.0</version>
11
<scope>provided</scope>
12
</dependency>
13
<dependency>
14
<groupId>org.flywaydb</groupId>
15
<artifactId>flyway-core</artifactId>
16
<version>11.0.0</version>
17
</dependency>
18
<dependency>
19
<groupId>org.postgresql</groupId>
20
<artifactId>postgresql</artifactId>
21
<version>42.7.4</version>
22
</dependency>
1
<!-- inside <dependencies> -->
2
<dependency>
3
<groupId>io.github.zyraz-io</groupId>
4
<artifactId>ekbatan-core</artifactId>
5
<version>0.1.0</version>
6
</dependency>
7
<dependency>
8
<groupId>io.github.zyraz-io</groupId>
9
<artifactId>ekbatan-annotation-processor</artifactId>
10
<version>0.1.0</version>
11
<scope>provided</scope>
12
</dependency>
13
<dependency>
14
<groupId>org.flywaydb</groupId>
15
<artifactId>flyway-core</artifactId>
16
<version>11.0.0</version>
17
</dependency>
18
<dependency>
19
<groupId>org.mariadb.jdbc</groupId>
20
<artifactId>mariadb-java-client</artifactId>
21
<version>3.4.1</version>
22
</dependency>
1
<!-- inside <dependencies> -->
2
<dependency>
3
<groupId>io.github.zyraz-io</groupId>
4
<artifactId>ekbatan-core</artifactId>
5
<version>0.1.0</version>
6
</dependency>
7
<dependency>
8
<groupId>io.github.zyraz-io</groupId>
9
<artifactId>ekbatan-annotation-processor</artifactId>
10
<version>0.1.0</version>
11
<scope>provided</scope>
12
</dependency>
13
<dependency>
14
<groupId>org.flywaydb</groupId>
15
<artifactId>flyway-core</artifactId>
16
<version>11.0.0</version>
17
</dependency>
18
<dependency>
19
<groupId>com.mysql</groupId>
20
<artifactId>mysql-connector-j</artifactId>
21
<version>9.1.0</version>
22
</dependency>
(1) Override the BOM-managed jOOQ version. Spring Boot 4.x’s BOM pins jOOQ to 3.19.x; Ekbatan needs 3.20.x. The codegen plugin generates classes against 3.20.x APIs (Constants.VERSION_3_20, resetTouchedOnNotNull, etc.), so leaving the BOM-managed version in place fails the compile step. Override it once and the override applies to every classpath (compile + runtime):
- Gradle:
extra["jooq.version"] = "3.20.10"at the top ofbuild.gradle.kts, outside thedependenciesblock. The dependency-management plugin reads the property and rewrites the BOM-managed version. AruntimeOnly("org.jooq:jooq:3.20.10")line does NOT work here - it only patches the runtime classpath, leaving the compile classpath on 3.19.x. - Maven:
<jooq.version>3.20.10</jooq.version>inside the project’s<properties>. Thespring-boot-dependenciesBOM reads this property when resolving its managed jOOQ version.
(1) Override the BOM-managed jOOQ version. Quarkus’s BOM pins jOOQ to 3.19.x; Ekbatan needs 3.20.x. The codegen plugin generates classes against 3.20.x APIs (Constants.VERSION_3_20, resetTouchedOnNotNull, etc.), so leaving the BOM-managed version in place fails the compile step. Override it once:
- Gradle:
extra["jooq.version"] = "3.20.10"at the top ofbuild.gradle.kts, outside thedependenciesblock. - Maven:
<jooq.version>3.20.10</jooq.version>inside the project’s<properties>.
(5) Micronaut needs the annotation-processor line. The ekbatan-micronaut jar ships an EkbatanStereotypeVisitor that runs at compile time and lifts your @Ekbatan*-annotated classes to @Singleton. Without the annotationProcessor(...) line above, the build succeeds but at runtime Micronaut finds no BeanDefinition for your actions / repositories / handlers.
(6) jOOQ codegen needs the driver on its own classpath. The dev.monosoul.jooq-docker plugin (wired in step 6 below) runs its codegen step on a separate Gradle configuration named jooqCodegen, not on your application classpath. The implementation / runtimeOnly line puts the driver on the application’s classpath; jooqCodegen puts it on the codegen plugin’s classpath. Same coordinate, two configurations. Forgetting the jooqCodegen line gives Driver class not found during build, not at app boot. For MariaDB and MySQL, the codegen also needs flyway-mysql (it covers both dialects despite the name) so Flyway can recognize the JDBC URL inside the codegen container.
(6) jOOQ codegen needs the driver on its own classpath. The jooq-codegen-maven plugin (wired in step 6 below) runs on the plugin classpath, isolated from your project <dependencies>. The driver and (for MariaDB / MySQL) flyway-mysql therefore go inside the plugin’s own <dependencies> block, not at the project level. Same coordinate, two places. Forgetting the inner <dependency> gives Driver class not found during mvn generate-sources, not at app boot.
4. Sneak peek — a Model and a Repository
Define a Wallet model and a WalletRepository.
1
@AutoBuilder
2
public final class Wallet extends Model<Wallet, Id<Wallet>, WalletState> {
3
4
public final UUID ownerId;
5
public final Currency currency;
6
public final BigDecimal balance;
7
8
Wallet(WalletBuilder builder) {
9
super(builder);
10
this.ownerId = Validate.notNull(builder.ownerId, "ownerId");
11
this.currency = Validate.notNull(builder.currency, "currency");
12
this.balance = Validate.notNull(builder.balance, "balance");
13
}
14
15
@Override
16
public WalletBuilder copy() {
17
return WalletBuilder.wallet().copyBase(this)
18
.ownerId(ownerId).currency(currency).balance(balance);
19
}
20
}
@AutoBuilder (used on Wallet above) is the compile-time processor from ekbatan-annotation-processor. It generates WalletBuilder next to Wallet at compile time — that is what WalletBuilder.wallet(), the fluent setters, and .build() resolve to in the code above. You do not need to write the builder by hand, although you can if you want to.
The processor runs as part of normal compilation. There is no separate code-generation command — whatever Gradle or Maven task triggers javac also runs the processor.
Run ./gradlew compileJava (or any task that depends on it, like build or test) to compile and trigger the processor. WalletBuilder.java lands under build/generated/sources/annotationProcessor/java/main/io/example/wallet/model/.
Run ./mvnw compile (or package, install, test) to trigger the processor as part of compilation. WalletBuilder.java lands under target/generated-sources/annotations/io/example/wallet/model/.
The processor emits WalletBuilder, and the Wallet compiles cleanly — no more red squiggles:
1
@AutoBuilder
2
public final class Wallet extends Model<Wallet, Id<Wallet>, WalletState> {
3
4
public final UUID ownerId;
5
public final Currency currency;
6
public final BigDecimal balance;
7
8
Wallet(WalletBuilder builder) {
9
super(builder);
10
this.ownerId = Validate.notNull(builder.ownerId, "ownerId");
11
this.currency = Validate.notNull(builder.currency, "currency");
12
this.balance = Validate.notNull(builder.balance, "balance");
13
}
14
15
@Override
16
public WalletBuilder copy() {
17
return WalletBuilder.wallet().copyBase(this)
18
.ownerId(ownerId).currency(currency).balance(balance);
19
}
20
}
Now the repository. There’s a catch — see the red squiggles.
1
@EkbatanRepository
2
public class WalletRepository extends ModelRepository<Wallet, WalletsRecord, Wallets, UUID> {
3
4
public WalletRepository(DatabaseRegistry databaseRegistry) {
5
super(Wallet.class, WALLETS, WALLETS.ID, databaseRegistry);
6
}
7
8
@Override
9
public Wallet fromRecord(WalletsRecord r) {
10
return WalletBuilder.wallet()
11
.id(Id.of(Wallet.class, r.getId()))
12
.version(r.getVersion())
13
.state(WalletState.valueOf(r.getState()))
14
.ownerId(r.getOwnerId())
15
.currency(Currency.getInstance(r.getCurrency()))
16
.balance(r.getBalance())
17
.createdDate(r.getCreatedDate())
18
.updatedDate(r.getUpdatedDate())
19
.build();
20
}
21
22
@Override
23
public WalletsRecord toRecord(Wallet w) {
24
return new WalletsRecord(
25
w.id.getValue(), w.version, w.state.name(),
26
w.ownerId, w.currency.getCurrencyCode(), w.balance,
27
w.createdDate, w.updatedDate);
28
}
29
}
The repository references WALLETS, Wallets, and WalletsRecord — jOOQ-generated classes that don’t exist yet. Build fails. Three steps to fix that: add a migration, wire jOOQ codegen, run it. Then the repository compiles.
5. Add a Flyway migration
Flyway runs every V*.sql in src/main/resources/db/migration/ on first boot — in numeric order — and the jOOQ codegen plugin (step 6) replays them against a throwaway container to discover the schema. Ekbatan does NOT bundle V0001/V0002 inside its jars — you write them, or copy verbatim from any ekbatan-examples/*-{pg,mariadb,mysql}/src/main/resources/db/migration/.
Where the framework’s outbox tables live differs by dialect. PostgreSQL has true schemas inside a database, so V0001 just does CREATE SCHEMA eventlog at the top — no separate database-creation migration is needed. MariaDB and MySQL treat “schema” and “database” as synonyms, so the eventlog namespace must be its own database, created by V0000__create_eventlog_database.sql before V0001 writes into it.
Files Flyway will run on first boot:
| File | What it is |
|---|---|
V0001__eventlog.sql | the framework’s outbox tables — eventlog.events, eventlog.event_notifications (starts with CREATE SCHEMA eventlog) |
V0002__scheduled_tasks.sql | db-scheduler’s required table; the local-event-handler module depends on it |
V0003__wallets.sql | your domain tables |
| File | What it is |
|---|---|
V0000__create_eventlog_database.sql | makes the eventlog database exist before V0001 writes into it |
V0001__eventlog.sql | the framework’s outbox tables — eventlog.events, eventlog.event_notifications |
V0002__scheduled_tasks.sql | db-scheduler’s required table; the local-event-handler module depends on it |
V0003__wallets.sql | your domain tables |
| File | What it is |
|---|---|
V0000__create_eventlog_database.sql | makes the eventlog database exist before V0001 writes into it |
V0001__eventlog.sql | the framework’s outbox tables — eventlog.events, eventlog.event_notifications |
V0002__scheduled_tasks.sql | db-scheduler’s required table; the local-event-handler module depends on it |
V0003__wallets.sql | your 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.
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;
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).
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');
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);
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.
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);
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
);
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
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);
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);
1
CREATE TABLE wallets (
2
id CHAR(36) PRIMARY KEY,
3
version BIGINT NOT NULL,
4
state VARCHAR(24) NOT NULL,
5
owner_id CHAR(36) NOT NULL,
6
currency CHAR(3) NOT NULL,
7
balance DECIMAL(20,4) NOT NULL,
8
created_date DATETIME(6) NOT NULL,
9
updated_date DATETIME(6) NOT NULL
10
);
11
CREATE INDEX wallets_owner_id ON wallets (owner_id);
On first boot Flyway runs them in order: V0000 (MariaDB/MySQL only) → V0001 (eventlog) → V0002 (scheduled_tasks) → V0003 (wallets). The same set is what jOOQ codegen (step 6) reads against its throwaway container — so what your code references and what the database has are guaranteed to match.
6. Wire jOOQ codegen
Every repository references generated classes (the WALLETS constant, the WalletsRecord POJO, the Wallets table type). jOOQ generates them at build time by reading a real DB schema.
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
}
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
}
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.
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>
1
<!-- 2. Inside <build><plugins>, in this exact declaration order — Maven runs
2
executions of the same lifecycle phase in declaration order. -->
3
4
<!-- (a) Start / stop the codegen Postgres container. -->
5
<plugin>
6
<groupId>io.fabric8</groupId>
7
<artifactId>docker-maven-plugin</artifactId>
8
<version>${fabric8-docker-plugin-version}</version>
9
<configuration>
10
<images>
11
<image>
12
<name>postgres:16</name>
13
<alias>postgres-codegen</alias>
14
<run>
15
<ports>
16
<port>codegen.db.port:5432</port>
17
</ports>
18
<env>
19
<POSTGRES_DB>${codegen.db.name}</POSTGRES_DB>
20
<POSTGRES_USER>${codegen.db.user}</POSTGRES_USER>
21
<POSTGRES_PASSWORD>${codegen.db.password}</POSTGRES_PASSWORD>
22
<TZ>UTC</TZ>
23
</env>
24
<!-- Postgres logs "ready to accept connections" twice during init:
25
once before the init script runs, once when the real server
26
starts. Match both so Flyway connects to the real one. -->
27
<wait>
28
<log>(?s)database system is ready to accept connections.*database system is ready to accept connections</log>
29
<time>30000</time>
30
</wait>
31
</run>
32
</image>
33
</images>
34
</configuration>
35
<executions>
36
<execution>
37
<id>start-codegen-postgres</id>
38
<phase>initialize</phase>
39
<goals><goal>start</goal></goals>
40
</execution>
41
<execution>
42
<id>stop-codegen-postgres</id>
43
<phase>prepare-package</phase>
44
<goals><goal>stop</goal></goals>
45
</execution>
46
</executions>
47
</plugin>
48
49
<!-- (b) Run Flyway migrations against the freshly-started container. -->
50
<plugin>
51
<groupId>org.flywaydb</groupId>
52
<artifactId>flyway-maven-plugin</artifactId>
53
<version>${flyway-maven-plugin-version}</version>
54
<configuration>
55
<url>${codegen.db.url}</url>
56
<user>${codegen.db.user}</user>
57
<password>${codegen.db.password}</password>
58
<schemas>
59
<schema>public</schema>
60
<schema>eventlog</schema>
61
</schemas>
62
<locations>
63
<location>filesystem:src/main/resources/db/migration</location>
64
</locations>
65
<placeholderReplacement>false</placeholderReplacement>
66
</configuration>
67
<!-- Flyway 10+ requires the per-dialect plugin module on the build classpath. -->
68
<dependencies>
69
<dependency>
70
<groupId>org.flywaydb</groupId>
71
<artifactId>flyway-database-postgresql</artifactId>
72
<version>${flyway-version}</version>
73
</dependency>
74
</dependencies>
75
<executions>
76
<execution>
77
<id>codegen-migrate</id>
78
<phase>initialize</phase>
79
<goals><goal>migrate</goal></goals>
80
</execution>
81
</executions>
82
</plugin>
83
84
<!-- (c) Introspect the migrated schema and emit jOOQ classes. -->
85
<plugin>
86
<groupId>org.jooq</groupId>
87
<artifactId>jooq-codegen-maven</artifactId>
88
<version>${jooq-codegen-maven-plugin-version}</version>
89
<!-- (6) jOOQ codegen needs the driver on its own classpath - see footnote after step 3. -->
90
<dependencies>
91
<dependency>
92
<groupId>org.postgresql</groupId>
93
<artifactId>postgresql</artifactId>
94
<version>${postgresql.version}</version>
95
</dependency>
96
</dependencies>
97
<executions>
98
<execution>
99
<id>jooq-codegen</id>
100
<phase>generate-sources</phase>
101
<goals><goal>generate</goal></goals>
102
<configuration>
103
<jdbc>
104
<driver>org.postgresql.Driver</driver>
105
<url>${codegen.db.url}</url>
106
<user>${codegen.db.user}</user>
107
<password>${codegen.db.password}</password>
108
</jdbc>
109
<generator>
110
<database>
111
<name>org.jooq.meta.postgres.PostgresDatabase</name>
112
<includes>.*</includes>
113
<excludes>flyway_schema_history</excludes>
114
<!-- public = your domain tables; eventlog = framework outbox.
115
outputSchemaToDefault keeps SQL refs unqualified (INSERT INTO
116
wallets, not public_schema.wallets). 'public' is renamed
117
because it's a Java keyword and can't be a package name. -->
118
<schemata>
119
<schema>
120
<inputSchema>public</inputSchema>
121
<outputSchema>public_schema</outputSchema>
122
<outputSchemaToDefault>true</outputSchemaToDefault>
123
</schema>
124
<schema>
125
<inputSchema>eventlog</inputSchema>
126
<outputSchema>eventlog_schema</outputSchema>
127
</schema>
128
</schemata>
129
<!-- jOOQ defaults: TIMESTAMP→LocalDateTime, JSONB→String.
130
Model.createdDate is Instant and outbox payloads are
131
ObjectNode — without these, step 7 won't compile. -->
132
<forcedTypes>
133
<forcedType>
134
<userType>java.time.Instant</userType>
135
<converter>io.ekbatan.core.persistence.jooq.converter.InstantConverter</converter>
136
<includeTypes>TIMESTAMP</includeTypes>
137
<includeExpression>.*</includeExpression>
138
</forcedType>
139
<forcedType>
140
<userType>tools.jackson.databind.node.ObjectNode</userType>
141
<converter>io.ekbatan.core.persistence.jooq.converter.JSONBObjectNodeConverter</converter>
142
<includeTypes>JSONB</includeTypes>
143
<includeExpression>.*</includeExpression>
144
</forcedType>
145
</forcedTypes>
146
</database>
147
<target>
148
<packageName>io.example.wallet.generated.jooq</packageName>
149
</target>
150
</generator>
151
</configuration>
152
</execution>
153
</executions>
154
</plugin>
Maven’s codegen path uses three plugins in <build><plugins>. docker-maven-plugin starts a throwaway MariaDB in initialize, flyway-maven-plugin runs your migrations against it (also initialize), and jooq-codegen-maven introspects the schema in generate-sources. prepare-package tears the container down. The codegen container connects as root so V0000__create_eventlog_database.sql can CREATE DATABASE; the runtime / test containers use a less-privileged user.
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>
1
<!-- 2. Inside <build><plugins>, in this exact declaration order. -->
2
3
<!-- (a) Start / stop the codegen MariaDB container. -->
4
<plugin>
5
<groupId>io.fabric8</groupId>
6
<artifactId>docker-maven-plugin</artifactId>
7
<version>${fabric8-docker-plugin-version}</version>
8
<configuration>
9
<images>
10
<image>
11
<name>mariadb:11.8</name>
12
<alias>mariadb-codegen</alias>
13
<run>
14
<ports>
15
<port>codegen.db.port:3306</port>
16
</ports>
17
<env>
18
<!-- root user has cross-database GRANTs that V0000's CREATE
19
DATABASE eventlog requires. -->
20
<MARIADB_ROOT_PASSWORD>${codegen.db.password}</MARIADB_ROOT_PASSWORD>
21
<MARIADB_DATABASE>${codegen.db.name}</MARIADB_DATABASE>
22
<TZ>UTC</TZ>
23
</env>
24
<!-- MariaDB logs "ready for connections" twice during init — once
25
for the temp init server, once when the real server starts.
26
Match the second so Flyway connects to the real one. -->
27
<wait>
28
<log>(?s)ready for connections.*ready for connections</log>
29
<time>60000</time>
30
</wait>
31
</run>
32
</image>
33
</images>
34
</configuration>
35
<executions>
36
<execution>
37
<id>start-codegen-mariadb</id>
38
<phase>initialize</phase>
39
<goals><goal>start</goal></goals>
40
</execution>
41
<execution>
42
<id>stop-codegen-mariadb</id>
43
<phase>prepare-package</phase>
44
<goals><goal>stop</goal></goals>
45
</execution>
46
</executions>
47
</plugin>
48
49
<!-- (b) Run Flyway migrations against the codegen container. -->
50
<plugin>
51
<groupId>org.flywaydb</groupId>
52
<artifactId>flyway-maven-plugin</artifactId>
53
<version>${flyway-maven-plugin-version}</version>
54
<configuration>
55
<url>${codegen.db.url}</url>
56
<user>${codegen.db.user}</user>
57
<password>${codegen.db.password}</password>
58
<!-- "schema" = "database" on MariaDB/MySQL; both namespaces are databases. -->
59
<schemas>
60
<schema>wallet</schema>
61
<schema>eventlog</schema>
62
</schemas>
63
<locations>
64
<location>filesystem:src/main/resources/db/migration</location>
65
</locations>
66
<placeholderReplacement>false</placeholderReplacement>
67
</configuration>
68
<dependencies>
69
<!-- flyway-mysql covers MariaDB JDBC URLs - same module for both dialects. -->
70
<dependency>
71
<groupId>org.flywaydb</groupId>
72
<artifactId>flyway-mysql</artifactId>
73
<version>${flyway-version}</version>
74
</dependency>
75
</dependencies>
76
<executions>
77
<execution>
78
<id>codegen-migrate</id>
79
<phase>initialize</phase>
80
<goals><goal>migrate</goal></goals>
81
</execution>
82
</executions>
83
</plugin>
84
85
<!-- (c) Introspect the migrated schema and emit jOOQ classes. -->
86
<plugin>
87
<groupId>org.jooq</groupId>
88
<artifactId>jooq-codegen-maven</artifactId>
89
<version>${jooq-codegen-maven-plugin-version}</version>
90
<!-- (6) jOOQ codegen needs the driver on its own classpath - see footnote after step 3. -->
91
<dependencies>
92
<dependency>
93
<groupId>org.mariadb.jdbc</groupId>
94
<artifactId>mariadb-java-client</artifactId>
95
<version>${mariadb-driver-version}</version>
96
</dependency>
97
</dependencies>
98
<executions>
99
<execution>
100
<id>jooq-codegen</id>
101
<phase>generate-sources</phase>
102
<goals><goal>generate</goal></goals>
103
<configuration>
104
<jdbc>
105
<driver>org.mariadb.jdbc.Driver</driver>
106
<url>${codegen.db.url}</url>
107
<user>${codegen.db.user}</user>
108
<password>${codegen.db.password}</password>
109
</jdbc>
110
<generator>
111
<database>
112
<name>org.jooq.meta.mariadb.MariaDBDatabase</name>
113
<includes>.*</includes>
114
<!-- Skip Flyway's bookkeeping table and db-scheduler's table. -->
115
<excludes>flyway_schema_history|scheduled_tasks</excludes>
116
<!-- MariaDB has no schemas (schema = database). Single-database
117
codegen on 'wallet'; outputSchemaToDefault keeps SQL refs
118
unqualified. -->
119
<inputSchema>wallet</inputSchema>
120
<outputSchemaToDefault>true</outputSchemaToDefault>
121
<!-- jOOQ defaults: DATETIME/TIMESTAMP→LocalDateTime, JSON→String.
122
Note: JSONObjectNodeConverter has no 'B' (vs. Postgres's JSONB).
123
No UUID converter — MariaDB 10.7+ has a native UUID type. -->
124
<forcedTypes>
125
<forcedType>
126
<userType>java.time.Instant</userType>
127
<converter>io.ekbatan.core.persistence.jooq.converter.InstantConverter</converter>
128
<includeTypes>(?i:DATETIME|TIMESTAMP)</includeTypes>
129
<includeExpression>.*</includeExpression>
130
</forcedType>
131
<forcedType>
132
<userType>tools.jackson.databind.node.ObjectNode</userType>
133
<converter>io.ekbatan.core.persistence.jooq.converter.JSONObjectNodeConverter</converter>
134
<includeTypes>(?i:JSON)</includeTypes>
135
<includeExpression>.*</includeExpression>
136
</forcedType>
137
</forcedTypes>
138
</database>
139
<target>
140
<packageName>io.example.wallet.generated.jooq</packageName>
141
</target>
142
</generator>
143
</configuration>
144
</execution>
145
</executions>
146
</plugin>
Maven’s codegen path uses three plugins in <build><plugins>. docker-maven-plugin starts a throwaway MySQL in initialize, flyway-maven-plugin runs your migrations against it (also initialize), and jooq-codegen-maven introspects the schema in generate-sources. prepare-package tears the container down. The codegen container connects as root so V0000__create_eventlog_database.sql can CREATE DATABASE; the runtime / test containers use a less-privileged user.
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>
1
<!-- 2. Inside <build><plugins>, in this exact declaration order. -->
2
3
<!-- (a) Start / stop the codegen MySQL container. -->
4
<plugin>
5
<groupId>io.fabric8</groupId>
6
<artifactId>docker-maven-plugin</artifactId>
7
<version>${fabric8-docker-plugin-version}</version>
8
<configuration>
9
<images>
10
<image>
11
<name>mysql:9.4.0</name>
12
<alias>mysql-codegen</alias>
13
<run>
14
<ports>
15
<port>codegen.db.port:3306</port>
16
</ports>
17
<env>
18
<!-- root user has cross-database GRANTs that V0000's CREATE
19
DATABASE eventlog requires. -->
20
<MYSQL_ROOT_PASSWORD>${codegen.db.password}</MYSQL_ROOT_PASSWORD>
21
<MYSQL_DATABASE>${codegen.db.name}</MYSQL_DATABASE>
22
<TZ>UTC</TZ>
23
</env>
24
<!-- MySQL logs "ready for connections" twice during init — once for
25
the temp init server, once when the real server starts. Match
26
the second so Flyway connects to the real one. -->
27
<wait>
28
<log>(?s)ready for connections.*ready for connections</log>
29
<time>60000</time>
30
</wait>
31
</run>
32
</image>
33
</images>
34
</configuration>
35
<executions>
36
<execution>
37
<id>start-codegen-mysql</id>
38
<phase>initialize</phase>
39
<goals><goal>start</goal></goals>
40
</execution>
41
<execution>
42
<id>stop-codegen-mysql</id>
43
<phase>prepare-package</phase>
44
<goals><goal>stop</goal></goals>
45
</execution>
46
</executions>
47
</plugin>
48
49
<!-- (b) Run Flyway migrations against the codegen container. -->
50
<plugin>
51
<groupId>org.flywaydb</groupId>
52
<artifactId>flyway-maven-plugin</artifactId>
53
<version>${flyway-maven-plugin-version}</version>
54
<configuration>
55
<url>${codegen.db.url}</url>
56
<user>${codegen.db.user}</user>
57
<password>${codegen.db.password}</password>
58
<schemas>
59
<schema>wallet</schema>
60
<schema>eventlog</schema>
61
</schemas>
62
<locations>
63
<location>filesystem:src/main/resources/db/migration</location>
64
</locations>
65
<placeholderReplacement>false</placeholderReplacement>
66
</configuration>
67
<dependencies>
68
<dependency>
69
<groupId>org.flywaydb</groupId>
70
<artifactId>flyway-mysql</artifactId>
71
<version>${flyway-version}</version>
72
</dependency>
73
</dependencies>
74
<executions>
75
<execution>
76
<id>codegen-migrate</id>
77
<phase>initialize</phase>
78
<goals><goal>migrate</goal></goals>
79
</execution>
80
</executions>
81
</plugin>
82
83
<!-- (c) Introspect the migrated schema and emit jOOQ classes. -->
84
<plugin>
85
<groupId>org.jooq</groupId>
86
<artifactId>jooq-codegen-maven</artifactId>
87
<version>${jooq-codegen-maven-plugin-version}</version>
88
<!-- (6) jOOQ codegen needs the driver on its own classpath - see footnote after step 3. -->
89
<dependencies>
90
<dependency>
91
<groupId>com.mysql</groupId>
92
<artifactId>mysql-connector-j</artifactId>
93
<version>${mysql-driver-version}</version>
94
</dependency>
95
</dependencies>
96
<executions>
97
<execution>
98
<id>jooq-codegen</id>
99
<phase>generate-sources</phase>
100
<goals><goal>generate</goal></goals>
101
<configuration>
102
<jdbc>
103
<driver>com.mysql.cj.jdbc.Driver</driver>
104
<url>${codegen.db.url}</url>
105
<user>${codegen.db.user}</user>
106
<password>${codegen.db.password}</password>
107
</jdbc>
108
<generator>
109
<database>
110
<name>org.jooq.meta.mysql.MySQLDatabase</name>
111
<includes>.*</includes>
112
<excludes>flyway_schema_history|scheduled_tasks</excludes>
113
<inputSchema>wallet</inputSchema>
114
<outputSchemaToDefault>true</outputSchemaToDefault>
115
<forcedTypes>
116
<forcedType>
117
<userType>java.time.Instant</userType>
118
<converter>io.ekbatan.core.persistence.jooq.converter.InstantConverter</converter>
119
<includeTypes>(?i:DATETIME|TIMESTAMP)</includeTypes>
120
<includeExpression>.*</includeExpression>
121
</forcedType>
122
<forcedType>
123
<userType>tools.jackson.databind.node.ObjectNode</userType>
124
<converter>io.ekbatan.core.persistence.jooq.converter.JSONObjectNodeConverter</converter>
125
<includeTypes>(?i:JSON)</includeTypes>
126
<includeExpression>.*</includeExpression>
127
</forcedType>
128
<!-- MySQL has no native UUID type — every UUID column is
129
CHAR(36) ASCII. UuidStringConverter maps back to
130
java.util.UUID. Scope to id / *_id columns so unrelated
131
CHAR(36) columns aren't bound to UUID. -->
132
<forcedType>
133
<userType>java.util.UUID</userType>
134
<converter>io.ekbatan.core.persistence.jooq.converter.mysql.UuidStringConverter</converter>
135
<includeTypes>CHAR\(36\)</includeTypes>
136
<includeExpression>.*\.id|.*_id</includeExpression>
137
</forcedType>
138
</forcedTypes>
139
</database>
140
<target>
141
<packageName>io.example.wallet.generated.jooq</packageName>
142
</target>
143
</generator>
144
</configuration>
145
</execution>
146
</executions>
147
</plugin>
Run it once: ./gradlew generateJooqClasses (Gradle) or ./mvnw generate-sources (Maven). The generated classes appear under build/generated/sources/jooq/ (Gradle) or target/generated-sources/jooq/ (Maven), with package io.example.wallet.generated.jooq.*.
7. The repository compiles now
Same file as before — but WALLETS, Wallets, and WalletsRecord now exist:
1
@EkbatanRepository
2
public class WalletRepository extends ModelRepository<Wallet, WalletsRecord, Wallets, UUID> {
3
4
public WalletRepository(DatabaseRegistry databaseRegistry) {
5
super(Wallet.class, WALLETS, WALLETS.ID, databaseRegistry);
6
}
7
8
@Override
9
public Wallet fromRecord(WalletsRecord r) {
10
return WalletBuilder.wallet()
11
.id(Id.of(Wallet.class, r.getId()))
12
.version(r.getVersion())
13
.state(WalletState.valueOf(r.getState()))
14
.ownerId(r.getOwnerId())
15
.currency(Currency.getInstance(r.getCurrency()))
16
.balance(r.getBalance())
17
.createdDate(r.getCreatedDate())
18
.updatedDate(r.getUpdatedDate())
19
.build();
20
}
21
22
@Override
23
public WalletsRecord toRecord(Wallet w) {
24
return new WalletsRecord(
25
w.id.getValue(), w.version, w.state.name(),
26
w.ownerId, w.currency.getCurrencyCode(), w.balance,
27
w.createdDate, w.updatedDate);
28
}
29
}
Build (./gradlew compileJava / ./mvnw compile) succeeds. No more squiggles.
8. Configure the DB connection
The framework reads connection coordinates from ekbatan.sharding.*. Each shard member (a physical database) carries three pools:
primary-config— general request/transaction work, sized for HTTP parallelism (~20 conns).jobs-config— the distributed-jobs runtime, kept separate (~5 conns) so a job storm can’t starve user requests.lock-config— backs the keyed-lock provider (~30 conns, one pinned per active lease;leak-detection-threshold: 0because leases may legitimately be held for a while).
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")andmember.configFor("lockConfig"), notjobs-configorlock-config. The local-event-handler config follows the same rule:local-event-handler/localEventHandler,fanout-poll-delay/fanoutPollDelay,handling-poll-delay/handlingPollDelay, etc.
Note what’s NOT here: no spring.datasource.*, no datasources.default, no quarkus.datasource.jdbc.url. The framework owns its pools directly; your stack’s datasource auto-config is intentionally not used — so Flyway and Ekbatan don’t argue over connection coords. Step 10 bridges Flyway to the same ekbatan.sharding.* block.
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
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
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
1
ekbatan.namespace=example.wallet
2
ekbatan.local-event-handler.handling.enabled=true
3
4
ekbatan.sharding.default-shard.group=0
5
ekbatan.sharding.default-shard.member=0
6
ekbatan.sharding.groups[0].group=0
7
ekbatan.sharding.groups[0].name=default
8
ekbatan.sharding.groups[0].members[0].member=0
9
ekbatan.sharding.groups[0].members[0].name=default
10
11
ekbatan.sharding.groups[0].members[0].configs.primary-config.jdbc-url=jdbc:postgresql://localhost:5432/wallet
12
ekbatan.sharding.groups[0].members[0].configs.primary-config.username=wallet
13
ekbatan.sharding.groups[0].members[0].configs.primary-config.password=wallet
14
ekbatan.sharding.groups[0].members[0].configs.primary-config.driver-class-name=org.postgresql.Driver
15
ekbatan.sharding.groups[0].members[0].configs.primary-config.maximum-pool-size=20
16
17
ekbatan.sharding.groups[0].members[0].configs.jobs-config.jdbc-url=jdbc:postgresql://localhost:5432/wallet
18
ekbatan.sharding.groups[0].members[0].configs.jobs-config.username=wallet
19
ekbatan.sharding.groups[0].members[0].configs.jobs-config.password=wallet
20
ekbatan.sharding.groups[0].members[0].configs.jobs-config.driver-class-name=org.postgresql.Driver
21
ekbatan.sharding.groups[0].members[0].configs.jobs-config.maximum-pool-size=5
22
23
ekbatan.sharding.groups[0].members[0].configs.lock-config.jdbc-url=jdbc:postgresql://localhost:5432/wallet
24
ekbatan.sharding.groups[0].members[0].configs.lock-config.username=wallet
25
ekbatan.sharding.groups[0].members[0].configs.lock-config.password=wallet
26
ekbatan.sharding.groups[0].members[0].configs.lock-config.driver-class-name=org.postgresql.Driver
27
ekbatan.sharding.groups[0].members[0].configs.lock-config.maximum-pool-size=30
28
ekbatan.sharding.groups[0].members[0].configs.lock-config.leak-detection-threshold=0
29
30
# Quarkus needs db-kind to bind a Flyway bean. URL/user/pass are intentionally NOT set —
31
# EkbatanShardFlywayCustomizer (step 9) overrides them from ekbatan.sharding.* at startup.
32
quarkus.datasource.db-kind=postgresql
33
quarkus.flyway.migrate-at-start=true
1
ekbatan.namespace=example.wallet
2
ekbatan.local-event-handler.handling.enabled=true
3
4
ekbatan.sharding.default-shard.group=0
5
ekbatan.sharding.default-shard.member=0
6
ekbatan.sharding.groups[0].group=0
7
ekbatan.sharding.groups[0].name=default
8
ekbatan.sharding.groups[0].members[0].member=0
9
ekbatan.sharding.groups[0].members[0].name=default
10
11
ekbatan.sharding.groups[0].members[0].configs.primary-config.jdbc-url=jdbc:mariadb://localhost:3306/wallet
12
ekbatan.sharding.groups[0].members[0].configs.primary-config.username=wallet
13
ekbatan.sharding.groups[0].members[0].configs.primary-config.password=wallet
14
ekbatan.sharding.groups[0].members[0].configs.primary-config.driver-class-name=org.mariadb.jdbc.Driver
15
ekbatan.sharding.groups[0].members[0].configs.primary-config.maximum-pool-size=20
16
17
ekbatan.sharding.groups[0].members[0].configs.jobs-config.jdbc-url=jdbc:mariadb://localhost:3306/wallet
18
ekbatan.sharding.groups[0].members[0].configs.jobs-config.username=wallet
19
ekbatan.sharding.groups[0].members[0].configs.jobs-config.password=wallet
20
ekbatan.sharding.groups[0].members[0].configs.jobs-config.driver-class-name=org.mariadb.jdbc.Driver
21
ekbatan.sharding.groups[0].members[0].configs.jobs-config.maximum-pool-size=5
22
23
ekbatan.sharding.groups[0].members[0].configs.lock-config.jdbc-url=jdbc:mariadb://localhost:3306/wallet
24
ekbatan.sharding.groups[0].members[0].configs.lock-config.username=wallet
25
ekbatan.sharding.groups[0].members[0].configs.lock-config.password=wallet
26
ekbatan.sharding.groups[0].members[0].configs.lock-config.driver-class-name=org.mariadb.jdbc.Driver
27
ekbatan.sharding.groups[0].members[0].configs.lock-config.maximum-pool-size=30
28
ekbatan.sharding.groups[0].members[0].configs.lock-config.leak-detection-threshold=0
29
30
# Quarkus needs db-kind to bind a Flyway bean. URL/user/pass are intentionally NOT set —
31
# EkbatanShardFlywayCustomizer (step 9) overrides them from ekbatan.sharding.* at startup.
32
quarkus.datasource.db-kind=mariadb
33
quarkus.flyway.migrate-at-start=true
1
ekbatan.namespace=example.wallet
2
ekbatan.local-event-handler.handling.enabled=true
3
4
ekbatan.sharding.default-shard.group=0
5
ekbatan.sharding.default-shard.member=0
6
ekbatan.sharding.groups[0].group=0
7
ekbatan.sharding.groups[0].name=default
8
ekbatan.sharding.groups[0].members[0].member=0
9
ekbatan.sharding.groups[0].members[0].name=default
10
11
# MySQL: serverTimezone=UTC keeps DATETIME round-tripping consistent with
12
# Ekbatan's UTC-everywhere contract (driver default would shift by host TZ).
13
ekbatan.sharding.groups[0].members[0].configs.primary-config.jdbc-url=jdbc:mysql://localhost:3306/wallet?serverTimezone=UTC
14
ekbatan.sharding.groups[0].members[0].configs.primary-config.username=wallet
15
ekbatan.sharding.groups[0].members[0].configs.primary-config.password=wallet
16
ekbatan.sharding.groups[0].members[0].configs.primary-config.driver-class-name=com.mysql.cj.jdbc.Driver
17
ekbatan.sharding.groups[0].members[0].configs.primary-config.maximum-pool-size=20
18
19
ekbatan.sharding.groups[0].members[0].configs.jobs-config.jdbc-url=jdbc:mysql://localhost:3306/wallet?serverTimezone=UTC
20
ekbatan.sharding.groups[0].members[0].configs.jobs-config.username=wallet
21
ekbatan.sharding.groups[0].members[0].configs.jobs-config.password=wallet
22
ekbatan.sharding.groups[0].members[0].configs.jobs-config.driver-class-name=com.mysql.cj.jdbc.Driver
23
ekbatan.sharding.groups[0].members[0].configs.jobs-config.maximum-pool-size=5
24
25
ekbatan.sharding.groups[0].members[0].configs.lock-config.jdbc-url=jdbc:mysql://localhost:3306/wallet?serverTimezone=UTC
26
ekbatan.sharding.groups[0].members[0].configs.lock-config.username=wallet
27
ekbatan.sharding.groups[0].members[0].configs.lock-config.password=wallet
28
ekbatan.sharding.groups[0].members[0].configs.lock-config.driver-class-name=com.mysql.cj.jdbc.Driver
29
ekbatan.sharding.groups[0].members[0].configs.lock-config.maximum-pool-size=30
30
ekbatan.sharding.groups[0].members[0].configs.lock-config.leak-detection-threshold=0
31
32
# Quarkus needs db-kind to bind a Flyway bean. URL/user/pass are intentionally NOT set —
33
# EkbatanShardFlywayCustomizer (step 9) overrides them from ekbatan.sharding.* at startup.
34
quarkus.datasource.db-kind=mysql
35
quarkus.flyway.migrate-at-start=true
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
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
1
2
ekbatan:
3
namespace: example.wallet
4
sharding:
5
default-shard:
6
group: 0
7
member: 0
8
groups:
9
- group: 0
10
name: default
11
members:
12
- member: 0
13
name: default
14
configs:
15
# MySQL: serverTimezone=UTC keeps DATETIME round-tripping consistent with
16
# Ekbatan's UTC-everywhere contract (driver default would shift by host TZ).
17
primary-config:
18
jdbc-url: jdbc:mysql://localhost:3306/wallet?serverTimezone=UTC
19
username: wallet
20
password: wallet
21
driver-class-name: com.mysql.cj.jdbc.Driver
22
maximum-pool-size: 20
23
jobs-config:
24
jdbc-url: jdbc:mysql://localhost:3306/wallet?serverTimezone=UTC
25
username: wallet
26
password: wallet
27
driver-class-name: com.mysql.cj.jdbc.Driver
28
maximum-pool-size: 5
29
lock-config:
30
jdbc-url: jdbc:mysql://localhost:3306/wallet?serverTimezone=UTC
31
username: wallet
32
password: wallet
33
driver-class-name: com.mysql.cj.jdbc.Driver
34
maximum-pool-size: 30
35
leak-detection-threshold: 0
36
local-event-handler:
37
handling:
38
enabled: true
39
Configure the DatabaseRegistry programmatically — there’s no DI container to read application config from. See Plain Java wiring for the full builder.
9. Wire Flyway to the Ekbatan sharding config
The framework reads connection coordinates from ekbatan.sharding.*. Flyway, when auto-configured by your stack, would otherwise read them from its own keys (spring.datasource.*, quarkus.datasource.*, flyway.datasources.default.*). Without a bridge, you’d duplicate the connection coords in two places. Each stack has its own way to point Flyway at the shard config Ekbatan already owns — a typed bean (Spring/Micronaut) or a CDI customizer (Quarkus).
Single-shard only. The pattern below works for a single-database setup (one group, one member) and migrates
groups.getFirst().members.getFirst().primaryConfig(). It does not cover a sharded configuration with multiple groups or members — each shard would need its own migration run and adependsOnedge into the framework’s job registry so db-scheduler does not start before every shard’s schema is in place. A dedicated page for the sharded-migration pattern is coming soon.
Spring Boot’s FlywayAutoConfiguration is gated on @ConditionalOnBean(DataSource.class). Produce the DataSource programmatically from ShardingConfig AND scope it to Flyway only via @FlywayDataSource — Spring won’t pick it up as your app’s main DataSource (Ekbatan keeps owning that), and you don’t write a separate customizer (the bean IS the customization).
1
import com.zaxxer.hikari.HikariDataSource;
2
import io.ekbatan.core.config.ShardingConfig;
3
import javax.sql.DataSource;
4
import org.springframework.boot.flyway.autoconfigure.FlywayDataSource;
5
import org.springframework.context.annotation.Bean;
6
import org.springframework.context.annotation.Configuration;
7
8
@Configuration
9
public class EkbatanShardFlywayDataSource {
10
11
@Bean
12
@FlywayDataSource
13
public DataSource flywayDataSource(ShardingConfig shardingConfig) {
14
var primary = shardingConfig.groups.getFirst().members.getFirst().primaryConfig();
15
var ds = new HikariDataSource();
16
ds.setJdbcUrl(primary.jdbcUrl);
17
ds.setUsername(primary.username);
18
ds.setPassword(primary.password);
19
ds.setMaximumPoolSize(2);
20
ds.setPoolName("flyway-migrations");
21
return ds;
22
}
23
}
Quarkus discovers any CDI bean implementing FlywayConfigurationCustomizer and calls customize(...) after applying quarkus.flyway.* / quarkus.datasource.* but before building the Flyway instance. The customizer overrides whatever datasource Quarkus prepared.
1
import io.ekbatan.core.config.ShardingConfig;
2
import io.quarkus.flyway.FlywayConfigurationCustomizer;
3
import jakarta.enterprise.context.ApplicationScoped;
4
import org.flywaydb.core.api.configuration.FluentConfiguration;
5
6
@ApplicationScoped
7
public class EkbatanShardFlywayCustomizer implements FlywayConfigurationCustomizer {
8
9
private final ShardingConfig shardingConfig;
10
11
public EkbatanShardFlywayCustomizer(ShardingConfig shardingConfig) {
12
this.shardingConfig = shardingConfig;
13
}
14
15
@Override
16
public void customize(FluentConfiguration configuration) {
17
var primary = shardingConfig.groups.getFirst().members.getFirst().primaryConfig();
18
configuration.dataSource(primary.jdbcUrl, primary.username, primary.password);
19
}
20
}
The path via micronaut-flyway’s FlywayConfigurationCustomizer works but requires a flyway.datasources.default block in application.yml with ${ekbatan.sharding...} placeholder interpolation — duplicating the source of truth. A cleaner option: skip the YAML block entirely and construct Flyway directly from the typed ShardingConfig in a @Context bean.
@Context makes Micronaut instantiate it eagerly at startup — before lazy @Singleton beans (like Ekbatan’s DatabaseRegistry) ever touch the database. The constructor calls .migrate() synchronously, guaranteeing migrations are applied first.
1
import io.ekbatan.core.config.ShardingConfig;
2
import io.micronaut.context.annotation.Context;
3
import org.flywaydb.core.Flyway;
4
5
@Context
6
public class EkbatanFlywayMigrator {
7
8
public EkbatanFlywayMigrator(ShardingConfig shardingConfig) {
9
var primary = shardingConfig.groups.getFirst().members.getFirst().primaryConfig();
10
Flyway.configure()
11
.dataSource(primary.jdbcUrl, primary.username, primary.password)
12
.load()
13
.migrate();
14
}
15
}
No DI container, no customizer hook. Configure Flyway programmatically with the same ShardingConfig primary you’ll hand to DatabaseRegistry. See Plain Java wiring for the full bootstrap.
10. Example Action
An Action is Ekbatan’s unit of work — it encapsulates a state change and the domain events that describe it, in a single atomic transaction. The row update and the event row commit together (or neither does). This is where the framework’s dual-write guarantee originates.
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:
- Read the wallet through the typed repository.
wallet.deposit(amount)returns a new immutableWalletwith the next balance and aWalletMoneyDepositedEventattached via.withEvent(...)— the model itself owns the invariant “balance changed ⇒ event emitted”.plan().update(updated)enqueues the row update for the framework’s persister. On commit, both the wallet row and the event row land atomically.
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.
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:
name()— cluster-stable handler identifier, stored inevent_notifications.handler_name. Renaming re-delivers every historical event the new name hasn’t seen.eventType()— the typed payload class; drives JSON deserialization from the outbox row’spayloadcolumn.handle(envelope)— your logic.envelope.eventis the typed payload;envelope.actionParamsis the source Action’s params as a JacksonObjectNode(so you can read fields the event itself doesn’t carry).
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().
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:
name()— cluster-wide unique. db-scheduler stores it inscheduled_tasks.task_nameand uses it as the lease key for at-most-once execution per schedule slot.schedule()— a db-schedulerSchedule.FixedDelay,Cron,DailyAt, etc. are all supported.execute(ctx)— runs on whichever cluster member won the lease for the current slot. Read from repositories freely (no transaction owned by the job); dispatchActions via the injectedActionExecutor— each one opens its own short transaction, emits its events, and commits.
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:javaAfter the app boots, verify the framework wired up by checking that its outbox tables exist:
docker compose exec wallet-db psql -U wallet -c "\dt eventlog.*"You should see eventlog.events and eventlog.event_notifications.
docker compose exec wallet-db mariadb -uwallet -pwallet -e "SHOW TABLES IN eventlog"docker compose exec wallet-db mysql -uwallet -pwallet -e "SHOW TABLES IN eventlog"If you see the eventlog tables, Flyway ran the framework’s bundled migrations and Ekbatan is fully wired.
14. Use the example projects as your reference
Every snippet on this page came from ekbatan-examples/ — 39 runnable wallet projects covering Spring Boot, Quarkus, and Micronaut, each on PostgreSQL/MariaDB/MySQL, in Gradle and Maven, with native-image siblings. The picker’s EXAMPLE row links straight to the one matching your picks.
A few sibling projects show patterns this tutorial doesn’t:
spring-boot-wallet-saga-gradle-pg— multi-Action coordinated flowspring-boot-wallet-rest-gradle-sharded-pg— two physical databases under one logical appspring-boot-job-worker-gradle-pg— job-only process (no HTTP), for scaling jobs separately
AI assistants benefit from the same matrix — point them at ekbatan-examples/ so they pattern-match against verified code instead of guessing from training data.
Next
→ Your first Action — write WalletDepositAction and watch a row land in eventlog.events after the commit.
See also
- Concepts → The dual-write trap — why the eventlog table exists
- Reference → DI integration — exhaustive per-stack wiring
ekbatan-examples/— 24 runnable wallet projects, one per(stack × build × dialect)