Eclipse Plugin Developer Guide

Plugins extend the Eclipse platform at runtime, without rebuilding or redeploying any service.
A plugin is an ordinary JAR uploaded through the Plugin API. The runtime scans it, loads it in an
isolated, security-restricted class loader, wires it into CDI-like injection, and hot-reloads it
within ~10 seconds — no restart.


1. Plugin categories

There are five extension mechanisms:

CategoryWhat you writeWired in by
Injection-point pluginsA bean with @OnNamedInjectionPoint methodsMethod annotation; auto-discovered
Implementation pluginsA class extending a Pluggable* base (wallet type, gateway, processor, fraud handler, ratify processor)implementationClass config / type registration
Provider pluginsA bean annotated @ExtensionProvider(type=…, name=…)The annotation; resolved by (type, name)
Batch pluginsA bean implementing ILocalBatchJobAuto-registered as a scheduled local batch
Wallet listener pluginsA class extending PluggableWalletListener (wallet service only)setSuperClass() delegation

A single JAR may contain any mix of these. It must contain exactly one class annotated with
@PluginDescriptor.


2. Core annotations

All three annotations live in com.ukheshe.arch.plugin.

@PluginDescriptor — required, class-level

@Retention(RUNTIME) @Target(TYPE)
public @interface PluginDescriptor {
    String name();                    // required, unique across all loaded plugins
    String version() default "1.0";
    String description() default "";
}

Exactly one class per JAR carries it (loading fails with "more than one PluginDescriptor" if two
do, or "no class annotated with @PluginDescriptor" if none). It is usually an empty marker class.
name() is the plugin's identity: re-uploading a JAR with the same name upgrades the existing
plugin in place (config and tenant mappings are preserved/merged).

@OnNamedInjectionPoint — method-level

@Retention(RUNTIME) @Target(METHOD)
public @interface OnNamedInjectionPoint {
    String injectionPoint();          // e.g. "wallet.postWalletTransfer"
    int priority() default 0;         // higher runs first
    boolean throwExceptions() default false;
}
  • priority — handlers for the same injection point run in descending priority order
    (ties keep registration order).
  • throwExceptions — by default a handler that throws is swallowed: the exception is
    recorded in the result and reported via UK.Exceptions.processException(...), and the remaining
    handlers still run. Set throwExceptions = true to make the throw propagate to the caller and
    abort the operation. This is how validator plugins veto an action.
  • The method must be public and non-static (validated at load; otherwise the handler is
    skipped with an error).

@ExtensionProvider — class-level

@Retention(RUNTIME) @Target(TYPE)
public @interface ExtensionProvider {
    String type();
    String name();
}

Registers a CDI-scoped plugin bean as a named provider. Platform code retrieves it with
UK.Plugins.getPluginBean(Clazz.class, type, name, tenantId) or
getPluginClass(type, name, tenantId). The (type, name) pair must be unique across all loaded
plugins. Used for tenant-integration processors:

@ApplicationScoped
@ExtensionProvider(type = "TenantIntegrationProcessor", name = "pcb")
public class PcbTenantIntegration implements ITenantIntegrationPlugin {
    public String doGenericIntegration(long tenantId, String postedJson) { … }
}

Resolution honours the plugin's tenant mapping and enabled state — getPluginBean throws if the
provider isn't on for the requested tenant or has been toggled off.


3. Packaging & building a plugin JAR

A plugin is a plain JAR — no main class. Plugins build against the eclipse parent POM and
depend on the individual service/model artifacts they touch:

<parent>
  <groupId>com.ukheshe</groupId>
  <artifactId>eclipse</artifactId>
  <version>5.0.3615</version>   <!-- latest in ~/.m2/repository/com/ukheshe/eclipse/ -->
</parent>
<artifactId>uk-plugin-my-thing</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>

<dependencies>
  <dependency><groupId>com.ukheshe</groupId><artifactId>uk-services-wallet-service</artifactId></dependency>
  <dependency><groupId>com.ukheshe</groupId><artifactId>uk-services-wallet-model</artifactId></dependency>
  <!-- add fraud / payment / loan / user service+model deps as needed -->
</dependencies>

<build><plugins>
  <plugin>
    <artifactId>maven-compiler-plugin</artifactId>
    <version>3.14.0</version>
    <configuration><release>25</release></configuration>
  </plugin>
</plugins></build>
cd plugin-<name>
mvn package -DskipTests          # JAR → target/uk-plugin-<name>-1.0.0.jar

The platform classpath provides all platform (com.ukheshe.*) classes at runtime, so never bundle
them in your JAR.

