Ekbatan
event-driven
java
persistence
framework.

v0.1.0 · Apache 2.0 · Java 25+

Ekbatan is a Java persistence framework for event-driven systems. One database transaction commits your data and the domain events; the events are then reliably shipped from the events outbox table to Kafka or any event broker.

A replacement for Hibernate, Spring Data, or hand-rolled JDBC. Drops into Spring Boot, Quarkus, or Micronaut — or plain java.

01/The dual-write trap

Two writes. Two systems. One silent drift.

Your service inserts a row into the database, then publishes an event to Kafka. Two independent operations across two systems. If the second fails — Kafka outage, network blip, service crash — the row is committed but the event is lost. Your database and your event stream silently disagree.

Two writes

✗ broken

Crash between writes ⇒ DB and Kafka disagree.

The solution is a known pattern: the Transactional Outbox. Write the row AND the event into the same database transaction — both commit or both roll back. A separate process (a CDC tool like Debezium, a background job, or similar) drains the outbox table to Kafka, retrying until delivery succeeds.

Ekbatan makes adopting this pattern hassle-free. No boilerplate, no glue code, no outbox plumbing to maintain. The publish step stays decoupled by design — pair it with Debezium or any CDC tool of your choice.

One write + outbox

✓ Ekbatan
app
database (one tx)
state
events
Kafka
consumer

CDC tails the outbox — events ship later, always in sync.

learn by example
01/Model

MODEL is Immutable. Attaches its own events upon change.

A domain object that emits events when it mutates. deposit(amount) returns a new Wallet with a WalletMoneyDepositedEvent attached inside the same builder call. State and event coupled at the source — never two writes. Don't need events for a table? Extend Entity instead — same persistence surface, no event emission.

Wallet.java
java
1 @AutoBuilder
2 public final class Wallet extends Model<Wallet, …> {
3 public final BigDecimal balance;
4 // …
5
6 public Wallet deposit(BigDecimal amount) {
7 return copy()
8 .withEvent(new WalletMoneyDepositedEvent(id, amount))
9 .balance(balance.add(amount))
10 .build();
11 }
12 }
learn by example
02/Action

ACTION Reads, mutates, PLAN the changes. ActionExecutor will persist the PLANNED changes.

A unit of business work. perform() reads from a repository, mutates the model, stages the new version on plan(). No transaction handling, no direct writes. Once perform() returns, ActionExecutor opens one database transaction and persists everything atomically — domain rows AND the matching events in the outbox table. All of it commits, or all of it rolls back.

WalletDepositAction.java
java
1 @EkbatanAction
2 public class WalletDepositAction extends Action<Params, Wallet> {
3
4 protected Wallet perform(Principal p, Params params) {
5 var wallet = walletRepository.getById(params.walletId());
6 return plan().update(wallet.deposit(params.amount()));
7 }
8 // …
9 }
learn by example
03/Action Executor

ACTION EXECUTOR runs the action. Wherever you call it from.

The framework's single entry point. Inject it via DI and call execute(SomeAction.class, params) from anywhere — a Spring @RestController (shown), a Quarkus resource, a scheduled job, a CLI command. The executor handles discovery, perform(), the transaction, and the atomic commit.

WalletController.java
java
1 @RestController
2 @RequestMapping("/wallets")
3 public class WalletController {
4
5 private final ActionExecutor executor;
6 // …
7
8 @PostMapping("/{id}/deposit")
9 public Wallet deposit(@PathVariable UUID id, @RequestBody Body body) throws Exception {
10 return executor.execute(() -> "rest-user", WalletDepositAction.class,
11 new Params(Id.of(Wallet.class, id), body.amount()));
12 }
13 }
learn by example
04/Repository

jOOQ-backed. Thin extension.

A short subclass of ModelRepository. Inherits getById / add / update; custom queries written in the typed jOOQ DSL when you need them — no JPA, no annotations soup.

WalletRepository.java
java
1 @EkbatanRepository
2 public class WalletRepository extends ModelRepository<Wallet, …> {
3
4 public List<Wallet> findAllByOwnerId(UUID ownerId) {
5 return readonlyDb()
6 .selectFrom(WALLETS)
7 .where(WALLETS.OWNER_ID.eq(ownerId))
8 .fetch(this::fromRecord);
9 }
10 // …
11 }
ATOMICOUTBOX-NATIVEJAVA 25JOOQVIRTUAL THREADSMULTI-DATABASESHARDEDSPRING · QUARKUS · MICRONAUT · PLAIN JAVAPOSTGRES · MARIADB · MYSQL
ATOMICOUTBOX-NATIVEJAVA 25JOOQVIRTUAL THREADSMULTI-DATABASESHARDEDSPRING · QUARKUS · MICRONAUT · PLAIN JAVAPOSTGRES · MARIADB · MYSQL