Ekbatan — Event-driven Java Persistence Framework<br>Menu GitHub ↗<br>Ekbatan event-driven java persistence framework.<br>v0.2.1 · Apache 2.0 · Java 25+<br>Ekbatan is a Java persistence framework for event-driven systems. One database transaction commits your data and the domain events; the persisted events can then be drained from the events outbox table to Kafka or any event broker.<br>A replacement for Hibernate, Spring Data, or hand-rolled JDBC. Drops into Spring Boot, Quarkus, or Micronaut — or plain java.<br>Get started → Star on GitHub
01/The dual-write trap<br>Two writes. Two systems. One silent drift.<br>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<br>✗ broken<br>app<br>dbState saved<br>kafkaPublish failed<br>consumerNo events
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.<br>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<br>✓ Ekbatan<br>app<br>database (one tx)<br>state<br>events
Kafka<br>consumer
CDC tails the outbox — events ship later, always in sync.
Read the full explanation →
learn by example<br>01/Model
MODEL is Immutable. Attaches its own events upon change.<br>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<br>java<br>public final BigDecimal balance;<br>// …
public Wallet deposit(BigDecimal amount) {<br>return copy()<br>.withEvent(new WalletMoneyDepositedEvent(id, amount))<br>.balance(balance.add(amount))<br>.build();<br>}" aria-label="Copy code" data-astro-cid-jgrc2lfe>Copy<br>@AutoBuilder
public final class Wallet extends ModelWallet, …> {
public final BigDecimal balance;
// …
public Wallet deposit(BigDecimal amount) {
return copy()
.withEvent(new WalletMoneyDepositedEvent(id, amount))
.balance(balance.add(amount))
10<br>.build();
11
12
learn by example<br>02/Action
ACTION Reads, mutates, PLAN the changes. ActionExecutor will persist the PLANNED changes.<br>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<br>java
protected Wallet perform(Principal p, Params params) {<br>var wallet = walletRepository.getById(params.walletId());<br>return plan().update(wallet.deposit(params.amount()));<br>// …<br>}" aria-label="Copy code" data-astro-cid-jgrc2lfe>Copy<br>@EkbatanAction
public class WalletDepositAction extends ActionParams, Wallet> {
protected Wallet perform(Principal p, Params params) {
var wallet = walletRepository.getById(params.walletId());
return plan().update(wallet.deposit(params.amount()));
// …
learn by example<br>03/Action Executor
ACTION EXECUTOR runs the action. Wherever you call it from.<br>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<br>java<br>"rest-user", WalletDepositAction.class,<br>new Params(Id.of(Wallet.class, id), body.amount()));<br>}" aria-label="Copy code" data-astro-cid-jgrc2lfe>Copy<br>@RestController
@RequestMapping("/wallets")
public class WalletController {
private final ActionExecutor executor;
// …
@PostMapping("/{id}/deposit")
public Wallet deposit(@PathVariable UUID id, @RequestBody Body body) throws Exception {
10<br>return executor.execute(() -> "rest-user", WalletDepositAction.class,
11<br>new Params(Id.of(Wallet.class, id), body.amount()));
12
13
learn by example<br>04/Repository
jOOQ-backed. Thin extension.<br>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<br>java
public List findAllByOwnerId(UUID ownerId) {<br>return readonlyDb()<br>.selectFrom(WALLETS)<br>.where(WALLETS.OWNER_ID.eq(ownerId))<br>.fetch(this::fromRecord);<br>// …<br>}" aria-label="Copy...