plugin-sdk.jar (lighter alternative). The repo root contains plugin-sdk.jar, built by
build-plugin-sdk.sh. That script scans every fireInjectionPoint(...) call site, collects the
non-JDK context-argument types, and bundles them with the core plugin API. It's a lighter
compile-time dependency if you only write injection-point handlers and don't want the full service
jars. Reference it with provided scope.

Class-loader sandbox (important)

Each plugin is loaded by a restricted class loader that only resolves:

  1. Classes contained in the plugin JAR itself, and
  2. Classes in a fixed whitelist of packages:
    java.lang., java.util., java.math., java.time., jakarta.persistence.,
    jakarta.ws.rs., jakarta.annotation., jakarta.inject., jakarta.enterprise.context.,
    org.slf4j., and com.ukheshe.

Anything else throws ClassNotFoundException: Class not on plugins whitelist. In addition, a
blacklist hard-blocks dangerous classes even from the JDK:
java.lang.ProcessBuilder, java.lang.System, java.lang.Runtime, java.lang.Thread,
java.lang.reflect.*, java.io.File, java.net.Socket, java.net.URL, java.net.URLClassLoader,
and everything under sun., com.sun., jdk.internal..

Practical consequences:

  • No reflection, threads, file I/O, sockets, process spawning, or arbitrary networking from
    plugin code. Use the injected REST clients / framework facades instead.
  • A bundled third-party library will load only if its transitive class references stay inside the
    whitelist — most won't. Keep plugins to platform-provided capabilities.
  • Each plugin's class loader is keyed by a SHA-256 of the JAR. Re-uploading identical bytes is a
    no-op; changed bytes trigger an unload + reload.

Instantiation contract

  • Every plugin bean needs a visible no-arg constructor ("needs a no-arg constructor" otherwise).
  • After construction the framework sets @Inject fields (see §5), then invokes any @PostConstruct
    method.

4. CDI scopes & lifecycle

A class is treated as a plugin bean if annotated with one of @Singleton, @ApplicationScoped,
@RequestScoped, or @Dependent. The scope controls instance lifetime:

ScopeInstance lifetime
@Singleton / @ApplicationScopedOne instance for the whole time the plugin is loaded (created eagerly at load)
@RequestScopedOne instance per request context (stored on the RequestContext)
@DependentA fresh instance on every use

Injection-point handler classes are typically @Singleton. Wallet types and gateways are typically
@Dependent (a new delegating instance per transaction). Every pluggable bean is also recorded in
the beans section of the plugin config (see §11) so it can be individually toggled.


5. @Inject support

Field @Inject is supported for a fixed, whitelisted set of types, resolved lazily at instantiation:

Injectable typeWhat you get
jakarta.persistence.EntityManagerThe runtime JPA entity manager
com.ukheshe.arch.rest.RequestContextCurrent request metadata (tenant, user, session, properties)
com.ukheshe.arch.rest.CallAsUserJsonRestClientREST client acting as the current user
com.ukheshe.arch.rest.CallAsSystemJsonRestClientREST client acting as the system
com.ukheshe.arch.rest.AdvancedJsonRestClientLower-level REST client
com.ukheshe.arch.plugin.PluginConfigThis plugin's live config (see §11)
@Singleton
public class MyHandlers {
    @Inject EntityManager em;
    @Inject RequestContext ctx;
    @Inject PluginConfig config;
    …
}
  • Injection happens before @PostConstruct, so post-construct code can use the fields.
  • An @Inject field of any other type fails loudly at load: "is @Inject but type […] is not
    injectable into plugins"
    . You get an error, not a silent null.
  • These same services are also resolvable as handler method parameters (see §6) — you can take
    them as arguments instead of injecting fields.

6. Injection-point plugins

Writing a handler

@PluginDescriptor(name = "my-plugin", version = "1.0", description = "Example")
public class MyPluginInfo { }            // marker

@Singleton
public class MyHandlers {

    @OnNamedInjectionPoint(injectionPoint = "wallet.postWalletTransfer", priority = 5)
    public void onTransfer(@Named("from") IWallet from,
                           @Named("to")   IWallet to,
                           @Named("history") List<WalletHistory> history) {
        // injection points fire for ALL wallets/tenants — filter as needed:
        if (!(from instanceof MyWalletType)) return;
        …
    }
}

Parameter resolution

For each method parameter the framework resolves an argument as follows:

  1. @Named("x") parameter → looked up by name among the NamedParams passed at the
    injection point. If the name isn't present, it falls back to resolving by type but only if the
    type is unambiguous
    (exactly one instance of that type is available). Otherwise a clear
    IllegalStateException is thrown telling you to pass NamedParam.of("x", value).
  2. Un-named parameter → resolved by type: exact match, then assignable match, then the
    global service context (the injectable services from §5). Unresolvable → IllegalStateException.

