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 expressioncommandCode + ':' + clientRequestId, 24 h TTL).operationType— optional operation hint (CREATE/UPDATE/DELETE).targetRecordId— optional target record id (forUPDATE/DELETE).auditContext— optional execution-source metadata for audit (never merged into the business payload handlers see).expectedVersion— optional optimistic-lock version.dryRun— whentrue, 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
| Caller | Tx | Notes |
|---|---|---|
CommandHandlerExtension.execute (runs in HandlerPhase) | Joins | Throwing rolls back the whole turn. |
EventListenerExtension.onEvent | Fresh | Listener runs after commit. |
| Scheduled task | Fresh per call | Each execute is its own tx. |
| Agent tool (via ConversationTurnService) | Wrapped | Tool framework owns the boundary. |
Common pitfalls
- Re-entering
executefrom 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
clientRequestIdin 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 maskrollback-onlyand produce ghost writes. - Leaving
dryRun = truein 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
- Commands pipeline
- CommandHandlerExtension
- ConversationTurnService — chat-tool wrapper around this
- REST API — HTTP equivalent