§ reference

ModelEvent

A domain event emitted by a Model. Subclasses describe state transitions worth recording in the event stream — created, deposited, transferred, closed, etc. — and become rows in the eventlog outbox when the action commits.

Type signature

package io.ekbatan.core.domain;

public abstract class ModelEvent<MODEL> implements Serializable {

    public final String modelId;        // source model's id, as String
    public final String modelName;      // source model class's simple name

    protected ModelEvent(String modelId, Class<MODEL> modelClass);
}

The type parameter MODEL ties the event to the model class that emits it (compile-time check — you can’t accidentally attach a WalletMoneyDepositedEvent to an Order). Subclasses add the payload fields specific to the event.

How events become outbox rows

Events are attached to a Model via the builder’s withEvent(...) method. ChangePersister extracts them when the action commits and persists them through the configured EventPersister. The persistence row carries modelId and modelName so downstream consumers can correlate events back to their source aggregate without inspecting the payload.

Action.perform()             ActionExecutor.persistChanges()
─────────────                ─────────────────────────────
wallet.deposit(amount)       ChangePersister.persist(...)
  ▼                            │
attaches                       ├─ writes wallet UPDATE row
WalletMoneyDepositedEvent      └─ writes eventlog.events INSERT row
to wallet.events                    │     id, namespace,
  ▼                                 │     model_id, model_name,
plan().update(wallet)               │     event_type, payload,
                                    │     action_id, ...
                                    └─ COMMIT (one transaction)

Writing an event

Concrete subclasses are typically simple data carriers — Java records or hand-written value classes — that serialize cleanly to JSON / Avro / Protobuf via the wire-format modules:

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

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

Don’t put mutable state, transient references, or circular object graphs in events. They serialize to JSON / Avro / Protobuf and may be read back hours or years later by consumers that never had access to your in-process object graph.

Attaching one

Always inside Model.copy().withEvent(...), inside an action’s perform() (not in a constructor, not in a service method — actions are the only place events legitimately enter the outbox):

public Wallet deposit(BigDecimal amount) {
    var newBalance = balance.add(amount);
    return copy()
            .withEvent(new WalletMoneyDepositedEvent(id, amount, newBalance))
            .balance(newBalance)
            .build();
}

Consumers

Two paths read these rows after they’re committed:

PathModuleHow it consumes
In-process EventHandlerslocal-event-handlerTwo DistributedJob workers drain eventlog.events and invoke typed EventHandler<E> beans with delivered=true flips. No broker.
Debezium → KafkastreamingCDC tails the table, optionally applies the OutboxToAvro / OutboxToProtobuf SMTs, and ships rows to per-event-type Kafka topics.

Both can run against the same outbox simultaneously.

See also