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 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 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.
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
}
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
}
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
}
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>
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>
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>
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
}
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
}
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
}
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>
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>
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>
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
}
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
}
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
}
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>
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>
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>
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
}
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
}
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
}
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>
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>
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.
1
package io.example.wallet.model;
2
3
public enum WalletState {
4
OPENED,
5
CLOSED,
6
DELETED
7
}
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.)
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
}
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
}
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:
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.
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:
| 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);
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.
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
<!-- 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.
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
<!-- 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.
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
<!-- 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:
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:
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.*. 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.
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
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
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
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. 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.
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.
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.
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(...)).
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
}
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, plus one optional rename helper:
name()— cluster-stable handler identifier. Stored inevent_notifications.handler_name. Don’t rename casually — changing it makes future notifications use a new handler identity, while queued rows under the old name only resolve if that old name is listed inaliases(). Already fanned-out historical events are not replayed for the new name; only source events still markeddelivered=falsecan be backfilled.aliases()— optional previous names for the same logical handler. Use this when you rename a handler but still need to process rows that were already written with the oldhandler_name. The handling job can resolve old rows throughaliases(), but the fan-out job writes only the currentname()for new rows.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 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:
spring-boot-wallet-saga-gradle-pg— multi-Action coordinated flowspring-boot-wallet-rest-gradle-sharded-pg— two physical databases under one logical appspring-boot-wallet-rest-gradle-native-sharded-pg— the same sharded app compiled and tested as a native imagespring-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.
After setup
→ Your first Action — write WalletDepositAction and watch a row land in eventlog.events after the commit.
See also
- Learn → The dual-write trap — why the eventlog table exists
- Reference → DI integration — exhaustive per-stack wiring
ekbatan-examples/— 38 runnable framework wallet REST projects, plus saga and job-worker examples