CommandExecutor

CommandExecutor is the server-side entry point to the Command Pipeline. Whatever the REST endpoint POST /api/meta/commands/execute/{commandCode} does over HTTP, CommandExecutor.execute(commandCode, request) does inline from any Java code path. Plugins use it to compose commands from listeners, scheduled tasks, agent tools, and migration scripts — without going back through HTTP.

Concept

The executor orchestrates a multi-phase pipeline: idempotency → authorize (CommandAuthorizationPhase) → load → validate (SchemaValidatePhase + ValidatorExtension) → pre-actions (PreActionsPhase) → handler (HandlerPhase, runs CommandHandlerExtension.execute) → state-check → post-execution (PostExecutionPhase) → completion. The whole pipeline runs inside one @Transactional boundary owned by the executor — handlers participate, listeners do not.

Call mode: execute(commandCode, request) returns the CommandExecuteResult synchronously after commit.

For chat tools, do not call CommandExecutor directly from an agent tool implementation — the tool framework wraps it with the approval gate and audit step. See ConversationTurnService.

Interface signature

package com.auraboot.framework.meta.service;

import com.auraboot.framework.meta.dto.CommandExecuteRequest;
import com.auraboot.framework.meta.dto.CommandExecuteResult;

public interface CommandExecutor {

    /** Execute a command synchronously inside the current (or a new) transaction. */
    CommandExecuteResult execute(String commandCode, CommandExecuteRequest request);
}

CommandExecuteResult exposes getCommandCode(), getPhaseReached(), getData() (e.g. the inserted record), getExecutionTimeMs(), and isIdempotentReplay() (true when the idempotency dedupe hit). CommandExecuteRequest carries:

  • payload — the command input data (Map<String, Object>).
  • clientRequestId — used for idempotency dedupe (key expression commandCode + ':' + clientRequestId, 24 h TTL).
  • operationType — optional operation hint (CREATE / UPDATE / DELETE).
  • targetRecordId — optional target record id (for UPDATE / DELETE).
  • auditContext — optional execution-source metadata for audit (never merged into the business payload handlers see).
  • expectedVersion — optional optimistic-lock version.
  • dryRun — when true, runs the full pipeline but forces the transaction to roll back, producing a side-effect-free write simulation.

Implementation example

A migration script that imports legacy CRM leads, deduped by an external id:

package com.acme.migration;

import com.auraboot.framework.meta.dto.CommandExecuteRequest;
import com.auraboot.framework.meta.dto.CommandExecuteResult;
import com.auraboot.framework.meta.service.CommandExecutor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.util.Map;

@Component
public class LegacyLeadImporter {

    @Autowired private CommandExecutor commandExecutor;

    public void importBatch(Iterable<LegacyLead> batch) {
        for (LegacyLead row : batch) {
            CommandExecuteRequest req = new CommandExecuteRequest();
            req.setPayload(Map.of(
                "lead_name", row.name(),
                "email", row.email(),
                "source", "legacy_import",
                "external_id", row.externalId()
            ));
            req.setClientRequestId("legacy:" + row.externalId());

            CommandExecuteResult result =
                commandExecutor.execute("crm:create_lead", req);
            if (result.isIdempotentReplay()) {
                // already imported in a prior run — skip
                continue;
            }
        }
    }
}

For listeners, the surrounding pattern is identical except you must remember listeners run after the originating commit — each execute call starts a fresh transaction:

@Override
public void onEvent(EventContext e) {
    CommandExecuteRequest req = new CommandExecuteRequest();
    req.setTargetRecordId(e.recordId());
    req.setPayload(Map.of("assigned_to", router.next(e.tenantId())));
    commandExecutor.execute("crm:update_lead", req);
}

Registration

Host-managed singleton. @Autowired from anywhere. No SPI — extension is via CommandHandlerExtension / ValidatorExtension on the pipeline, not by replacing the executor.

Transaction boundary cheat-sheet

CallerTxNotes
CommandHandlerExtension.execute (runs in HandlerPhase)JoinsThrowing rolls back the whole turn.
EventListenerExtension.onEventFreshListener runs after commit.
Scheduled taskFresh per callEach execute is its own tx.
Agent tool (via ConversationTurnService)WrappedTool framework owns the boundary.

Common pitfalls

  • Re-entering execute from inside a handler. Re-entrant pipeline — at best wastes work; at worst dead-locks. Refactor the logic into the same command, or decouple via an event/listener.
  • Skipping clientRequestId in retry-prone paths. Listeners are at-least-once; without it you get duplicate inserts.
  • Catching the exception thrown by execute. catch (Exception e) here is the same red-line §8 anti-pattern — you mask rollback-only and produce ghost writes.
  • Leaving dryRun = true in a flow that must persist. dryRun forces a rollback and produces a side-effect-free simulation; never enable it for a user flow that needs to commit.

Related