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:
| Category | What you write | Wired in by |
|---|---|---|
| Injection-point plugins | A bean with @OnNamedInjectionPoint methods | Method annotation; auto-discovered |
| Implementation plugins | A class extending a Pluggable* base (wallet type, gateway, processor, fraud handler, ratify processor) | implementationClass config / type registration |
| Provider plugins | A bean annotated @ExtensionProvider(type=…, name=…) | The annotation; resolved by (type, name) |
| Batch plugins | A bean implementing ILocalBatchJob | Auto-registered as a scheduled local batch |
| Wallet listener plugins | A 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
@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
@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 viaUK.Exceptions.processException(...), and the remaining
handlers still run. SetthrowExceptions = trueto 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
@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.jarThe 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 containsplugin-sdk.jar, built by
build-plugin-sdk.sh. That script scans everyfireInjectionPoint(...)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 withprovidedscope.
Class-loader sandbox (important)
Each plugin is loaded by a restricted class loader that only resolves:
- Classes contained in the plugin JAR itself, and
- 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., andcom.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
@Injectfields (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:
| Scope | Instance lifetime |
|---|---|
@Singleton / @ApplicationScoped | One instance for the whole time the plugin is loaded (created eagerly at load) |
@RequestScoped | One instance per request context (stored on the RequestContext) |
@Dependent | A 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
@Inject supportField @Inject is supported for a fixed, whitelisted set of types, resolved lazily at instantiation:
| Injectable type | What you get |
|---|---|
jakarta.persistence.EntityManager | The runtime JPA entity manager |
com.ukheshe.arch.rest.RequestContext | Current request metadata (tenant, user, session, properties) |
com.ukheshe.arch.rest.CallAsUserJsonRestClient | REST client acting as the current user |
com.ukheshe.arch.rest.CallAsSystemJsonRestClient | REST client acting as the system |
com.ukheshe.arch.rest.AdvancedJsonRestClient | Lower-level REST client |
com.ukheshe.arch.plugin.PluginConfig | This 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
@Injectfield of any other type fails loudly at load: "is @Inject but type […] is not
injectable into plugins". You get an error, not a silentnull. - 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:
@Named("x")parameter → looked up by name among theNamedParams 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
IllegalStateExceptionis thrown telling you to passNamedParam.of("x", value).- 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
PluginResultsUK.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:
| Helper | Meaning |
|---|---|
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
PluggableWalletListener| Injection point | When | Params |
|---|---|---|
wallet.preWalletCreate | before wallet persisted | Wallet (newWallet) |
wallet.postWalletCreate | after wallet persisted | Wallet (newWallet) |
wallet.onWalletUpdate | wallet updated | Wallet |
wallet.preReservation | before a reservation | BigDecimal amount, @Named sessionId, ZonedDateTime expiryDate, @Named description, @Named force |
wallet.preWalletTransfer | before transfer | @Named from (IWallet), @Named to (IWallet), @Named bulk, @Named scheduled |
wallet.postWalletTransfer | after transfer | @Named from, @Named to, @Named history (List<WalletHistory>), @Named bulk, @Named scheduled |
wallet.augmentTransfer | augment/split a transfer | @Named from, @Named to, @Named bulk, @Named scheduled |
wallet.doSMSForFailedTransfer | SMS for failed transfer | Transfer, @Named from, @Named to, String failedReason |
wallet.onTransactionFailure | ledger-level failure | @Named walletId, @Named otherWalletId, BigDecimal amount, @Named currency, @Named sessionId |
wallet.postWalletTypeCreate | after wallet type created | WalletType |
wallet.onWalletTypeUpdate | wallet type updated | WalletType, DbWalletType (existing) |
Payment — PluggablePaymentListener
PluggablePaymentListener| Injection point | When | Params |
|---|---|---|
payment.preCreatePayment | before payment persisted | NewPayment |
payment.postCreatePayment | after payment persisted | Payment |
payment.preCreateWithdrawal | before withdrawal persisted | Withdrawal |
payment.postCreateWithdrawal | after withdrawal persisted | Withdrawal |
payment.postPaymentComplete | payment reaches completed | Payment |
payment.postWithdrawalComplete | withdrawal reaches completed | Withdrawal |
payment.prePaymentAcquiring | before acquiring | DbPayment |
Loan — PluggableLoanListener
PluggableLoanListener| Injection point | When | Params |
|---|---|---|
loan.preCreateLoan | before loan persisted | NewLoan |
loan.postCreateLoan | after loan persisted | Loan |
loan.preLoanUpdate | before loan updated | Loan, DbLoan (existing) |
loan.postLoanUpdate | after loan updated | Loan |
Remittance — PluggableRemittanceListener
PluggableRemittanceListener| Injection point | When | Params |
|---|---|---|
remittance.preCreateRemittance | before remittance persisted | NewRemittance |
remittance.postCreateRemittance | after remittance persisted | Remittance |
remittance.preRemittanceUpdate | before remittance updated | Remittance, DbRemittance (existing) |
remittance.postRemittanceUpdate | after remittance updated | Remittance |
remittance.postRemittanceComplete | remittance completed | Remittance |
remittance.preRemittanceAcquiring | before acquiring | Remittance |
User & organisation — PluggableUserListener
PluggableUserListenerCredentials are always stripped before these fire (passwords, secrets, key/info).
| Injection point | When | Params |
|---|---|---|
user.onUserCreate | before user create | User (newUser) |
user.onUserCreated | after user create | User |
user.onUserUpdate | before user update | User (changes), DbUser (existing) |
user.onUserUpdated | after user update | User |
user.onUserDelete | user deleted | User |
user.onOrganisationCreate | before org create | Organisation (new) |
user.onOrganisationCreated | after org create | Organisation |
user.onOrganisationUpdate | before org update | Organisation (changes), DbOrganisation (existing) |
user.onOrganisationUpdated | after org update | Organisation |
user.onOrganisationDelete | org deleted | Organisation |
user.onUserIdentityCreate | identity created | UserIdentity (sanitised) |
user.onUserIdentityDelete | identity deleted | UserIdentity |
user.preAuth | before authentication | AuthCredentials (password stripped) |
user.onAuthSuccess | auth succeeded | AuthCredentials, User |
user.onAuthFailure | auth failed | AuthCredentials, DbUser |
user.onDocumentCreate / onDocumentUpdate / onDocumentDelete | document lifecycle | Document (binary stripped) |
user.onAddressCreate | address created | Address |
user.onAddressUpdate | before address update | Address (userAddress), DbAddress (existing) |
user.onAddressUpdated | after address update | Address |
user.onAddressDelete | address deleted | Address |
user.onUserContactCreate | contact created | UserContact |
user.onUserContactUpdate | before contact update | UserContact, DbUserContact (existing) |
user.onUserContactUpdated | after contact update | UserContact (updated) |
user.onUserContactDelete | contact deleted | UserContact |
user.onUserPositionCreate / onUserPositionDelete | position lifecycle | UserPosition |
user.onUserRoleCreate / onUserRoleDelete | role lifecycle | UserRole |
user.onUserRatified | user KYC ratified | RatifyResult |
user.onOrganisationRatified | org KYC ratified | RatifyResult |
user.onManualRatify | manual ratify submitted | String ratifyResultData |
user.postRatifyBatchCompletion | ratify batch finished | String queueName, RatifyBatchType type, Long batchId, String checks |
user.onPasswordUpdate | password changed | UserIdentityPasswordChange |
user.onUserIdentityPasswordChangeInit | password-change flow start | PasswordChangeInit |
Attachment — PluggableAttachmentListener
PluggableAttachmentListener| Injection point | When | Params |
|---|---|---|
attachment.onGetAttachments | attachments fetched | List<Attachment>, String fields |
attachment.onAttachmentCreated | after create | Attachment |
attachment.onAttachmentUpdated | after update | Attachment |
attachment.onAttachmentDeleted | after delete | Attachment |
Property — PluggablePropertyListener
PluggablePropertyListenerProperty is a global resource: tenantId is always null at these points (global mapping or
explicit tenant mapping required to receive them).
| Injection point | When | Params |
|---|---|---|
property.postPropertyCreate | after create | Property |
property.onPropertyUpdate | on update | Property |
property.onPropertyDelete | on delete | Property |
property.onPropertyGet | on fetch | List<DbProperty> |
Postilion / card — PluggablePostilionListener
PluggablePostilionListener| Injection point | When | Params |
|---|---|---|
postilion.onCardCreated | card created | PostilionCard |
postilion.onCardUpdated | card updated | PostilionCardUpdate, @Named postilionCard (PostilionCard) |
postilion.onStandardLimitsUpdated | standard limits updated | PnCardStandardLimit |
postilion.onAdvancedLimitsUpdated | advanced limits updated | PnCardAdvancedLimit |
Cross-cutting / framework
| Injection point | When | Params | Return used |
|---|---|---|---|
conductorRequest | inbound Conductor request (after AAA) | ContainerRequestContext | — (use JaxRsUtils.replyImmediately to short-circuit) |
conductorResponse | outbound Conductor response | ContainerRequestContext, ContainerResponseContext | — |
domainEvent | any domain event published | DomainEvent | — |
exception | exception processed by handler | Throwable, List<? extends ExceptionData> | — |
alert | alert sent via UK.Alerts | ExceptionSeverity severity, @Named channelName, @Named message, ZonedDateTime alertDate, Throwable t, @Named traceId | — |
Libraries — return-producing points
| Injection point | When | Params | Expected return |
|---|---|---|---|
convertCurrency | spot FX rate needed | @Named baseCurrency, @Named toCurrency | ExchangeRate (via getFirstNonNullPluginResponse) |
convertCurrencyHistoric | historic FX rate needed | @Named baseCurrency, @Named toCurrency, @Named date (Instant) | ExchangeRate |
sendTextSms | platform sends an SMS | @Named cardBin, @Named to, @Named message, @Named senderId, @Named rawObject | String 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
actionstring 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.TRUEto 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 realsuper.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 kind | Extend | setSuperClass(...) argument | Domain interface |
|---|---|---|---|
| Wallet type | …wallet.pluggable.PluggableWallet | LimitCheckingWallet (impl of BiDirectionalDelegatingWallet) | IWallet |
| Payment gateway | …payment.gateway.pluggable.PluggablePaymentGateway | BasePaymentGateway (BiDirectionalDelegatingPaymentGateway) | IPaymentGateway |
| Withdrawal gateway | …payment.gateway.pluggable.PluggableWithdrawalGateway | BaseWithdrawalGateway | IWithdrawalGateway |
| Loan gateway | …loan.gateway.pluggable.PluggableLoanGateway | BaseLoanGateway | ILoanGateway |
| Remittance gateway | …remittance.gateway.pluggable.PluggableRemittanceGateway | BaseRemittanceGateway | IRemittanceGateway |
| Payment processor | …conductor.service.payments.PluggablePaymentProcessor | StandardPaymentProcessor (direct concrete delegate) | IPaymentProcessor |
| Withdrawal processor | …conductor.service.withdrawals.PluggableWithdrawalProcessor | StandardWithdrawalProcessor | IWithdrawalProcessor |
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 byClassName#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.
| Action | Method | Path | Body |
|---|---|---|---|
| List | GET | /rest/v1/plugins | — |
| Create / upsert | POST | /rest/v1/plugins | Plugin |
| Update (incl. replace JAR) | PUT | /rest/v1/plugins/{pluginId} | Plugin |
| Toggle status | PUT | /rest/v1/plugins/{pluginId}/status | Status (ON/OFF/DELETED) |
| Update config | PUT | /rest/v1/plugins/{pluginId}/configs | config JSON string |
| Update mappings | PUT | /rest/v1/plugins/{pluginId}/mappings | Plugin (uses global + mappings.tenantIds) |
| Delete | DELETE | /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:
| Module | Kind | What it does |
|---|---|---|
plugin-day-limit | Wallet type | @Dependent extends PluggableWallet enforcing a daily limit |
plugin-email-alert | Wallet type + injection-point handler | EmailAlertWalletType + TransferEmailAlert on wallet.postWalletTransfer |
plugin-attachment-info-length-validator | Validator (injection-point) | attachment.onAttachmentCreated/Updated with throwExceptions = true to veto |
plugin-pcb-attachment-validator | Validator | same pattern, PCB-specific |
plugin-demo | Mixed | wallet type + batch (PaulsDemoBatch) + tenant-integration provider (PcbTenantIntegration) |
plugin-ipos | Provider | @ExtensionProvider(type="TenantIntegrationProcessor", name="saveStoreData") |
plugin-user-service-fire-event-demo | Listeners | user.onUserCreated, preAuth, onAuthSuccess/Failure, onPasswordUpdate |
plugin-user-service-oss-demo | OSS action processor | returns Boolean.TRUE to suppress default KYC processing |
plugin-user-service-ratify-processor-demo | Ratify processor | extends PluggableRatifyItemProcessor |
plugin-test | Catch-all | fraud 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 andUK.*facades. - ❌ Bundling
com.ukheshe.*classes — provided by the platform; don't ship them. - ❌
@Injectof an unsupported type — fails at load. Only the six types in §5 work. - ❌ Forgetting
setSuperClass(...)in@PostConstructfor aPluggable*implementation — the
delegate is null andsuper.*calls fail. - ❌ Expecting an injection point to fire only for your wallet/tenant — they fire broadly; filter by
instanceof/getWalletTypeId()/ tenant. - ✔️ Use
throwExceptions = trueonly when you intend to veto the operation; otherwise let errors
be collected.