JSON coercion — when a @Named value's runtime type doesn't match the declared parameter type,
the framework serialises it to JSON and deserialises into the requested type. This lets a plugin
declare its own DTO classes (or String, Map, List, or a JsonPathDocument) for a parameter
and receive a converted copy, instead of depending on the exact platform model class. If the raw
value is already a String it is treated as the JSON payload directly.

Duplicate-type guard — if an injection point passes the same bare (non-NamedParam) type more
than once
, the framework skips all handlers for that injection point and logs a warning.
Platform code wraps such arguments in NamedParam precisely to avoid this; your handler then
distinguishes them with @Named.

Return values — PluginResults

UK.Plugins.fireInjectionPoint(point, tenantId, ctx…) returns a PluginResults collecting one
PluginResult{descriptor, pluginClass, pluginMethod, response, error} per handler. Most injection
points ignore the result (fire-and-forget listeners). A handful consume it:

HelperMeaning
getFirstNonNullPluginResponse()First non-null returned value (as Optional)
getFirstString()/getFirstBigDecimal()/getFirstLong()/…Typed convenience accessors
getAllNonNullPluginResponses()All non-null returns, priority order
throwFirstException() / throwOrElse()Re-throw the first handler error (used by sendTextSms, FX)

A handler that returns a value can supply data the platform uses (FX rate, SMS provider id, an
OSS suppression flag). See §7.


7. Injection-point catalog

Parameters list the @Named key where one is used, otherwise the type. Most injection points will only fire if they are tenant specific and the plugin is mapped to that tenant. If the injection point is not tenant specific then the plugin must be global in order to be fired..

Wallet — PluggableWalletListener

Injection pointWhenParams
wallet.preWalletCreatebefore wallet persistedWallet (newWallet)
wallet.postWalletCreateafter wallet persistedWallet (newWallet)
wallet.onWalletUpdatewallet updatedWallet
wallet.preReservationbefore a reservationBigDecimal amount, @Named sessionId, ZonedDateTime expiryDate, @Named description, @Named force
wallet.preWalletTransferbefore transfer@Named from (IWallet), @Named to (IWallet), @Named bulk, @Named scheduled
wallet.postWalletTransferafter transfer@Named from, @Named to, @Named history (List<WalletHistory>), @Named bulk, @Named scheduled
wallet.augmentTransferaugment/split a transfer@Named from, @Named to, @Named bulk, @Named scheduled
wallet.doSMSForFailedTransferSMS for failed transferTransfer, @Named from, @Named to, String failedReason
wallet.onTransactionFailureledger-level failure@Named walletId, @Named otherWalletId, BigDecimal amount, @Named currency, @Named sessionId
wallet.postWalletTypeCreateafter wallet type createdWalletType
wallet.onWalletTypeUpdatewallet type updatedWalletType, DbWalletType (existing)

Payment — PluggablePaymentListener

Injection pointWhenParams
payment.preCreatePaymentbefore payment persistedNewPayment
payment.postCreatePaymentafter payment persistedPayment
payment.preCreateWithdrawalbefore withdrawal persistedWithdrawal
payment.postCreateWithdrawalafter withdrawal persistedWithdrawal
payment.postPaymentCompletepayment reaches completedPayment
payment.postWithdrawalCompletewithdrawal reaches completedWithdrawal
payment.prePaymentAcquiringbefore acquiringDbPayment

Loan — PluggableLoanListener

Injection pointWhenParams
loan.preCreateLoanbefore loan persistedNewLoan
loan.postCreateLoanafter loan persistedLoan
loan.preLoanUpdatebefore loan updatedLoan, DbLoan (existing)
loan.postLoanUpdateafter loan updatedLoan

Remittance — PluggableRemittanceListener

Injection pointWhenParams
remittance.preCreateRemittancebefore remittance persistedNewRemittance
remittance.postCreateRemittanceafter remittance persistedRemittance
remittance.preRemittanceUpdatebefore remittance updatedRemittance, DbRemittance (existing)
remittance.postRemittanceUpdateafter remittance updatedRemittance
remittance.postRemittanceCompleteremittance completedRemittance
remittance.preRemittanceAcquiringbefore acquiringRemittance

User & organisation — PluggableUserListener

Credentials are always stripped before these fire (passwords, secrets, key/info).

