Skip to content

Lumnik Hooks Cookbook

10 recipes covering every hook pattern.

Illustrative entities. BusinessPartner / Repair used throughout are example class names — no such module ships with lumnik. Substitute the entity of your own module; the hook mechanics (manifest, bindings, context API) are what each recipe demonstrates.


Recipe 1: BEFORE_CREATE lifecycle hook — block invalid data

Pattern: Cancel a create operation when a business rule is violated.

Use case: Block BusinessPartner creation if creditLimit > 100000 and the user lacks lm_credit_approver role.

hooks.yaml:

- id: credit-limit-check
  target: io.lumnik.businesspartner.partner.BusinessPartner
  events: [BEFORE_CREATE, BEFORE_UPDATE]
  handler:
    type: java
    class: io.lumnik.custom.partner.CreditLimitHook

Handler:

@ApplicationScoped
public class CreditLimitHook implements LifecycleHook<BusinessPartner> {
    private static final BigDecimal THRESHOLD = new BigDecimal("100000");

    @Override public void onBeforeCreate(BusinessPartner p, HookContext ctx) { check(p, ctx); }
    @Override public void onBeforeUpdate(BusinessPartner p, HookContext ctx) { check(p, ctx); }

    private void check(BusinessPartner p, HookContext ctx) {
        if (p.getCreditLimit() != null
            && p.getCreditLimit().compareTo(THRESHOLD) > 0
            && !ctx.user().hasRole("lm_credit_approver")) {
            ctx.fail("credit_limit_exceeded",
                     "Credit limit > " + THRESHOLD + " requires lm_credit_approver role");
        }
    }
}

Expected: POST /api/tenants/{tid}/partners with creditLimit: 200000409 credit_limit_exceeded.


Recipe 2: BEFORE_CREATE — happy path

Pattern: Same hook, values within the allowed range pass through.

Expected: POST /api/tenants/{tid}/partners with creditLimit: 5000201.


Recipe 3: BEFORE action hook — audit on status change

Pattern: Fire logic before an @ActionHook-annotated method executes.

Use case: Audit-log every repair status change.

hooks.yaml:

- id: repair-status-change-audit
  action: repair.status-change
  when: BEFORE
  handler:
    type: java
    class: io.lumnik.custom.repair.RepairStatusChangeAuditHook

Handler:

@ApplicationScoped
public class RepairStatusChangeAuditHook implements ActionHandler {
    @Override
    public void handle(Object payload, HookContext ctx) {
        ctx.log("Action repair.status-change BEFORE invoked",
                Map.of("payload", payload == null ? "null" : payload.toString()));
    }
}

Expected: POST /api/tenants/{tid}/repairs/{id}/status200 (hook logs and does not block).


Recipe 4: AFTER_COMPLETE lifecycle hook — post-completion side effect

Pattern: Run code after a document completes (e.g., notify, publish event).

Use case: Log and publish repair.completed event when a repair moves to TERMINE.

hooks.yaml:

- id: repair-complete-notify
  target: io.lumnik.repair.Repair
  events: [AFTER_COMPLETE]
  handler:
    type: java
    class: io.lumnik.custom.repair.RepairCompleteHook

Handler:

@ApplicationScoped
public class RepairCompleteHook implements LifecycleHook<Repair> {
    @Override
    public void onAfterComplete(Repair r, HookContext ctx) {
        ctx.log("Repair " + (r.getRef() != null ? r.getRef() : "?")
              + " completed for " + (r.getClientName() != null ? r.getClientName() : "?"));
        ctx.event("repair.completed", Map.of(
            "repairId",   r.getId(),
            "ref",        r.getRef() != null ? r.getRef() : "",
            "clientName", r.getClientName() != null ? r.getClientName() : "",
            "tenantId",   ctx.tenantId()));
    }
}

Expected: POST /api/tenants/{tid}/repairs/{id}/status with status: TERMINE200, hook fires.


Recipe 5: Callout — suggest priority for CRITICAL equipment

Pattern: Field-level reactive logic triggered by the frontend.

Use case: When the equipment field of a repair changes, suggest URGENT priority if it contains "CRITICAL".

hooks.yaml:

callouts:
  - id: repair-equipment-changed
    entity: io.lumnik.repair.Repair
    field: equipment
    handler:
      type: java
      class: io.lumnik.custom.repair.RepairEquipmentCallout

Handler:

@ApplicationScoped
public class RepairEquipmentCallout implements Callout<Repair> {
    @Override
    public CalloutResult onChange(Repair r, HookContext ctx) {
        if (r.getEquipment() != null && r.getEquipment().toUpperCase().contains("CRITICAL"))
            return CalloutResult.patch(Map.of("priority", "URGENT"));
        return CalloutResult.patch(Map.of("priority", "NORMAL"));
    }
}

Expected: POST /api/callouts/repair/Repair/equipment with {"equipment":"Server CRITICAL unit"}200, patch.priority = URGENT.


Recipe 6: Callout — default path returns NORMAL

Pattern: Same callout, non-critical equipment value.

Expected: POST /api/callouts/repair/Repair/equipment with standard equipment → 200, patch.priority = NORMAL.


Recipe 7: Admin endpoint lists all registered hooks

Pattern: Introspect the live hook registry.

Expected: GET /api/platform/hooks200, response contains credit-limit-check, repair-complete-notify, etc.

curl -H "X-Tenant-ID: 1" http://localhost:8080/api/platform/hooks | jq '.hooks[].id'

Recipe 8: Transactional rollback — blocked create leaves no trace

Pattern: Verify that ctx.fail(...) causes a full transaction rollback. The entity must not appear in subsequent queries.

Expected: 1. POST /partners with creditLimit: 999999409 2. GET /partners → list does NOT contain the blocked partner code


Recipe 9: Kotlin script hook — VIP discount log

Pattern: Delegate business logic to a hot-reloadable Kotlin script.

hooks.yaml:

- id: vip-discount-rule
  target: io.lumnik.businesspartner.partner.BusinessPartner
  events: [BEFORE_UPDATE]
  handler:
    type: kotlin
    script: scripts/io/lumnik/custom/VipDiscount.kts
  priority: 200

Script (VipDiscount.kts):

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 discount eligible for ${ent.code}")
}

Expected: PUT /partners/{id} where partner name contains "VIP" → 200, no error (script only logs).


Recipe 10: Admin endpoint — hook detail by ID

Pattern: Retrieve a specific hook by its ID.

Expected: GET /api/platform/hooks/credit-limit-check200, body contains id, module, enabled: true.

{
  "id": "credit-limit-check",
  "module": "my-customization",
  "enabled": true,
  "events": ["BEFORE_CREATE", "BEFORE_UPDATE"],
  ...
}