CommandHandlerExtension

CommandHandlerExtension is the primary extension point for injecting custom Java logic into AuraBoot's Command Pipeline. Every write operation in the platform — create, update, delete, state transitions, custom commands — flows through CommandExecutor, which dispatches across 16 pipeline phases (Load → Command Authorization → Schema Validate → Idempotency → Entitlement → SoD Check → State Check → Assert → Pre-Actions → Pre-Invariant → Auto Set → Field Map → Computed Fields → Handler → Post-Execution → Completion). A CommandHandlerExtension binds to a specific command type and contributes logic at the Handler phase.

Concept

A handler is scoped to a single command type, matched by the namespace:command-name value returned from getCommandType(). The pipeline invokes execute(CommandContext) at the Handler phase, which returns a result object (may be null).

The platform runs exactly one primary handler per command — the highest-getPriority() handler among those whose chainsAfterPrimary() returns false (higher priority wins). A handler that returns true from chainsAfterPrimary() is a secondary: it does not participate in primary selection and instead runs after the primary, inside the same command transaction, letting a downstream plugin add behaviour to a command another plugin already owns. Multiple secondaries run in descending getPriority() order.

All write side effects inside execute participate in the surrounding command transaction owned by CommandExecutor. Do not swallow exceptions — see Common pitfalls.

Interface signature

package com.auraboot.framework.plugin.extension;

import org.pf4j.ExtensionPoint;
import java.util.Set;

public interface CommandHandlerExtension extends ExtensionPoint {

    /** Command type this handler processes. Format "namespace:command-name", e.g. "billing:generate-invoice". */
    String getCommandType();

    /** All command types this handler can process (import-time validation needs a finite list). Default: {getCommandType()}. */
    default Set<String> getSupportedCommandTypes() {
        String commandType = getCommandType();
        return commandType == null ? Set.of() : Set.of(commandType);
    }

    /** Execute the command. Returns a result object (may be null). */
    Object execute(CommandContext context) throws Exception;

    /** Whether this handler supports the given command type; default exact match on getCommandType(). */
    default boolean supports(String commandType) {
        return getCommandType().equals(commandType);
    }

    /** Handler priority; higher runs first. Default 0. */
    default int getPriority() {
        return 0;
    }

    /** Run as a secondary chained after the primary; default false (participates in primary selection). */
    default boolean chainsAfterPrimary() {
        return false;
    }

    /** Whether this handler is safe under dryRun; default false (skipped under dry-run). */
    default boolean supportsDryRun() {
        return false;
    }
}

CommandContext is a record; fields are read through record accessors: ctx.tenantId(), ctx.commandType(), ctx.modelCode(), ctx.recordId(), ctx.payload() (Map<String, Object>), ctx.settings() (Map<String, Object>), ctx.pluginId(), ctx.namespace(), ctx.dryRun(). Data access goes through ctx.dataAccessor() (or ctx.settings().get("__dataAccessor")) to reach the platform-injected DataAccessor; convenience bridges include ctx.aiProviderAccessor(), ctx.fileAccessor(), and ctx.asyncTaskAccessor().

Implementation example

The following handler processes the crm:create_lead command, computes a score, and writes it back to the payload:

package com.acme.crm.handler;

import com.auraboot.framework.plugin.extension.CommandHandlerExtension;
import com.auraboot.framework.plugin.extension.DataAccessor;
import org.pf4j.Extension;
import org.springframework.beans.factory.annotation.Autowired;

import java.util.Map;

@Extension
public class LeadScoringHandler implements CommandHandlerExtension {

    @Autowired private LeadScoringService scoring;

    @Override
    public String getCommandType() {
        return "crm:create_lead";
    }

    @Override
    public Object execute(CommandContext ctx) throws Exception {
        DataAccessor db = ctx.dataAccessor();
        Map<String, Object> payload = ctx.payload();

        Integer score = scoring.score(payload);
        payload.put("lead_score", score);

        return Map.of("recordId", ctx.recordId(), "score", score);
    }
}

Registration

AuraBoot uses PF4J-style extension discovery (@Extension) layered on Spring. Two steps:

  1. Annotate the class with @Extension (from org.pf4j).
  2. List the extension class in META-INF/extensions.idx, generated at compile time by the org.pf4j:pf4j annotation processor (annotationProcessor 'org.pf4j:pf4j').

In plugin.json, ensure the extension's package is included in plugin class scanning. Handler-to-command binding is by interface + annotation + getCommandType() (runtime dispatch then goes through supports(...)), not by a handler reference in commands.json or bindingRules.json.

To call other services, use standard Spring @Autowired. The plugin classloader resolves platform beans through the host's ApplicationContext.

Common pitfalls

  • Swallowing exceptions. A catch (Exception e) { log.warn(...) } inside execute while the pipeline holds an active transaction results in rollback-only flags but no surfaced error — see red line §8. Re-throw, or use @Transactional(NOT_SUPPORTED) on out-of-band side effects.
  • Inline bindingRules in commands.json. Not loaded by the importer — see red line §6. Handler-to-command binding is purely by getCommandType().
  • External side effects under dry-run. Transaction rollback only undoes JDBC writes; HTTP / email / MQ / object-storage side effects escape the rollback envelope. supportsDryRun() defaults to false, so the handler is skipped under dry-run; before returning true you must inspect ctx.dryRun() and short-circuit every external call.
  • Long-running I/O in execute. You are holding the request thread plus the transaction. Publish to the outbox or process asynchronously via ctx.asyncTaskAccessor().

Related