Injection pointWhenParams
user.onUserCreatebefore user createUser (newUser)
user.onUserCreatedafter user createUser
user.onUserUpdatebefore user updateUser (changes), DbUser (existing)
user.onUserUpdatedafter user updateUser
user.onUserDeleteuser deletedUser
user.onOrganisationCreatebefore org createOrganisation (new)
user.onOrganisationCreatedafter org createOrganisation
user.onOrganisationUpdatebefore org updateOrganisation (changes), DbOrganisation (existing)
user.onOrganisationUpdatedafter org updateOrganisation
user.onOrganisationDeleteorg deletedOrganisation
user.onUserIdentityCreateidentity createdUserIdentity (sanitised)
user.onUserIdentityDeleteidentity deletedUserIdentity
user.preAuthbefore authenticationAuthCredentials (password stripped)
user.onAuthSuccessauth succeededAuthCredentials, User
user.onAuthFailureauth failedAuthCredentials, DbUser
user.onDocumentCreate / onDocumentUpdate / onDocumentDeletedocument lifecycleDocument (binary stripped)
user.onAddressCreateaddress createdAddress
user.onAddressUpdatebefore address updateAddress (userAddress), DbAddress (existing)
user.onAddressUpdatedafter address updateAddress
user.onAddressDeleteaddress deletedAddress
user.onUserContactCreatecontact createdUserContact
user.onUserContactUpdatebefore contact updateUserContact, DbUserContact (existing)
user.onUserContactUpdatedafter contact updateUserContact (updated)
user.onUserContactDeletecontact deletedUserContact
user.onUserPositionCreate / onUserPositionDeleteposition lifecycleUserPosition
user.onUserRoleCreate / onUserRoleDeleterole lifecycleUserRole
user.onUserRatifieduser KYC ratifiedRatifyResult
user.onOrganisationRatifiedorg KYC ratifiedRatifyResult
user.onManualRatifymanual ratify submittedString ratifyResultData
user.postRatifyBatchCompletionratify batch finishedString queueName, RatifyBatchType type, Long batchId, String checks
user.onPasswordUpdatepassword changedUserIdentityPasswordChange
user.onUserIdentityPasswordChangeInitpassword-change flow startPasswordChangeInit

Attachment — PluggableAttachmentListener

Injection pointWhenParams
attachment.onGetAttachmentsattachments fetchedList<Attachment>, String fields
attachment.onAttachmentCreatedafter createAttachment
attachment.onAttachmentUpdatedafter updateAttachment
attachment.onAttachmentDeletedafter deleteAttachment

Property — PluggablePropertyListener

Property is a global resource: tenantId is always null at these points (global mapping or
explicit tenant mapping required to receive them).

Injection pointWhenParams
property.postPropertyCreateafter createProperty
property.onPropertyUpdateon updateProperty
property.onPropertyDeleteon deleteProperty
property.onPropertyGeton fetchList<DbProperty>

Postilion / card — PluggablePostilionListener

Injection pointWhenParams
postilion.onCardCreatedcard createdPostilionCard
postilion.onCardUpdatedcard updatedPostilionCardUpdate, @Named postilionCard (PostilionCard)
postilion.onStandardLimitsUpdatedstandard limits updatedPnCardStandardLimit
postilion.onAdvancedLimitsUpdatedadvanced limits updatedPnCardAdvancedLimit

Cross-cutting / framework

Injection pointWhenParamsReturn used
conductorRequestinbound Conductor request (after AAA)ContainerRequestContext— (use JaxRsUtils.replyImmediately to short-circuit)
conductorResponseoutbound Conductor responseContainerRequestContext, ContainerResponseContext
domainEventany domain event publishedDomainEvent
exceptionexception processed by handlerThrowable, List<? extends ExceptionData>
alertalert sent via UK.AlertsExceptionSeverity severity, @Named channelName, @Named message, ZonedDateTime alertDate, Throwable t, @Named traceId

Libraries — return-producing points

Injection pointWhenParamsExpected return
convertCurrencyspot FX rate needed@Named baseCurrency, @Named toCurrencyExchangeRate (via getFirstNonNullPluginResponse)
convertCurrencyHistorichistoric FX rate needed@Named baseCurrency, @Named toCurrency, @Named date (Instant)ExchangeRate
sendTextSmsplatform sends an SMS@Named cardBin, @Named to, @Named message, @Named senderId, @Named rawObjectString provider message id (via throwOrElse().getFirstString())

If no plugin returns a value, the platform's built-in provider is used.

Dynamic injection points

