§ concepts

Models and Entities

Two flavors of persistable domain object. Both are immutable, both are version-tracked, both end up in tables with a `state` column that supports soft delete. The difference is whether mutations to them produce events.

ModelEntity
id, state, versionyesyes
created_date / updated_date managed by the frameworkyesno
Mutations emit ModelEventsyesno

Choose Model when a mutation should be recorded as an event — for downstream consumers, listen-to-yourself patterns within the same service, audit and compliance trails, or any other reason a discrete record of what happened is valuable.

Choose Entity when the current state alone is sufficient and the history of individual mutations is not needed — lookup tables, configuration records, auxiliary references that participate in an action but don’t need their own event trail.

Entity does not ship framework-managed created_date / updated_date columns, but a subclass is free to add its own timestamp columns to the underlying table — the framework simply will not populate or track them automatically.

A Model

A Model is a generic class parameterized by <MODEL, ID, STATE>. Fields are public final; mutations return a new instance via the builder. Events are accumulated through withEvent(...) on the builder:

@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 static WalletBuilder createWallet(UUID ownerId, Currency currency, BigDecimal balance, Instant now) {
        final var id = Id.random(Wallet.class);
        return WalletBuilder.wallet()
                .id(id)
                .state(OPENED)
                .ownerId(ownerId)
                .currency(currency)
                .balance(balance)
                .createdDate(now)
                .withInitialVersion()
                .withEvent(new WalletCreatedEvent(id, ownerId, currency, balance));
    }

    @Override
    public WalletBuilder copy() {
        return WalletBuilder.wallet()
                .copyBase(this)
                .ownerId(ownerId)
                .currency(currency)
                .balance(balance);
    }

    public Wallet deposit(BigDecimal amount) {
        Validate.notNull(amount, "amount cannot be null");
        Validate.isTrue(amount.compareTo(BigDecimal.ZERO) > 0, "Deposit amount must be positive");

        final var newBalance = balance.add(amount);
        return copy()
                .withEvent(new WalletMoneyDepositedEvent(id, amount, newBalance))
                .balance(newBalance)
                .build();
    }

    public Wallet close() {
        if (state.equals(CLOSED)) {
            return this;
        }
        return copy()
                .withEvent(new WalletClosedEvent(id))
                .state(CLOSED)
                .build();
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        if (!super.equals(o)) return false;
        Wallet wallet = (Wallet) o;
        return ownerId.equals(wallet.ownerId)
                && currency.equals(wallet.currency)
                && balance.compareTo(wallet.balance) == 0;
    }
}

Two patterns to call out:

ModelEvent

ModelEvent<M> is an abstract parent for domain events. Subclasses carry domain-specific fields:

public class WalletMoneyDepositedEvent extends ModelEvent<Wallet> {
    public final BigDecimal amount;
    public final BigDecimal newBalance;

    public WalletMoneyDepositedEvent(ModelId<UUID> walletId, BigDecimal amount, BigDecimal newBalance) {
        super(walletId.getId().toString(), Wallet.class);
        this.amount = amount;
        this.newBalance = newBalance;
    }
}

Events are persisted via Jackson — the public final fields are serialized as JSON in eventlog.events.payload.

Deserialization (for in-process handlers)

When events are read back from the outbox by the local-event-handler dispatch job to invoke a typed EventHandler<E>, Jackson reconstructs them. If your event has constructor parameters or non-default field names, add a @JsonCreator constructor:

public class WidgetCreatedEvent extends ModelEvent<Widget> {
    public final String name;
    public final String color;

    public WidgetCreatedEvent(Id<Widget> widgetId, String name, String color) {
        super(widgetId.getValue().toString(), Widget.class);
        this.name = name;
        this.color = color;
    }

    @JsonCreator
    private WidgetCreatedEvent(
            @JsonProperty("modelId") String modelId,
            @JsonProperty("modelName") String modelName,
            @JsonProperty("name") String name,
            @JsonProperty("color") String color) {
        super(modelId, Widget.class);
        this.name = name;
        this.color = color;
    }
}

Without the @JsonCreator, Jackson can’t rebuild the event and the dispatch job will fail to invoke the handler. Streaming-only consumers (Debezium → Kafka) don’t need this — they read JSON directly off the broker.

Optimistic locking, always

Every persistable carries a version. Updates always include WHERE version = ?:

UPDATE wallets
SET balance = ?, version = ?
WHERE id = ? AND version = ?

If another transaction got there first, zero rows are affected and Ekbatan throws StaleRecordException. The action’s transaction unwinds. The default ExecutionConfiguration retries the whole action once with a 100ms delay (tunable — see Actions).

The framework’s own write path takes no pessimistic row locks. Concurrent conflicts surface as StaleRecordException rather than blocked threads. When pessimistic locking is genuinely required, use KeyedLockProvider, or issue SELECT ... FOR UPDATE directly through the DSLContext.

Soft deletion

By default, records are never physically removed. Their state flips to DELETED and queries automatically filter them out, keeping the history of every row intact. When a physical delete is genuinely required — to honor an erasure request or purge expired data — applications can issue the DELETE directly through the JOOQ repository.

@AutoBuilder — generated builders

Domain classes use the builder pattern. Writing the builder by hand is repetitive — it just mirrors the model’s fields. The @AutoBuilder annotation processor eliminates the boilerplate at compile time:

@AutoBuilder
public final class Widget extends Model<Widget, Id<Widget>, WidgetState> {
    public final String name;
    public final String color;
    // … constructor, factories, copy(), business methods
}

The processor generates WidgetBuilder in the same package:

@Generated("io.ekbatan.core.processor.AutoBuilderProcessor")
public final class WidgetBuilder
        extends Model.Builder<Id<Widget>, WidgetBuilder, Widget, WidgetState> {

    String name;
    String color;

    private WidgetBuilder() {}

    public static WidgetBuilder widget() { return new WidgetBuilder(); }

    public WidgetBuilder name(String name)   { this.name = name; return this; }
    public String        name()              { return this.name; }

    public WidgetBuilder color(String color) { this.color = color; return this; }
    public String        color()             { return this.color; }

    @Override
    public Widget build() { return new Widget(this); }
}

What’s generated:

What’s not generated — write these on the model yourself:

The annotation has source retention; nothing of @AutoBuilder survives in the bytecode.

Inherited builder methods

Both Model.Builder and Entity.Builder provide a shared set of methods on the base. They use a self-type pattern (B self()) so the return type stays as the concrete builder — WalletBuilder, not Model.Builder.

MethodOn Model.BuilderOn Entity.Builder
id(ID)yesyes
state(STATE)yesyes
version(Long) / withInitialVersion() / increaseVersion()yesyes
withEvent(ModelEvent) / events(List<ModelEvent>)yes
createdDate(Instant) / updatedDate(Instant)yes
copyBase(M)yesyes

copyBase(this) is what model.copy() typically returns first — it pre-populates the builder with the model’s id, state, version, events list (model only), createdDate (model only), and updatedDate (model only) so domain code only has to override the fields that changed.

Naming conventions

See also