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.
A replacement for Hibernate, Spring Data, or hand-rolled JDBC. Drops into Spring Boot, Quarkus, or Micronaut — or plain java.
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.
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.
CDC tails the outbox — events ship later, always in sync.
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.
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
}
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.
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
}
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.
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
}
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.
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
}
A progressive path through the framework: why it exists, how Actions commit state and events, how to consume events, how sharding and sagas fit, then the complete project setup when you're ready.
Every class, every config, every dialect specific. Signatures, semantics, source links. If you already know what you're looking for, start here.