Two producers build the injection-point name at runtime, so the name isn't a fixed string:

  • State publisher"statePublish." + <uniqueId> (the state-publisher's configured unique id).
    Params: @Named resultRow / @Named resultSet (JSON string) and @Named config. Fired per-row
    and/or for the whole result set. tenantId = null.
  • User OSS action processors — the injection-point name is the OSS action string from the
    domain event, e.g. oss.user.update, oss.wallet.create, oss.wallet.update,
    oss.document.create/update, oss.address.create/update. Param: DomainEvent. Return
    Boolean.TRUE to suppress the platform's default KYC/processing for that action; any other
    value lets default processing proceed.

8. Implementation plugins (bidirectional delegation)

Implementation plugins replace or extend a platform component: a wallet type, a payment /
withdrawal / loan / remittance gateway
, or a payment / withdrawal processor. They all use the
same bidirectional delegation triad, because a plugin loaded at runtime cannot directly subclass a
Quarkus build-time managed bean (super.method() would hit an unmanaged instance with null
injected fields).

The triad:

  • Pluggable* — a plain POJO implementing the domain interface (IWallet, IPaymentGateway,
    …). Your plugin extends this. It holds a delegate and forwards each interface method to the
    delegate's underscore twin (method_()).
  • BiDirectionalDelegating* — an interface extending the domain interface; declares
    setSubclass(...) plus an underscore twin of every method.
  • Base* / delegate bean@Dependent @Unremovable, extends the real managed implementation
    and implements the interface. Non-underscore methods delegate down to your subclass; the
    method_() twins call the real super.method().

You wire it up in @PostConstruct:

@PluginDescriptor(name = "Day Limit Wallet", version = "1.0")
@Dependent
public class DayLimitWallet extends PluggableWallet {
    @PostConstruct void init() {
        setSuperClass(UK.Runtime.getBean(LimitCheckingWallet.class));
    }
    @Override public BigDecimal getAvailableBalance() {
        // your logic; call super.getAvailableBalance() to reach the real implementation
        return super.getAvailableBalance();
    }
}

When framework code higher up calls this.method(), it lands in the Base* bean which routes to
your subclass (overrides win). When you call super.method(), it routes through the Pluggable*
POJO → delegate's method_() → the real implementation. You get true inheritance semantics over a
CDI-managed bean.

Triads available

Plugin kindExtendsetSuperClass(...) argumentDomain interface
Wallet type…wallet.pluggable.PluggableWalletLimitCheckingWallet (impl of BiDirectionalDelegatingWallet)IWallet
Payment gateway…payment.gateway.pluggable.PluggablePaymentGatewayBasePaymentGateway (BiDirectionalDelegatingPaymentGateway)IPaymentGateway
Withdrawal gateway…payment.gateway.pluggable.PluggableWithdrawalGatewayBaseWithdrawalGatewayIWithdrawalGateway
Loan gateway…loan.gateway.pluggable.PluggableLoanGatewayBaseLoanGatewayILoanGateway
Remittance gateway…remittance.gateway.pluggable.PluggableRemittanceGatewayBaseRemittanceGatewayIRemittanceGateway
Payment processor…conductor.service.payments.PluggablePaymentProcessorStandardPaymentProcessor (direct concrete delegate)IPaymentProcessor
Withdrawal processor…conductor.service.withdrawals.PluggableWithdrawalProcessorStandardWithdrawalProcessorIWithdrawalProcessor

The two processors use a simplified variant: setSuperClass(...) takes the concrete
Standard*Processor directly and calls setSubclass(this) internally; there is no separate
BiDirectionalDelegating*Processor interface.

Wallet type — key overridable methods

init(...) is the mandatory bootstrap (sets the subclass and delegates). All other methods have a
working base implementation; override only what you need. Highlights:

getAvailableBalance(), getCurrentBalance(), getCreditLimit(), getCreditReservations(),
getDebitReservations(),
deltaBalance(amount, uniqueId, type, skipLimitChecks, isReversal, isReallocation),
reserveForTransfer(amount, currency, sessionId, expiryDate, description, uniqueId, force, cardId, info, onlyCheck),
reserveForServiceCharging(units, ReservationRequest), chargeForService(units, ChargeRequest),
getWalletLimits(limitConfigName), getAllConfig(), getConfigValue(name), and the currency
conversion helpers.

Gateway — key methods

init(Db…) bootstraps. Methods that are empty/stubbed on the Pluggable* base must be
implemented by your plugin
(no _ delegate); the rest delegate to the real base.

  • Payment gateway — implement initiatePayment(Object). Delegated: paymentModified, sync,
    cancel, reverse, expire, refund(DbRefund), getDbPayment, supportsPreProcessing,
    supportsReverse, supportsHold, getPaymentProofs(DbPayment, fields).
  • Withdrawal gateway — implement processWithdrawal(). Delegated: withdrawalModified,
    withdrawalQuote(amount), cancel, expireWithdrawal, sync, supportsPreProcessing,
    getWithdrawalLimits(tenantId, currency, gateway, type, subType).
  • Loan gateway — implement initiateLoan(Object), loanOptIn(NewLoanOptIn),
    updateLoan(Object), loanLimit(customerId, productId), getLoan(DbLoan),
    getLoans(filter, offset, limit). Delegated: loanOptOut, loanRePayment,
    supportsPreProcessing, supportsReverse, getLoanTransactions(tenantId, loanId, walletId).
  • Remittance gateway — implement initiateRemittance(Object), getRemittance(DbRemittance).
    Delegated: remittanceModified, cancel, reverse, userEnrollment, getUserEnrollmentStatus,
    updateUserEnrollment, supportsPreProcessing, supportsReverse, sync, getDbRemittance,
    getQuickQuotes(...), getConfirmationQuotes(DbRemittance).

Processor — key methods

  • Payment processor (IPaymentProcessor): enrichForCreation(NewEclipsePayment, NewPayment),
    enrichWithUpdates(UpdatedPaymentTransactionData, Payment), enrichResult(PaymentResult, Payment),
    attemptPaymentCompletion(long tenantId, Payment),
    enrichForRefund(Payment, EclipsePaymentRefund, NewPaymentRefund),
    validatePaymentReceivingWallet(NewPayment).
  • Withdrawal processor (IWithdrawalProcessor):
    enrichForCreation(Wallet, NewEclipseWalletWithdrawal, Withdrawal, Long destWalletId),
    enrichWithUpdates(Wallet, UpdatedWalletWithdrawal, Withdrawal),
    enrichResult(Wallet, WalletWithdrawalResult, Withdrawal),
    getWithdrawalFee(long tenantId, Wallet, String type, BigDecimal amount),
    getWithdrawalLimit(long tenantId, Wallet, String type).

Wiring a wallet type to its implementation class

Set implementationClass as a configuration AVP on the wallet type (mode PLUGGABLE):

curl -s -X PUT \
  "$BASE/eclipse-conductor/rest/v1/tenants/{tenantId}/wallet-types/{walletTypeId}" \
  -H "Authorization: $TOKEN" -H "Content-Type: application/json" \
  -d '{ "name":"My Wallet Type", "mode":"PLUGGABLE",
        "configuration":[ {"att":"implementationClass","val":"com.eftcorp.plugins.DayLimitWallet"} ],
        "version": <current-version> }'

