Skip to content

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

  1. Create a Maven module (or add to an existing one) that depends on lumnik-hub.
  2. Add src/main/resources/META-INF/hooks.yaml.
  3. Implement hook handler classes annotated with @ApplicationScoped.
  4. 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 when tenants is absent)
  • tenants: [1, 42] — fires only for those tenant IDs
  • tenants: ['*'] can be combined with runtime checks via ctx.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.