Lumnik Hooks Guide¶
Lumnik's hook system lets you extend or modify behavior without touching core module code. You register hooks in a META-INF/hooks.yaml file shipped with your customization JAR.
Quick start¶
- Create a Maven module (or add to an existing one) that depends on
lumnik-hub. - Add
src/main/resources/META-INF/hooks.yaml. - Implement hook handler classes annotated with
@ApplicationScoped. - Add your JAR to the platform's runtime classpath.
The HookManifestScanner picks up every META-INF/hooks.yaml on the classpath at startup.
hooks.yaml structure¶
module: my-customization # lowercase with hyphens; must be unique
version: 1.0.0
description: "Optional description"
hooks:
- id: my-lifecycle-hook
target: io.lumnik.businesspartner.partner.BusinessPartner
events: [BEFORE_CREATE, BEFORE_UPDATE]
handler:
type: java
class: com.acme.hooks.MyLifecycleHook
priority: 100 # lower = earlier; default 100
tenants: ['*'] # '*' = all tenants; or [1, 2, 3]
enabled: true
- id: my-action-hook
action: repair.status-change # format: module.action (hyphen allowed)
when: BEFORE # BEFORE or AFTER
handler:
type: java
class: com.acme.hooks.MyActionHook
- id: my-kotlin-hook
target: io.lumnik.repair.Repair
events: [AFTER_COMPLETE]
handler:
type: kotlin
script: scripts/com/acme/MyScript.kts
callouts:
- id: my-callout
entity: io.lumnik.repair.Repair
field: equipment
handler:
type: java
class: com.acme.hooks.MyCallout
Validation rules¶
module:^[a-z][a-z0-9-]*$id:^[a-z][a-z0-9-]*$action:^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$(note: hyphens allowed, underscores are not)- A hook must have either (
target+events) or (action+when), not both.
Hook types¶
Lifecycle hook¶
Fires on entity CRUD and document workflow events.
@ApplicationScoped
public class MyHook implements LifecycleHook<BusinessPartner> {
@Override
public void onBeforeCreate(BusinessPartner p, HookContext ctx) {
if (someCondition(p)) {
ctx.fail("my_error_code", "Human-readable message");
}
}
@Override
public void onAfterUpdate(BusinessPartner p, HookContext ctx) {
ctx.log("Partner updated: " + p.getCode());
}
}
All lifecycle methods are default no-ops — implement only what you need.
Available events: BEFORE_CREATE, AFTER_CREATE, BEFORE_UPDATE, AFTER_UPDATE, BEFORE_DELETE, AFTER_DELETE, BEFORE_ARCHIVE, AFTER_ARCHIVE, BEFORE_PREPARE, AFTER_PREPARE, BEFORE_COMPLETE, AFTER_COMPLETE, BEFORE_VOID, AFTER_VOID, BEFORE_CLOSE, AFTER_CLOSE, BEFORE_REACTIVATE, AFTER_REACTIVATE.
Action hook¶
Fires when a method annotated with @ActionHook("module.action") is called.
@ApplicationScoped
public class MyActionHook implements ActionHandler {
@Override
public void handle(Object payload, HookContext ctx) {
ctx.log("Action fired", Map.of("payload", payload.toString()));
}
}
Kotlin script hook¶
Scripts receive three bindings: event (LifecycleEvent, may be null for action hooks), entity (the domain object), ctx (HookContext).
import io.lumnik.businesspartner.partner.BusinessPartner
import io.lumnik.hub.hook.HookContext
val ent = bindings["entity"] as BusinessPartner
val ctx = bindings["ctx"] as HookContext
if (ent.name?.contains("VIP", ignoreCase = true) == true) {
ctx.log("VIP partner: ${ent.code}")
}
Callout¶
Field-level reactive logic triggered by the frontend when a field value changes.
@ApplicationScoped
public class MyCallout implements Callout<Repair> {
@Override
public CalloutResult onChange(Repair r, HookContext ctx) {
if (r.getEquipment() != null && r.getEquipment().contains("CRITICAL"))
return CalloutResult.patch(Map.of("priority", "URGENT"));
return CalloutResult.patch(Map.of("priority", "NORMAL"));
}
}
The frontend calls POST /api/callouts/{moduleId}/{entityType}/{field} with the partial entity state and receives a CalloutResult with patches to apply.
HookContext API¶
ctx.tenantId() // current tenant
ctx.userId() // current user
ctx.user().hasRole("lm_admin") // role check
ctx.fail("code", "message") // cancel the operation (throws BusinessException → 409)
ctx.fail("code", "message", details) // cancel with extra detail map
ctx.event("my.event", payloadMap) // publish a domain event via outbox (async post-commit)
ctx.log("message") // structured log
ctx.log("message", Map.of("k", "v")) // structured log with extra fields
ctx.lookup(BusinessPartner.class, id) // read-only entity lookup (Optional<E>)
Priority¶
Hooks with the same target/event are sorted by priority ascending (lower = first). Default is 100. If multiple hooks fire, a cancellation in any BEFORE hook stops processing — subsequent hooks do not run.
Failure behavior¶
ctx.fail(...)→BusinessException→ HTTP 409, transaction rolled back — the intended veto path.- A hook that throws any other exception (a bug) → HTTP 500 and full transaction rollback. There is no swallow-and-continue.
Trust boundary (Kotlin scripts)¶
Kotlin hook scripts run with full JVM trust — no sandbox, no timeout, no memory cap. In v1
scripts must live on the classpath (classpath: refs only; file: is rejected). Hot-reload is
off by default (lumnik.hooks.kotlin.hot-reload=false); scripts are compiled once and cached.
Tenant scoping¶
tenants: ['*']— fires for all tenants (default whentenantsis absent)tenants: [1, 42]— fires only for those tenant IDstenants: ['*']can be combined with runtime checks viactx.tenantId()
Admin endpoints¶
GET /api/platform/hooks → list all hooks and callouts
GET /api/platform/hooks/{id} → detail for a specific hook
POST /api/platform/hooks/{hookId}/toggle → enable/disable a hook
GET /api/platform/hooks/overrides?tenantId=N → per-tenant script overrides (HookOverrideResource)
POST /api/platform/hooks/overrides → body carries tenantId (400 without it)
Requires lm_admin role. In dev-bypass mode (%test/%dev profiles) all roles are granted automatically.
Connector mapping hooks (the other hook family)¶
Distinct from the platform lifecycle hooks above: mapping hooks transform data during
ingestion, declared in a connector manifest's spec.mapping block — 33 built-ins in three
tiers (cell / row / dataset), including a kotlin escape hatch bound by the same
trust boundary as above. Full parameter reference:
Transform at ingestion. One truth to keep in mind: the
aggregator row hook exists in the engine but is refused on csv-file connector runs —
chunked transactions would silently lose its cross-chunk buffer.
Testing¶
Use @QuarkusTest with RestAssured for integration tests. Pass tenant/user via HTTP headers
(the /partners endpoint below is illustrative — stand in your own module's endpoint;
the hub itself does not ship one):
given()
.header("X-Tenant-ID", TENANT)
.header("X-User-ID", 1L)
.contentType("application/json")
.body("{...}")
.when()
.post("/api/tenants/" + TENANT + "/partners")
.then()
.statusCode(409)
.body("code", equalTo("my_error_code"));
HookTestKit (in the lumnik-hub test tree, io.lumnik.hub.hook.testkit) provides CDI accessors
for the registry and executor:
HookRegistry registry = HookTestKit.registry();
registry.allBindings().forEach(b -> System.out.println(b.id()));
HookTestKit.fire(LifecycleEvent.BEFORE_CREATE, myEntity);
See the Hooks cookbook for 10 recipes covering all hook patterns.