9. Other implementation plugin types

Fraud event handler

Extend the abstract com.ukheshe.services.fraud.publish.pluggable.PluggableFraudEventPublishHandler
(implements FraudEventPublishHandler) and implement the one method:

@Singleton
public class MyFraudHandler extends PluggableFraudEventPublishHandler {
    @Override public FraudEventPublishResponse handle(FraudEventPublishEvent event) {
        var res = new FraudEventPublishResponse();
        res.setFraudResult(FraudEventPublishResult.NOT_FRAUD);
        return res;
    }
}

The FraudEventPublishHandler interface also provides static/default config helpers
(getRequestConfig, getResponseConfig, getFieldMappingConfig(...)) for tenant-specific field
mappings. Registration is by handler type:
fraud.event.handlers.<handlerType> = com.example.MyFraudHandler, where <handlerType> matches the
handlerType on the incoming FraudEventPublishEvent.

Ratify (KYC) item processor

Extend com.ukheshe.services.user.ratify.PluggableRatifyItemProcessor (abstract; provides injected
EntityManager/RequestContext and getRatifyInputData()). Implement the processing hook (go())
and emit a result with the supplied helpers.

SMS provider / exchange-rate converter

These two are driven purely through the return-producing injection points sendTextSms,
convertCurrency, and convertCurrencyHistoric (§7) — implement those handlers and return the
value. The platform consumes the first non-null plugin response and falls back to the built-in
provider if none is returned.


10. Batch plugins

A pluggable bean that implements com.ukheshe.arch.batch.ILocalBatchJob<T> is automatically
registered as a scheduled local batch job
when the plugin loads (and deregistered on unload) — no
extra wiring.

