Java Clean Architecture Masterclass

Java Clean Architecture Masterclass28-29 May

Join

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.

Maven Central Java 25+ License: Apache 2.0

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

Two writes: state saved, publish failed, no events
Crash between the two writes β‡’ DB and Kafka disagree
βœ“ One write + outbox

One write plus outbox: app writes state and events in one transaction, CDC ships events to the broker
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

Core

Database

Jobs

Events out

Observability & native


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:


Non-goals


Stack & requirements


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.


Documentation

Join libs.tech

...and unlock some superpowers

GitHub

We won't share your data with anyone else.