§ reference

Domain classes & the `@Ekbatan*` annotations

Four annotations and five domain classes — that's everything you write to wire up Ekbatan in a DI-managed app. The annotations are pure markers; your classes don't depend on Spring, Quarkus, or Micronaut. The same source compiles and runs identically against all three.

What each DI container does under the hood to find these annotated classes — Spring’s auto-config + AOT processor, Quarkus’ Jandex build steps, Micronaut’s compile-time visitor — lives on the per-framework pages: spring.md, quarkus.md, micronaut.md. If you don’t use a DI container at all, the same domain classes work without the annotations — see wiring/without-di.md.

The four annotations

AnnotationApply toWhat it tells the DI integration
@EkbatanActionAn Action<P, R> subclassDiscover this class, register it as a framework-private singleton, and add it to ActionRegistry so ActionExecutor.execute(...) can find it.
@EkbatanRepositoryAn AbstractRepository<…> subclassDiscover and register as a managed DI bean; inject into RepositoryRegistry keyed by domain class.
@EkbatanEventHandlerAn EventHandler<E> implementationRegister as a managed DI bean; add to EventHandlerRegistry (only effective when the local-event-handler module is on the classpath).
@EkbatanDistributedJobA DistributedJob subclassRegister as a managed DI bean; add to JobRegistry (only effective when the distributed-jobs module is on the classpath).

All four are pure markers (@Target(TYPE) @Retention(RUNTIME), no parameters) defined in io.ekbatan.di:annotations. They carry no behavior themselves; they exist so each DI integration can find your classes without classpath scanning every type.

@AutoBuilder (used on Model/Entity subclasses) is not one of the @Ekbatan* DI annotations — it’s a compile-time builder generator, completely independent of DI. See Models and Entities → @AutoBuilder.

The five domain classes

The running example: a Wallet model that supports deposits and closes, an action that performs deposits, a repository, an in-process event handler that reacts to deposits, and a daily report job. Five classes, five annotations (@AutoBuilder + four @Ekbatan*).

Wallet — the Model

@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 createdDate) {
        final var id = Id.random(Wallet.class);
        return WalletBuilder.wallet()
                .id(id)
                .state(OPENED)
                .ownerId(ownerId)
                .currency(currency)
                .balance(balance)
                .createdDate(createdDate)
                .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;
    }
}

WalletDepositAction — the Action

@EkbatanAction registers it with ActionRegistry. Constructor params are resolved by the DI container.

@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);
    }
}

WalletRepository — the Repository

@EkbatanRepository registers it as a managed bean and into RepositoryRegistry.

@EkbatanRepository
public class WalletRepository extends ModelRepository<Wallet, WalletsRecord, Wallets, UUID> {

    public WalletRepository(DatabaseRegistry databaseRegistry) {
        super(Wallet.class, WALLETS, WALLETS.ID, databaseRegistry);
    }

    @Override
    public Wallet fromRecord(WalletsRecord r) {
        return WalletBuilder.wallet()
                .id(Id.of(Wallet.class, r.getId()))
                .version(r.getVersion())
                .state(WalletState.valueOf(r.getState()))
                .ownerId(r.getOwnerId())
                .currency(Currency.getInstance(r.getCurrency()))
                .balance(r.getBalance())
                .createdDate(r.getCreatedDate())
                .updatedDate(r.getUpdatedDate())
                .build();
    }

    @Override
    public WalletsRecord toRecord(Wallet w) {
        return new WalletsRecord(
                w.id.getValue(), w.version, w.state.name(),
                w.ownerId, w.currency.getCurrencyCode(), w.balance,
                w.createdDate, w.updatedDate);
    }
}

WalletMoneyDepositedEventHandler — the EventHandler

@EkbatanEventHandler registers it with EventHandlerRegistry. Only effective when the local-event-handler module is on the classpath.

@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);
    }
}

DailyWalletReportJob — the DistributedJob

@EkbatanDistributedJob registers it with JobRegistry. Only effective when the distributed-jobs module is on the classpath.

@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"; }
    @Override public Schedule schedule() { return Schedules.daily(LocalTime.of(2, 0)); }

    @Override
    public void execute(ExecutionContext ctx) {
        reportService.generateAndSend();
    }
}

What gets exposed as injectable beans

Once these five classes are on your classpath and your DI container is wired up (per spring.md, quarkus.md, or micronaut.md), the following beans are available for @Inject / @Autowired anywhere in your application:

BeanAlways availableNotes
ActionExecutoryesThe thing application code calls
DatabaseRegistryyesDirect shard / DSLContext access if you need it
RepositoryRegistryyesCross-type repository lookup
ClockyesDefaults to Clock.systemUTC(), overridable in tests
Each @EkbatanRepository instanceyesInject by its concrete class
EventHandlerRegistryonly with local-event-handler on classpath
EventFanoutJobonly with local-event-handler on classpathAuto-registered into JobRegistry
EventHandlingJobonly with ekbatan.local-event-handler.handling.enabled=trueOpt-in (off by default)
JobRegistryonly with distributed-jobs on classpath
EventPersisternot auto-registered — define your own bean to override the executor’s default SingleTableJsonEventPersister (e.g. for encrypted payloads or an alternate sink)

The Action instances are intentionally not exposed as DI beans — see the per-framework pages for the rationale.

See also