@Dependent
public class NightlySweep implements ILocalBatchJob<String> {
    public int  getCommitFrequency()            { return 100; }     // items per committed tx
    public int  getMaxTps()                      { return 50;  }     // throttle per node
    public void init(Map<String,Object> state)  { /* setup; throw UnsupportedOperationException to skip */ }
    public Stream<String> supplyItems()          { return …; }       // produce work items
    public void processItem(String item)         { /* per-item work */ }
    public void onCommit(String lastCommitted)   { /* after each commit batch + final item */ }
    public Map<String,Object> onCompletion()     { return Map.of(); } // persisted as next run's state
    // from IBatchJob:
    public String getCron()                       { return "0 2 * * *"; } // null = kickoff-only, no schedule
    public ZoneId getCronTimeZone()               { return UK.DateTime.getUtcTimeZone(); }
    // optional gate (default true):
    // public boolean isJobProcessingWindowOpen() { return true; }
}

Lifecycle: init runs in its own transaction (throw UnsupportedOperationException to skip the
run); items from supplyItems() are processed by a producer/consumer pool throttled to
getMaxTps(), committing every getCommitFrequency() items and calling onCommit; onCompletion()'s
returned map is persisted as the next run's state. If getCron() is non-null the runner registers a
scheduled job (groupName = "Local Batch", uniqueId = <className>); if null the job is
kickoff-only. Throughput defaults are tunable via batch.max.tps.per.node (100) and
batch.threads.per.node (1).


11. Listener plugins (wallet service only)

Internally, each listenable service is built as a four-layer listener chain:

<Service>Listener            (interface — the contract)
   ↑  SilentXxxListener       no-op base; super.x() does nothing
   ↑  PublishingXxxListener   super.x() publishes the domain event
   ↑  PluggableXxxListener    super.x() FIRES the named injection point, then chains up
   ↑  EclipseXxxListener      super.x() runs production logic (webhooks, fees, SMS, …)

For payment, loan, remittance, user, attachment, property, and card (Postilion) this chain is
purely internal plumbing. Their PluggableXxxListener layer exists only to fire injection points
and chain to EclipseXxxListener; it has no plugin extension hook. To customise those services,
write injection-point handlers (§6 / §7) — that is the only supported mechanism for them.

Only the wallet service exposes its PluggableWalletListener as a true plugin extension point.
It alone defines setSuperClass(...) / a superclassDelegate, so a plugin can subclass it and be
wired in as the active wallet listener:

@ApplicationScoped @Unremovable @RegisterForReflection
public class MyWalletListener extends PluggableWalletListener {
    @PostConstruct void init() { setSuperClass(UK.Runtime.getBean(EclipseWalletListener.class)); }

    @Override public void postWalletCreate(Wallet wallet) {
        // before
        super.postWalletCreate(wallet);   // → delegate (EclipseWalletListener): injection point + publish + Eclipse logic
        // after
    }
}

When a delegate is set, each PluggableWalletListener method routes to it; calling
super.method(...) propagates through the injection-point firing, event publishing, and Eclipse
production layers, so all of them still happen. Skip the super call to suppress them.

Even for wallets, prefer injection-point handlers (§6) unless you specifically need to wrap or
replace the production listener — they're lighter, individually toggleable, and don't take over the
active listener bean.


12. Plugin configuration JSON

On first upload the runtime scans the JAR and auto-generates a config JSON with two sections,
every entry defaulting to true:

{
  "injectionPoints": { "com.eftcorp.plugins.MyHandlers#onTransfer": true },
  "beans":           { "com.eftcorp.plugins.DayLimitWallet": true }
}
  • injectionPoints — keyed by ClassName#methodName; toggles individual @OnNamedInjectionPoint
    handlers on/off.
  • beans — keyed by fully-qualified class name; toggles individual pluggable beans (wallet
    types, gateways, providers, batch jobs) on/off.

Updating the config merges with the stored version (your toggles win, auto-discovered keys are
preserved, removed keys are dropped) and applies the new enabled states to the live registries
immediately. Plugins can read arbitrary additional keys you place in this JSON via the injected
PluginConfig (get(jsonPath), getList(jsonPath, clazz), getAll()).


13. Tenant mappings

A plugin is either global (global = true, fires for every tenant) or restricted to an explicit
set of tenantIds. Injection-point handlers and provider beans both respect this — a non-global
plugin only fires for its mapped tenants, and getPluginBean throws for unmapped tenants. Note
property injection points pass tenantId = null, so a non-global plugin won't receive them.


14. Plugin management REST API

Implemented in plugin-service under @Path("/rest/v1/plugins"); reached through the conductor
gateway prefix /eclipse-conductor. All endpoints are authorization-gated.

