Ekbatan
Ekbatan is an event-driven Java persistence framework with built-in outbox pattern, atomic transactions with domain events, and support for Spring, Quarkus, and Micronaut or plain Java.
The outbox pattern stores domain events alongside state in the same atomic database transaction - both writes land together, or neither does - making the database the single source of truth for both. Downstream tools like Debezium can later publish those events to Kafka, Pulsar, or any other broker, without the dual-write trap.
Ekbatan is a Java library you embed in your application - Spring, Quarkus, Micronaut, or plain Java. It does not replace your full-stack framework; it replaces the persistence layer where Spring Data, Hibernate, JPA, or hand-rolled JDBC usually live.
Built for Java 25+, sitting directly on JOOQ, designed around virtual threads.
Works with: PostgreSQL Β· MariaDB Β· MySQL β and Spring Boot Β· Quarkus Β· Micronaut Β· plain Java. The same domain code compiles and runs identically against every database and DI container; only the wiring at the edges changes.
Read more in the Ekbatan documentation website: https://zyraz-io.github.io/ekbatan/
Add to your project
Ekbatan is published on Maven Central under groupId io.github.zyraz-io.
Gradle (Kotlin DSL)
Spring Boot:
dependencies {
implementation("io.github.zyraz-io:ekbatan-spring-boot-starter:0.2.1")
}
Quarkus:
dependencies {
implementation("io.github.zyraz-io:ekbatan-quarkus:0.2.1")
}
Micronaut β the annotationProcessor line is required (without it, Micronaut won't generate BeanDefinitions for @Ekbatan* classes):
dependencies {
implementation("io.github.zyraz-io:ekbatan-micronaut:0.2.1")
annotationProcessor("io.github.zyraz-io:ekbatan-micronaut:0.2.1")
annotationProcessor("io.micronaut:micronaut-inject-java")
}
Plain Java (no DI container) β the integration jars above pull most of these transitively; here every optional module is spelled out:
dependencies {
// ββ Required ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
implementation("io.github.zyraz-io:ekbatan-core:0.2.1")
// ββ Optional capabilities βββββββββββββββββββββββββββββββββββββββββββββββ
// @AutoBuilder code generation β generates *Builder classes for Models/Entities
// (skip if you'd rather write the builders by hand)
compileOnly("io.github.zyraz-io:ekbatan-annotation-processor:0.2.1")
annotationProcessor("io.github.zyraz-io:ekbatan-annotation-processor:0.2.1")
// In-process event handlers (fanout + handling jobs over the eventlog)
implementation("io.github.zyraz-io:ekbatan-local-event-handler:0.2.1")
// Distributed background jobs (db-scheduler facade; cluster-exclusive scheduling)
implementation("io.github.zyraz-io:ekbatan-distributed-jobs:0.2.1")
// Redis-backed distributed KeyedLockProvider (Redisson under the hood)
implementation("io.github.zyraz-io:ekbatan-keyed-lock-redis:0.2.1")
// GraalVM native-image Features (auto-loaded; include only if you build native binaries)
implementation("io.github.zyraz-io:ekbatan-native:0.2.1")
// Testing helpers: ActionSpec, ActionAssert, VirtualClock, and Testcontainers utilities
testImplementation("io.github.zyraz-io:ekbatan-test-support:0.2.1")
// ββ Wire-format DTOs (only for Kafka consumer apps reading from the eventlog) ββ
// Pick the one matching your Kafka serializer; not needed in the producer app itself.
implementation("io.github.zyraz-io:ekbatan-action-event-json:0.2.1")
implementation("io.github.zyraz-io:ekbatan-action-event-avro:0.2.1")
implementation("io.github.zyraz-io:ekbatan-action-event-protobuf:0.2.1")
}
Maven
Substitute the artifactId for your stack β ekbatan-spring-boot-starter, ekbatan-quarkus, ekbatan-micronaut, or ekbatan-core:
<dependency>
<groupId>io.github.zyraz-io</groupId>
<artifactId>ekbatan-spring-boot-starter</artifactId>
<version>0.2.1</version>
</dependency>
Using Ekbatan with Maven? docs/maven/ is the dedicated guide.
Per-stack setup details: Spring Boot Β· Quarkus Β· Micronaut Β· Plain Java.
Learn by Example: A Wallet
Model & Entity
A wallet is a Model β a domain object whose changes produce events. Models are the right fit when a record's history matters downstream: every mutation returns a new immutable instance with a ModelEvent attached, and the framework persists state and events together in one transaction. The contrast is an Entity β same persistence and version tracking, but no events: use it for lookup tables, settings, or auxiliary records whose history isn't consumed by other systems. Wallet produces events (deposits, closures) and is a Model; a Country reference table would be an Entity.
Our Wallet has an owner, a currency, and a balance:
@AutoBuilder
public final class Wallet extends Model<Wallet, Id<Wallet>, WalletState> {
public final UUID ownerId;
public final Currency currency;
public final BigDecimal balance;
Wallet(WalletBuilder builder) {
super(builder);
this.ownerId = Validate.notNull(builder.ownerId, "ownerId cannot be null");
this.currency = Validate.notNull(builder.currency, "currency cannot be null");
this.balance = Validate.notNull(builder.balance, "balance cannot be null");
}
public Wallet deposit(BigDecimal amount) {
var newBalance = balance.add(amount);
return copy()
.withEvent(new WalletMoneyDepositedEvent(id, amount, newBalance))
.balance(newBalance)
.build();
}
// close(), copy()...
}
deposit(...) returns a new wallet with the corresponding event attached - no database call is made here.
Action & ActionPlan
An Action is a unit of business work β deposit money, create order, ship parcel. It does not write to the database itself; its perform(...) method stages changes onto an ActionPlan, and the executor commits that plan in one atomic transaction afterwards. One class per operation, with a typed Params record and a perform(...) method:
@EkbatanAction
public class WalletDepositAction extends Action<WalletDepositAction.Params, Wallet> {
public record Params(Id<Wallet> walletId, BigDecimal amount) {}
private final WalletRepository walletRepository;
public WalletDepositAction(Clock clock, WalletRepository walletRepository) {
super(clock);
this.walletRepository = walletRepository;
}
@Override
protected Wallet perform(Principal principal, Params params) {
var wallet = walletRepository.getById(params.walletId().getValue());
var updated = wallet.deposit(params.amount());
return plan().update(updated);
}
}
The plan() accessor inside perform(...) returns the ActionPlan β a per-action staging area for everything the framework should persist. Call plan().add(newDomainObject) for inserts and plan().update(modifiedDomainObject) for updates; the call above (plan().update(updated)) registers the deposited wallet and returns the same value as the action's result. Nothing is committed yet: the plan is just an in-memory list of intended writes. The executor flushes the whole plan, plus any attached events, in a single transaction once perform(...) returns β see the two-phase lifecycle below.
Action Executor
The Action Executor runs the action by class and parameters:
Wallet result = executor.execute(
() -> "alice",
WalletDepositAction.class,
new WalletDepositAction.Params(walletId, new BigDecimal("25.50")));
The executor opens a single transaction, writes the new wallet row, writes the WalletMoneyDepositedEvent row into the events table, and commits. If any step throws, both writes are rolled back together.
Repository
The WalletRepository injected into the action above maps the domain to JOOQ records. The base class gives you full CRUD; you add custom queries - readonlyDb() for replica reads, db() for primary reads:
@EkbatanRepository
public class WalletRepository extends ModelRepository<Wallet, WalletsRecord, Wallets, UUID> {
public WalletRepository(DatabaseRegistry databaseRegistry) {
super(Wallet.class, WALLETS, WALLETS.ID, databaseRegistry);
}
// Replica read - list / search queries that tolerate replication lag
public List<Wallet> findAllByOwnerId(UUID ownerId) {
return readonlyDb()
.selectFrom(WALLETS)
.where(WALLETS.OWNER_ID.eq(ownerId))
.fetch(this::fromRecord);
}
// Primary read - strongly-consistent reads (e.g. immediately after a write)
public Optional<Wallet> findByIdOnPrimary(UUID walletId) {
return db()
.selectFrom(WALLETS)
.where(WALLETS.ID.eq(walletId))
.fetchOptional(this::fromRecord);
}
@Override
public Wallet fromRecord(WalletsRecord r) { /* β¦ */ }
@Override
public WalletsRecord toRecord(Wallet w) { /* β¦ */ }
}
Distributed Job
A Distributed Job is periodic background work that should run on at most one instance across the cluster - daily reports, hourly cleanups, periodic reconciliations:
@EkbatanDistributedJob
public class DailyWalletReportJob extends DistributedJob {
private final ReportService reportService;
public DailyWalletReportJob(ReportService reportService) {
this.reportService = reportService;
}
@Override public String name() { return "daily-wallet-report"; } // cluster-wide unique
@Override public Schedule schedule() { return Schedules.daily(LocalTime.of(2, 0)); }
@Override
public void execute(ExecutionContext ctx) {
reportService.generateAndSend();
}
}
Event Handler
An Event Handler reacts to a specific event in the same JVM after the action commits - for sending notifications, writing audit rows, or triggering downstream workflows. The framework's fan-out and dispatch jobs deliver each committed event with retry and at-least-once semantics:
@EkbatanEventHandler
public class WalletMoneyDepositedEventHandler implements EventHandler<WalletMoneyDepositedEvent> {
private final NotificationService notificationService;
public WalletMoneyDepositedEventHandler(NotificationService notificationService) {
this.notificationService = notificationService;
}
@Override public String name() { return "wallet-deposit-notification"; }
@Override public Class<WalletMoneyDepositedEvent> eventType() { return WalletMoneyDepositedEvent.class; }
@Override
public void handle(EventEnvelope<WalletMoneyDepositedEvent> envelope) {
notificationService.notifyDeposit(envelope.event.modelId, envelope.event.amount);
}
}
The Action Lifecycle
Every executor.execute(...) call runs in two distinct phases - pure construction first, then a single atomic transaction:
executor.execute(WalletDepositAction.class, params)
β
βΌ
ββββ Phase 1 - Action.perform() (no DB transaction yet) βββββ
β β
β 1. Read from repositories β
β 2. Build new immutable Models / Entities β
β 3. Attach Events to the Models β
β 4. Stage them on the ActionPlan: β
β plan.add(newOrder) β
β plan.update(updatedWallet) β
β 5. Return a result value β
β β
β No database writes in this phase. β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
ββββ Phase 2 - Executor.persistChanges() (one atomic TX) ββββ
β β
β TransactionManager.inTransaction(() -> { β
β Repository.addAll / updateAll β domain rows β
β EventPersister.persistActionEvents β outbox rows β
β commit βorβ rollback β
β }); β
β β
β All writes land together, or none at all. β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
result returned to the caller
Phase 1 is pure construction - reads are allowed, but no writes happen. Phase 2 is the only place the framework opens a transaction, and it always wraps every staged change plus the matching event rows together. Anything that throws inside Phase 2 rolls the whole transaction back; on optimistic-lock conflicts (StaleRecordException) the executor re-runs the entire action from Phase 1 with a fresh plan.
Supporting types - ModelEvent, Repository, TransactionManager, DatabaseRegistry, ShardedUUID - are introduced in the relevant sections below.
The Big Picture
Every business change produces two things at once - a new state, and an event recording how it got there. They have to travel together:
ββββΆ STATE: balance = $0
Sara opens wallet βββ€
ββββΆ EVENT: WalletCreated
ββββΆ STATE: balance = $250
Sara deposits $250 βββ€
ββββΆ EVENT: MoneyDeposited($250)
ββββΆ STATE: balance = $150
Sara spends $100 βββ€
ββββΆ EVENT: MoneySpent($100)
The state is what your application reads. The events are what downstream systems consume - audit logs, analytics, other services. Persisting them as two separate writes - state to the database, events to Kafka - is where things break:
β Two writes β the dual-write problem
Crash between the two writes β DB and Kafka disagree |
β One write + outbox
CDC tails the outbox β events shipped later, always in sync with state |
The outbox pattern - the right-hand side - is well known and not specific to Ekbatan. What Ekbatan adds is making it easy to do: the outbox table, the row schema, and the write path are part of the framework. Applications just attach events to their domain objects, and the framework persists state-and-events together.
Visually, every action's commit looks like this - one transaction can touch as many domain tables as the action needs, plus the outbox:
βββββββββββββββββββ ONE DATABASE TRANSACTION βββββββββββββββββββββββ
β β
β ββββββββββββββββββ ββββββββββββββββββ ββββββββββββββββββββββ β
β β wallets β β orders β β eventlog.events β β
β β (UPDATE) β β (INSERT) β β (INSERT) β β
β ββββββββββββββββββ€ ββββββββββββββββββ€ ββββββββββββββββββββββ€ β
β β id β β id β β id β β
β β balance β β wallet_id β β action_id β β
β β version β β amount β β event_type β β
β β ... β β status: placed β β payload (JSONB) β β
β ββββββββββββββββββ β ... β β ... β β
β ββββββββββββββββββ ββββββββββββββββββββββ β
β domain domain outbox β
β β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
commit (all rows persist) -or- rollback (nothing persists)
Placing an order, for example, might insert a new orders row, debit the wallets row, and insert one eventlog.events row per emitted event - all in the same atomic write. Whatever the action stages on its ActionPlan, the executor flushes together.
Around this core, the framework provides multi-database support, optional horizontal sharding, configurable retry policies, and built-in observability. Each capability is opt-in and adds no overhead when unused.
Capabilities
Each topic links to a focused deep-dive doc with the full surface area, schema, and examples.
Get started
- Wiring without DI - full plain-Java end-to-end snippet, every line explained
- Domain classes & the
@Ekbatan*annotations - the four annotations and five domain classes that span every DI container - Wiring with Spring Boot - one starter dep +
@Ekbatan*annotations - Wiring with Quarkus - extension + build-step / native specifics
- Wiring with Micronaut - integration jar + compile-time visitor
- Building with Gradle - per-stack
build.gradle.ktsblocks, AP wiring,dev.monosoul.jooq-dockerplugin, optional add-ons - Building with Maven -
pom.xmlwalkthrough, AP wiring, thefabric8 docker + flyway-maven + jooq-codegen-mavenchain
Core
- The outbox: atomic state + events - the framework's atomic state-and-events guarantee
- Actions, ActionPlan, ActionExecutor - the two-phase lifecycle, retries, no nesting, single-threaded perform
- Models and Entities - when to use which, immutability,
@AutoBuilder, optimistic locking
Database
- Repositories on JOOQ -
db()/readonlyDb()/txDb()/txDbElseDb(), soft delete, custom queries - TransactionManager - direct transactional DB access outside the Action pipeline
- Outbox schema - the SQL DDL of
eventlog.events, thedeliveredflag,event_notifications, indexes - Sharding - group + member,
ShardedUUID, customShardingStrategy, cross-shard rules - Pessimistic locking via
KeyedLockProvider- five backends (Postgres, MySQL, MariaDB, Redis, in-process), reentrancy contract, caller-side acquisition pattern - Multi-database (PostgreSQL / MySQL / MariaDB) - cross-dialect cheatsheet: type mapping, init scripts, partial indexes
- PostgreSQL setup - native
UUID+JSONB, real schemas, partial indexes β the smoothest fit - MariaDB setup - native
UUID(10.7+),JSON,DATETIME(6),eventlogas a separate database - MySQL setup -
CHAR(36)UUIDs viaUuidStringConverter, cross-database GRANT foreventlog - JOOQ codegen - what codegen generates, the seven framework converters, per-dialect modeling rationale (plugin syntax: Gradle Β· Maven)
Jobs
- Distributed background jobs -
@EkbatanDistributedJob, db-scheduler-backed cluster exclusivity
Events out
- Listen-to-yourself: in-process event handlers -
@EkbatanEventHandler, fan-out + dispatch jobs, retry & expiry, idempotency - Streaming via Debezium β Kafka - JSON / Avro / Protobuf SMTs, the router, topic naming
Observability & native
- OpenTelemetry tracing - span hierarchy, attributes, retry events
- GraalVM native-image - auto-loading Features, scan-package overrides, framework-specific notes
Where Ekbatan fits
Ekbatan is not a replacement for Spring, Quarkus, or Micronaut. HTTP, dependency injection, configuration, security - those concerns remain with the host framework.
Ekbatan is a replacement for the persistence layer typically built with Spring Data, Hibernate, JPA, MyBatis, or hand-rolled JDBC + transaction management. It is intended for applications that need:
- writes to a relational database with strong transactional guarantees,
- a reliable audit trail of business changes,
- propagation of changes to downstream consumers - via Kafka (Debezium SMT pipeline) or in-process (fan-out + handling jobs) - without dual-write coordination.
Non-goals
- Nested or composable actions. The action boundary is the transaction boundary; cross-action orchestration belongs above the framework.
- Saga orchestration. Cross-service workflows are the responsibility of the layer above this framework. Ekbatan can still be used to build saga-style workflows from committed actions and outbox events.
- Reactive runtime. Concurrency is handled by Java 25 virtual threads.
- Bridging to Spring's
@Transactional/PlatformTransactionManager. Ekbatan owns its ownTransactionManager. Code outside an Action that needs database transactions should use the host framework's facilities directly on its own datasource.
Stack & requirements
- Java 25 - required (uses
ScopedValue, records, recent language features). The Kafka Connect SMT plugins target Java 21 to match the Connect runtime; everything else targets 25. - JOOQ 3.20 - type-safe SQL.
- HikariCP 7 - connection pooling.
- PostgreSQL, MySQL, or MariaDB - dialect differences handled internally.
- (Optional) OpenTelemetry SDK - for tracing.
- (Optional) Debezium + Kafka Connect - for event streaming.
- (Optional) Redis (Redisson) - for the distributed
KeyedLockProvider.
Examples & runnable references
Two directories, two audiences. Read this section if you want to copy code into your own app.
ekbatan-examples/ β start here
Standalone runnable applications that consume Ekbatan from Maven Central, exactly the way you would in your own project. Each subdirectory is its own Gradle/Maven project with its own wrapper; clone, cd, build, run. The layout is a uniform 3 stacks Γ 2 build tools Γ 3 dialects grid β every combination of (Spring Boot / Quarkus / Micronaut) Γ (Gradle / Maven) Γ (PostgreSQL / MariaDB / MySQL), each with a JVM and a GraalVM-native variant β plus a handful of specialized examples for sharding, sagas, and a no-HTTP job worker.
| Pattern | Spring Boot wallet | Quarkus wallet | Micronaut wallet |
|---|---|---|---|
| Framework Flyway extension | spring-boot-starter-flyway + @FlywayDataSource @Bean DataSource |
quarkus-flyway + FlywayConfigurationCustomizer (CDI) |
micronaut-flyway + FlywayConfigurationCustomizer @Named("default") |
| HTTP serialization | spring-boot-starter-web (Jackson via auto-config) |
quarkus-rest-jackson (pulls quarkus-jackson) |
micronaut-serde-jackson + @Serdeable (compile-time serdes) |
| Native-image | nativeTest (Spring AOT + GraalVM Build Tools) |
testNative (Quarkus native pipeline) |
nativeTest (Micronaut + GraalVM Build Tools) |
Per-stack starting points (every one has 6 sibling DB / build-tool variants β see ekbatan-examples/README.md for the full grid):
| Example | What it demonstrates |
|---|---|
spring-boot-wallet-rest-gradle-pg |
Spring Boot wallet β REST + 4 Actions + listen-to-yourself handler + caller-side KeyedLockProvider + Testcontainers test, using spring-boot-starter-flyway |
quarkus-wallet-rest-gradle-pg |
Quarkus wallet β same surface, using quarkus-flyway + a CDI FlywayConfigurationCustomizer |
micronaut-wallet-rest-gradle-pg |
Micronaut wallet β same surface, using micronaut-flyway + a @Named("default") customizer and micronaut-serde-jackson |
spring-boot-wallet-rest-gradle-sharded-pg |
Multi-shard Spring Boot wallet β 2 Postgres instances, ShardedUUID, and WalletTransferAction as an allowCrossShard(true) mechanics demo with one independent transaction per shard; use the saga example for production transfer workflows |
spring-boot-wallet-rest-gradle-native-sharded-pg |
Native-image sibling of the multi-shard Spring Boot wallet β same two-shard runtime path, plus ekbatan-native, native migration resources, and nativeTest coverage |
spring-boot-wallet-saga-gradle-pg |
Saga pattern β InitiateTransferAction β CompleteTransferAction β RefundTransferAction chained by @EkbatanEventHandlers, forward-only compensation |
spring-boot-job-worker-gradle-pg |
@EkbatanDistributedJob as the primary feature β no HTTP, spring.main.web-application-type=none, two jobs running end-to-end |
Use these as the template when wiring Ekbatan into your own project. They show the canonical framework-native dep choices (quarkus-flyway, spring-boot-starter-flyway, or ekbatan-flyway when you need a programmatic one-or-many-shard migrator) and the hooks each framework uses to feed connection coordinates from ekbatan.sharding.* into Flyway.
ekbatan-integration-tests/ β framework's own smoke tests
These are not examples in the "copy me" sense β they're the framework's own integration test suite, exercising ekbatan-core, ekbatan-events:local-event-handler, ekbatan-distributed-jobs, the four KeyedLockProvider backends, and the three Debezium SMT serializers directly. Each runs against real Testcontainers and produces real coverage; together they're the green-light check before a release. They deliberately do not use a DI framework (except where the test target is the DI integration itself), and they call raw Flyway via FlywayMigrator.migrate(url, user, pass) β see Flyway on native for why that's right for these tests and usually not the first choice for framework apps.
| Subproject | What it covers |
|---|---|
postgres-simple/ |
Single-database wallet β the smallest end-to-end action+repository+executor smoke test |
postgres-sharded/ |
The same wallet sharded across multiple Postgres instances, with cross-shard tests |
core-repo/{pg,mysql,mariadb}/ |
Repository CRUD coverage across all three supported databases (81 tests per dialect) |
keyed-lock-provider/{pg,mariadb,mysql,redis}/ |
KeyedLockProvider implementations and reentrancy/timeout coverage |
distributed-jobs-pg/ |
JobRegistry + DistributedJob cluster-exclusive scheduling |
event-pipeline/ |
End-to-end Debezium β Kafka pipeline with JSON, Avro (SMT), and Protobuf (SMT) variants |
local-event-handler/{shared,pg,mariadb,mysql}/ |
In-process consumer (fan-out + handling jobs) on PG / MariaDB / MySQL |
di/{spring-boot-starter,quarkus,micronaut}/ |
DI integration smoke tests for the framework's auto-config / extension / visitor |
Every test module also has a nativeTest task (Gradle) β the full 402-test suite passes against the compiled native binary, validating the GraalVM substitutions, reachability metadata, and FlywayMigrator's native-image classpath migration path end-to-end.
Building & testing
./gradlew build # full build (includes spotlessApply)
./gradlew test # all tests
./gradlew spotlessApply # format
Tests use TestContainers and require Docker to be running.