Ekbatan – Java persistence framework for event-driven systems

unikzforce1 pts0 comments

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...

wallet java event amount params events

Related Articles