ActionMethodPathBody
ListGET/rest/v1/plugins
Create / upsertPOST/rest/v1/pluginsPlugin
Update (incl. replace JAR)PUT/rest/v1/plugins/{pluginId}Plugin
Toggle statusPUT/rest/v1/plugins/{pluginId}/statusStatus (ON/OFF/DELETED)
Update configPUT/rest/v1/plugins/{pluginId}/configsconfig JSON string
Update mappingsPUT/rest/v1/plugins/{pluginId}/mappingsPlugin (uses global + mappings.tenantIds)
DeleteDELETE/rest/v1/plugins/{pluginId}

The Plugin DTO fields: pluginId (long), name, description, version,
status (ON/OFF/DELETED), global (boolean), created, lastModified,
base64EncodedJar (String — the JAR bytes, Base64), mappings ({ tenantIds: [long…] }), and
config (String). To replace a plugin's code, PUT the same pluginId with a new
base64EncodedJar.

Status semantics

  • ON — loaded; handlers/beans active.
  • OFF — unloaded from memory; nothing fires (still stored).
  • DELETED — unloaded; purged from the database by a periodic sweep.

Deploy example

TOKEN=$(jwt_login [email protected] .yoursecret | cut -d' ' -f2-3)
BASE=https://eclipse-java-sandbox.ukheshe.rocks
JAR_BASE64=$(base64 -w 0 target/uk-plugin-my-thing-1.0.0.jar)

# create
curl -s -X POST "$BASE/eclipse-conductor/rest/v1/plugins" \
  -H "Authorization: $TOKEN" -H "Content-Type: application/json" \
  -d "{\"status\":\"ON\",\"global\":true,\"base64EncodedJar\":\"$JAR_BASE64\",\"mappings\":{}}"

# update existing (replace JAR for pluginId 8)
curl -s -X PUT "$BASE/eclipse-conductor/rest/v1/plugins/8" \
  -H "Authorization: $TOKEN" -H "Content-Type: application/json" \
  -d "{\"pluginId\":8,\"status\":\"ON\",\"global\":true,\"base64EncodedJar\":\"$JAR_BASE64\",\"mappings\":{}}"

Hot reload

The runtime polls the database every ~10 seconds and reconciles: new/changed JARs (compared by
SHA-256) are loaded, OFF/DELETED plugins are unloaded, mapping/config changes are applied to the
live registries. Uploading through the API also triggers an immediate sync.


15. Worked examples

Plugins under plugins/ to copy from:

ModuleKindWhat it does
plugin-day-limitWallet type@Dependent extends PluggableWallet enforcing a daily limit
plugin-email-alertWallet type + injection-point handlerEmailAlertWalletType + TransferEmailAlert on wallet.postWalletTransfer
plugin-attachment-info-length-validatorValidator (injection-point)attachment.onAttachmentCreated/Updated with throwExceptions = true to veto
plugin-pcb-attachment-validatorValidatorsame pattern, PCB-specific
plugin-demoMixedwallet type + batch (PaulsDemoBatch) + tenant-integration provider (PcbTenantIntegration)
plugin-iposProvider@ExtensionProvider(type="TenantIntegrationProcessor", name="saveStoreData")
plugin-user-service-fire-event-demoListenersuser.onUserCreated, preAuth, onAuthSuccess/Failure, onPasswordUpdate
plugin-user-service-oss-demoOSS action processorreturns Boolean.TRUE to suppress default KYC processing
plugin-user-service-ratify-processor-demoRatify processorextends PluggableRatifyItemProcessor
plugin-testCatch-allfraud handler, domainEvent, wallet types, many injection points

16. Quick reference

// Minimal injection-point plugin
@PluginDescriptor(name = "my-plugin", version = "1.0", description = "Example")
public class MyPluginInfo { }

@Singleton
public class MyHandlers {
    @Inject EntityManager em;                       // one of the whitelisted @Inject types
    @OnNamedInjectionPoint(injectionPoint = "wallet.postWalletCreate", priority = 1)
    public void onWalletCreated(Wallet wallet) { … }
}

Common pitfalls

  • ❌ Reflection, threads, File, sockets, System/Runtime, arbitrary HTTP libs — blocked by the
    sandbox (§3). Use injected REST clients and UK.* facades.
  • ❌ Bundling com.ukheshe.* classes — provided by the platform; don't ship them.
  • @Inject of an unsupported type — fails at load. Only the six types in §5 work.
  • ❌ Forgetting setSuperClass(...) in @PostConstruct for a Pluggable* implementation — the
    delegate is null and super.* calls fail.
  • ❌ Expecting an injection point to fire only for your wallet/tenant — they fire broadly; filter by
    instanceof / getWalletTypeId() / tenant.
  • ✔️ Use throwExceptions = true only when you intend to veto the operation; otherwise let errors
    be collected.